最新修改,初版

This commit is contained in:
2026-02-25 09:38:25 +08:00
parent 1f5bfba3b3
commit 0378198f55
15 changed files with 1691 additions and 824 deletions

314
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"axios": "^1.13.2",
"element-plus": "^2.11.8",
"vue": "^3.5.22",
"vue-router": "^5.0.2"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -83,6 +83,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz",
"integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
@@ -966,6 +967,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -976,6 +978,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -986,6 +989,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1001,6 +1005,7 @@
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1385,33 +1390,6 @@
"vue": "^3.2.25"
}
},
"node_modules/@vue-macros/common": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz",
"integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
"license": "MIT",
"dependencies": {
"@vue/compiler-sfc": "^3.5.22",
"ast-kit": "^2.1.2",
"local-pkg": "^1.1.2",
"magic-string-ast": "^1.0.2",
"unplugin-utils": "^0.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/vue-macros"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/@vue/babel-helper-vue-transform-on": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
@@ -1516,13 +1494,10 @@
}
},
"node_modules/@vue/devtools-api": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.5.tgz",
"integrity": "sha512-DgVcW8H/Nral7LgZEecYFFYXnAvGuN9C3L3DtWekAncFBedBczpNW8iHKExfaM559Zm8wQWrwtYZ9lXthEHtDw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.0.5"
}
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/devtools-core": {
"version": "8.0.5",
@@ -1565,6 +1540,7 @@
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.5.tgz",
"integrity": "sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^8.0.5",
@@ -1580,6 +1556,7 @@
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.5.tgz",
"integrity": "sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
@@ -1723,18 +1700,6 @@
}
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
@@ -1745,38 +1710,6 @@
"node": ">=14"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
"integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-walker-scope": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
"integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"ast-kit": "^2.1.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1814,6 +1747,7 @@
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz",
"integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -1903,21 +1837,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1930,12 +1849,6 @@
"node": ">= 0.8"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1947,6 +1860,7 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
@@ -2211,16 +2125,11 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2396,6 +2305,7 @@
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-docker": {
@@ -2437,6 +2347,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -2472,6 +2383,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -2484,6 +2396,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -2499,23 +2412,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -2558,21 +2454,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magic-string-ast": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.19"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2613,37 +2494,9 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.1"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -2661,12 +2514,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2728,12 +2575,14 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz",
"integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
@@ -2746,6 +2595,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2754,17 +2604,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -2799,39 +2638,11 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rollup": {
@@ -2889,12 +2700,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2933,6 +2738,7 @@
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2942,6 +2748,7 @@
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
"integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
@@ -2954,6 +2761,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2976,30 +2784,11 @@
"node": ">=6"
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/unplugin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"dev": true,
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
@@ -3244,56 +3033,20 @@
}
},
"node_modules/vue-router": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
"integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==",
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.28.6",
"@vue-macros/common": "^3.1.1",
"@vue/devtools-api": "^8.0.0",
"ast-walker-scope": "^0.8.3",
"chokidar": "^5.0.0",
"json5": "^2.2.3",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"muggle-string": "^0.4.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"scule": "^1.3.0",
"tinyglobby": "^0.2.15",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1",
"yaml": "^2.8.2"
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@pinia/colada": ">=0.21.2",
"@vue/compiler-sfc": "^3.5.17",
"pinia": "^3.0.4",
"vue": "^3.5.0"
},
"peerDependenciesMeta": {
"@pinia/colada": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
@@ -3321,7 +3074,10 @@
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View File

@@ -16,7 +16,7 @@
"axios": "^1.13.2",
"element-plus": "^2.11.8",
"vue": "^3.5.22",
"vue-router": "^5.0.2"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",

View File

@@ -42,5 +42,29 @@ export const ticketApi = {
// 获取运营人员列表
getOperators() {
return request.get('/operators')
},
// 获取员工列表
getStaffs(params){
return request.get('/getStaffList', { params })
}
}
export const customerApi = {
getCustomers(){
return request.get('/customers')
},
// 添加客户,data 结构应为 { company_name: 'xxx' }
addCustomerName(data){
return request.post('/addcustomername',data)
},
addEmployee(data) {
return request.post('/addemployee', data) // 对应后端新增员工
},
deleteEmployee(id) {
return request.delete(`/employee/${id}`) // 对应后端删除员工
}
}

View File

@@ -9,24 +9,53 @@
<el-radio-button :label="0">未分配</el-radio-button>
<el-radio-button :label="1">进行中</el-radio-button>
<el-radio-button :label="2">已完成</el-radio-button>
<el-radio-button :label="3">已完结</el-radio-button>
</el-radio-group>
</el-form-item>
<div class="input-group">
<el-form-item label="相关人员">
<el-select v-model="localFilters.operator_name" placeholder="运营人员" clearable class="w-150">
<el-option v-for="op in operators" :key="op" :label="op" :value="op" />
<el-form-item label="运营人员">
<el-select
v-model="localFilters.operator_name"
placeholder="全部运营人员"
clearable
filterable
class="w-150"
@change="handleSearch"
>
<el-option
v-for="op in operators"
:key="op.id"
:label="`${op.name} (${op.company})`"
:value="op.name"
/>
</el-select>
<span class="divider">/</span>
<el-select v-model="localFilters.principalpeople" placeholder="负责人" clearable class="w-150">
<el-option v-for="op in operators" :key="op" :label="op" :value="op" />
</el-form-item>
<span class="divider">/</span>
<el-form-item label="我方负责人">
<el-select
v-model="localFilters.staff_id"
placeholder="全部负责人"
clearable
filterable
class="w-150"
@change="handleSearch"
>
<el-option
v-for="staff in staffs"
:key="staff.id"
:label="staff.name"
:value="staff.id"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="localFilters.search"
placeholder="搜索网站名或备注..."
placeholder="搜索网站、运营、备注..."
clearable
@keyup.enter="handleSearch"
class="search-input"
@@ -43,14 +72,14 @@
<el-button type="primary" :icon="Search" @click="handleSearch" class="search-btn">
执行筛选
</el-button>
<el-button @click="$emit('reset')" plain>重置</el-button>
<el-button @click="$emit('reset')" plain>重置条件</el-button>
<el-dropdown trigger="click" class="more-filter">
<el-button icon="Sort">排序</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="localFilters.sort_order = 'desc'">时间降序</el-dropdown-item>
<el-dropdown-item @click="localFilters.sort_order = 'asc'">时间升序</el-dropdown-item>
<el-dropdown-item @click="setSort('desc')">创建时间 (新→旧)</el-dropdown-item>
<el-dropdown-item @click="setSort('asc')">创建时间 (旧→新)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -59,103 +88,94 @@
</el-form>
</el-card>
</template>
<script setup>
import { reactive, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
const props = defineProps({
filters: {
type: Object,
required: true
},
operators: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['filter-change', 'reset'])
const localFilters = reactive({ ...props.filters })
watch(
() => props.filters,
(newFilters) => {
Object.assign(localFilters, newFilters)
},
{ deep: true }
)
const handleSearch = () => {
emit('filter-change', { ...localFilters })
}
</script>
<style scoped>
.filter-card {
border: none;
border-radius: 12px;
background: linear-gradient(to right, #ffffff, #f9fafc);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.filter-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(149, 157, 165, 0.2);
}
<script setup>
import { reactive, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
.search-container {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 20px;
const props = defineProps({
filters: {
type: Object,
required: true
},
// 传入的是所有客户员工的名字数组 [ "张三", "李四", ... ]
operators: {
type: Array,
default: () => []
},
// 传入的是我方员工对象数组 [ {id: 1, name: "管理员"}, ... ]
staffs: {
type: Array,
default: () => []
}
})
.filter-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
const emit = defineEmits(['filter-change', 'reset'])
.input-group {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
// 本地副本
const localFilters = reactive({ ...props.filters })
.w-150 { width: 150px; }
.search-input { width: 280px; }
// 深度监听父组件 filters,用于同步重置状态
watch(
() => props.filters,
(newFilters) => {
Object.assign(localFilters, newFilters)
},
{ deep: true }
)
.divider {
margin: 0 8px;
color: #dcdfe6;
}
const handleSearch = () => {
emit('filter-change', { ...localFilters })
}
.filter-actions {
display: flex;
gap: 10px;
padding-top: 4px;
}
const setSort = (order) => {
localFilters.sort_order = order
handleSearch()
}
</script>
.search-btn {
padding: 12px 24px;
font-weight: bold;
letter-spacing: 1px;
transition: all 0.2s;
}
<style scoped>
/* 保持你的原样样式,仅微调间距 */
.filter-card {
border: none;
border-radius: 12px;
background: linear-gradient(to right, #ffffff, #f9fafc);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.filter-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(149, 157, 165, 0.2);
}
.search-container {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 20px;
}
.filter-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.w-150 { width: 160px; }
.search-input { width: 280px; }
.divider { margin: 0 4px; color: #dcdfe6; }
.filter-actions { display: flex; gap: 10px; padding-top: 4px; }
.search-btn { padding: 12px 24px; font-weight: bold; letter-spacing: 1px; }
.search-btn:hover {
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
}
/* 移动端适配 */
@media (max-width: 768px) {
.search-container { flex-direction: column; }
.search-input { width: 100%; }
}
</style>
@media (max-width: 1024px) {
.search-input { width: 200px; }
}
@media (max-width: 768px) {
.search-container { flex-direction: column; }
.search-input { width: 100%; }
}
</style>

View File

@@ -2,14 +2,13 @@
<el-dialog v-model="dialogVisible" :title="isEditing ? '📝 编辑工单任务' : '✨ 创建新工单'" width="550px" class="modern-dialog"
destroy-on-close @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="compact-form">
<div class="form-section">
<div class="section-title">业务基础信息</div>
<el-form-item label="网站地址" prop="website">
<el-input v-model="form.website" placeholder="" clearable>
<el-input v-model="form.website" placeholder="请输入网站 URL" clearable>
<template #prefix>
<el-icon>
<Link />
</el-icon>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
@@ -18,28 +17,53 @@
<div class="form-section people-grid">
<div class="section-title column-full">权责分配</div>
<el-form-item label="运营人员 (外方)" prop="operator" class="client-item">
<el-select v-model="form.operator" placeholder="请选择" style="width: 100%">
<el-option v-for="op in operators" :key="op" :label="op" :value="op" />
<el-form-item label="客户单位 (外方)" prop="customer_id" class="client-item">
<el-select v-model="current_customer_id" placeholder="请选择单位" style="width: 100%" filterable @change="handleCustomerChange">
<el-option
v-for="item in customers"
:key="item.customer_id"
:label="item.company_name"
:value="item.customer_id"
/>
</el-select>
</el-form-item>
<el-form-item label="负责人 (我方)" prop="principalpeople" class="owner-item">
<el-input v-model="form.principalpeople" placeholder="请输入姓名">
<template #prefix>
<el-icon>
<UserFilled />
</el-icon>
</template>
</el-input>
<el-form-item label="运营人员 (外方)" prop="operator" class="client-item">
<el-select
v-model="form.operator"
placeholder="请选择人员"
style="width: 100%"
filterable
:disabled="!current_customer_id"
>
<el-option
v-for="item in filteredEmployees"
:key="item.employee_id"
:label="item.employee_name"
:value="item.employee_name"
/>
</el-select>
</el-form-item>
<el-form-item label="负责人 (我方)" prop="staff_id" class="owner-item column-full">
<el-select v-model="form.staff_id" placeholder="请选择我方负责人" style="width: 100%" filterable>
<el-option
v-for="item in staffs"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<div class="form-section">
<el-form-item label="当前进度">
<el-radio-group v-model="form.is_complete" class="modern-radio-group">
<el-radio-button :label="0">进行中</el-radio-button>
<el-radio-button :label="1">已完成</el-radio-button>
<el-radio-button label="未分配">未分配</el-radio-button>
<el-radio-button label="进行中">进行中</el-radio-button>
<el-radio-button label="已完成">已完成</el-radio-button>
<el-radio-button label="已完结">已完结</el-radio-button>
</el-radio-group>
</el-form-item>
@@ -61,36 +85,51 @@
</template>
<script setup>
import { ref, reactive, watch, nextTick } from 'vue'
import { Link, UserFilled } from '@element-plus/icons-vue'
import { ref, reactive, watch, computed, nextTick } from 'vue'
import { Link } from '@element-plus/icons-vue'
const emit = defineEmits(['update:modelValue', 'submit'])
const dialogVisible = ref(false)
const formRef = ref()
const submitting = ref(false)
const props = defineProps({
modelValue: Boolean,
formData: Object,
operators: Array,
customers: Array, // 外部客户单位列表
customerEmployees: Array, // 外部客户员工列表
staffs: Array, // 我方员工列表
isEditing: Boolean
})
const dialogVisible = ref(false)
const formRef = ref()
const submitting = ref(false)
const current_customer_id = ref(null) // 级联用的中间变量
const form = reactive({
website: '',
operator: '',
principalpeople: '',
is_complete: 0,
operator: '',
staff_id: null,
is_complete: "未分配",
remark: ''
})
const rules = {
website: [{ required: true, message: '请输入网站地址', trigger: 'blur' }],
operator: [{ required: true, message: '请选择运营人员', trigger: 'change' }],
principalpeople: [{ required: true, message: '请输入负责人', trigger: 'blur' }]
staff_id: [{ required: true, message: '请选择负责人', trigger: 'change' }]
}
// 监听显隐
// 核心逻辑:级联过滤
const filteredEmployees = computed(() => {
if (!current_customer_id.value) return []
return props.customerEmployees.filter(emp => emp.customer_id === current_customer_id.value)
})
// 单位切换时清空人员
const handleCustomerChange = () => {
form.operator = ''
}
// 监听弹窗显隐与数据回显
watch(() => props.modelValue, (val) => {
dialogVisible.value = val
if (val) {
@@ -98,6 +137,11 @@ watch(() => props.modelValue, (val) => {
resetForm()
if (props.isEditing && props.formData) {
Object.assign(form, props.formData)
// 回显逻辑:根据 operator 姓名反查所属 customer_id
const employee = props.customerEmployees.find(e => e.employee_name === props.formData.operator)
if (employee) {
current_customer_id.value = employee.customer_id
}
}
})
}
@@ -107,7 +151,14 @@ watch(() => dialogVisible.value, (val) => emit('update:modelValue', val))
const resetForm = () => {
if (formRef.value) formRef.value.resetFields()
Object.assign(form, { website: '', operator: '', principalpeople: '', is_complete: 0, remark: '' })
current_customer_id.value = null
Object.assign(form, {
website: '',
operator: '',
staff_id: null,
is_complete: "未分配",
remark: ''
})
}
const handleClose = () => resetForm()
@@ -119,7 +170,7 @@ const handleSubmit = async () => {
submitting.value = true
emit('submit', { ...form })
} catch (e) {
// 验证失败
console.log('表单校验失败')
} finally {
submitting.value = false
}
@@ -127,79 +178,17 @@ const handleSubmit = async () => {
</script>
<style scoped>
/* 深度修改对话框圆角 */
:deep(.modern-dialog) {
border-radius: 12px;
overflow: hidden;
}
.form-section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
display: flex;
align-items: center;
}
.section-title::before {
content: "";
width: 4px;
height: 14px;
background: #409eff;
margin-right: 8px;
border-radius: 2px;
}
/* 两列布局 */
.people-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.column-full {
grid-column: span 2;
}
/* 人员项区分颜色提示 */
:deep(.client-item .el-form-item__label) {
color: #909399 !important;
}
:deep(.owner-item .el-form-item__label) {
color: #409eff !important;
}
/* 现代单选框组 */
.modern-radio-group {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
/* 底部按钮 */
.dialog-footer {
padding-top: 10px;
}
.submit-btn {
padding-left: 24px;
padding-right: 24px;
font-weight: 600;
}
/* 调整输入框间距 */
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-form-item__label) {
padding-bottom: 4px !important;
font-size: 13px;
}
:deep(.modern-dialog) { border-radius: 12px; overflow: hidden; }
.form-section { margin-bottom: 24px; }
.section-title { font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 12px; display: flex; align-items: center; }
.section-title::before { content: ""; width: 4px; height: 14px; background: #409eff; margin-right: 8px; border-radius: 2px; }
.people-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.column-full { grid-column: span 2; }
:deep(.client-item .el-form-item__label) { color: #909399 !important; }
:deep(.owner-item .el-form-item__label) { color: #409eff !important; }
.modern-radio-group { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); border-radius: 8px; overflow: hidden; }
.dialog-footer { padding-top: 10px; }
.submit-btn { padding-left: 24px; padding-right: 24px; font-weight: 600; }
:deep(.el-form-item) { margin-bottom: 18px; }
:deep(.el-form-item__label) { padding-bottom: 4px !important; font-size: 13px; }
</style>

View File

@@ -1,6 +1,7 @@
<template>
<el-card class="compact-card" shadow="never">
<el-table :data="tickets" v-loading="loading" header-cell-class-name="table-header" style="width: 100%">
<el-table-column prop="website" label="网站地址" min-width="180">
<template #default="{ row }">
<el-link type="primary" :href="row.website" target="_blank" class="site-link">
@@ -9,22 +10,55 @@
</template>
</el-table-column>
<el-table-column label="协同人员" width="240">
<el-table-column label="数据量" width="100">
<template #default="{ row }">
<span v-if="row.datanum && row.datanum !== '0'" class="data-num-text">
{{ row.datanum }}
</span>
<span v-else class="empty-text">未录入</span>
</template>
</el-table-column>
<el-table-column label="任务进度" width="140">
<template #default="{ row }">
<div class="progress-container">
<el-progress
:percentage="Number(row.progress || 0)"
:status="Number(row.progress) === 100 ? 'success' : ''"
:stroke-width="6"
:text-inside="false"
/>
</div>
</template>
</el-table-column>
<el-table-column label="协同人员" width="180">
<template #default="{ row }">
<div class="staff-wrapper">
<div class="staff-item client">
<span class="staff-role">运营</span>
<span class="staff-name">{{ row.operator }}</span>
<span class="staff-name">{{ row.operator?.trim() || '未指派' }}</span>
</div>
<div class="staff-item internal">
<span class="staff-role">负责</span>
<span class="staff-name">{{ row.principalpeople }}</span>
<span class="staff-name">
{{ row.principalStaff?.name || '未指派' }}
</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="is_complete" label="状态" width="100" align="center">
<el-table-column label="备注" min-width="150">
<template #default="{ row }">
<el-tooltip v-if="row.remark" effect="dark" :content="row.remark" placement="top">
<span class="remark-text">{{ row.remark }}</span>
</el-tooltip>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column prop="is_complete" label="状态" width="90" align="center">
<template #default="{ row }">
<span class="status-badge" :style="getStatusStyle(row.is_complete)">
{{ row.is_complete }}
@@ -32,7 +66,24 @@
</template>
</el-table-column>
<el-table-column label="时间节点" width="200">
<el-table-column label="锁定状态" width="90" align="center">
<template #default="{ row }">
<el-tag
v-if="row.lock_time && row.lock_time !== '0'"
type="success"
size="small"
effect="light"
>已锁定</el-tag>
<el-tag
v-else
type="info"
size="small"
effect="plain"
>未锁定</el-tag>
</template>
</el-table-column>
<el-table-column label="时间节点" width="160">
<template #default="{ row }">
<div class="time-wrapper">
<span>创建:{{ formatTime(row.create_time) }}</span>
@@ -41,13 +92,22 @@
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="right">
<el-table-column label="操作" width="120" fixed="right" align="right">
<template #default="{ row }">
<el-button link type="primary" @click="$emit('edit', row)">编辑</el-button>
<el-divider direction="vertical" />
<el-button link type="danger" @click="$emit('delete', row.id)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading">
<el-empty description="暂无相关工单数据" :image-size="120" />
</div>
<div v-else class="loading-placeholder">
<p>正在努力加载中...</p>
</div>
</template>
</el-table>
</el-card>
</template>
@@ -62,8 +122,14 @@ defineEmits(['edit', 'delete'])
const formatTime = (time) => {
if (!time) return '-';
if (typeof time === 'string' && time.includes('-')) return time;
const date = new Date(time);
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
const m = (date.getMonth() + 1).toString().padStart(2, '0');
const d = date.getDate().toString().padStart(2, '0');
const h = date.getHours().toString().padStart(2, '0');
const i = date.getMinutes().toString().padStart(2, '0');
return `${m}-${d} ${h}:${i}`;
}
const getStatusStyle = (status) => {
@@ -78,127 +144,76 @@ const getStatusStyle = (status) => {
</script>
<style scoped>
.progress-container {
padding-right: 10px;
}
/* 调整进度条内部文字大小 */
:deep(.el-progress__text) {
font-size: 11px !important;
min-width: 28px;
}
.staff-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
}
.staff-item {
display: flex;
align-items: center;
height: 24px;
font-size: 13px;
border-radius: 4px;
overflow: hidden;
border: 1px solid transparent;
}
.staff-role {
width: 40px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
}
.staff-name {
padding-left: 8px;
font-weight: 500;
}
/* 外方样式:低调工业风 */
.staff-item.client {
background: #f4f4f5;
border-color: #e9e9eb;
color: #909399;
}
.staff-item.client .staff-role {
background: #909399;
color: #fff;
}
/* 我方样式:专业商务蓝 */
.staff-item.internal {
background: #ecf5ff;
border-color: #d9ecff;
color: #409eff;
}
.staff-item.internal .staff-role {
background: #409eff;
color: #fff;
}
.compact-card {
border: 1px solid #ebeef5;
border-radius: 4px;
}
/* 链接样式优化 */
.site-link {
font-weight: normal;
font-size: 13px;
}
/* 人员标签化区分 */
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.role-tag {
.staff-item {
display: flex;
align-items: center;
height: 22px;
font-size: 12px;
padding: 2px 8px;
border-radius: 2px;
width: fit-content;
border-radius: 4px;
overflow: hidden;
}
.role-tag.client {
background: #f0f2f5;
color: #666;
border-left: 3px solid #dcdfe6;
.staff-role {
width: 34px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
background: #909399;
color: #fff;
}
.role-tag.owner {
background: #e8f4ff;
color: #1890ff;
border-left: 3px solid #1890ff;
.staff-name {
padding-left: 6px;
color: inherit;
}
.staff-item.client { background: #f4f4f5; color: #909399; border: 1px solid #e9e9eb; }
.staff-item.internal { background: #ecf5ff; color: #409eff; border: 1px solid #d9ecff; }
.staff-item.internal .staff-role { background: #409eff; }
/* 状态小红点风格 */
.data-num-text {
font-family: 'Monaco', monospace;
font-weight: bold;
color: #409eff;
}
.remark-text {
font-size: 12px;
color: #606266;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.empty-text { color: #c0c4cc; font-size: 12px; }
.compact-card { border-radius: 8px; border: 1px solid #ebeef5; }
.status-badge {
display: inline-block;
padding: 0 10px;
height: 24px;
line-height: 24px;
font-size: 12px;
border-radius: 12px;
font-weight: 500;
padding: 0 8px;
height: 22px;
line-height: 22px;
font-size: 11px;
border-radius: 11px;
}
/* 时间列紧凑化 */
.time-wrapper {
display: flex;
flex-direction: column;
font-size: 12px;
font-size: 11px;
color: #606266;
}
.update-label {
color: #909399;
}
/* 表头加深,增加稳重感 */
:deep(.table-header) {
background-color: #fafafa !important;
color: #333;
font-weight: 600;
}
.update-label { color: #909399; }
:deep(.table-header) { background-color: #fafafa !important; font-weight: 600; }
</style>

View File

@@ -19,12 +19,16 @@
<el-icon><icon-menu /></el-icon>
<span>工单管理</span>
</el-menu-item>
<el-menu-item index="/stats">
<el-icon>
<document />
</el-icon>
<el-icon><document /></el-icon>
<span>数据统计</span>
</el-menu-item>
<el-menu-item index="/customers">
<el-icon><User /></el-icon>
<span>客户管理</span>
</el-menu-item>
</el-menu>
</el-aside>
@@ -207,7 +211,12 @@
<script setup>
import { reactive, onMounted } from 'vue'
import { Menu as IconMenu, Document } from '@element-plus/icons-vue' // 补充图标引入
import {
Menu as IconMenu,
Document,
User
} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'

View File

@@ -1,17 +1,25 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // 1. 导入我们创建的 router
import router from './router'
// 导入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 导入中文语言包
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router) // 2. 在这里使用 router
// 在这里配置全局中文
app.use(ElementPlus, {
locale: zhCn,
})
app.use(router)
app.mount('#app')

View File

@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus' // 引入提示组件
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -8,52 +9,97 @@ const router = createRouter({
component: () => import('../layouts/MainLayout.vue'),
children: [
{
path: '', // 默认访问 / 显示工单管理
path: '',
name: 'home',
component: () => import('../views/TicketManagement.vue'),
meta: { requiresAuth: true }
// 标记需要 admin 权限
meta: { requiresAuth: true, role: 'admin' }
},
{
path: 'stats', // 访问 /stats
path: 'stats',
name: 'stats',
component: () => import('../views/StatsView.vue'),
meta: { requiresAuth: true }
}
meta: { requiresAuth: true, role: 'admin' }
},
{
path: 'customers',
name: 'customers',
component: () => import('../views/CustomersView.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
]
},
{
path: '/user',
name: 'user_workdesk',
component: () => import('../views/UserView.vue'),
// 标记只需要普通用户权限
meta: { requiresAuth: true, role: 'user' }
},
{
path: '/client',
name: 'client_monitor',
component: () => import('../views/ClientView.vue'),
// 标记只需要普通用户权限
meta: { requiresAuth: true, role: 'client' }
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue') // 懒加载登录组件,
component: () => import('../views/LoginView.vue')
},
]
})
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 1. 尝试从 localStorage 或 sessionStorage 获取 Token
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
// 获取用户信息以判断角色
const userInfoStr = localStorage.getItem('user_info') || sessionStorage.getItem('user_info');
const userInfo = userInfoStr ? JSON.parse(userInfoStr) : null;
// 简单的角色判定逻辑:如果用户名为 admin 则视为管理员,否则为普通用户
// 你也可以根据后端返回的具体的 role 字段来判断
const userRole = userInfo?.username === 'admin' ? 'admin' : 'user';
// 2. 判断目标路由是否需要登录权限
// matches 可以检查嵌套路由中父级或子级是否有 requiresAuth
const isRequiresAuth = to.matched.some(record => record.meta.requiresAuth);
// 1. 未登录拦截
if (isRequiresAuth && !token) {
// 3. 如果需要登录但没有 Token,强制拦截并跳转到登录页
// 顺便把当前想去的页面路径传给登录页,方便登录后跳回来(可选增强逻辑)
next({
name: 'login',
query: { redirect: to.fullPath }
});
} else if (to.name === 'login' && token) {
// 4. 如果用户已经登录了,还想手动输入 /login 去登录页,直接把他弹回首页
next({ name: 'home' });
} else {
// 5. 否则直接放行
next();
next({ name: 'login', query: { redirect: to.fullPath } });
return;
}
// 2. 已登录状态下的逻辑
if (token) {
// 如果想去登录页,根据角色重定向到对应首页
if (to.name === 'login') {
userRole === 'admin' ? next({ name: 'home' }) : next({ name: 'user_workdesk' });
return;
}
// --- 核心修复:权限校验 ---
// 检查目标路由是否定义了需要的角色
const requiredRole = to.meta.role;
if (requiredRole && requiredRole !== userRole) {
// 如果是员工想进管理员页面
if (userRole === 'user' && requiredRole === 'admin') {
ElMessage.error('权限不足,无法访问管理后台');
next({ name: 'user_workdesk' }); // 踢回员工工作台
return;
}
// 如果是管理员想进员工页面 (通常允许,如果不允许也可以踢回 /)
if (userRole === 'admin' && requiredRole === 'user') {
next({ name: 'home' });
return;
}
}
}
// 3. 正常放行
next();
});
export default router

293
src/views/ClientView.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<div class="client-view-container">
<header class="client-header">
<div class="brand">
<div class="logo-box">
<el-icon><DataLine /></el-icon>
</div>
<div class="brand-text">
<span class="title">数据交付监控台</span>
<span class="version">Real-time Monitor v2.0</span>
</div>
</div>
<div class="user-info">
<el-space :size="20">
<div class="user-badge">
<el-icon><User /></el-icon>
<span>{{ currentUser.username }}</span>
</div>
<el-button type="danger" plain size="small" @click="handleLogout" :icon="SwitchButton">退出</el-button>
</el-space>
</div>
</header>
<section class="client-stats">
<el-row :gutter="20">
<el-col :span="6" v-for="stat in summaryStats" :key="stat.label">
<div class="stat-card-premium" :style="{ '--theme-color': stat.color }">
<div class="stat-info">
<span class="s-label">{{ stat.label }}</span>
<span class="s-value">{{ stat.value }}</span>
</div>
<div class="stat-icon-wrapper">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
</div>
</el-col>
</el-row>
</section>
<main class="data-section">
<div class="section-header">
<div class="header-left">
<span class="indicator"></span>
<span class="section-title">实时交付看板</span>
</div>
<div class="header-right">
<el-button type="primary" link @click="loadData" :loading="loading">
<el-icon><Refresh /></el-icon>&nbsp;同步最新数据
</el-button>
</div>
</div>
<el-table :data="paginatedTickets" v-loading="loading" class="custom-client-table">
<el-table-column prop="id" label="单号" width="90" align="center">
<template #default="{row}">
<span class="id-tag">#{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="website" label="网站地址" min-width="260">
<template #default="{row}">
<div class="project-cell">
<el-icon class="project-icon"><Monitor /></el-icon>
<span class="project-name" :title="row.website">{{ row.website }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="交付进度" width="260">
<template #default="scope">
<div class="progress-box">
<div class="progress-labels">
<span class="percent-val">{{ scope.row.progress || 0 }}%</span>
</div>
<el-progress
:percentage="Number(scope.row.progress || 0)"
:status="Number(scope.row.progress) === 100 ? 'success' : ''"
:stroke-width="10"
:show-text="false"
striped
striped-flow
/>
</div>
</template>
</el-table-column>
<el-table-column prop="datanum" label="总数据量" width="160" align="center">
<template #default="scope">
<div class="data-val-box">
<span class="data-num">{{ formatNumber(scope.row.datanum) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="交付状态" width="120" align="center">
<template #default="scope">
<div :class="['status-dot-box', getStatusClass(scope.row.is_complete)]">
<span class="dot"></span>
<span class="text">{{ scope.row.is_complete }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="更新日期" width="140" align="center">
<template #default="scope">
<span class="date-text">{{ formatDateOnly(scope.row.lock_time || scope.row.update_time || scope.row.create_time) }}</span>
</template>
</el-table-column>
</el-table>
<footer class="pagination-footer">
<el-pagination
v-model:current-page="page"
v-model:page-size="limit"
:total="total"
layout="total, prev, pager, next"
background
@current-change="handlePageChange"
/>
</footer>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { DataLine, SwitchButton, User, Refresh, Files, Timer, CircleCheck, Monitor } from '@element-plus/icons-vue'
import { ticketApi } from '@/api/ticket'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const tickets = ref([])
const page = ref(1)
const limit = ref(15)
const currentUser = computed(() => {
const info = localStorage.getItem('user_info') || sessionStorage.getItem('user_info')
return info ? JSON.parse(info) : { username: '外部客户' }
})
const filteredTickets = computed(() => {
const allowed = ['未分配', '进行中', '已完成']
return tickets.value.filter(t => allowed.includes(t.is_complete))
})
const paginatedTickets = computed(() => {
const start = (page.value - 1) * limit.value
return filteredTickets.value.slice(start, start + limit.value)
})
const total = computed(() => filteredTickets.value.length)
const summaryStats = computed(() => [
{ label: '项目总数', value: total.value, color: '#409EFF', icon: Monitor },
{ label: '等待排期', value: filteredTickets.value.filter(t => t.is_complete === '未分配').length, color: '#A8ABB2', icon: Files },
{ label: '正在采集', value: filteredTickets.value.filter(t => t.is_complete === '进行中').length, color: '#E6A23C', icon: Timer },
{ label: '已就绪', value: filteredTickets.value.filter(t => t.is_complete === '已完成').length, color: '#67C23A', icon: CircleCheck }
])
const formatNumber = (val) => {
if (!val || val === '0') return '0';
const num = Number(val);
return isNaN(num) ? val : num.toLocaleString();
}
const getStatusClass = (status) => {
const map = { '未分配': 'is-waiting', '进行中': 'is-running', '已完成': 'is-success' }
return map[status] || ''
}
const loadData = async () => {
loading.value = true
try {
const res = await ticketApi.getTickets({ page: 1, limit: 1000 })
if (res.code === 200 || res.code === true) {
tickets.value = res.data.data
}
} catch (e) {
ElMessage.error('同步失败')
} finally {
loading.value = false
}
}
const formatDateOnly = (ts) => {
if (!ts || ts === '0' || ts === '') return '-'
const date = !isNaN(ts) ? new Date(String(ts).length === 10 ? ts * 1000 : parseInt(ts)) : new Date(ts)
return isNaN(date.getTime()) ? '-' : `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
}
const handlePageChange = (p) => {
page.value = p
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleLogout = () => {
localStorage.clear(); sessionStorage.clear(); router.push('/login')
}
onMounted(loadData)
</script>
<style scoped>
.client-view-container {
padding: 24px 40px;
background-color: #f6f8fb;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* 顶部导航 */
.client-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo-box {
background: linear-gradient(135deg, #409EFF, #007aff);
width: 38px; height: 38px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 20px;
box-shadow: 0 4px 12px rgba(64,158,255,0.35);
}
.title { font-size: 19px; font-weight: 700; color: #1d1d1f; }
.version { font-size: 10px; color: #a1a1a6; margin-left: 8px; letter-spacing: 0.5px; }
.user-badge {
display: flex; align-items: center; gap: 8px;
background: #fff; padding: 6px 14px;
border-radius: 20px; font-size: 13px; color: #606266;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
/* 指标卡片 */
.stat-card-premium {
background: #fff;
padding: 22px;
border-radius: 12px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.03);
border-left: 4px solid var(--theme-color);
transition: all 0.3s ease;
}
.stat-card-premium:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
.s-label { font-size: 13px; color: #86868b; display: block; margin-bottom: 6px; }
.s-value { font-size: 28px; font-weight: 800; color: #1d1d1f; }
.stat-icon-wrapper { font-size: 36px; color: var(--theme-color); opacity: 0.1; }
/* 列表容器 */
.data-section {
background: #fff; padding: 24px; border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
}
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-left { display: flex; align-items: center; gap: 10px; }
.indicator { width: 3px; height: 16px; background: #409EFF; border-radius: 2px; }
.section-title { font-size: 16px; font-weight: 600; color: #303133; }
/* 表格样式微调 */
.id-tag { color: #909399; font-family: 'Consolas', monospace; font-size: 12px; }
.project-cell { display: flex; align-items: center; gap: 8px; }
.project-icon { color: #c0c4cc; font-size: 16px; }
.project-name { font-weight: 500; color: #2c3e50; }
.progress-labels { display: flex; justify-content: flex-end; margin-bottom: 4px; }
.percent-val { font-size: 11px; font-weight: 700; color: #409eff; }
.data-val-box { display: flex; flex-direction: column; align-items: center; }
.data-num { font-size: 16px; font-weight: 700; color: #2c3e50; font-family: 'Helvetica Neue', sans-serif; }
.unit { font-size: 9px; color: #a8abb2; transform: scale(0.9); }
/* 状态指示器 */
.status-dot-box { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; }
.dot { width: 6px; height: 6px; border-radius: 50%; }
.is-waiting { color: #909399; } .is-waiting .dot { background: #909399; }
.is-running { color: #E6A23C; } .is-running .dot { background: #E6A23C; box-shadow: 0 0 6px rgba(230,162,60,0.4); }
.is-success { color: #67C23A; } .is-success .dot { background: #67C23A; }
.date-text { font-size: 12px; color: #909399; }
.pagination-footer { margin-top: 24px; display: flex; justify-content: center; }
:deep(.el-table) { --el-table-header-bg-color: #f8f9fb; }
:deep(.el-table__row:hover) { background-color: #faffff !important; }
</style>

253
src/views/CustomersView.vue Normal file
View File

@@ -0,0 +1,253 @@
<template>
<div class="customers-container">
<h2>客户及员工管理系统</h2>
<div class="action-bar">
<div class="search-group">
<el-input v-model="searchQuery" placeholder="搜索客户单位" style="width: 250px" clearable />
</div>
<div class="button-group">
<el-button type="primary" :icon="Plus" @click="handleAddCustomer">新增客户单位</el-button>
<el-button type="success" :icon="User" @click="handleAddEmployee">新增员工</el-button>
</div>
</div>
<el-table :data="filteredData" border style="width: 100%; margin-top: 20px" v-loading="loading">
<el-table-column prop="customer_id" label="客户ID" width="100" />
<el-table-column prop="company_name" label="客户单位名称" />
<el-table-column label="员工管理" width="250" align="center">
<template #default="scope">
<span style="margin-right: 15px">
共 <b style="color: #409EFF">{{ getEmployeeCount(scope.row.customer_id) }}</b> 人
</span>
<el-button
size="small"
type="primary"
link
:icon="View"
@click="showEmployeeDetails(scope.row)"
>查看详情</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button size="small" type="danger" plain @click="handleDeleteCustomer(scope.row)">删除单位</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="customerDialogVisible" title="新增客户单位" width="400px">
<el-form :model="customerForm" label-width="100px">
<el-form-item label="单位名称" required>
<el-input v-model="customerForm.company_name" placeholder="例如:方舟星河" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="customerDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitCustomer">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="employeeDialogVisible" title="新增客户员工" width="450px">
<el-form :model="employeeForm" label-width="100px">
<el-form-item label="员工姓名" required>
<el-input v-model="employeeForm.employee_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="所属客户" required>
<el-select v-model="employeeForm.customer_id" placeholder="请选择归属单位" style="width: 100%">
<el-option
v-for="item in customerList"
:key="item.customer_id"
:label="item.company_name"
:value="item.customer_id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="employeeDialogVisible = false">取消</el-button>
<el-button type="success" @click="submitEmployee">保存员工</el-button>
</template>
</el-dialog>
<el-dialog v-model="employeeDetailVisible" :title="`【${activeCustomerName}】员工名单`" width="500px">
<el-table :data="currentCustomerEmployees" border stripe>
<el-table-column prop="employee_id" label="员工ID" width="100" />
<el-table-column prop="employee_name" label="姓名" />
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button
size="small"
type="danger"
link
@click="confirmDeleteEmployee(scope.row.employee_id)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Plus, User, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { customerApi } from '@/api/ticket' // 确保路径指向包含 addCustomerName 的 API 文件
// --- 状态数据 ---
const loading = ref(false)
const customerList = ref([])
const employeeList = ref([])
const searchQuery = ref('')
const employeeDetailVisible = ref(false)
const activeCustomerName = ref('')
const activeCustomerId = ref(null)
// --- 表单数据 ---
const customerDialogVisible = ref(false)
const customerForm = ref({ company_name: '' })
const employeeDialogVisible = ref(false)
const employeeForm = ref({ employee_name: '', customer_id: null })
// --- 核心方法 ---
// 1. 获取全局数据
const fetchCustomers = async () => {
loading.value = true
try {
const res = await customerApi.getCustomers()
if (res.code === 200) {
customerList.value = res.customers || []
employeeList.value = res.employees || []
} else {
ElMessage.error(res.msg || '数据加载失败')
}
} catch (error) {
console.error('API Error:', error)
ElMessage.error('无法连接到服务器')
} finally {
loading.value = false
}
}
// 2. 统计处理
const getEmployeeCount = (id) => {
return employeeList.value.filter(emp => emp.customer_id === id).length
}
const currentCustomerEmployees = computed(() => {
return employeeList.value.filter(emp => emp.customer_id === activeCustomerId.value)
})
const filteredData = computed(() => {
return customerList.value.filter(item =>
item.company_name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
// 3. 客户管理逻辑
const handleAddCustomer = () => {
customerForm.value.company_name = ''
customerDialogVisible.value = true
}
const submitCustomer = async () => {
if (!customerForm.value.company_name) return ElMessage.error('名称不能为空')
try {
const res = await customerApi.addCustomerName({
company_name: customerForm.value.company_name
})
if (res.code === 200) {
ElMessage.success('客户单位创建成功')
customerDialogVisible.value = false
fetchCustomers()
}
} catch (error) {
ElMessage.error('创建失败')
}
}
const handleDeleteCustomer = (row) => {
ElMessageBox.confirm(`确定删除单位【${row.company_name}】吗?这将导致关联员工数据异常!`, '警告', {
type: 'error',
confirmButtonText: '确定删除',
cancelButtonText: '取消'
}).then(async () => {
// 这里应调用你后端的删除接口,例如:
// await customerApi.deleteCustomer(row.customer_id)
ElMessage.warning('请确保后端已实现删除逻辑')
fetchCustomers()
}).catch(() => {})
}
// 4. 员工管理逻辑
const handleAddEmployee = () => {
if (customerList.value.length === 0) return ElMessage.warning('请先创建客户单位')
employeeForm.value = { employee_name: '', customer_id: null }
employeeDialogVisible.value = true
}
const submitEmployee = async () => {
if (!employeeForm.value.employee_name || !employeeForm.value.customer_id) {
return ElMessage.error('请填写员工姓名并选择所属单位')
}
try {
// 注意:确保你的 api 文件中有 addEmployee 方法
const res = await customerApi.addEmployee({
emp_name: employeeForm.value.employee_name,
customer_id: employeeForm.value.customer_id
})
if (res.code === 200) {
ElMessage.success('员工保存成功')
employeeDialogVisible.value = false
fetchCustomers()
}
} catch (error) {
ElMessage.error('保存失败')
}
}
const showEmployeeDetails = (row) => {
activeCustomerId.value = row.customer_id
activeCustomerName.value = row.company_name
employeeDetailVisible.value = true
}
// 需要修改,,这只是临时前端过滤,,并没有真正对接数据库
const confirmDeleteEmployee = (empId) => {
ElMessageBox.confirm('确定要从该单位移除此员工吗?', '提示', { type: 'warning' }).then(async () => {
try {
// 假设接口:await customerApi.deleteEmployee(empId)
ElMessage.success('操作成功(请对接后端删除接口)')
// 临时前端过滤
employeeList.value = employeeList.value.filter(e => e.employee_id !== empId)
} catch (e) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
onMounted(() => {
fetchCustomers()
})
</script>
<style scoped>
.customers-container { padding: 30px; background-color: #f5f7fa; min-height: 100vh; }
h2 { margin-bottom: 25px; color: #303133; font-weight: 600; }
.action-bar {
display: flex;
justify-content: space-between;
background: #fff;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
}
.button-group .el-button { margin-left: 10px; }
:deep(.el-table__header) { color: #606266; font-weight: bold; }
</style>

View File

@@ -87,12 +87,15 @@ const handleLogin = async () => {
loading.value = true;
const res = await userApi.login(loginForm.username, loginForm.password);
// console.log(res)
// 注意:这里由于你之前的封装,可能需要判断 res.data.code 还是 res.code
// 如果用了拦截器,直接判断 res.code
if (res.code === 200) {
const token = res.data.token;
const userInfo = JSON.stringify(res.data.user_info);
const role = res.data.user_info.role;
// console.log(role)
// --- 核心逻辑开始 ---
if (rememberMe.value) {
@@ -108,8 +111,22 @@ const handleLogin = async () => {
ElMessage.success('身份验证通过,方舟系统加载中...');
// 3. 根据角色执行路由跳转
setTimeout(() => {
router.push('/');
switch (role) {
case 'admin':
router.push('/'); // 管理员跳转至主页
break;
case 'user':
router.push('/user'); // 跳转至 UserView (请确保路由配置中有此路径)
break;
case 'client':
router.push('/client'); // 跳转至 ClientView (请确保路由配置中有此路径)
break;
default:
ElMessage.warning('未识别的角色权限,请联系管理员');
break;
}
}, 1000);
} else {
ElMessage.error(res.message || '登录失败');

View File

@@ -1,21 +1,40 @@
<template>
<div class="ticket-management">
<div class="stats-container">
<div class="stat-item total">
<div class="stat-label">总工单数</div>
<div class="stat-value">{{ allStats.total }}</div>
<div class="stat-card total">
<div class="stat-info">
<div class="stat-label">总工单数</div>
<div class="stat-value">{{ allStats.total }}</div>
</div>
<el-icon class="stat-icon"><Memo /></el-icon>
</div>
<div class="stat-item pending">
<div class="stat-label">未分配</div>
<div class="stat-value">{{ allStats.pending }}</div>
<div class="stat-card pending">
<div class="stat-info">
<div class="stat-label">未分配</div>
<div class="stat-value">{{ allStats.pending }}</div>
</div>
<el-icon class="stat-icon"><Clock /></el-icon>
</div>
<div class="stat-item ongoing">
<div class="stat-label">进行中</div>
<div class="stat-value">{{ allStats.ongoing }}</div>
<div class="stat-card ongoing">
<div class="stat-info">
<div class="stat-label">进行中</div>
<div class="stat-value">{{ allStats.ongoing }}</div>
</div>
<el-icon class="stat-icon"><Loading /></el-icon>
</div>
<div class="stat-item success">
<div class="stat-label">已完成/已完结</div>
<div class="stat-value">{{ allStats.completed }}</div>
<div class="stat-card success">
<div class="stat-info">
<div class="stat-label">已完成</div>
<div class="stat-value">{{ allStats.completed }}</div>
</div>
<el-icon class="stat-icon"><CircleCheck /></el-icon>
</div>
<div class="stat-card finished">
<div class="stat-info">
<div class="stat-label">已完结</div>
<div class="stat-value">{{ allStats.is_completed }}</div>
</div>
<el-icon class="stat-icon"><Checked /></el-icon>
</div>
</div>
@@ -24,80 +43,149 @@
<div class="left">
<el-button type="primary" @click="handleAdd" :icon="Plus">新增任务</el-button>
</div>
<TicketFilter :filters="filters" :operators="operators" @filter-change="handleFilterChange"
@reset="handleFilterReset" />
<TicketFilter
:operators="operatorsForFilter"
:staffs="staffList"
:filters="filters"
@filter-change="handleFilterChange"
@reset="handleFilterReset"
/>
</div>
<TicketList :tickets="tickets" :loading="loading" @edit="handleEdit" @delete="handleDelete" />
<TicketList
:tickets="tickets"
:loading="loading"
@edit="handleEdit"
@delete="handleDelete"
/>
<div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
:total="pagination.total" :page-sizes="[15, 30, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<TicketForm v-model="showAddDialog" :form-data="currentTicket" :operators="operators" :is-editing="isEditing"
@submit="handleFormSubmit" />
<TicketForm
v-model="showAddDialog"
:form-data="currentTicket"
:customers="customerList"
:customer-employees="allCustomerEmployees"
:staffs="staffList"
:is-editing="isEditing"
@submit="handleFormSubmit"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed} from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { ticketApi } from '@/api/ticket'
import { Plus, Memo, Clock, Loading, CircleCheck, Checked } from '@element-plus/icons-vue'
// API 引入
import { ticketApi ,customerApi} from '@/api/ticket'
// 组件引入
import TicketFilter from '@/components/TicketFilter.vue'
import TicketList from '@/components/TicketList.vue'
import TicketForm from '@/components/TicketForm.vue'
// --- 响应式数据 ---
// --- 响应式状态 ---
const tickets = ref([])
const operators = ref([])
const operators = ref([])
const staffList = ref([])
const customerList = ref([])
const allCustomerEmployees = ref([])
const loading = ref(false)
const showAddDialog = ref(false)
const isEditing = ref(false)
const currentTicket = ref({})
// 独立统计数据:专门给顶部的卡片使用
const allStats = reactive({
total: 0,
pending: 0,
ongoing: 0,
completed: 0
total: 0, pending: 0, ongoing: 0, completed: 0, is_completed: 0
})
const pagination = reactive({
page: 1,
limit: 15,
total: 0
page: 1, limit: 15, total: 0
})
const filters = reactive({
status: null,
operator_name: null,
principalpeople: null,
staff_id: null,
search: null,
sort_order: 'desc'
})
// --- 方法 ---
// --- 核心数据加载方法 ---
// 在 TicketManagement.vue 中利用 computed 提取
const operatorsForFilter = computed(() => {
// 返回包含 ID、姓名和公司名的对象数组
return allCustomerEmployees.value.map(emp => {
// 在 allCustomers 中找到该员工所属的公司名
const company = customerList.value.find(c => c.customer_id === emp.customer_id);
return {
id: emp.employee_id,
name: emp.employee_name,
company: company ? company.company_name : '未知单位'
};
});
});
/**
* 核心:获取全局统计数据
* 逻辑:发送一个不带任何 filters 的请求,专门计算总数
* 加载客户单位及对应员工数据
* 根据你提供的接口返回结构进行了适配
*/
const loadCustomerData = async () => {
try {
const res = await customerApi.getCustomers()
// 适配点:根据返回的 code: 200 逻辑进行判断
if (res.code === 200) {
// 适配点:直接从 res 中获取 customers 和 employees 数组
customerList.value = res.customers || []
allCustomerEmployees.value = res.employees || []
}
} catch (error) {
console.error('获取客户数据失败:', error)
}
}
/**
* 加载我方员工列表
*/
const loadStaffData = async () => {
try {
const res = await ticketApi.getStaffs()
if (res.code) {
staffList.value = res.data
}
} catch (error) {
console.error('获取我方员工列表失败:', error)
}
}
/**
* 获取全局统计数据
*/
const initGlobalStats = async () => {
try {
// 请求第一页,且不带任何筛选条件(filters)
// 目的只是为了拿到全量数据来统计卡片
const response = await ticketApi.getTickets({ page: 1, limit: 1000 })
if (response.code) {
const allData = response.data.data
allStats.total = response.data.total
allStats.pending = allData.filter(t => t.is_complete === '未分配').length
allStats.ongoing = allData.filter(t => t.is_complete === '进行中').length
allStats.completed = allData.filter(t => t.is_complete === '已完成' || t.is_complete === '已完结').length
allStats.completed = allData.filter(t => t.is_complete === '已完成').length
allStats.is_completed = allData.filter(t => t.is_complete === '已完结').length
}
} catch (error) {
console.error('统计全局数据失败:', error)
@@ -105,7 +193,7 @@ const initGlobalStats = async () => {
}
/**
* 加载列表数据(受分页和筛选影响)
* 加载列表数据
*/
const loadTickets = async () => {
loading.value = true
@@ -116,10 +204,9 @@ const loadTickets = async () => {
...filters
}
const response = await ticketApi.getTickets(params)
// console.log('工单列表响应:', response)
if (response.code) {
tickets.value = response.data.data
pagination.total = response.total
tickets.value = response.data.data || []
pagination.total = response.data.total || 0
}
} catch (error) {
ElMessage.error('加载列表失败')
@@ -128,195 +215,135 @@ const loadTickets = async () => {
}
}
// 筛选逻辑
const handleFilterChange = (newFilters) => {
Object.assign(filters, newFilters)
pagination.page = 1
loadTickets() // 只刷新列表,不刷新卡片
}
const handleFilterReset = () => {
Object.assign(filters, { status: null, operator_name: null, principalpeople: null, search: null, sort_order: 'desc' })
pagination.page = 1
loadTickets()
}
// 新增/编辑/删除后,需要同时刷新列表和统计卡片
const refreshAll = () => {
loadTickets()
initGlobalStats()
}
const handleFormSubmit = async (formData) => {
// ... 提交逻辑 ...
// 成功后调用 refreshAll()
// --- 事件处理 ---
const handleFilterChange = (newFilters) => {
Object.assign(filters, newFilters)
pagination.page = 1
loadTickets()
}
const handleDelete = async (id) => {
try {
// 1. 弹出二次确认框,防止用户点错
await ElMessageBox.confirm(
'确定要删除该工单吗?此操作不可逆',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
// 2. 开启 loading(如果有的话)
// loading.value = true
// 3. 调用 API 发送删除请求
const res = await ticketApi.deleteTicket(id)
// console.log('删除响应:', res)
// 4. 根据你的后端返回结构判断是否成功
// 假设后端返回 code 为 200 表示成功
if (res.code) {
ElMessage.success('删除成功')
// 5. 成功后调用刷新列表的方法
if (typeof refreshAll === 'function') {
refreshAll()
}
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
// 处理取消操作或接口报错
if (error !== 'cancel') {
console.error('删除出错:', error)
ElMessage.error('系统错误,删除失败')
}
} finally {
// loading.value = false
}
const handleFilterReset = () => {
Object.assign(filters, {
status: null,
operator_name: null,
staff_id: null,
search: null,
sort_order: 'desc'
})
pagination.page = 1
loadTickets()
}
/**
* 新增任务:清空表单并打开弹窗
*/
const handleAdd = () => {
isEditing.value = false
// 初始化一个空的工单对象
currentTicket.value = {
website: '',
operator: '',
principalpeople: '',
is_complete: '未分配'
staff_id: null,
is_complete: '未分配',
remark: ''
}
showAddDialog.value = true
}
/**
* 编辑任务:克隆数据并打开弹窗
*/
// 编辑工单
const handleEdit = (row) => {
isEditing.value = true
// 使用解构赋值 {...row} 进行浅拷贝,避免修改弹窗时直接影响表格行
currentTicket.value = { ...row }
currentTicket.value = { ...row }
showAddDialog.value = true
}
// 处理工单提交
const handleFormSubmit = async (formData) => {
try {
let response;
if (isEditing.value) {
response = await ticketApi.updateTicket(currentTicket.value.id, formData);
} else {
response = await ticketApi.addTicket(formData);
}
/**
* 加载运营人员列表(用于弹窗下拉框)
*/
if (response.code) {
ElMessage.success(isEditing.value ? '修改成功 ✨' : '创建成功 🚀');
showAddDialog.value = false;
refreshAll();
} else {
ElMessage.error(response.msg || '操作失败');
}
} catch (error) {
ElMessage.error('提交失败,系统异常');
}
}
// 删除工单
const handleDelete = async (id) => {
try {
await ElMessageBox.confirm('确定要删除该工单吗?此操作不可逆', '提示', {
type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'
})
const res = await ticketApi.deleteTicket(id)
// console.log('删除',res)
if (res.code) {
ElMessage.success('删除成功')
refreshAll()
}
} catch (error) {
if (error !== 'cancel') ElMessage.error('删除失败')
}
}
// 加载所有运营人员
const loadOperators = async () => {
try {
const response = await ticketApi.getOperators()
console.log('运营人员列表响应:', response.data)
// 这里的判断逻辑要根据你后端的实际返回结构调整
// 如果后端直接返回数组:operators.value = response
// 如果后端返回带 code 的结构:
if (response.code) {
operators.value = response.data || response
operators.value = response.data
}
} catch (error) {
console.error('加载运营人员失败:', error)
console.error('获取运营人员筛选列表失败:', error)
}
}
// 分页逻辑
const handleSizeChange = (val) => { pagination.limit = val; loadTickets(); }
const handleCurrentChange = (val) => { pagination.page = val; loadTickets(); }
// 初始化
onMounted(() => {
loadTickets() // 渲染表格
initGlobalStats() // 锁定卡片统计
loadTickets()
initGlobalStats()
loadOperators()
loadStaffData()
loadCustomerData() // 级联数据加载
})
</script>
<style scoped>
/* 保持原有样式不变 */
.ticket-management {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.stats-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.stat-item {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
border-left: 5px solid #409eff;
}
.stat-item.pending {
border-left-color: #909399;
}
.stat-item.ongoing {
border-left-color: #e6a23c;
}
.stat-item.success {
border-left-color: #67c23a;
}
.stat-label {
font-size: 14px;
color: #666;
}
.stat-value {
font-size: 26px;
font-weight: bold;
color: #333;
margin-top: 5px;
}
.main-content {
background: white;
padding: 20px;
border-radius: 8px;
}
.action-bar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 样式部分保持不变 */
.ticket-management { padding: 20px; background-color: #f8fafc; min-height: 100vh; }
.stats-container { display: grid; grid-template-columns: repeat(5, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); display: flex; justify-content: space-between; align-items: center; transition: all 0.3s ease; border-bottom: 3px solid transparent; }
.stat-card:hover { transform: translateY(-5px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); }
.total { border-bottom-color: #409eff; }
.total .stat-icon { color: #409eff; background: #ecf5ff; }
.pending { border-bottom-color: #909399; }
.pending .stat-icon { color: #909399; background: #f4f4f5; }
.ongoing { border-bottom-color: #e6a23c; }
.ongoing .stat-icon { color: #e6a23c; background: #fdf6ec; }
.success { border-bottom-color: #67c23a; }
.success .stat-icon { color: #67c23a; background: #f0f9eb; }
.finished { border-bottom-color: #009688; }
.finished .stat-icon { color: #009688; background: #e6f6f5; }
.stat-label { font-size: 13px; color: #64748b; margin-bottom: 8px; font-weight: 500; }
.stat-value { font-size: 24px; font-weight: 700; color: #1e293b; }
.stat-icon { font-size: 24px; padding: 12px; border-radius: 10px; }
.main-content { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); }
.action-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.pagination-container { margin-top: 20px; display: flex; justify-content: flex-end; }
@media (max-width: 1400px) { .stats-container { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .stats-container { grid-template-columns: 1fr 1fr; } }
</style>

409
src/views/UserView.vue Normal file
View File

@@ -0,0 +1,409 @@
<template>
<div class="user-view-container">
<div class="user-header-card">
<div class="header-main">
<div class="welcome-container">
<div class="welcome-section">
<h2 class="greeting">{{ welcomeMessage }},{{ currentUser.username }}</h2>
<p class="sub-text">欢迎回到你的垃圾堆,开启牛马的一天。</p>
</div>
<div class="identity-badges">
<el-space :size="8">
<el-tag v-if="realStaffInfo" type="warning" effect="plain" round size="small">
<el-icon><User /></el-icon> 牛马身份: {{ realStaffInfo.name }}
</el-tag>
<el-tag v-else type="danger" round size="small">未录入员工表,无权接单</el-tag>
</el-space>
</div>
</div>
</div>
<div class="header-action">
<el-button type="danger" plain @click="handleLogout">
<el-icon><SwitchButton /></el-icon>&nbsp;退出登录
</el-button>
</div>
</div>
<div class="status-tabs-wrapper">
<el-radio-group v-model="currentStatus" @change="handleStatusChange" class="custom-radio-group">
<el-radio-button label="未分配">
<el-icon><Notification /></el-icon> 待分配任务
</el-radio-button>
<el-radio-button label="进行中">
<el-icon><Loading v-if="loading" /><Timer v-else /></el-icon> 我的进行中
</el-radio-button>
<el-radio-button label="已完成">
<el-icon><CircleCheck /></el-icon> 我的已完成
</el-radio-button>
<el-radio-button label="已完结">
<el-icon><Checked /></el-icon> 我的已完结
</el-radio-button>
</el-radio-group>
</div>
<el-table :data="paginatedTickets" v-loading="loading" border style="width: 100%; margin-top: 20px" class="custom-table">
<template #empty>
<el-empty :image-size="120" :description="emptyText">
<el-button type="primary" plain @click="fetchTickets">刷新列表</el-button>
</el-empty>
</template>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="website" label="网站名称" min-width="150" show-overflow-tooltip />
<el-table-column label="数据量" width="150" align="center">
<template #default="scope">
<div class="cell-content-card">
<div class="data-display-area">
<span v-if="scope.row.datanum && scope.row.datanum !== '0'" class="display-val">{{ scope.row.datanum }}</span>
<span v-else class="no-data-placeholder">-</span>
</div>
<el-popover v-if="currentStatus !== '未分配'" placement="top" :width="200" trigger="click" @show="prepareEdit(scope.row, 'datanum')">
<template #reference>
<el-button type="primary" size="small" link class="edit-link-btn">
{{ (scope.row.datanum && scope.row.datanum !== '0') ? '[ 修改数据 ]' : '[ 输入数据 ]' }}
</el-button>
</template>
<div class="popover-content">
<div class="pop-header">录入站点数据量</div>
<el-input v-model="editValue" placeholder="请输入数值" size="small" clearable />
<div class="pop-footer">
<el-button type="primary" size="small" @click="submitEdit(scope.row, 'datanum')">确认提交</el-button>
</div>
</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column label="任务进度" width="150" align="center">
<template #default="scope">
<div class="cell-content-card">
<div class="data-display-area" style="width: 100%; padding: 0 10px;">
<el-progress
:percentage="Number(scope.row.progress || 0)"
:status="Number(scope.row.progress) === 100 ? 'success' : ''"
:stroke-width="12"
striped
striped-flow
/>
</div>
<el-popover v-if="currentStatus === '进行中'" placement="top" :width="240" trigger="click" @show="prepareEdit(scope.row, 'progress')">
<template #reference>
<el-button type="primary" size="small" link class="edit-link-btn">
[ 更新进度 ]
</el-button>
</template>
<div class="popover-content">
<div class="pop-header">调整当前进度 (%)</div>
<el-radio-group v-model="editValue" size="small" class="progress-radio-group">
<el-radio-button :label="0">0</el-radio-button>
<el-radio-button :label="30">30</el-radio-button>
<el-radio-button :label="50">50</el-radio-button>
<el-radio-button :label="70">70</el-radio-button>
<el-radio-button :label="100">100</el-radio-button>
</el-radio-group>
<div class="pop-footer">
<el-button type="primary" size="small" @click="submitEdit(scope.row, 'progress')">确定</el-button>
</div>
</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column prop="operator" label="运营" width="120" align="center" />
<el-table-column label="备注" min-width="150" align="center">
<template #default="scope">
<div class="cell-content-card">
<div class="data-display-area">
<div v-if="scope.row.remark" class="display-remark" :title="scope.row.remark">{{ scope.row.remark }}</div>
<span v-else class="no-data-placeholder">-</span>
</div>
<el-popover v-if="currentStatus !== '未分配'" placement="top" :width="250" trigger="click" @show="prepareEdit(scope.row, 'remark')">
<template #reference>
<el-button type="warning" size="small" link class="edit-link-btn">
{{ scope.row.remark ? '[ 修改备注 ]' : '[ 输入备注 ]' }}
</el-button>
</template>
<div class="popover-content">
<div class="pop-header">录入工单备注</div>
<el-input v-model="editValue" type="textarea" :rows="3" placeholder="请输入备注" size="small" />
<div class="pop-footer">
<el-button type="primary" size="small" @click="submitEdit(scope.row, 'remark')">提交保存</el-button>
</div>
</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column :label="['已完成', '已完结'].includes(currentStatus) ? '锁定日期' : '创建日期'" width="140" align="center">
<template #default="scope">
<span class="pure-date">
{{ formatDateOnly(['已完成', '已完结'].includes(currentStatus) ? scope.row.lock_time : scope.row.create_time) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="scope">
<el-button v-if="scope.row.is_complete === '未分配'" type="primary" size="small" :disabled="!realStaffInfo" @click="handleAccept(scope.row)">立即接单</el-button>
<el-button v-if="scope.row.is_complete === '进行中' && String(scope.row.staff_id) === String(realStaffInfo?.id)" type="success" size="small" @click="handleFinish(scope.row)">完成任务</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="filteredTickets.length"
:page-sizes="[15, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, SwitchButton, Timer, Notification, CircleCheck, Checked, Loading } from '@element-plus/icons-vue'
import { ticketApi } from '@/api/ticket'
const router = useRouter()
const loading = ref(false)
const allTickets = ref([])
const currentStatus = ref('未分配')
const realStaffInfo = ref(null)
const editValue = ref('')
const currentPage = ref(1)
const pageSize = ref(15)
const formatDateOnly = (timestamp) => {
if (!timestamp || timestamp === '0' || timestamp === '') return '-';
let date = !isNaN(timestamp) ? new Date(String(timestamp).length === 10 ? timestamp * 1000 : parseInt(timestamp)) : new Date(timestamp);
if (isNaN(date.getTime())) return '-';
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
}
// js计算时间
const welcomeMessage = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '凌晨好'; if (hour < 9) return '早上好'; if (hour < 12) return '上午好';
if (hour < 14) return '中午好'; if (hour < 18) return '下午好'; return '晚上好'
})
// 获取当前用户信息
const currentUser = computed(() => {
const info = localStorage.getItem('user_info') || sessionStorage.getItem('user_info')
return info ? JSON.parse(info) : { id: null, username: '未知' }
})
// 获取工单信息
const fetchTickets = async () => {
loading.value = true
try {
const res = await ticketApi.getTickets({ page: 1, limit: 1000 })
// console.log(res)
if (res.code === 200 || res.code === true) allTickets.value = res.data.data
} catch (e) { ElMessage.error('获取列表失败') }
finally { loading.value = false }
}
// 过滤工单
const filteredTickets = computed(() => {
return allTickets.value.filter(ticket => {
if (ticket.is_complete !== currentStatus.value) return false
if (currentStatus.value !== '未分配') return String(ticket.staff_id) === String(realStaffInfo.value?.id)
return true
})
})
const paginatedTickets = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredTickets.value.slice(start, end)
})
const handleSizeChange = (val) => { pageSize.value = val; currentPage.value = 1; }
const handleCurrentChange = (val) => { currentPage.value = val; }
const handleStatusChange = () => { currentPage.value = 1; fetchTickets(); }
const prepareEdit = (row, field) => { editValue.value = row[field] || '' }
const submitEdit = async (row, field) => {
try {
const payload = {
datanum: field === 'datanum' ? String(editValue.value) : String(row.datanum || ''),
remark: field === 'remark' ? editValue.value : (row.remark || ''),
progress: field === 'progress' ? Number(editValue.value) : Number(row.progress || 0)
}
const res = await ticketApi.updateTicket(row.id, payload)
if (res.code === 200 || res.code === true) {
row[field] = editValue.value;
ElMessage.success('更新成功');
document.body.click()
}
} catch (e) { ElMessage.error('操作失败') }
}
// 点击接受工单
const handleAccept = (row) => {
ElMessageBox.confirm(`确认接手工单 #${row.id}?`).then(async () => {
try {
const res = await ticketApi.updateTicket(row.id, { staff_id: realStaffInfo.value.id, is_complete: '进行中', progress: 0 })
if (res.code === 200 || res.code === true) { ElMessage.success('接单成功'); fetchTickets() }
} catch (e) { ElMessage.error('操作失败') }
})
}
// 点击完成
const handleFinish = (row) => {
ElMessageBox.confirm('确认已完成任务?').then(async () => {
try {
const currentTimestamp = Math.floor(Date.now() / 1000);
// 关键修改:在提交时增加 progress: 100
const res = await ticketApi.updateTicket(row.id, {
is_complete: '已完成',
lock_time: String(currentTimestamp),
progress: 100 // 默认改为100
})
if (res.code === 200 || res.code === true) {
ElMessage.success('任务已完成,进度已更新为100%');
fetchTickets()
}
} catch (e) {
ElMessage.error('操作失败')
}
})
}
const handleLogout = () => {
ElMessageBox.confirm('确定退出当前账号?', '提示', { type: 'warning' }).then(() => {
localStorage.clear(); sessionStorage.clear(); router.push('/login')
})
}
onMounted(async () => {
try {
const res = await ticketApi.getStaffs()
if (res.code === 200 || res.code === true) {
const match = (res.data || []).find(s => s.name === currentUser.value.username)
if (match) realStaffInfo.value = match
}
} catch (e) {}
fetchTickets()
})
</script>
<style scoped>
/* 容器布局 */
.header-main {
display: flex;
flex-direction: column;
justify-content: center;
}
.welcome-container {
display: flex;
flex-direction: column;
gap: 8px; /* 这里统一控制欢迎语、副标题、标签之间的垂直间距 */
}
/* 清除标题默认的垂直 margin,防止撑开间距 */
.greeting {
margin: 0;
color: #1a1a1a;
font-size: 22px;
line-height: 1.2;
}
/* 副标题紧贴标题 */
.sub-text {
margin: 4px 0 0 0; /* 仅顶部保留微小间距 */
color: #8c8c8c;
font-size: 13px;
}
/* 标签组位置微调 */
.identity-badges {
margin-top: 4px; /* 让标签组靠近上方的文字 */
display: flex;
align-items: center;
}
/* 针对 el-tag 内部图标和文字的微调 */
:deep(.el-tag) {
display: inline-flex;
align-items: center;
border-style: dashed; /* 既然是牛马,边框可以搞成虚线更有自嘲感 */
}
.user-view-container { padding: 24px; background-color: #f0f2f5; min-height: 100vh; }
.user-header-card { background: white; padding: 24px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.04); }
.greeting { margin: 0 0 8px 0; color: #1a1a1a; font-size: 24px; }
.status-tabs-wrapper { background: white; padding: 8px; border-radius: 8px; display: inline-block; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.custom-radio-group :deep(.el-radio-button__inner) { border: none !important; border-radius: 6px !important; margin: 0 4px; padding: 10px 20px; display: flex; align-items: center; gap: 6px; }
.custom-table { border-radius: 12px; overflow: hidden; box-shadow: 0 4px 16px rgba(0,0,0,0.05); }
.cell-content-card { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 60px; }
.data-display-area { min-height: 24px; display: flex; align-items: center; justify-content: center; width: 100%; }
.display-val { font-weight: 700; color: #409eff; font-size: 15px; }
.display-remark { font-size: 12px; color: #595959; max-width: 180px; line-height: 1.4; background: #f5f7fa; padding: 4px 10px; border-radius: 4px; text-align: center; }
.no-data-placeholder { color: #dcdfe6; font-size: 12px; }
.pure-date { color: #606266; font-weight: 500; font-family: monospace; font-size: 14px; }
.edit-link-btn { font-size: 12px; height: auto; padding: 0; opacity: 0.7; }
.edit-link-btn:hover { opacity: 1; text-decoration: underline; }
.pagination-container { margin-top: 24px; display: flex; justify-content: flex-end; padding: 10px 20px; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.progress-radio-group { display: flex; justify-content: center; margin: 10px 0; }
.progress-radio-group :deep(.el-radio-button__inner) { padding: 8px 10px !important; }
:deep(.el-table__row) { height: 100px; }
:deep(.el-table .cell) { overflow: visible !important; }
.pop-header { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #434343; text-align: center; }
.pop-footer { margin-top: 12px; text-align: right; }
/* 1. 强行修正进度条内部容器的对齐方式 */
:deep(.el-progress-bar__innerText) {
display: flex !important;
align-items: center !important; /* 垂直居中 */
justify-content: center !important; /* 水平居中 */
height: 100% !important;
line-height: 1 !important;
/* 移除默认的基线对齐 */
vertical-align: middle !important;
}
/* 2. 针对 100% 时出现的那个 svg 图标进行位移修正 */
:deep(.el-progress-bar__innerText svg),
:deep(.el-progress-bar__innerText .el-icon) {
margin: 0 !important;
/* 关键:利用相对定位向上平移 1px,抵消视觉上的下沉 */
position: relative !important;
top: -1px !important;
/* 稍微调整图标大小,让它在 10px 的进度条里更协调 */
width: 0.8em;
height: 0.8em;
}
/* 3. 如果还是觉得整体偏,可以给进度条容器加一个微调 */
:deep(.el-progress) {
display: flex;
align-items: center;
}
</style>

View File

@@ -6,6 +6,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
base: '/', // 添加这一行,确保部署在根目录或通过Nginx运行目录指向时路径正确
plugins: [
vue(),
vueDevTools(),
@@ -16,10 +17,10 @@ export default defineConfig({
},
},
server: {
port: 8080, // 前端启动的端口
port: 80, // 前端启动的端口
proxy: {
'/api': { // 所有以api开头的请求都会被代理
target: 'http://localhost:80',
target: 'http://112.5.15.136:25536',
changeOrigin: true,
}
}