Сделан баланс, проверка чеков, начата система создания серверов
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",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2"
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.23",
|
"@types/express": "^4.17.23",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
@@ -296,10 +299,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.15",
|
"version": "20.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||||
"integrity": "sha512-W3bqcbLsRdFDVcmAM5l6oLlcl67vjevn8j1FPZ4nx+K5jNoWCh+FC/btxFoBPnvQlrHHDwfjp1kjIEDfwJ0Mog==",
|
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -410,6 +423,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/arg": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
@@ -423,6 +442,23 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -521,9 +557,19 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -617,6 +663,18 @@
|
|||||||
"consola": "^3.2.3"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -624,6 +682,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/confbox": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||||
@@ -723,6 +796,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -870,6 +952,21 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -992,6 +1089,42 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1155,6 +1288,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -1478,7 +1626,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -1503,6 +1650,36 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1729,6 +1906,12 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
@@ -1796,6 +1979,20 @@
|
|||||||
"destr": "^2.0.3"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -2045,6 +2242,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -2260,6 +2474,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.2",
|
"version": "5.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||||
@@ -2290,6 +2510,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -2326,7 +2552,6 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2"
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.23",
|
"@types/express": "^4.17.23",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"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
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
@@ -10,14 +9,61 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
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 {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String
|
username String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
plans Plan[]
|
plans Plan[] @relation("UserPlans")
|
||||||
operator Int @default(0) // Добавляем новую колонку operator
|
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 {
|
model Plan {
|
||||||
@@ -28,8 +74,8 @@ model Plan {
|
|||||||
isCustom Boolean @default(false)
|
isCustom Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
userId Int
|
userId Int
|
||||||
owner User @relation(fields: [userId], references: [id])
|
owner User @relation("UserPlans", fields: [userId], references: [id])
|
||||||
services Service[]
|
services Service[] @relation("PlanServices")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
@@ -37,7 +83,7 @@ model Service {
|
|||||||
name String @unique
|
name String @unique
|
||||||
price Float
|
price Float
|
||||||
planId Int?
|
planId Int?
|
||||||
plan Plan? @relation(fields: [planId], references: [id])
|
plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ticket {
|
model Ticket {
|
||||||
@@ -48,10 +94,8 @@ model Ticket {
|
|||||||
status String @default("open")
|
status String @default("open")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
responses Response[] // связь
|
responses Response[] @relation("TicketResponses")
|
||||||
|
user User? @relation("UserTickets", fields: [userId], references: [id])
|
||||||
// Если нужна связь с User:
|
|
||||||
// user User @relation(fields: [userId], references: [id])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Response {
|
model Response {
|
||||||
@@ -60,5 +104,6 @@ model Response {
|
|||||||
operatorId Int
|
operatorId Int
|
||||||
message String
|
message String
|
||||||
createdAt DateTime @default(now())
|
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 cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import authRoutes from './modules/auth/auth.routes';
|
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();
|
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/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;
|
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) => {
|
export const getMe = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).userId;
|
const userId = req.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ message: 'Не авторизован.' });
|
return res.status(401).json({ message: 'Не авторизован.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
@@ -79,16 +78,17 @@ export const getMe = async (req: Request, res: Response) => {
|
|||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
operator: true, // Добавляем поле operator
|
operator: true,
|
||||||
|
balance: true,
|
||||||
|
servers: true,
|
||||||
|
tickets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('API /api/auth/me user:', user);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ message: 'Пользователь не найден.' });
|
return res.status(404).json({ message: 'Пользователь не найден.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ user });
|
res.status(200).json({ user });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при получении данных пользователя:', error);
|
console.error('Ошибка при получении данных пользователя:', error);
|
||||||
res.status(500).json({ message: 'Ошибка сервера.' });
|
res.status(500).json({ message: 'Ошибка сервера.' });
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
const prisma = new PrismaClient();
|
||||||
userId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
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 {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader) {
|
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 };
|
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();
|
next();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка в мидлваре аутентификации:', error);
|
console.error('Ошибка в мидлваре аутентификации:', error);
|
||||||
if (error instanceof jwt.JsonWebTokenError) {
|
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 Dashboard from './pages/dashboard/mainpage';
|
||||||
import Loginpage from './pages/login';
|
import Loginpage from './pages/login';
|
||||||
import Registerpage from './pages/register';
|
import Registerpage from './pages/register';
|
||||||
import Tariffspage from './pages/tariffs';
|
import TariffsPage from './pages/tariffs';
|
||||||
import Aboutpage from './pages/about';
|
import Aboutpage from './pages/about';
|
||||||
import Privateroute from './components/privateroute';
|
import Privateroute from './components/privateroute';
|
||||||
import { AuthProvider } from './context/authcontext';
|
import { AuthProvider } from './context/authcontext';
|
||||||
@@ -17,7 +17,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Обычные страницы с footer */}
|
{/* Обычные страницы с footer */}
|
||||||
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
<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="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
||||||
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
|
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
|
||||||
<Route path="/register" element={<Pagetempl><Registerpage /></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 React from 'react';
|
||||||
import useAuth from '../context/useAuth';
|
import useAuth from '../context/useAuth';
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@ interface PrivateRouteProps {
|
|||||||
|
|
||||||
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
|
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
|
||||||
const { isLoggedIn } = useAuth();
|
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;
|
export default PrivateRoute;
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
import { createContext, useState, useEffect } from 'react';
|
import { createContext, useState, useEffect } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { UserData } from '../pages/dashboard/types';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
userData: UserData | null;
|
||||||
|
setUserData: (data: UserData | null) => void;
|
||||||
login: (token: string) => void;
|
login: (token: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
@@ -11,6 +15,8 @@ interface AuthContextType {
|
|||||||
// Создаем контекст с начальными значениями
|
// Создаем контекст с начальными значениями
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
userData: null,
|
||||||
|
setUserData: () => {},
|
||||||
login: () => {},
|
login: () => {},
|
||||||
logout: () => {},
|
logout: () => {},
|
||||||
});
|
});
|
||||||
@@ -22,11 +28,12 @@ interface AuthProviderProps {
|
|||||||
// Создаем провайдер, который будет управлять состоянием
|
// Создаем провайдер, который будет управлять состоянием
|
||||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [userData, setUserData] = useState<UserData | null>(null);
|
||||||
|
|
||||||
// Проверяем статус входа при загрузке приложения
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
setIsLoggedIn(!!token);
|
setIsLoggedIn(!!token);
|
||||||
|
// Можно добавить загрузку userData при наличии токена
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = (token: string) => {
|
const login = (token: string) => {
|
||||||
@@ -37,10 +44,11 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
|
setUserData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ isLoggedIn, login, logout }}>
|
<AuthContext.Provider value={{ isLoggedIn, userData, setUserData, login, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './app.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -1,41 +1,62 @@
|
|||||||
// 3. Исправляем frontend/src/pages/dashboard/billing.tsx
|
// 3. Исправляем frontend/src/pages/dashboard/billing.tsx
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import axios from 'axios';
|
||||||
import QRCode from 'react-qr-code';
|
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 Billing = () => {
|
||||||
const [amount, setAmount] = useState(0);
|
const [amount, setAmount] = useState(0);
|
||||||
const [isPaymentGenerated, setIsPaymentGenerated] = useState(false);
|
const [isPaymentGenerated, setIsPaymentGenerated] = useState(false);
|
||||||
const [copyStatus, setCopyStatus] = useState('');
|
const [copyStatus, setCopyStatus] = useState('');
|
||||||
|
const [checkFile, setCheckFile] = useState<File | null>(null);
|
||||||
// ИСПРАВЛЕНО: используем правильные переменные окружения для Vite
|
const [checkStatus, setCheckStatus] = useState('');
|
||||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER || '';
|
const [uploadLoading, setUploadLoading] = useState(false);
|
||||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL || '';
|
|
||||||
|
|
||||||
const handleGeneratePayment = () => {
|
const handleGeneratePayment = () => {
|
||||||
if (amount <= 0) {
|
if (amount > 0) setIsPaymentGenerated(true);
|
||||||
alert('Пожалуйста, введите сумму больше нуля.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!cardNumber || !sbpUrl) {
|
|
||||||
alert('Данные для оплаты не настроены. Пожалуйста, обратитесь к администратору.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsPaymentGenerated(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyCard = () => {
|
const handleCopyCard = () => {
|
||||||
if (cardNumber) {
|
if (cardNumber) {
|
||||||
navigator.clipboard.writeText(cardNumber);
|
navigator.clipboard.writeText(cardNumber);
|
||||||
setCopyStatus('Номер карты скопирован!');
|
setCopyStatus('Скопировано!');
|
||||||
setTimeout(() => setCopyStatus(''), 2000);
|
setTimeout(() => setCopyStatus(''), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleCheckUpload = async () => {
|
||||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
if (!checkFile || amount <= 0) return;
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Пополнение баланса</h2>
|
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 max-w-2xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 mb-6">Пополнение баланса</h2>
|
||||||
|
{/* Только QR-код и карта, без реквизитов */}
|
||||||
{!isPaymentGenerated ? (
|
{!isPaymentGenerated ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg text-gray-500 mb-4">
|
<p className="text-lg text-gray-500 mb-4">
|
||||||
@@ -61,52 +82,53 @@ const Billing = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg text-gray-700 mb-4">
|
<div>
|
||||||
Для пополнения баланса переведите <strong>₽{amount}</strong>.
|
<p className="text-lg text-gray-700 mb-4">
|
||||||
</p>
|
Для пополнения баланса переведите <strong>₽{amount}</strong>.
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
</p>
|
||||||
Ваш заказ будет обработан вручную после проверки чека.
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
</p>
|
Ваш заказ будет обработан вручную после проверки чека.
|
||||||
|
</p>
|
||||||
{sbpUrl && (
|
</div>
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
|
{/* QR-код для оплаты по СБП */}
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
|
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
|
||||||
<QRCode value={sbpUrl} size={256} />
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||||
</div>
|
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE-QR-LINK'} size={256} />
|
||||||
<p className="mt-4 text-sm text-gray-600">
|
|
||||||
Отсканируйте QR-код через мобильное приложение вашего банка.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="mt-4 text-sm text-gray-600">
|
||||||
|
Отсканируйте QR-код через мобильное приложение вашего банка.
|
||||||
{cardNumber && (
|
</p>
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
|
</div>
|
||||||
<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>
|
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
|
||||||
<button
|
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
|
||||||
onClick={handleCopyCard}
|
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber || '0000 0000 0000 0000'}</p>
|
||||||
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
|
||||||
>
|
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>
|
</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">
|
<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="font-bold text-red-800">Важно:</p>
|
||||||
<p className="text-sm text-red-700">
|
<p className="text-sm text-red-700">
|
||||||
После оплаты сделайте скриншот или сохраните чек и отправьте его нам в тикет поддержки.
|
После оплаты сделайте скриншот или сохраните чек и загрузите его для проверки.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-gray-600">
|
<p className="mt-4 text-gray-600">
|
||||||
После подтверждения ваш баланс будет пополнен. Перейдите в раздел{' '}
|
После подтверждения ваш баланс будет пополнен. Ожидайте проверки чека оператором.
|
||||||
<Link to="/dashboard/tickets" className="text-ospab-primary font-bold hover:underline">
|
|
||||||
Тикеты
|
|
||||||
</Link>
|
|
||||||
, чтобы отправить нам чек.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 (
|
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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ import { useContext } from 'react';
|
|||||||
|
|
||||||
// Импортируем компоненты для вкладок
|
// Импортируем компоненты для вкладок
|
||||||
import Summary from './summary';
|
import Summary from './summary';
|
||||||
import Servers from './servers';
|
import ServerManagementPage from './servermanagement';
|
||||||
import Tickets from './tickets';
|
import TicketsPage from './tickets';
|
||||||
import Billing from './billing';
|
import Billing from './billing';
|
||||||
import Settings from './settings';
|
import Settings from './settings';
|
||||||
import CheckVerification from './checkverification';
|
import CheckVerification from './checkverification';
|
||||||
import TicketResponse from './ticketresponse';
|
import TicketResponse from './ticketresponse';
|
||||||
|
import Checkout from './checkout';
|
||||||
|
import TariffsPage from '../tariffs';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [userData, setUserData] = useState<import('./types').UserData | null>(null);
|
const [userData, setUserData] = useState<import('./types').UserData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { logout } = useContext(AuthContext);
|
const { logout } = useContext(AuthContext);
|
||||||
@@ -44,15 +46,13 @@ const Dashboard = () => {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||||
|
|
||||||
setUserData({
|
setUserData({
|
||||||
user: userRes.data.user,
|
user: userRes.data.user,
|
||||||
balance: 1500,
|
balance: userRes.data.user.balance ?? 0,
|
||||||
servers: [],
|
servers: userRes.data.user.servers ?? [],
|
||||||
tickets: [],
|
tickets: userRes.data.user.tickets ?? [],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка загрузки данных:', err);
|
console.error('Ошибка загрузки данных:', err);
|
||||||
@@ -67,35 +67,48 @@ const Dashboard = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [logout, navigate]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<span className="text-gray-500 text-lg">Загрузка...</span>
|
||||||
<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>
|
</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 (
|
return (
|
||||||
<div className="flex min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
{/* Sidebar - фиксированный слева */}
|
{/* Sidebar - фиксированный слева */}
|
||||||
@@ -103,7 +116,7 @@ const Dashboard = () => {
|
|||||||
{/* Заголовок сайдбара */}
|
{/* Заголовок сайдбара */}
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-gray-200">
|
||||||
<h2 className="text-xl font-bold text-gray-800">
|
<h2 className="text-xl font-bold text-gray-800">
|
||||||
Привет, {userData.user.username}!
|
Привет, {userData?.user?.username || 'Гость'}!
|
||||||
</h2>
|
</h2>
|
||||||
{isOperator && (
|
{isOperator && (
|
||||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full mt-1">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,7 +137,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'summary' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'summary' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">📊</span>
|
|
||||||
Сводка
|
Сводка
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -133,7 +145,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'servers' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'servers' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">🖥️</span>
|
|
||||||
Серверы
|
Серверы
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -142,7 +153,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'tickets' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'tickets' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">🎫</span>
|
|
||||||
Тикеты
|
Тикеты
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -151,7 +161,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'billing' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'billing' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">💳</span>
|
|
||||||
Пополнить баланс
|
Пополнить баланс
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -160,7 +169,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'settings' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'settings' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">⚙️</span>
|
|
||||||
Настройки
|
Настройки
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +185,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'checkverification' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'checkverification' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">✅</span>
|
|
||||||
Проверка чеков
|
Проверка чеков
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -186,7 +193,6 @@ const Dashboard = () => {
|
|||||||
activeTab === 'ticketresponse' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === 'ticketresponse' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-3">💬</span>
|
|
||||||
Ответы на тикеты
|
Ответы на тикеты
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +213,7 @@ const Dashboard = () => {
|
|||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* Хлебные крошки/заголовок */}
|
{/* Хлебные крошки/заголовок */}
|
||||||
<div className="bg-white border-b border-gray-200 px-8 py-4">
|
<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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 capitalize">
|
<h1 className="text-2xl font-bold text-gray-900 capitalize">
|
||||||
{activeTab === 'summary' ? 'Сводка' :
|
{activeTab === 'summary' ? 'Сводка' :
|
||||||
@@ -228,32 +234,22 @@ const Dashboard = () => {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Контент страницы */}
|
{/* Контент страницы */}
|
||||||
<div className="flex-1 p-8">
|
<div className="flex-1 p-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Summary userData={userData} />} />
|
<Route path="/" element={<Summary userData={userData ?? { user: { username: '', operator: 0 }, balance: 0, servers: [], tickets: [] }} />} />
|
||||||
<Route path="servers" element={<Servers servers={userData.servers} />} />
|
<Route path="servers" element={<ServerManagementPage />} />
|
||||||
<Route path="tickets" element={<Tickets tickets={userData.tickets} />} />
|
<Route path="checkout" element={<Checkout onSuccess={() => window.location.reload()} />} />
|
||||||
<Route path="billing" element={<Billing />} />
|
<Route path="tariffs" element={<TariffsPage />} />
|
||||||
|
{userData && (
|
||||||
|
<Route path="tickets" element={<TicketsPage setUserData={setUserData} />} />
|
||||||
|
)}
|
||||||
|
{userData && (
|
||||||
|
<Route path="billing" element={<Billing />} />
|
||||||
|
)}
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
|
|
||||||
{isOperator && (
|
{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 {
|
interface Server {
|
||||||
servers: unknown[];
|
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 (
|
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>
|
<div className="flex items-center justify-between mb-6">
|
||||||
{servers.length === 0 ? (
|
<h2 className="text-3xl font-bold text-gray-800">Серверы</h2>
|
||||||
<p className="text-lg text-gray-500">У вас пока нет активных серверов.</p>
|
{/* Кнопка 'Купить сервер' только если серверов нет */}
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import type { UserData } from './types';
|
import type { UserData, Ticket, Server } from './types';
|
||||||
|
|
||||||
interface SummaryProps {
|
interface SummaryProps {
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Summary = ({ userData }: SummaryProps) => {
|
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 (
|
return (
|
||||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Сводка по аккаунту</h2>
|
<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="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">
|
<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-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>
|
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">Пополнить баланс →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
<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-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>
|
<Link to="/dashboard/servers" className="text-sm text-gray-500 hover:underline mt-2">Управлять →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
<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-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>
|
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">Служба поддержки →</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Ответы на тикеты</h2>
|
<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>
|
</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 {
|
// Глобальный логгер ошибок для axios
|
||||||
tickets: unknown[];
|
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 (
|
return (
|
||||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Тикеты поддержки</h2>
|
<h2 className="text-3xl font-bold text-gray-800 mb-6">Мои тикеты</h2>
|
||||||
{tickets.length === 0 ? (
|
<form onSubmit={createTicket} className="mb-8 max-w-xl bg-gray-50 rounded-2xl shadow p-6 flex flex-col gap-4">
|
||||||
<p className="text-lg text-gray-500">У вас пока нет открытых тикетов.</p>
|
<label className="font-semibold text-lg">Тема тикета</label>
|
||||||
) : (
|
<input
|
||||||
<p className="text-lg text-gray-500">Список ваших тикетов будет здесь...</p>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tickets;
|
export default TicketsPage;
|
||||||
|
|||||||
@@ -3,9 +3,26 @@ export interface User {
|
|||||||
operator: number;
|
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 {
|
export interface UserData {
|
||||||
user: User;
|
user: User;
|
||||||
balance: number;
|
balance: number;
|
||||||
servers: unknown[];
|
servers: Server[];
|
||||||
tickets: unknown[];
|
tickets: Ticket[];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import useAuth from '../context/useAuth';
|
import useAuth from '../context/useAuth';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ const LoginPage = () => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
@@ -22,9 +23,13 @@ const LoginPage = () => {
|
|||||||
password: password,
|
password: password,
|
||||||
});
|
});
|
||||||
|
|
||||||
login(response.data.token);
|
localStorage.setItem('token', response.data.token);
|
||||||
// ИСПРАВЛЕНО: правильный путь к дашборду
|
login(response.data.token);
|
||||||
navigate('/dashboard');
|
// Возврат на исходную страницу, если был редирект
|
||||||
|
type LocationState = { from?: { pathname?: string } };
|
||||||
|
const state = location.state as LocationState | null;
|
||||||
|
const from = state?.from?.pathname || '/dashboard';
|
||||||
|
navigate(from);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
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 TariffsPage = () => {
|
||||||
const [cpu, setCpu] = useState(1);
|
const [tariffs, setTariffs] = useState<Array<{id:number;name:string;price:number;description?:string}>>([]);
|
||||||
const [ram, setRam] = useState(1);
|
const [loading, setLoading] = useState(true);
|
||||||
const [storage, setStorage] = useState(50);
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isLoggedIn } = useContext(AuthContext);
|
||||||
|
|
||||||
const cpuPrice = 500;
|
useEffect(() => {
|
||||||
const ramPrice = 300;
|
const fetchTariffs = async () => {
|
||||||
const storagePrice = 5;
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const total = cpu * cpuPrice + ram * ramPrice + storage * storagePrice;
|
const handleBuy = (tariffId: number) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/dashboard/checkout?tariff=${tariffId}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-20">
|
<div className="min-h-screen bg-gray-50 py-20">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-center mb-16 text-gray-900">Выберите подходящий тариф</h1>
|
<h1 className="text-4xl md:text-5xl font-bold text-center mb-16 text-gray-900">Тарифы</h1>
|
||||||
|
{loading ? (
|
||||||
{/* Basic Tariffs Section */}
|
<p className="text-lg text-gray-500 text-center">Загрузка...</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
|
) : error ? (
|
||||||
{/* Tariff Card 1 */}
|
<p className="text-lg text-red-500 text-center">{error}</p>
|
||||||
<div className="bg-white p-8 rounded-2xl shadow-xl text-center transition-transform hover:scale-105 duration-300">
|
) : tariffs.length === 0 ? (
|
||||||
<h2 className="text-3xl font-bold text-gray-800">Базовый</h2>
|
<p className="text-lg text-gray-500 text-center">Нет доступных тарифов.</p>
|
||||||
<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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
|
||||||
<li>1 ядро CPU</li>
|
{tariffs.map(tariff => (
|
||||||
<li>2 ГБ RAM</li>
|
<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]">
|
||||||
<li>100 ГБ SSD</li>
|
<div>
|
||||||
<li>Неограниченный трафик</li>
|
<h2 className="text-4xl font-bold text-gray-800 mb-4">{tariff.name}</h2>
|
||||||
</ul>
|
<p className="mb-4 text-5xl font-extrabold text-ospab-primary">₽{tariff.price}<span className="text-xl font-normal text-gray-500">/мес</span></p>
|
||||||
<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">
|
{tariff.description && (
|
||||||
Выбрать
|
<ul className="text-lg text-gray-700 mb-6 list-disc list-inside">
|
||||||
</button>
|
{tariff.description.split(',').map((desc, i) => (
|
||||||
</div>
|
<li key={i}>{desc.trim()}</li>
|
||||||
|
))}
|
||||||
{/* Tariff Card 2 */}
|
</ul>
|
||||||
<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>
|
</div>
|
||||||
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">₽4000<span className="text-lg font-normal text-gray-500">/мес</span></p>
|
<button
|
||||||
<ul className="mt-4 text-gray-600 space-y-2">
|
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"
|
||||||
<li>4 ядра CPU</li>
|
onClick={() => handleBuy(tariff.id)}
|
||||||
<li>8 ГБ RAM</li>
|
>
|
||||||
<li>250 ГБ SSD</li>
|
Купить
|
||||||
<li>Приоритетная поддержка</li>
|
</button>
|
||||||
</ul>
|
</div>
|
||||||
<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">
|
))}
|
||||||
Выбрать
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TariffsPage;
|
export default TariffsPage;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||