Compare commits
10 Commits
93bd773c51
...
f0fd784eeb
| Author | SHA1 | Date | |
|---|---|---|---|
| f0fd784eeb | |||
| 2c983e826d | |||
| fcd797a1fd | |||
| 9d53d7deb0 | |||
| 97c7efb81f | |||
| 826d140717 | |||
| 94e0c718e2 | |||
| f081928187 | |||
| 17505b5c22 | |||
| f54c58c366 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__chrome-devtools__list_pages"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
.github/agents/vue-expert.agent.md
vendored
Normal file
30
.github/agents/vue-expert.agent.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
description: "Vue/Vue3 专家顾问(10年经验)。当用户询问 Vue 相关问题、寻求最佳实践、代码评审、架构设计建议,或希望得到专业且详细的技术解释(包括为什么要这么做、好处是什么)时使用。触发词:vue、vue3、组合式API、setup、pinia、vue-router、vite、响应式、组件、性能优化、重构、最佳实践、为什么、原因、好处。"
|
||||||
|
name: "Vue 高级工程师"
|
||||||
|
tools: [read, search, web, edit, execute]
|
||||||
|
user-invocable: true
|
||||||
|
argument-hint: "描述你的 Vue 问题、需要评审的代码,或想了解的方案"
|
||||||
|
---
|
||||||
|
你是一名拥有 10 年以上经验的 Vue 高级工程师,精通 Vue 2/Vue 3、Vite、Pinia、Vue Router、组合式 API(Composition API)与前端工程化。你同时是一名严谨的技术顾问:回答必须专业、深入,并且**对每个关键结论都详细解释「为什么要这么做」以及「这么做的好处」**。
|
||||||
|
|
||||||
|
## 核心职责
|
||||||
|
- 解答 Vue 相关技术问题(响应式原理、组件设计、状态管理、路由、构建优化等)。
|
||||||
|
- 评审与优化当前工作区(d:\vue_tickets,Vue 3 + Vite 项目)的代码。
|
||||||
|
- 提供架构设计与最佳实践建议。
|
||||||
|
|
||||||
|
## 回答方式(务必遵循)
|
||||||
|
1. **直接给结论**:先用一两句话给出明确答案。
|
||||||
|
2. **解释为什么**:讲清背后的技术原理、与其他方案的对比与权衡(trade-off)。
|
||||||
|
3. **说明好处**:明确指出这样做带来的收益(可维护性、性能、可测试性、可扩展性、协作成本等)。
|
||||||
|
4. **指出注意点**:说明边界条件、常见坑、何时不应采用。
|
||||||
|
5. **给出示例**:提供可运行、符合项目实际结构的代码片段。
|
||||||
|
|
||||||
|
## 专业准则
|
||||||
|
- 只推荐 Vue 官方认可、社区验证过的最佳实践,不推荐已废弃或反模式的做法。
|
||||||
|
- 不确定的地方要明确说明,不臆造。
|
||||||
|
- 引用项目内真实文件路径(如 src/views、src/components)使建议落地。
|
||||||
|
- 使用简体中文回答,技术术语保留英文便于检索。
|
||||||
|
|
||||||
|
## 限制
|
||||||
|
- 不要堆砌没有依据的「最佳实践」清单。
|
||||||
|
- 若问题超出 Vue 范畴,先给出 Vue 侧最佳方案,再补充必要说明。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tokens": {},
|
||||||
|
"components": {}
|
||||||
|
}
|
||||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -14,7 +14,8 @@
|
|||||||
"element-plus": "^2.11.8",
|
"element-plus": "^2.11.8",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
@@ -1744,6 +1745,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansis": {
|
"node_modules/ansis": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||||
@@ -1881,6 +1891,28 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -1916,6 +1948,18 @@
|
|||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -2233,6 +2277,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2801,6 +2854,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/superjson": {
|
"node_modules/superjson": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
|
||||||
@@ -3120,6 +3185,24 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wsl-utils": {
|
"node_modules/wsl-utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||||
@@ -3136,6 +3219,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"element-plus": "^2.11.8",
|
"element-plus": "^2.11.8",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
13
src/api/login.js
Normal file
13
src/api/login.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { adminRequest, indexRequest } from '@/utils/request'
|
||||||
|
|
||||||
|
// ==================== 前台接口(/api/index) ====================
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
login(username, password) {
|
||||||
|
return indexRequest.post('/login', { username, password })
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return indexRequest.post('/logout')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,20 @@ export const customerApi = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 客户接口(/api/index) ====================
|
||||||
|
|
||||||
|
export const clientApi = {
|
||||||
|
// 获取当前客户关联的工单列表
|
||||||
|
getTickets(params) {
|
||||||
|
return indexRequest.get('/client/tickets', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前客户的工单统计
|
||||||
|
getStats() {
|
||||||
|
return indexRequest.get('/client/stats')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const accountApi = {
|
export const accountApi = {
|
||||||
getAllUsers() {
|
getAllUsers() {
|
||||||
return adminRequest.get('/getAllUsers')
|
return adminRequest.get('/getAllUsers')
|
||||||
@@ -86,10 +100,16 @@ export const accountApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateAccountStatus(id, status) {
|
updateAccountStatus(id, status) {
|
||||||
return adminRequest.put(`/account/${id}/status`, { status })
|
return adminRequest.put(`/updateAccountStatus/${id}/status`, { status })
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteAccount(id) {
|
deleteAccount(id) {
|
||||||
return adminRequest.delete(`/account/${id}`)
|
return adminRequest.delete(`/deleteAccount/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportApi = {
|
||||||
|
exportdata(range) {
|
||||||
|
return adminRequest.post('/getExportData', { range })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,316 @@
|
|||||||
/* 重置基础样式 */
|
/* ============================================
|
||||||
|
Global Design System — Refined Minimal
|
||||||
|
============================================ */
|
||||||
|
:root {
|
||||||
|
/* ===== 品牌主色(高饱和靛蓝 → 紫) ===== */
|
||||||
|
--color-primary: #4f46e5;
|
||||||
|
--color-primary-deep: #4338ca;
|
||||||
|
--color-primary-soft: #eef0ff;
|
||||||
|
--brand-gradient: linear-gradient(135deg, #4f46e5 0%, #6d28d9 60%, #9333ea 100%);
|
||||||
|
|
||||||
|
/* ===== 背景层次:深侧栏 → 浅内容 → 白卡片 ===== */
|
||||||
|
--color-bg: #eef1f8;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-soft: #f6f8fd;
|
||||||
|
|
||||||
|
--color-border: #e3e8f2;
|
||||||
|
--color-border-light: #eef1f8;
|
||||||
|
|
||||||
|
/* ===== 语义色(高饱和) ===== */
|
||||||
|
--color-success: #16a34a;
|
||||||
|
--color-success-soft: #e7f6ed;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-warning-soft: #fff4e0;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
--color-danger-soft: #fdecec;
|
||||||
|
--color-info: #0ea5e9;
|
||||||
|
--color-info-soft: #e6f6fe;
|
||||||
|
|
||||||
|
/* ===== 文本层次 ===== */
|
||||||
|
--text-primary: #17203f;
|
||||||
|
--text-secondary: #5b6478;
|
||||||
|
--text-muted: #98a0b5;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
|
/* ===== 圆角 ===== */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
|
||||||
|
/* ===== 阴影分级(层次感核心) ===== */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(23, 32, 63, 0.05);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(23, 32, 63, 0.06), 0 1px 2px rgba(23, 32, 63, 0.04);
|
||||||
|
--shadow-md: 0 8px 20px rgba(79, 70, 229, 0.10), 0 2px 6px rgba(23, 32, 63, 0.05);
|
||||||
|
--shadow-lg: 0 18px 44px rgba(23, 32, 63, 0.16), 0 6px 16px rgba(23, 32, 63, 0.08);
|
||||||
|
|
||||||
|
--font-sans: "PingFang SC", "Microsoft YaHei", -apple-system, "Helvetica Neue", sans-serif;
|
||||||
|
--font-mono: "SF Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
|
||||||
|
/* ===== Element Plus 对齐 ===== */
|
||||||
|
--el-color-primary: var(--color-primary);
|
||||||
|
--el-color-primary-light-3: rgba(79, 70, 229, 0.30);
|
||||||
|
--el-color-primary-light-5: rgba(79, 70, 229, 0.16);
|
||||||
|
--el-color-primary-light-7: rgba(79, 70, 229, 0.07);
|
||||||
|
--el-color-primary-light-8: rgba(79, 70, 229, 0.04);
|
||||||
|
--el-color-primary-light-9: rgba(79, 70, 229, 0.02);
|
||||||
|
--el-color-primary-dark-2: var(--color-primary-deep);
|
||||||
|
--el-color-success: var(--color-success);
|
||||||
|
--el-color-warning: var(--color-warning);
|
||||||
|
--el-color-danger: var(--color-danger);
|
||||||
|
--el-color-error: var(--color-danger);
|
||||||
|
--el-color-info: var(--color-info);
|
||||||
|
--el-border-radius-base: 8px;
|
||||||
|
--el-text-color-primary: var(--text-primary);
|
||||||
|
--el-text-color-regular: var(--text-secondary);
|
||||||
|
--el-border-color: var(--color-border);
|
||||||
|
--el-border-color-light: var(--color-border-light);
|
||||||
|
--el-fill-color-blank: var(--color-surface);
|
||||||
|
--el-bg-color: var(--color-surface);
|
||||||
|
--el-bg-color-page: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 全局基础 ===== */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f5f7fa;
|
background: var(--color-bg);
|
||||||
/* 给背景一个淡灰色,更有后台质感 */
|
color: var(--text-primary);
|
||||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
/* 移除原有的 grid 和 center 布局 */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: block;
|
display: block;
|
||||||
/* 恢复块级布局 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 如果你希望内容有一个最大宽度并居中,可以这样写 */
|
|
||||||
.ticket-management {
|
|
||||||
max-width: 1440px;
|
|
||||||
/* 常见的管理后台舒适宽度 */
|
|
||||||
margin: 0 auto;
|
|
||||||
/* 水平居中 */
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 链接样式微调 */
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #409eff;
|
color: var(--color-primary);
|
||||||
transition: 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对移动端的小适配 */
|
/* ===== Element Plus overrides ===== */
|
||||||
@media (max-width: 768px) {
|
/* ---- Button ---- */
|
||||||
.ticket-management {
|
.el-button {
|
||||||
padding: 10px;
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
--el-button-bg-color: var(--color-primary);
|
||||||
|
--el-button-border-color: var(--color-primary);
|
||||||
|
--el-button-hover-bg-color: var(--color-primary-deep);
|
||||||
|
--el-button-hover-border-color: var(--color-primary-deep);
|
||||||
|
--el-button-active-bg-color: #3730a3;
|
||||||
|
--el-button-active-border-color: #3730a3;
|
||||||
|
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary:hover {
|
||||||
|
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.36);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--success {
|
||||||
|
--el-button-bg-color: var(--color-success);
|
||||||
|
--el-button-border-color: var(--color-success);
|
||||||
|
--el-button-hover-bg-color: #15803d;
|
||||||
|
--el-button-hover-border-color: #15803d;
|
||||||
|
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Input / Select ---- */
|
||||||
|
.el-input .el-input__wrapper,
|
||||||
|
.el-select .el-input__wrapper {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-border) inset;
|
||||||
|
transition: box-shadow 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input .el-input__wrapper:hover,
|
||||||
|
.el-select .el-input__wrapper:hover {
|
||||||
|
box-shadow: 0 0 0 1px rgba(79, 70, 229, 0.5) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input .el-input__wrapper.is-focus,
|
||||||
|
.el-select .el-input__wrapper.is-focus {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.16), 0 0 0 1px var(--color-primary) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Table(层次感:表头深、斑马行、悬停抬升) ---- */
|
||||||
|
.el-table {
|
||||||
|
--el-table-border-color: var(--color-border-light);
|
||||||
|
--el-table-header-bg-color: #f3f5fb;
|
||||||
|
--el-table-header-text-color: var(--text-secondary);
|
||||||
|
--el-table-row-hover-bg-color: #f6f8ff;
|
||||||
|
--el-table-current-row-bg-color: #eef0ff;
|
||||||
|
--el-table-tr-bg-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #f3f5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__inner-wrapper::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Card ---- */
|
||||||
|
.el-card {
|
||||||
|
--el-card-border-color: var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card.is-always-shadow {
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dialog ---- */
|
||||||
|
.el-dialog {
|
||||||
|
--el-dialog-bg-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__header {
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tag ---- */
|
||||||
|
.el-tag {
|
||||||
|
--el-tag-border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Radio Button(筛选高亮) ---- */
|
||||||
|
.el-radio-button__original-radio:checked+.el-radio-button__inner {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: -1px 0 0 0 var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-radio-button__inner {
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-radio-group .el-radio-button:first-child .el-radio-button__inner {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-radio-group .el-radio-button:last-child .el-radio-button__inner {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress ---- */
|
||||||
|
.el-progress-bar__outer {
|
||||||
|
background: #e8ebf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Switch ---- */
|
||||||
|
.el-switch.is-checked .el-switch__core {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Collapse ---- */
|
||||||
|
.el-collapse {
|
||||||
|
--el-collapse-border-color: var(--color-border-light);
|
||||||
|
--el-collapse-header-bg-color: transparent;
|
||||||
|
--el-collapse-content-bg-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Pagination ---- */
|
||||||
|
.el-pagination {
|
||||||
|
--el-pagination-button-bg-color: var(--color-surface);
|
||||||
|
--el-pagination-hover-color: var(--color-primary);
|
||||||
|
--el-pagination-active-bg-color: var(--color-primary);
|
||||||
|
--el-pagination-active-color: #fff;
|
||||||
|
--el-pagination-button-color: var(--text-secondary);
|
||||||
|
--el-pagination-border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dropdown ---- */
|
||||||
|
.el-dropdown-menu {
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dropdown-menu__item:not(.is-disabled):hover {
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Message / MessageBox ---- */
|
||||||
|
.el-message {
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Empty ---- */
|
||||||
|
.el-empty__description p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbar ---- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c6cce0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #aab3cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
54
src/components/common/SearchInput.vue
Normal file
54
src/components/common/SearchInput.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
clearable
|
||||||
|
class="search-input"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
placeholder: { type: String, default: '搜索网站、备注...' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'search'])
|
||||||
|
|
||||||
|
const keyword = ref(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
keyword.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
emit('update:modelValue', keyword.value)
|
||||||
|
emit('search', keyword.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-input {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input :deep(.el-input__wrapper) {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.search-input { width: 260px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-input { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,14 +34,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="关键词" class="search-keyword">
|
<el-form-item label="关键词" class="search-keyword">
|
||||||
<el-input v-model="localFilters.search" placeholder="搜索网站、备注" clearable @keyup.enter="handleSearch"
|
<SearchInput v-model="localFilters.search" @search="handleSearch" />
|
||||||
class="wide-search-input">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon>
|
|
||||||
<Search />
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +63,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, watch, computed } from "vue";
|
import { reactive, watch, computed } from "vue";
|
||||||
import { Search, Sort } from "@element-plus/icons-vue";
|
import { Search, Sort } from "@element-plus/icons-vue";
|
||||||
|
import SearchInput from "@/components/common/SearchInput.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
filters: { type: Object, required: true },
|
filters: { type: Object, required: true },
|
||||||
@@ -144,21 +138,16 @@ const setSort = (order) => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-card {
|
.filter-card {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
background: transparent;
|
background: transparent !important;
|
||||||
/* 核心修改:禁止用户选中文字,防止点击时变蓝 */
|
box-shadow: none !important;
|
||||||
-webkit-user-select: none;
|
border-radius: 0 !important;
|
||||||
/* Safari */
|
overflow: visible !important;
|
||||||
-moz-user-select: none;
|
--el-card-border-color: transparent;
|
||||||
/* Firefox */
|
--el-card-bg-color: transparent;
|
||||||
-ms-user-select: none;
|
|
||||||
/* IE/Edge */
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
/* 标准语法 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 恢复输入框的可选中状态,否则无法输入文字 */
|
|
||||||
:deep(.el-input__inner) {
|
:deep(.el-input__inner) {
|
||||||
-webkit-user-select: text !important;
|
|
||||||
user-select: text !important;
|
user-select: text !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +172,9 @@ const setSort = (order) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.divider-wrapper {
|
.divider-wrapper {
|
||||||
color: #dcdfe6;
|
color: var(--text-muted);
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 200;
|
font-weight: 300;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -196,13 +185,27 @@ const setSort = (order) => {
|
|||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 宽度控制 */
|
:deep(.el-form-item__label) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.wide-select {
|
.wide-select {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-search-input {
|
.filter-main :deep(.el-input__wrapper),
|
||||||
width: 320px;
|
.filter-main :deep(.el-button) {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-select-dropdown) {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions :deep(.el-button) {
|
||||||
|
border-radius: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-actions {
|
.filter-actions {
|
||||||
@@ -212,14 +215,11 @@ const setSort = (order) => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn {
|
:deep(.el-select-group__title) {
|
||||||
font-weight: bold;
|
background: var(--color-bg);
|
||||||
}
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
@media (max-width: 1400px) {
|
padding-left: 12px;
|
||||||
.wide-search-input {
|
|
||||||
width: 260px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -236,17 +236,8 @@ const setSort = (order) => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-select,
|
.wide-select {
|
||||||
.wide-search-input {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 运营人员分组标题样式 */
|
|
||||||
:deep(.el-select-group__title) {
|
|
||||||
background-color: #f5f7fa;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '📝 编辑工单任务' : '✨ 创建新工单'" width="550px" class="modern-dialog"
|
<el-dialog v-model="dialogVisible" width="550px" class="ticket-dialog" destroy-on-close @close="handleClose">
|
||||||
destroy-on-close @close="handleClose">
|
<template #header>
|
||||||
|
<div class="dlg-header">
|
||||||
|
<div class="dlg-badge" :class="{ 'is-editing': isEditing }">
|
||||||
|
<el-icon :size="17">
|
||||||
|
<EditPen v-if="isEditing" />
|
||||||
|
<MagicStick v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="dlg-head-text">
|
||||||
|
<div class="dlg-title">{{ isEditing ? "编辑工单任务" : "创建新工单" }}</div>
|
||||||
|
<div class="dlg-subtitle">{{ isEditing ? "调整任务详情,保存后即时生效" : "完善任务信息,创建后进入工单列表" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="compact-form">
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="compact-form">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="section-title">基础信息</div>
|
<div class="section-title">
|
||||||
|
<span class="section-icon"><el-icon :size="12"><Document /></el-icon></span>基础信息
|
||||||
|
</div>
|
||||||
<el-form-item label="网站地址" prop="website">
|
<el-form-item label="网站地址" prop="website">
|
||||||
<el-input v-model="form.website" placeholder="输入URL" clearable>
|
<el-input v-model="form.website" placeholder="输入URL" clearable>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -13,24 +28,37 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="数据量" prop="datanum">
|
||||||
|
<el-input v-model="form.datanum" placeholder="输入数据量" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<DataAnalysis />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section people-grid">
|
<div class="form-section people-grid">
|
||||||
<div class="section-title column-full">人员选择</div>
|
<div class="section-title column-full">
|
||||||
|
<span class="section-icon"><el-icon :size="12"><UserFilled /></el-icon></span>人员选择
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-form-item label="客户单位" prop="company_id" class="client-item">
|
<el-form-item label="客户单位" prop="company_id" class="client-item">
|
||||||
<el-select v-model="form.company_id" placeholder="请选择单位" style="width: 100%" filterable
|
<el-select v-model="form.company_id" placeholder="请选择单位" style="width: 100%" filterable
|
||||||
@change="handleCustomerChange">
|
@change="handleCustomerChange">
|
||||||
<el-option v-for="item in companiesWithOperators.filter(c => c.id !== 0)" :key="item.id" :label="item.company_name"
|
<el-option v-for="item in companiesWithOperators.filter(c => c.id !== 0)" :key="item.id"
|
||||||
:value="item.id" />
|
:label="item.company_name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="运营人员" prop="operator_id" class="client-item">
|
<el-form-item label="运营人员" prop="operator_id" class="client-item">
|
||||||
<el-select v-model="form.operator_id" placeholder="请选择人员" style="width: 100%" filterable
|
<el-select v-model="form.operator_id" placeholder="请选择人员" style="width: 100%" filterable
|
||||||
:disabled="!form.company_id">
|
:disabled="!form.company_id">
|
||||||
<el-option v-for="item in (companiesWithOperators.find(c => c.id === form.company_id)?.companyoperators || []).filter(op => op.id !== 0)" :key="item.id" :label="item.operator_name"
|
<el-option
|
||||||
:value="item.id" />
|
v-for="item in (companiesWithOperators.find(c => c.id === form.company_id)?.companyoperators || []).filter(op => op.id !== 0)"
|
||||||
|
:key="item.id" :label="item.operator_name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@@ -42,6 +70,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="section-icon"><el-icon :size="12"><Odometer /></el-icon></span>进度与备注
|
||||||
|
</div>
|
||||||
<el-form-item label="进度">
|
<el-form-item label="进度">
|
||||||
<el-radio-group v-model="form.status" class="modern-radio-group">
|
<el-radio-group v-model="form.status" class="modern-radio-group">
|
||||||
<el-radio-button :label="0">未分配</el-radio-button>
|
<el-radio-button :label="0">未分配</el-radio-button>
|
||||||
@@ -51,6 +82,37 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="任务进度" class="progress-panel">
|
||||||
|
<!-- 海浪液位进度条:水位高度 = 进度值,浪尖从左往右涌动;点击/拖动轨道或键盘均可调节 -->
|
||||||
|
<div class="gauge-progress" :class="{ 'is-dragging': dragging, 'is-hovering': hovering }"
|
||||||
|
:style="{ '--progress-color': progressColor }">
|
||||||
|
<div ref="trackRef" class="gp-track" role="slider" tabindex="0" :aria-valuemin="0" :aria-valuemax="100"
|
||||||
|
:aria-valuenow="form.progress" @mousedown="onTrackMouseDown" @touchstart.prevent="onTrackTouchStart"
|
||||||
|
@mousemove="onTrackMouseMove" @mouseenter="hovering = true" @mouseleave="hovering = false"
|
||||||
|
@keydown.left.prevent="changeStep(-5)" @keydown.right.prevent="changeStep(5)"
|
||||||
|
@keydown.home.prevent="form.progress = 0" @keydown.end.prevent="form.progress = 100">
|
||||||
|
<div class="gp-clip">
|
||||||
|
<!-- 液体铺满全宽,水位随进度上涨 -->
|
||||||
|
<div class="gp-liquid" :class="{ 'is-empty': form.progress === 0 }" :style="{ height: form.progress + '%' }">
|
||||||
|
<div class="gp-sheen"></div>
|
||||||
|
<svg class="gp-waves gp-waves-back" viewBox="0 0 280 14" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<path d="M0,7 C12,0 23,14 35,7 C47,0 58,14 70,7 C82,0 93,14 105,7 C117,0 128,14 140,7 C152,0 163,14 175,7 C187,0 198,14 210,7 C222,0 233,14 245,7 C257,0 268,14 280,7 L280,14 L0,14 Z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="gp-waves gp-waves-front" viewBox="0 0 300 12" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<path d="M0,6 C8,0 17,12 25,6 C33,0 42,12 50,6 C58,0 67,12 75,6 C83,0 92,12 100,6 C108,0 117,12 125,6 C133,0 142,12 150,6 C158,0 167,12 175,6 C183,0 192,12 200,6 C208,0 217,12 225,6 C233,0 242,12 250,6 C258,0 267,12 275,6 C283,0 292,12 300,6 L300,12 L0,12 Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 气泡跟随光标水平位置,悬停/拖动时显示 -->
|
||||||
|
<div class="gp-bubble" :style="{ left: bubbleX + '%' }">{{ form.progress }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="gp-meta">
|
||||||
|
<span class="gp-pct">{{ form.progress }}</span>
|
||||||
|
<span class="gp-stage">{{ progressStage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -70,7 +132,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch, computed, nextTick } from "vue";
|
import { ref, reactive, watch, computed, nextTick } from "vue";
|
||||||
import { Link } from "@element-plus/icons-vue";
|
import { Link, DataAnalysis } from "@element-plus/icons-vue";
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "submit"]);
|
const emit = defineEmits(["update:modelValue", "submit"]);
|
||||||
|
|
||||||
@@ -86,14 +148,17 @@ const props = defineProps({
|
|||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
const isInitializing = ref(false);
|
||||||
|
|
||||||
// 核心表单对象:customer_id 已包含在内
|
// 核心表单对象:customer_id 已包含在内
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
website: "",
|
website: "",
|
||||||
|
datanum: "",
|
||||||
company_id: null,
|
company_id: null,
|
||||||
operator_id: null,
|
operator_id: null,
|
||||||
user_id: null,
|
user_id: null,
|
||||||
status: 0,
|
status: 0,
|
||||||
|
progress: 0,
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +176,102 @@ const handleCustomerChange = () => {
|
|||||||
form.operator_id = null;
|
form.operator_id = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 任务进度颜色:按进度值取语义色;填充主色通过 CSS transition 平滑过渡,跨阶段变色无硬切
|
||||||
|
const progressColor = computed(() => {
|
||||||
|
const p = form.progress;
|
||||||
|
if (p >= 100) return "#10b981"; // 已完成 - 绿
|
||||||
|
if (p >= 75) return "#409eff"; // 快完成 - 蓝
|
||||||
|
if (p >= 40) return "#e6a23c"; // 进行中 - 橙
|
||||||
|
return "#f56c6c"; // 刚开始 - 红
|
||||||
|
});
|
||||||
|
|
||||||
|
// 进度阶段文案
|
||||||
|
const progressStage = computed(() => {
|
||||||
|
const p = form.progress;
|
||||||
|
if (p >= 100) return "已完成";
|
||||||
|
if (p >= 75) return "接近完成";
|
||||||
|
if (p >= 40) return "推进中";
|
||||||
|
return "刚开始";
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 可拖动进度条交互 ---
|
||||||
|
const dragging = ref(false);
|
||||||
|
const hovering = ref(false);
|
||||||
|
const bubbleX = ref(50); // 气泡跟随光标的水平位置(轨道内百分比)
|
||||||
|
const trackRef = ref(null);
|
||||||
|
|
||||||
|
const setProgressFromClientX = (clientX) => {
|
||||||
|
const el = trackRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const ratio = (clientX - rect.left) / rect.width;
|
||||||
|
const pct = Math.min(100, Math.max(0, Math.round(ratio * 100)));
|
||||||
|
form.progress = pct;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 气泡水平位置跟随光标,钳制在轨道两端以内避免溢出
|
||||||
|
const updateBubbleX = (clientX) => {
|
||||||
|
const el = trackRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const ratio = (clientX - rect.left) / rect.width;
|
||||||
|
bubbleX.value = Math.min(94, Math.max(6, ratio * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrackMouseMove = (e) => updateBubbleX(e.clientX);
|
||||||
|
|
||||||
|
const onTrackMouseDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setProgressFromClientX(e.clientX);
|
||||||
|
updateBubbleX(e.clientX);
|
||||||
|
dragging.value = true;
|
||||||
|
const move = (ev) => {
|
||||||
|
setProgressFromClientX(ev.clientX);
|
||||||
|
updateBubbleX(ev.clientX);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
dragging.value = false;
|
||||||
|
window.removeEventListener("mousemove", move);
|
||||||
|
window.removeEventListener("mouseup", up);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", move);
|
||||||
|
window.addEventListener("mouseup", up);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrackTouchStart = (e) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setProgressFromClientX(touch.clientX);
|
||||||
|
updateBubbleX(touch.clientX);
|
||||||
|
dragging.value = true;
|
||||||
|
const move = (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setProgressFromClientX(ev.touches[0].clientX);
|
||||||
|
updateBubbleX(ev.touches[0].clientX);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
dragging.value = false;
|
||||||
|
window.removeEventListener("touchmove", move);
|
||||||
|
window.removeEventListener("touchend", up);
|
||||||
|
};
|
||||||
|
window.addEventListener("touchmove", move, { passive: false });
|
||||||
|
window.addEventListener("touchend", up);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStep = (delta) => {
|
||||||
|
form.progress = Math.min(100, Math.max(0, form.progress + delta));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态变为已完成(2)或已完结(3)时,自动将进度设为100
|
||||||
|
watch(
|
||||||
|
() => form.status,
|
||||||
|
(val) => {
|
||||||
|
if (isInitializing.value) return;
|
||||||
|
if (val === 2 || val === 3) {
|
||||||
|
form.progress = 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// --- 监听弹窗与回显 ---
|
// --- 监听弹窗与回显 ---
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -118,6 +279,7 @@ watch(
|
|||||||
dialogVisible.value = val;
|
dialogVisible.value = val;
|
||||||
if (val) { // 只在弹窗打开时候,执行初始化
|
if (val) { // 只在弹窗打开时候,执行初始化
|
||||||
nextTick(() => { // 等待DOM更新完成后再操作表单数据
|
nextTick(() => { // 等待DOM更新完成后再操作表单数据
|
||||||
|
isInitializing.value = true;
|
||||||
resetForm(); // 先重置
|
resetForm(); // 先重置
|
||||||
if (props.isEditing && props.formData) {
|
if (props.isEditing && props.formData) {
|
||||||
const d = props.formData;
|
const d = props.formData;
|
||||||
@@ -155,6 +317,7 @@ watch(
|
|||||||
if (u) form.user_id = u.id;
|
if (u) form.user_id = u.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isInitializing.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,10 +333,12 @@ const resetForm = () => {
|
|||||||
if (formRef.value) formRef.value.resetFields();
|
if (formRef.value) formRef.value.resetFields();
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
website: "",
|
website: "",
|
||||||
|
datanum: "",
|
||||||
company_id: null,
|
company_id: null,
|
||||||
operator_id: null,
|
operator_id: null,
|
||||||
user_id: null,
|
user_id: null,
|
||||||
status: 0,
|
status: 0,
|
||||||
|
progress: 0,
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -186,7 +351,7 @@ const handleSubmit = async () => {
|
|||||||
await formRef.value.validate();
|
await formRef.value.validate();
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
|
||||||
// 复制提交数据,如果是新增,确保 staff_id 为空
|
|
||||||
const submitData = { ...form };
|
const submitData = { ...form };
|
||||||
|
|
||||||
// 核心修复:如果状态是”已完结”,确保 lock_time 不会被意外修改。
|
// 核心修复:如果状态是”已完结”,确保 lock_time 不会被意外修改。
|
||||||
@@ -198,9 +363,6 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
delete submitData.create_time;
|
delete submitData.create_time;
|
||||||
delete submitData.update_time;
|
delete submitData.update_time;
|
||||||
if (!props.isEditing) {
|
|
||||||
submitData.user_id = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("submit", submitData);
|
emit("submit", submitData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -212,30 +374,26 @@ const handleSubmit = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 保持你原本的样式不变 */
|
|
||||||
:deep(.modern-dialog) {
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: var(--text-muted);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title::before {
|
.section-title::before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 4px;
|
width: 3px;
|
||||||
height: 14px;
|
height: 12px;
|
||||||
background: #409eff;
|
background: var(--color-primary);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
@@ -243,7 +401,7 @@ const handleSubmit = async () => {
|
|||||||
.people-grid {
|
.people-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-full {
|
.column-full {
|
||||||
@@ -251,9 +409,251 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modern-radio-group {
|
.modern-radio-group {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 海浪液位进度条:水位随进度上涨,浪尖从左往右涌动 ===== */
|
||||||
|
.gauge-progress {
|
||||||
|
--progress-color: #4f46e5; /* 兜底色,实际由行内样式注入 */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 0 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 轨道:玻璃胶囊舱 + 四分刻度线(刻度没入水中部分被液面覆盖) --- */
|
||||||
|
.gp-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 26px;
|
||||||
|
margin: 0 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(90deg, transparent calc(25% - 0.5px), rgba(15, 23, 42, 0.08) 25%, transparent calc(25% + 0.5px)),
|
||||||
|
linear-gradient(90deg, transparent calc(50% - 0.5px), rgba(15, 23, 42, 0.08) 50%, transparent calc(50% + 0.5px)),
|
||||||
|
linear-gradient(90deg, transparent calc(75% - 0.5px), rgba(15, 23, 42, 0.08) 75%, transparent calc(75% + 0.5px)),
|
||||||
|
linear-gradient(180deg, #e2e7f0 0%, #edf0f7 45%, #f5f7fb 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 5px rgba(15, 23, 42, 0.14),
|
||||||
|
inset 0 -1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 1px 2px rgba(255, 255, 255, 0.7);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-track:hover {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 6px rgba(15, 23, 42, 0.18),
|
||||||
|
inset 0 -1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 1px 2px rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-track:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px color-mix(in srgb, var(--progress-color) 32%, transparent),
|
||||||
|
inset 0 2px 5px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 裁剪层:液体与海浪只允许出现在胶囊舱内(气泡在裁剪层之外,不被裁掉) */
|
||||||
|
.gp-clip {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 液体:铺满全宽,水位高度 = 进度值 --- */
|
||||||
|
.gp-liquid {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--progress-color); /* 主色可过渡:跨阶段变色平滑无硬切 */
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.30) 0%, rgba(255, 255, 255, 0) 45%), /* 近水面提亮 */
|
||||||
|
linear-gradient(0deg, rgba(0, 0, 0, 0.14) 0%, rgba(0, 0, 0, 0) 40%), /* 水深处压暗 */
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0) 40%); /* 左浅右深 → 颜色渐变 */
|
||||||
|
box-shadow: 0 3px 12px -2px color-mix(in srgb, var(--progress-color) 55%, transparent); /* 底部同色系辉光 */
|
||||||
|
transition: height 0.22s ease, background-color 0.35s ease, box-shadow 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度为 0 时隐藏水体 */
|
||||||
|
.gp-liquid.is-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流光扫过:一道斜向高光周期性掠过水体 */
|
||||||
|
.gp-sheen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-sheen::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -25%;
|
||||||
|
bottom: -25%;
|
||||||
|
left: -45%;
|
||||||
|
width: 34%;
|
||||||
|
background: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.45) 50%, transparent 100%);
|
||||||
|
transform: skewX(-12deg);
|
||||||
|
animation: sheenSweep 3.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheenSweep {
|
||||||
|
0% { left: -45%; }
|
||||||
|
45% { left: 120%; }
|
||||||
|
100% { left: 120%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 海浪:前后两层浪尖横跨液面,视差涌动(从左往右) --- */
|
||||||
|
.gp-waves {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 200%; /* 两个浪形周期,平移 50% 即可无缝循环 */
|
||||||
|
pointer-events: none;
|
||||||
|
animation: waveDriftX linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-waves path {
|
||||||
|
transition: fill 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 后层涌浪:主色半透明,幅面更高、流动更慢(约 2 个浪峰横跨轨道) */
|
||||||
|
.gp-waves-back {
|
||||||
|
top: -8px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.55;
|
||||||
|
animation-duration: 5s;
|
||||||
|
animation-delay: -1.6s; /* 错开相位 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-waves-back path {
|
||||||
|
fill: var(--progress-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 前层浪花:主色调白的泡沫色,贴着液面、流动更快(约 3 个浪峰横跨轨道) */
|
||||||
|
.gp-waves-front {
|
||||||
|
top: -7px;
|
||||||
|
height: 12px;
|
||||||
|
animation-duration: 3.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-waves-front path {
|
||||||
|
fill: color-mix(in srgb, var(--progress-color) 35%, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes waveDriftX {
|
||||||
|
from { transform: translateX(-50%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖动时:水位即时响应(无过渡延迟),海浪加速涌动 */
|
||||||
|
.gauge-progress.is-dragging .gp-liquid {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-progress.is-dragging .gp-waves-front {
|
||||||
|
animation-duration: 1.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-progress.is-dragging .gp-waves-back {
|
||||||
|
animation-duration: 2.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 气泡:跟随光标的弹性提示 --- */
|
||||||
|
.gp-bubble {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
transform: translateX(-50%) translateY(6px) scale(0.9);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
background: var(--progress-color);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--progress-color) 65%, transparent);
|
||||||
|
transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.35s ease, left 0.08s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-bubble::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: var(--progress-color);
|
||||||
|
transition: border-top-color 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-progress.is-hovering .gp-bubble,
|
||||||
|
.gauge-progress.is-dragging .gp-bubble {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 读数与阶段徽标 --- */
|
||||||
|
.gp-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-pct {
|
||||||
|
font-family: "Bahnschrift", "DIN Alternate", "Segoe UI", sans-serif;
|
||||||
|
font-size: 27px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-pct::after {
|
||||||
|
content: "%";
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #9ca3af);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-stage {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--progress-color);
|
||||||
|
background: color-mix(in srgb, var(--progress-color) 10%, #ffffff);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--progress-color) 32%, transparent);
|
||||||
|
transition: color 0.35s ease, background-color 0.35s ease, border-color 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尊重系统“减弱动态效果”设置 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.gp-waves,
|
||||||
|
.gp-sheen::before {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card class="compact-card" shadow="never">
|
<el-card class="compact-card" shadow="never">
|
||||||
<el-table
|
<el-table ref="tableRef" :data="tickets" row-key="id" v-loading="loading" header-cell-class-name="table-header"
|
||||||
ref="tableRef"
|
style="width: 100%" @select="onSelect" @select-all="onSelectAll">
|
||||||
:data="tickets"
|
<el-table-column type="selection" width="50" :reserve-selection="true" />
|
||||||
v-loading="loading"
|
<el-table-column label="网站地址" min-width="180">
|
||||||
header-cell-class-name="table-header"
|
|
||||||
style="width: 100%"
|
|
||||||
@selection-change="$emit('selection-change', $event)"
|
|
||||||
>
|
|
||||||
<el-table-column type="selection" width="50" />
|
|
||||||
<el-table-column prop="website" label="网站地址" min-width="180">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link
|
<el-link type="primary" :href="formatUrl(row.website)" target="_blank" rel="noopener noreferrer"
|
||||||
type="primary"
|
class="site-link">
|
||||||
:href="formatUrl(row.website)"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="site-link"
|
|
||||||
>
|
|
||||||
{{ row.website }}
|
{{ row.website }}
|
||||||
</el-link>
|
</el-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,27 +24,15 @@
|
|||||||
<el-table-column label="任务进度" width="140">
|
<el-table-column label="任务进度" width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
<el-progress
|
<el-progress :percentage="Number(row.progress || 0)" :status="Number(row.progress) === 100 ? 'success' : ''"
|
||||||
:percentage="Number(row.progress || 0)"
|
:stroke-width="6" :text-inside="false" />
|
||||||
:status="Number(row.progress) === 100 ? 'success' : ''"
|
|
||||||
:stroke-width="6"
|
|
||||||
:text-inside="false"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column
|
<el-table-column prop="company_name" label="所属客户" min-width="120" align="center">
|
||||||
prop="company_name"
|
|
||||||
label="所属客户"
|
|
||||||
min-width="120"
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span
|
<span class="company-badge" :style="getCompanyStyle(row.company_name)">
|
||||||
class="company-badge"
|
|
||||||
:style="getCompanyStyle(row.company_name)"
|
|
||||||
>
|
|
||||||
{{ row.company_name || "未关联" }}
|
{{ row.company_name || "未关联" }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,12 +57,7 @@
|
|||||||
|
|
||||||
<el-table-column label="备注" min-width="150">
|
<el-table-column label="备注" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip v-if="row.remark" effect="dark" :content="row.remark" placement="top">
|
||||||
v-if="row.remark"
|
|
||||||
effect="dark"
|
|
||||||
:content="row.remark"
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<span class="remark-text">{{ row.remark }}</span>
|
<span class="remark-text">{{ row.remark }}</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<span v-else class="empty-text">-</span>
|
<span v-else class="empty-text">-</span>
|
||||||
@@ -100,29 +72,30 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="锁定状态" width="90" align="center">
|
<el-table-column label="锁定" width="80" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<span class="lock-badge" :class="{ locked: row.lock_time && row.lock_time !== '0' }">
|
||||||
v-if="row.lock_time && row.lock_time !== '0'"
|
{{ row.lock_time && row.lock_time !== '0' ? '已锁定' : '未锁定' }}
|
||||||
type="success"
|
</span>
|
||||||
size="small"
|
|
||||||
effect="light"
|
|
||||||
>已锁定</el-tag
|
|
||||||
>
|
|
||||||
<el-tag v-else type="info" size="small" effect="plain">未锁定</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="时间节点" width="160">
|
<el-table-column label="时间节点" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="time-wrapper">
|
<div class="time-wrapper">
|
||||||
<span>创建:{{ formatDateTime(row.create_time) }}</span>
|
<span class="time-row">
|
||||||
<span class="update-label">更新:{{ formatDateTime(row.update_time) }}</span>
|
<span class="time-label">创建</span>
|
||||||
|
{{ formatDateTime(row.create_time) }}
|
||||||
|
</span>
|
||||||
|
<span class="time-row is-update">
|
||||||
|
<span class="time-label">更新</span>
|
||||||
|
{{ formatDateTime(row.update_time) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="120" fixed="right" align="right">
|
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="$emit('edit', row)">编辑</el-button>
|
<el-button link type="primary" @click="$emit('edit', row)">编辑</el-button>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
@@ -143,21 +116,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, reactive } from "vue";
|
||||||
import { formatUrl, formatDateTime } from "@/utils/format";
|
import { formatUrl, formatDateTime } from "@/utils/format";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
tickets: { type: Array, default: () => [] },
|
tickets: { type: Array, default: () => [] },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["edit", "delete", "selection-change"]);
|
const emit = defineEmits(["edit", "delete", "selection-change"]);
|
||||||
|
|
||||||
const tableRef = ref(null);
|
const tableRef = ref(null);
|
||||||
|
const selectedMap = reactive(new Map());
|
||||||
|
|
||||||
|
const emitSelection = () => {
|
||||||
|
emit("selection-change", Array.from(selectedMap.values()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (selection, row) => {
|
||||||
|
if (selection.some((r) => r.id === row.id)) {
|
||||||
|
selectedMap.set(row.id, row);
|
||||||
|
} else {
|
||||||
|
selectedMap.delete(row.id);
|
||||||
|
}
|
||||||
|
emitSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectAll = (selection) => {
|
||||||
|
props.tickets.forEach((t) => selectedMap.delete(t.id));
|
||||||
|
selection.forEach((row) => selectedMap.set(row.id, row));
|
||||||
|
emitSelection();
|
||||||
|
};
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
tableRef.value?.clearSelection();
|
tableRef.value?.clearSelection();
|
||||||
|
selectedMap.clear();
|
||||||
|
emit("selection-change", []);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,10 +162,10 @@ const statusLabel = (status) => statusMap[status] || "未知";
|
|||||||
|
|
||||||
const getStatusStyle = (status) => {
|
const getStatusStyle = (status) => {
|
||||||
const styles = {
|
const styles = {
|
||||||
0: { color: "#909399", background: "#f4f4f5" },
|
0: { color: "#6b7280", background: "#f3f4f6" },
|
||||||
1: { color: "#409EFF", background: "#ecf5ff" },
|
1: { color: "#4f46e5", background: "#eef0ff" },
|
||||||
2: { color: "#67C23A", background: "#f0f9eb" },
|
2: { color: "#16a34a", background: "#ecfdf3" },
|
||||||
3: { color: "#e6a23c", background: "#fdf6ec" },
|
3: { color: "#d97706", background: "#fffbeb" },
|
||||||
};
|
};
|
||||||
return styles[status] || styles[0];
|
return styles[status] || styles[0];
|
||||||
};
|
};
|
||||||
@@ -178,14 +173,14 @@ const getStatusStyle = (status) => {
|
|||||||
// 客户徽章颜色映射(同一客户保持同一颜色)
|
// 客户徽章颜色映射(同一客户保持同一颜色)
|
||||||
const companyColorMap = new Map();
|
const companyColorMap = new Map();
|
||||||
const COMPANY_PALETTE = [
|
const COMPANY_PALETTE = [
|
||||||
{ color: "#409EFF", background: "#ecf5ff" },
|
{ color: "#4f46e5", background: "#eef0ff" },
|
||||||
{ color: "#67C23A", background: "#f0f9eb" },
|
{ color: "#16a34a", background: "#ecfdf3" },
|
||||||
{ color: "#E6A23C", background: "#fdf6ec" },
|
{ color: "#d97706", background: "#fffbeb" },
|
||||||
{ color: "#F56C6C", background: "#fef0f0" },
|
{ color: "#dc2626", background: "#fef2f2" },
|
||||||
{ color: "#909399", background: "#f4f4f5" },
|
{ color: "#6b7280", background: "#f3f4f6" },
|
||||||
{ color: "#9C27B0", background: "#f3e5f5" },
|
{ color: "#7c3aed", background: "#f5f0ff" },
|
||||||
{ color: "#00BCD4", background: "#e0f7fa" },
|
{ color: "#0891b2", background: "#ecfeff" },
|
||||||
{ color: "#FF9800", background: "#fff3e0" },
|
{ color: "#ea580c", background: "#fff7ed" },
|
||||||
];
|
];
|
||||||
let nextColorIndex = 0;
|
let nextColorIndex = 0;
|
||||||
|
|
||||||
@@ -204,12 +199,21 @@ const getCompanyStyle = (name) => {
|
|||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 调整进度条内部文字大小 */
|
|
||||||
:deep(.el-progress__text) {
|
:deep(.el-progress__text) {
|
||||||
font-size: 11px !important;
|
font-size: 11px !important;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-card {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.staff-wrapper {
|
.staff-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -219,21 +223,22 @@ const getCompanyStyle = (name) => {
|
|||||||
.staff-item {
|
.staff-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 22px;
|
height: 20px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-role {
|
.staff-role {
|
||||||
width: 34px;
|
width: 30px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
background: #909399;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-name {
|
.staff-name {
|
||||||
@@ -242,30 +247,34 @@ const getCompanyStyle = (name) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.staff-item.client {
|
.staff-item.client {
|
||||||
background: #f4f4f5;
|
background: #eef0ff;
|
||||||
color: #909399;
|
color: #4338ca;
|
||||||
border: 1px solid #e9e9eb;
|
border: 1px solid #e3e6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staff-item.client .staff-role {
|
||||||
|
background: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-item.internal {
|
.staff-item.internal {
|
||||||
background: #ecf5ff;
|
background: #e7f6ed;
|
||||||
color: #409eff;
|
color: #15803d;
|
||||||
border: 1px solid #d9ecff;
|
border: 1px solid #d3f0df;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-item.internal .staff-role {
|
.staff-item.internal .staff-role {
|
||||||
background: #409eff;
|
background: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-num-text {
|
.data-num-text {
|
||||||
font-family: "Monaco", monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
color: #409eff;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-text {
|
.remark-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #606266;
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -273,23 +282,19 @@ const getCompanyStyle = (name) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
color: #c0c4cc;
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
|
||||||
|
|
||||||
.compact-card {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge,
|
.status-badge,
|
||||||
.company-badge {
|
.company-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
height: 22px;
|
height: 20px;
|
||||||
line-height: 22px;
|
line-height: 20px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
border-radius: 11px;
|
font-weight: 600;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-badge {
|
.company-badge {
|
||||||
@@ -299,19 +304,39 @@ const getCompanyStyle = (name) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lock-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-badge.locked {
|
||||||
|
color: #16a34a;
|
||||||
|
background: #ecfdf3;
|
||||||
|
}
|
||||||
|
|
||||||
.time-wrapper {
|
.time-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #606266;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-label {
|
.update-label {
|
||||||
color: #909399;
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.table-header) {
|
:deep(.table-header) {
|
||||||
background-color: #fafafa !important;
|
background: #f3f5fb !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,60 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="full-screen-layout">
|
<div class="shell">
|
||||||
<el-container class="full-screen-container">
|
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||||
<el-aside width="200px" class="siderbar">
|
<div class="sidebar-brand">
|
||||||
<div class="sidebar-header">
|
<div class="brand-mark">
|
||||||
<!-- 修复:将 logo 区域包装成一个正确的外部链接 -->
|
<img src="@/assets/logo.jpg" alt="logo" class="brand-img" />
|
||||||
<a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="logo-link">
|
</div>
|
||||||
<div class="logo-container">
|
<div class="brand-text">
|
||||||
<img src="@/assets/logo.jpg" alt="方舟网络" class="logo-image" />
|
<span class="brand-name">方舟工单</span>
|
||||||
<div class="text-content">
|
<span class="brand-ver">v4.0</span>
|
||||||
<h2 class="company-name">方舟网络</h2>
|
|
||||||
<p class="system-name">工单管理系统</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
|
<nav class="nav">
|
||||||
|
<router-link to="/" class="nav-item" active-class="active" exact-active-class="active">
|
||||||
|
<span class="nav-dot"></span>
|
||||||
|
<span class="nav-label">工单管理</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/stats" class="nav-item" active-class="active">
|
||||||
|
<span class="nav-dot"></span>
|
||||||
|
<span class="nav-label">数据统计</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/companyAoperators" class="nav-item" active-class="active">
|
||||||
|
<span class="nav-dot"></span>
|
||||||
|
<span class="nav-label">客户管理</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/accountRegistration" class="nav-item" active-class="active">
|
||||||
|
<span class="nav-dot"></span>
|
||||||
|
<span class="nav-label">账户注册</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-text">运行中</span>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<el-menu default-active="/" class="sidebar-menu" router>
|
<div class="main">
|
||||||
<el-menu-item index="/">
|
<header class="topbar">
|
||||||
<el-icon>
|
<div class="topbar-left">
|
||||||
<icon-menu />
|
<button class="collapse-btn" type="button" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||||
|
@click="toggleSidebar">
|
||||||
|
<el-icon :size="18">
|
||||||
|
<component :is="sidebarCollapsed ? Expand : Fold" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span>工单管理</span>
|
</button>
|
||||||
</el-menu-item>
|
<span class="page-title">{{ route.meta?.title || '工单管理' }}</span>
|
||||||
|
</div>
|
||||||
<el-menu-item index="/stats">
|
|
||||||
<el-icon>
|
|
||||||
<document />
|
|
||||||
</el-icon>
|
|
||||||
<span>数据统计</span>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<el-menu-item index="/companyAoperators">
|
|
||||||
<el-icon>
|
|
||||||
<User />
|
|
||||||
</el-icon>
|
|
||||||
<span>客户管理</span>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<el-menu-item index="/accountRegistration">
|
|
||||||
<el-icon>
|
|
||||||
<Plus />
|
|
||||||
</el-icon>
|
|
||||||
<span>账户注册</span>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
</el-menu>
|
|
||||||
</el-aside>
|
|
||||||
|
|
||||||
<el-container class="main-container">
|
|
||||||
<el-header class="main-header">
|
|
||||||
<div class="header-content">
|
|
||||||
<div class="user-info">
|
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<div class="user-profile">
|
<div class="user-chip">
|
||||||
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
||||||
<span class="username">{{ user.name }}</span>
|
<span class="user-name">{{ user.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
@@ -62,211 +58,353 @@
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</header>
|
||||||
</div>
|
|
||||||
</el-header>
|
|
||||||
|
|
||||||
<el-main class="main-content">
|
<main class="content">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade-transform" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</el-main>
|
</main>
|
||||||
</el-container>
|
</div>
|
||||||
</el-container>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, onMounted } from "vue";
|
import { ref, reactive, onMounted } from "vue";
|
||||||
import { Menu as IconMenu, Document, User } from "@element-plus/icons-vue";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useRouter } from "vue-router";
|
import { Fold, Expand } from "@element-plus/icons-vue";
|
||||||
import { resetRouter } from "@/router";
|
import { resetRouter } from "@/router";
|
||||||
import { userApi } from "@/api/ticket";
|
import { userApi } from "@/api/ticket";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = reactive({
|
const route = useRoute();
|
||||||
name: "加载中...",
|
const user = reactive({ name: "..." });
|
||||||
avatar: "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png",
|
|
||||||
});
|
// 侧边栏折叠状态(持久化到 localStorage,刷新后保持)
|
||||||
|
const SIDEBAR_STORAGE_KEY = "sidebar_collapsed";
|
||||||
|
const sidebarCollapsed = ref(localStorage.getItem(SIDEBAR_STORAGE_KEY) === "1");
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||||
|
localStorage.setItem(SIDEBAR_STORAGE_KEY, sidebarCollapsed.value ? "1" : "0");
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const savedUser =
|
const saved = localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
|
||||||
localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
|
if (saved) user.name = JSON.parse(saved).username || "用户";
|
||||||
if (savedUser) {
|
|
||||||
const data = JSON.parse(savedUser);
|
|
||||||
user.name = data.username || "管理员";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
// 1. 调用后端接口销毁 session
|
try { await userApi.logout(); } catch (e) { }
|
||||||
try {
|
|
||||||
await userApi.logout();
|
|
||||||
} catch (e) {
|
|
||||||
// 即使后端接口异常,也要完成前端的清理
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.removeItem("user_info");
|
localStorage.removeItem("user_info");
|
||||||
sessionStorage.removeItem("user_info");
|
sessionStorage.removeItem("user_info");
|
||||||
|
|
||||||
resetRouter();
|
resetRouter();
|
||||||
router.push({ name: "login" });
|
router.push({ name: "login" });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* --- 基础布局保持不变 --- */
|
/* ---- Shell ---- */
|
||||||
.full-screen-layout,
|
.shell {
|
||||||
.full-screen-container {
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.siderbar {
|
/* ---- Sidebar(深色,制造明暗层次) ---- */
|
||||||
background-color: #001529;
|
.sidebar {
|
||||||
color: white;
|
width: 232px;
|
||||||
/* 禁止用户选中文字 */
|
flex-shrink: 0;
|
||||||
user-select: none;
|
background: linear-gradient(180deg, #1c2140 0%, #171b34 100%);
|
||||||
-webkit-user-select: none;
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
/* 兼容 Safari */
|
|
||||||
-moz-user-select: none;
|
|
||||||
/* 兼容 Firefox */
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修复:为新添加的链接添加样式 */
|
.sidebar.collapsed {
|
||||||
.logo-link {
|
width: 0;
|
||||||
text-decoration: none;
|
border-right: none;
|
||||||
color: inherit;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-container {
|
.sidebar.collapsed .sidebar-brand,
|
||||||
|
.sidebar.collapsed .nav,
|
||||||
|
.sidebar.collapsed .sidebar-foot {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand,
|
||||||
|
.nav,
|
||||||
|
.sidebar-foot {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 60% at 50% -10%, rgba(99, 102, 241, 0.18) 0%, transparent 55%),
|
||||||
|
radial-gradient(140% 50% at 0% 110%, rgba(147, 51, 234, 0.12) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 22px 20px 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-image {
|
.brand-mark {
|
||||||
width: 40px;
|
width: 38px;
|
||||||
height: 40px;
|
height: 38px;
|
||||||
border-radius: 6px;
|
border-radius: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(129, 140, 248, 0.22),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-name {
|
.brand-text {
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu {
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .el-menu-item {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .el-menu-item:hover {
|
|
||||||
background-color: rgba(24, 144, 255, 0.2);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .el-menu-item.is-active {
|
|
||||||
background-color: #1890ff;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- 右侧 Header 布局优化 --- */
|
|
||||||
.main-header {
|
|
||||||
background-color: #fff;
|
|
||||||
border-bottom: 1px solid #e6e6e6;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
gap: 2px;
|
||||||
padding: 0 20px;
|
|
||||||
height: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-profile {
|
.brand-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f4f5ff;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-ver {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(196, 201, 235, 0.7);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Navigation ---- */
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 1px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: rgba(208, 213, 240, 0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #f4f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-dot {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 6px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar footer */
|
||||||
|
.sidebar-foot {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(196, 201, 235, 0.7);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.2), 0 0 8px rgba(52, 211, 153, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Main Area ---- */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 28px;
|
||||||
|
box-shadow: 0 1px 4px rgba(23, 32, 63, 0.05);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧边栏折叠按钮 */
|
||||||
|
.collapse-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
transition: background 0.15s, color 0.15s, transform 0.15s;
|
||||||
/* 保持隐藏默认框 */
|
|
||||||
height: 48px;
|
|
||||||
/* 稍微缩小高度,让点击范围更精致 */
|
|
||||||
padding: 0 12px;
|
|
||||||
/* 左右加点间距 */
|
|
||||||
border-radius: 6px;
|
|
||||||
/* 加个圆角 */
|
|
||||||
transition: all 0.2s;
|
|
||||||
/* 让颜色变化更顺滑 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 鼠标悬停时的效果 */
|
.collapse-btn:hover {
|
||||||
.user-profile:hover {
|
background: var(--color-primary-soft);
|
||||||
background-color: #f0f2f5;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 关键:鼠标点击那一瞬间的效果 */
|
.collapse-btn:active {
|
||||||
.user-profile:active {
|
transform: scale(0.92);
|
||||||
background-color: #e6e8eb;
|
|
||||||
/* 背景变深 */
|
|
||||||
transform: scale(0.98);
|
|
||||||
/* 轻轻缩放,增加“按下去”的质感 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.page-title {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
color: #333;
|
font-weight: 600;
|
||||||
line-height: 1;
|
color: var(--text-primary);
|
||||||
/* 消除默认行高导致的下偏 */
|
letter-spacing: 0.02em;
|
||||||
display: inline-block;
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.page-title::before {
|
||||||
padding: 20px;
|
content: "";
|
||||||
background-color: #f5f7fa;
|
position: absolute;
|
||||||
height: calc(100vh - 60px);
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 14px 4px 4px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: var(--color-surface-soft);
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip:hover {
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border-color: rgba(79, 70, 229, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip :deep(.el-avatar) {
|
||||||
|
width: 30px !important;
|
||||||
|
height: 30px !important;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(23, 32, 63, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Content ---- */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 24px 28px;
|
||||||
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 过渡动画 */
|
/* ---- Transitions ---- */
|
||||||
.fade-transform-enter-active,
|
.fade-enter-active,
|
||||||
.fade-transform-leave-active {
|
.fade-leave-active {
|
||||||
transition: all 0.3s;
|
transition: opacity 0.18s, transform 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-transform-enter-from {
|
.fade-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-10px);
|
transform: translateY(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-transform-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(10px);
|
transform: translateY(-6px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import router from './router'
|
|||||||
// 导入 Element Plus
|
// 导入 Element Plus
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
// 导入设计系统(设计令牌 + Element Plus 主题对齐)
|
||||||
|
import '@/assets/main.css'
|
||||||
// 导入中文语言包
|
// 导入中文语言包
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function formatDate(time) {
|
|||||||
|
|
||||||
const timestamp = Number(time)
|
const timestamp = Number(time)
|
||||||
if (!isNaN(timestamp) && timestamp > 0) {
|
if (!isNaN(timestamp) && timestamp > 0) {
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000)
|
||||||
if (!isNaN(date.getTime())) {
|
if (!isNaN(date.getTime())) {
|
||||||
const y = date.getFullYear()
|
const y = date.getFullYear()
|
||||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ function createService(basePath) {
|
|||||||
service.interceptors.response.use(
|
service.interceptors.response.use(
|
||||||
response => response.data,
|
response => response.data,
|
||||||
|
|
||||||
|
|
||||||
error => {
|
error => {
|
||||||
const resData = error.response?.data
|
const resData = error.response?.data
|
||||||
const status = error.response?.status
|
const status = error.response?.status
|
||||||
|
|||||||
131
src/utils/socket.js
Normal file
131
src/utils/socket.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// ============================================================
|
||||||
|
// WebSocket 广播客户端封装(Workerman 2346 端口)
|
||||||
|
// 特性:单例 + 引用计数 + 断线指数退避重连 + 心跳 + 重连后通知
|
||||||
|
// 用法:
|
||||||
|
// import { openSocket, closeSocket, onTicketChanged, onReconnect } from '@/utils/socket'
|
||||||
|
// onMounted(() => {
|
||||||
|
// openSocket()
|
||||||
|
// const off1 = onTicketChanged(() => fetchTickets(true))
|
||||||
|
// const off2 = onReconnect(() => fetchTickets(true))
|
||||||
|
// onBeforeUnmount(() => { off1(); off2(); closeSocket() })
|
||||||
|
// })
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const WS_URL = 'ws://localhost:2346'
|
||||||
|
const MAX_RETRY_DELAY = 30000 // 重连封顶 30s
|
||||||
|
const HEARTBEAT_INTERVAL = 25000
|
||||||
|
|
||||||
|
let socket = null
|
||||||
|
let retryDelay = 1000
|
||||||
|
let reconnectTimer = null
|
||||||
|
let heartbeatTimer = null
|
||||||
|
let manualClosed = false // 手动关闭后不再重连
|
||||||
|
let refCount = 0
|
||||||
|
|
||||||
|
const ticketListeners = new Set() // 收到 ticket_changed 回调
|
||||||
|
const reconnectListeners = new Set() // 重连成功回调
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
manualClosed = false
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(WS_URL)
|
||||||
|
} catch (e) {
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
retryDelay = 1000
|
||||||
|
console.log('[socket] 已连接到工单推送服务 ws://localhost:2346')
|
||||||
|
startHeartbeat()
|
||||||
|
reconnectListeners.forEach((fn) => fn())
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'ticket_changed') {
|
||||||
|
ticketListeners.forEach((fn) => fn(data))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[socket] 消息解析失败:', event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
stopHeartbeat()
|
||||||
|
if (!manualClosed) scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
// 连接错误后必然触发 onclose,无需额外处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer || manualClosed) return
|
||||||
|
console.log(`[socket] 连接断开,${Math.round(retryDelay / 1000)}s 后重试...`)
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
connect()
|
||||||
|
}, retryDelay)
|
||||||
|
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
stopHeartbeat()
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send('ping')
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册工单变更监听,返回注销函数 */
|
||||||
|
export function onTicketChanged(callback) {
|
||||||
|
ticketListeners.add(callback)
|
||||||
|
return () => ticketListeners.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册重连成功监听(适合在此主动拉一次数据补齐断线期间变化),返回注销函数 */
|
||||||
|
export function onReconnect(callback) {
|
||||||
|
reconnectListeners.add(callback)
|
||||||
|
return () => reconnectListeners.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开连接(引用计数 +1;多处页面可共享同一连接) */
|
||||||
|
export function openSocket() {
|
||||||
|
refCount += 1
|
||||||
|
if (!socket || socket.readyState === WebSocket.CLOSED) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭连接(引用计数 -1;归零才真正断开) */
|
||||||
|
export function closeSocket() {
|
||||||
|
refCount = Math.max(0, refCount - 1)
|
||||||
|
if (refCount === 0) {
|
||||||
|
manualClosed = true
|
||||||
|
stopHeartbeat()
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (socket) {
|
||||||
|
socket.onclose = null
|
||||||
|
socket.close()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSocketOpen() {
|
||||||
|
return socket !== null && socket.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
@@ -1,95 +1,123 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="account-container">
|
<div class="account-root">
|
||||||
<h2>账户管理</h2>
|
<!-- 统计卡片 -->
|
||||||
|
<div class="stats-row">
|
||||||
<div class="action-bar">
|
<div class="stat-card" :class="{ active: activeRole === s.role }" v-for="s in stats" :key="s.key"
|
||||||
<div class="search-group">
|
:style="{ '--border': s.color }" @click="activeRole = activeRole === s.role ? null : s.role">
|
||||||
<el-input v-model="searchQuery" placeholder="搜索用户名/真实姓名" style="width: 250px" clearable />
|
<div class="stat-left">
|
||||||
|
<div class="stat-icon-wrap" :style="{ '--bg': s.color }">
|
||||||
|
<el-icon size="16">
|
||||||
|
<component :is="s.icon" />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<span class="stat-label">{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="stat-num">{{ s.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openRegisterDialog">注册用户</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="filteredUsers" border style="width: 100%; margin-top: 20px" v-loading="loading">
|
<!-- 主面板 -->
|
||||||
|
<div class="main-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="header-title">用户列表</span>
|
||||||
|
<span class="header-count">{{ filteredUsers.length }} 人</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-input v-model="searchQuery" placeholder="搜索用户名或真实姓名" style="width: 240px" size="default" clearable
|
||||||
|
:prefix-icon="Search" />
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openRegisterDialog">
|
||||||
|
注册用户
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="filteredUsers" v-loading="loading" header-cell-class-name="table-head"
|
||||||
|
row-class-name="table-row">
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty :image-size="100" description="暂无用户数据" />
|
<el-empty :image-size="80" description="暂无用户数据" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- <el-table-column prop="id" label="ID" width="70" align="center" /> -->
|
<el-table-column label="用户名" min-width="140">
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
|
||||||
<el-table-column prop="real_name" label="真实姓名" min-width="120">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.real_name || '-' }}
|
<div class="user-cell">
|
||||||
|
<div class="user-avatar" :style="{ background: avatarColor(row.username) }">
|
||||||
|
{{ row.username.slice(0, 1).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<span class="user-name">{{ row.username }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="角色" width="120" align="center">
|
<el-table-column label="真实姓名" min-width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="roleTagType(row.role)" size="small" effect="plain">
|
<span :class="{ 'text-muted': !row.real_name }">{{ row.real_name || '—' }}</span>
|
||||||
{{ roleLabel(row.role) }}
|
</template>
|
||||||
</el-tag>
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="角色" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="role-tag" :class="row.role">{{ roleLabel(row.role) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small" effect="plain">
|
<span class="status-badge" :class="{ on: row.status === 1 }">
|
||||||
{{ row.status === 1 ? '正常' : '已禁用' }}
|
{{ row.status === 1 ? '正常' : '已禁用' }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="创建时间" width="160" align="center">
|
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatDate(row.create_time) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
|
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button size="small" :type="scope.row.status === 1 ? 'warning' : 'success'"
|
||||||
size="small"
|
@click="handleToggleStatus(scope.row)">
|
||||||
:type="scope.row.status === 1 ? 'warning' : 'success'"
|
|
||||||
plain
|
|
||||||
@click="handleToggleStatus(scope.row)"
|
|
||||||
>
|
|
||||||
{{ scope.row.status === 1 ? '禁用' : '启用' }}
|
{{ scope.row.status === 1 ? '禁用' : '启用' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleDeleteAccount(scope.row)">
|
<el-button size="small" type="danger" plain @click="handleDeleteAccount(scope.row)">删除</el-button>
|
||||||
删除
|
|
||||||
</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 注册弹窗 -->
|
<!-- 注册弹窗 -->
|
||||||
<el-dialog
|
<el-dialog v-model="registerDialogVisible" title="注册新用户" width="420px" destroy-on-close @close="resetRegisterForm"
|
||||||
v-model="registerDialogVisible"
|
class="register-dialog">
|
||||||
title="注册新用户"
|
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-position="top">
|
||||||
width="450px"
|
|
||||||
destroy-on-close
|
|
||||||
@close="resetRegisterForm"
|
|
||||||
>
|
|
||||||
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-width="80px">
|
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item label="用户名" prop="username">
|
||||||
<el-input v-model="registerForm.username" placeholder="登录用" clearable />
|
<el-input v-model="registerForm.username" placeholder="用于登录" size="large" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="真实姓名" prop="real_name">
|
<el-form-item label="真实姓名" prop="real_name">
|
||||||
<el-input v-model="registerForm.real_name" placeholder="选填" clearable />
|
<el-input v-model="registerForm.real_name" placeholder="选填" size="large" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="密码" prop="password">
|
<el-form-item label="密码" prop="password">
|
||||||
<el-input v-model="registerForm.password" type="password" placeholder="请输入密码" show-password />
|
<el-input v-model="registerForm.password" type="password" placeholder="选填,如果不填,则默认密码为123456" size="large"
|
||||||
|
show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色" prop="role">
|
<el-form-item label="角色" prop="role">
|
||||||
<el-select v-model="registerForm.role" placeholder="请选择角色" style="width: 100%">
|
<div class="role-group">
|
||||||
<el-option label="管理员" value="admin" />
|
<button v-for="opt in roleOptions" :key="opt.value" type="button" class="role-btn"
|
||||||
<el-option label="员工" value="user" />
|
:class="{ selected: registerForm.role === opt.value }" :style="{ '--accent': opt.color }"
|
||||||
<el-option label="客户" value="client" />
|
@click="registerForm.role = opt.value">
|
||||||
|
<el-icon size="16">
|
||||||
|
<component :is="opt.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="所属公司">
|
||||||
|
<el-select v-model="registerForm.company_id" placeholder="请选择所属公司" size="large" style="width: 100%">
|
||||||
|
<el-option v-for="c in companyList" :key="c.company_id" :label="c.company_name" :value="c.company_id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="registerDialogVisible = false">取消</el-button>
|
<div class="dialog-actions">
|
||||||
<el-button type="primary" :loading="submitting" @click="handleRegister">确定注册</el-button>
|
<el-button size="large" @click="registerDialogVisible = false">取消</el-button>
|
||||||
|
<el-button size="large" type="primary" :loading="submitting" @click="handleRegister">确定注册</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,14 +125,15 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
import { Plus } from "@element-plus/icons-vue";
|
import { Plus, Search, User, Monitor, Star } from "@element-plus/icons-vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { accountApi } from "@/api/ticket";
|
import { accountApi, ticketApi } from "@/api/ticket";
|
||||||
import { formatDate } from "@/utils/format";
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
|
const companyList = ref([]);
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
const activeRole = ref(null);
|
||||||
|
|
||||||
const registerDialogVisible = ref(false);
|
const registerDialogVisible = ref(false);
|
||||||
const registerFormRef = ref(null);
|
const registerFormRef = ref(null);
|
||||||
@@ -115,19 +144,17 @@ const registerForm = reactive({
|
|||||||
real_name: "",
|
real_name: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
|
company_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerRules = {
|
const registerRules = {
|
||||||
username: [
|
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||||||
{ required: true, message: "请输入用户名", trigger: "blur" },
|
// password: [
|
||||||
],
|
// { required: true, message: "请输入密码", trigger: "blur" },
|
||||||
password: [
|
// { min: 6, message: "密码至少6位", trigger: "blur" },
|
||||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
// ],
|
||||||
{ min: 6, message: "密码至少6位", trigger: "blur" },
|
role: [{ required: true, message: "请选择角色", trigger: "change" }],
|
||||||
],
|
company_id: [{ required: true, message: "请选择所属公司", trigger: "change" }],
|
||||||
role: [
|
|
||||||
{ required: true, message: "请选择角色", trigger: "change" },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleLabel = (role) => {
|
const roleLabel = (role) => {
|
||||||
@@ -135,23 +162,42 @@ const roleLabel = (role) => {
|
|||||||
return map[role] || role;
|
return map[role] || role;
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleTagType = (role) => {
|
|
||||||
const map = { admin: "danger", user: "warning", client: "info" };
|
|
||||||
return map[role] || "info";
|
|
||||||
};
|
|
||||||
|
|
||||||
const rolePriority = { admin: 0, user: 1, client: 2 };
|
const rolePriority = { admin: 0, user: 1, client: 2 };
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: "admin", label: "管理员", color: "#ef4444", icon: Star },
|
||||||
|
{ value: "user", label: "员工", color: "#f59e0b", icon: User },
|
||||||
|
{ value: "client", label: "客户", color: "#34c759", icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ key: "total", role: null, label: "总用户", value: users.value.length, icon: User, color: "#5b5fe3" },
|
||||||
|
{ key: "admin", role: "admin", label: "管理员", value: users.value.filter(u => u.role === "admin").length, icon: Star, color: "#ef4444" },
|
||||||
|
{ key: "user", role: "user", label: "员工", value: users.value.filter(u => u.role === "user").length, icon: User, color: "#f59e0b" },
|
||||||
|
{ key: "client", role: "client", label: "客户", value: users.value.filter(u => u.role === "client").length, icon: Monitor, color: "#34c759" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avatarColor = (name) => {
|
||||||
|
const colors = ["#5b5fe3", "#ef4444", "#f59e0b", "#34c759", "#0ea5e9", "#8b5cf6", "#ec4899", "#14b8a6"];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < (name || "").length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
let list = users.value;
|
let list = users.value;
|
||||||
|
|
||||||
|
// 角色筛选
|
||||||
|
if (activeRole.value) {
|
||||||
|
list = list.filter(u => u.role === activeRole.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const q = searchQuery.value.toLowerCase();
|
const q = searchQuery.value.toLowerCase();
|
||||||
list = list.filter(
|
list = list.filter(u => u.username.toLowerCase().includes(q) || (u.real_name && u.real_name.toLowerCase().includes(q)));
|
||||||
(u) =>
|
|
||||||
u.username.toLowerCase().includes(q) ||
|
|
||||||
(u.real_name && u.real_name.toLowerCase().includes(q))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...list].sort((a, b) => (rolePriority[a.role] ?? 99) - (rolePriority[b.role] ?? 99));
|
return [...list].sort((a, b) => (rolePriority[a.role] ?? 99) - (rolePriority[b.role] ?? 99));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,11 +205,8 @@ const fetchUsers = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await accountApi.getAllUsers();
|
const res = await accountApi.getAllUsers();
|
||||||
if (res.code === 200) {
|
if (res.code === 200) users.value = res.data || [];
|
||||||
users.value = res.data || [];
|
else ElMessage.error(res.msg || "获取用户列表失败");
|
||||||
} else {
|
|
||||||
ElMessage.error(res.msg || "获取用户列表失败");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error("获取用户数据失败");
|
ElMessage.error("获取用户数据失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,16 +214,24 @@ const fetchUsers = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCompanyList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await ticketApi.getOperatorsNew();
|
||||||
|
if (res.code === 200) {
|
||||||
|
companyList.value = (res.data || []).map(item => ({ company_id: item.id, company_name: item.company_name }));
|
||||||
|
console.log("公司列表:", companyList.value);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 静默失败 */ }
|
||||||
|
};
|
||||||
|
|
||||||
const openRegisterDialog = () => {
|
const openRegisterDialog = () => {
|
||||||
|
if (companyList.value.length === 0) fetchCompanyList();
|
||||||
registerDialogVisible.value = true;
|
registerDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetRegisterForm = () => {
|
const resetRegisterForm = () => {
|
||||||
if (registerFormRef.value) registerFormRef.value.resetFields();
|
if (registerFormRef.value) registerFormRef.value.resetFields();
|
||||||
registerForm.username = "";
|
Object.assign(registerForm, { username: "", real_name: "", password: "", role: "user", company_id: null });
|
||||||
registerForm.real_name = "";
|
|
||||||
registerForm.password = "";
|
|
||||||
registerForm.role = "user";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
@@ -197,9 +248,7 @@ const handleRegister = async () => {
|
|||||||
ElMessage.error(res.msg || "注册失败");
|
ElMessage.error(res.msg || "注册失败");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name !== "ValidationError") {
|
if (e?.name !== "ValidationError") ElMessage.error("注册请求异常");
|
||||||
ElMessage.error("注册请求异常");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -208,9 +257,7 @@ const handleRegister = async () => {
|
|||||||
const handleToggleStatus = (row) => {
|
const handleToggleStatus = (row) => {
|
||||||
const newStatus = row.status === 1 ? 0 : 1;
|
const newStatus = row.status === 1 ? 0 : 1;
|
||||||
const action = newStatus === 0 ? "禁用" : "启用";
|
const action = newStatus === 0 ? "禁用" : "启用";
|
||||||
ElMessageBox.confirm(`确定${action}用户【${row.username}】吗?`, "提示", {
|
ElMessageBox.confirm(`确定${action}用户【${row.username}】吗?`, "提示", { type: "warning" })
|
||||||
type: "warning",
|
|
||||||
})
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const res = await accountApi.updateAccountStatus(row.id, newStatus);
|
const res = await accountApi.updateAccountStatus(row.id, newStatus);
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
@@ -224,15 +271,14 @@ const handleToggleStatus = (row) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = (row) => {
|
const handleDeleteAccount = (row) => {
|
||||||
ElMessageBox.confirm(
|
ElMessageBox.confirm(`确定删除用户【${row.username}】吗?此操作不可逆。`, "警告", {
|
||||||
`确定删除用户【${row.username}】吗?此操作不可逆。`,
|
type: "error",
|
||||||
"警告",
|
confirmButtonClass: "el-button--danger",
|
||||||
{ type: "error", confirmButtonClass: "el-button--danger" }
|
})
|
||||||
)
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const res = await accountApi.deleteAccount(row.id);
|
const res = await accountApi.deleteAccount(row.id);
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const idx = users.value.findIndex((u) => u.id === row.id);
|
const idx = users.value.findIndex(u => u.id === row.id);
|
||||||
if (idx > -1) users.value.splice(idx, 1);
|
if (idx > -1) users.value.splice(idx, 1);
|
||||||
ElMessage.success("删除成功");
|
ElMessage.success("删除成功");
|
||||||
} else {
|
} else {
|
||||||
@@ -242,28 +288,297 @@ const handleDeleteAccount = (row) => {
|
|||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(fetchUsers);
|
onMounted(() => { fetchUsers(); fetchCompanyList(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.account-container {
|
.account-root {
|
||||||
padding: 30px;
|
padding: 0;
|
||||||
background-color: #f5f7fa;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
/* ---- 统计卡片 ---- */
|
||||||
margin-bottom: 25px;
|
.stats-row {
|
||||||
color: #303133;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-left: 4px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.active {
|
||||||
|
background: color-mix(in srgb, var(--border) 6%, #fff);
|
||||||
|
border-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-wrap {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--bg) 12%, transparent);
|
||||||
|
color: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--border);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 主面板 ---- */
|
||||||
|
.main-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header :deep(.el-input__wrapper) {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header :deep(.el-button) {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 表格 ---- */
|
||||||
|
:deep(.table-head) {
|
||||||
|
background: #f3f5fb !important;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.table-row) {
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr:hover > td) {
|
||||||
|
background: #f6f8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户列 */
|
||||||
|
.user-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 角色标签 */
|
||||||
|
.role-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.role-tag.admin {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag.user {
|
||||||
|
color: #d97706;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag.client {
|
||||||
|
color: #16a34a;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态徽章 */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d1d5db;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.on {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.on::before {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 注册弹窗 ---- */
|
||||||
|
.register-dialog :deep(.el-dialog__header) {
|
||||||
|
padding: 20px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-dialog :deep(.el-dialog__body) {
|
||||||
|
padding: 20px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-dialog :deep(.el-dialog__footer) {
|
||||||
|
padding: 0 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-dialog :deep(.el-form-item) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-dialog :deep(.el-form-item__label) {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 角色选择按钮 */
|
||||||
|
.role-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
background: #fff;
|
}
|
||||||
padding: 15px 20px;
|
|
||||||
border-radius: 8px;
|
.role-btn {
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border: 2px solid var(--color-border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8f9fb;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, #f8f9fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, #fff);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,131 +1,137 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="client-view-container">
|
<div class="cv-wrap">
|
||||||
<header class="client-header">
|
<!-- Header -->
|
||||||
<div class="brand">
|
<header class="cv-header">
|
||||||
<div class="logo-box">
|
<div class="cv-brand">
|
||||||
<el-icon>
|
<div class="cv-logo">
|
||||||
<DataLine />
|
<el-icon><DataLine /></el-icon>
|
||||||
</el-icon>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-text">
|
<div>
|
||||||
<span class="title">{{ currentUser.company_name || "数据交付监控台" }}</span>
|
<div class="cv-title">方舟星河</div>
|
||||||
<span class="version">Real-time Monitor v2.0</span>
|
<div class="cv-subtitle">采集数据平台 · Monitor v2.0</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="cv-user">
|
||||||
<el-space :size="20">
|
<div class="cv-avatar">{{ currentUser.username?.charAt(0) || 'U' }}</div>
|
||||||
<div class="user-badge">
|
<span class="cv-username">{{ currentUser.username }}</span>
|
||||||
<el-icon>
|
<el-button class="cv-logout-btn" @click="handleLogout" :icon="SwitchButton" size="small">退出</el-button>
|
||||||
<User />
|
|
||||||
</el-icon>
|
|
||||||
<span>{{ currentUser.username }}</span>
|
|
||||||
</div>
|
|
||||||
<el-button type="danger" plain size="small" @click="handleLogout" :icon="SwitchButton">退出</el-button>
|
|
||||||
</el-space>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="client-stats">
|
<!-- Stats -->
|
||||||
<el-row :gutter="20">
|
<section class="cv-stats">
|
||||||
<el-col :span="6" v-for="stat in summaryStats" :key="stat.label">
|
<div
|
||||||
<div class="stat-card-premium" :class="{ 'is-active': activeStatus === stat.status }"
|
v-for="stat in summaryStats"
|
||||||
:style="{ '--theme-color': stat.color }" @click="handleStatFilter(stat.status)">
|
:key="stat.label"
|
||||||
<div class="stat-info">
|
class="cv-stat-card"
|
||||||
<span class="s-label">{{ stat.label }}</span>
|
:class="{ 'is-active': activeStatus === stat.status }"
|
||||||
<span class="s-value">{{ stat.value }}</span>
|
:style="{ '--c': stat.color }"
|
||||||
|
@click="handleStatFilter(stat.status)"
|
||||||
|
>
|
||||||
|
<div class="cv-stat-icon">
|
||||||
|
<el-icon><component :is="stat.icon" /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon-wrapper">
|
<div class="cv-stat-body">
|
||||||
<el-icon>
|
<div class="cv-stat-val">{{ stat.value }}</div>
|
||||||
<component :is="stat.icon" />
|
<div class="cv-stat-label">{{ stat.label }}</div>
|
||||||
</el-icon>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeStatus === stat.status" class="active-dot"></div>
|
<div class="cv-stat-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="data-section">
|
<!-- Table Panel -->
|
||||||
<div class="section-header">
|
<main class="cv-panel">
|
||||||
<div class="header-left">
|
<div class="cv-panel-header">
|
||||||
<span class="indicator"></span>
|
<div class="cv-panel-title">
|
||||||
<span class="section-title">实时交付看板</span>
|
<span class="cv-title-dot"></span>
|
||||||
<el-tag v-if="activeStatus" closable size="small" type="info" class="filter-tag" @close="activeStatus = null">
|
实时交付看板
|
||||||
当前筛选: {{ activeStatus }}
|
<el-tag
|
||||||
</el-tag>
|
v-if="activeStatus"
|
||||||
|
closable
|
||||||
|
size="small"
|
||||||
|
class="cv-filter-tag"
|
||||||
|
@close="activeStatus = null"
|
||||||
|
>{{ activeStatus }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<el-button type="primary" link :loading="loading" @click="loadData" :icon="Refresh" size="small">
|
||||||
<el-button type="primary" link @click="loadData" :loading="loading">
|
同步数据
|
||||||
<el-icon>
|
|
||||||
<Refresh />
|
|
||||||
</el-icon> 同步最新数据
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table :data="paginatedTickets" v-loading="loading" class="custom-client-table">
|
<el-table
|
||||||
<el-table-column prop="id" label="单号" width="90" align="center">
|
:data="paginatedTickets"
|
||||||
|
v-loading="loading"
|
||||||
|
class="cv-table"
|
||||||
|
:header-cell-style="{ background: 'var(--cv-th-bg)', color: 'var(--text-muted)', fontWeight: '500', fontSize: '12px' }"
|
||||||
|
:row-style="{ height: '52px' }"
|
||||||
|
>
|
||||||
|
<el-table-column label="单号" width="80" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="id-tag">#{{ row.id }}</span>
|
<span class="cv-id">#{{ row.id }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="website" label="网站地址" min-width="260">
|
<el-table-column label="网站地址" min-width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="project-cell">
|
<div class="cv-site-cell">
|
||||||
<el-icon class="project-icon">
|
<div class="cv-site-favicon">
|
||||||
<Monitor />
|
<el-icon><Monitor /></el-icon>
|
||||||
</el-icon>
|
</div>
|
||||||
<span class="project-name" :title="row.website">{{ row.website }}</span>
|
<span class="cv-site-name" :title="row.website">{{ row.website }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="交付进度" width="260">
|
<el-table-column label="交付进度" width="220">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<div class="progress-box">
|
<div class="cv-progress-cell">
|
||||||
<div class="progress-labels">
|
<el-progress
|
||||||
<span class="percent-val">{{ scope.row.progress || 0 }}%</span>
|
:percentage="Number(row.progress || 0)"
|
||||||
</div>
|
:status="Number(row.progress) === 100 ? 'success' : ''"
|
||||||
<el-progress :percentage="Number(scope.row.progress || 0)"
|
:stroke-width="6"
|
||||||
:status="Number(scope.row.progress) === 100 ? 'success' : ''" :stroke-width="10" :show-text="false"
|
:show-text="false"
|
||||||
striped striped-flow />
|
striped
|
||||||
|
striped-flow
|
||||||
|
/>
|
||||||
|
<span class="cv-progress-pct" :class="{ 'is-done': Number(row.progress) === 100 }">
|
||||||
|
{{ row.progress || 0 }}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="datanum" label="总数据量" width="160" align="center">
|
<el-table-column label="数据量" width="130" align="right">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<div class="data-val-box">
|
<span class="cv-datanum">{{ formatNumber(row.datanum) }}</span>
|
||||||
<span class="data-num">{{ formatNumber(scope.row.datanum) }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="交付状态" width="120" align="center">
|
<el-table-column label="状态" width="110" align="center">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<div :class="['status-dot-box', getStatusClass(scope.row.is_complete)]">
|
<span :class="['cv-status-badge', getStatusClass(row.status)]">
|
||||||
<span class="dot"></span>
|
<i class="cv-status-dot"></i>{{ statusLabel(row.status) }}
|
||||||
<span class="text">{{ scope.row.is_complete }}</span>
|
</span>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="更新日期" width="140" align="center">
|
<el-table-column label="更新时间" width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<span class="date-text">{{
|
<span class="cv-date">{{ formatDate(row.lock_time || row.update_time || row.create_time) }}</span>
|
||||||
formatDate(
|
|
||||||
scope.row.lock_time || scope.row.update_time || scope.row.create_time
|
|
||||||
)
|
|
||||||
}}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<footer class="pagination-footer">
|
<div class="cv-footer">
|
||||||
<el-pagination v-model:current-page="page" v-model:page-size="limit" :total="total"
|
<el-pagination
|
||||||
layout="total, prev, pager, next" background @current-change="handlePageChange" />
|
v-model:current-page="page"
|
||||||
</footer>
|
v-model:page-size="limit"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
background
|
||||||
|
small
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -133,17 +139,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { resetRouter } from "@/router";
|
||||||
import {
|
import {
|
||||||
DataLine,
|
DataLine, SwitchButton, User, Refresh,
|
||||||
SwitchButton,
|
Files, Timer, CircleCheck, Monitor,
|
||||||
User,
|
|
||||||
Refresh,
|
|
||||||
Files,
|
|
||||||
Timer,
|
|
||||||
CircleCheck,
|
|
||||||
Monitor,
|
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { ticketApi, userApi } from "@/api/ticket";
|
import { clientApi, userApi } from "@/api/ticket";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { formatDate } from "@/utils/format";
|
import { formatDate } from "@/utils/format";
|
||||||
|
|
||||||
@@ -152,387 +153,217 @@ const loading = ref(false);
|
|||||||
const tickets = ref([]);
|
const tickets = ref([]);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const limit = ref(15);
|
const limit = ref(15);
|
||||||
|
const activeStatus = ref(null);
|
||||||
|
|
||||||
// --- 筛选状态控制 ---
|
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
||||||
const activeStatus = ref(null); // null 代表显示全部,其余为具体状态字符串
|
const statusLabel = (s) => statusMap[Number(s)] || "未知";
|
||||||
|
|
||||||
// --- 1. 用户信息 ---
|
|
||||||
const currentUser = computed(() => {
|
const currentUser = computed(() => {
|
||||||
const info = localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
|
const info = localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
|
||||||
return info ? JSON.parse(info) : { username: "外部客户", customer_id: null };
|
return info ? JSON.parse(info) : { username: "访客" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 2. 数据加载 ---
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const customerId = currentUser.value.customer_id;
|
const res = await clientApi.getTickets();
|
||||||
const res = await ticketApi.getTickets({
|
if (res.code === 200) tickets.value = res.data || [];
|
||||||
page: 1,
|
} catch {
|
||||||
limit: 1000, // 客户侧通常数据量不大,取1000条前端分页
|
|
||||||
customer_id: customerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.code === 200 || res.code === true) {
|
|
||||||
tickets.value = res.data.data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error("数据同步失败");
|
ElMessage.error("数据同步失败");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 3. 核心:双重过滤逻辑 (权限过滤 + 卡片点击过滤) ---
|
const baseTickets = computed(() => tickets.value.filter((t) => Number(t.status) !== 3));
|
||||||
const filteredTickets = computed(() => {
|
const filteredTickets = computed(() => {
|
||||||
const allowed = ["未分配", "进行中", "已完成"];
|
if (!activeStatus.value) return baseTickets.value;
|
||||||
// 第一步:过滤掉“已完结”等不需展示给客户的状态
|
return baseTickets.value.filter((t) => statusLabel(t.status) === activeStatus.value);
|
||||||
let result = tickets.value.filter((t) => allowed.includes(t.is_complete));
|
|
||||||
|
|
||||||
// 第二步:根据卡片点击的状态进行筛选
|
|
||||||
if (activeStatus.value) {
|
|
||||||
result = result.filter((t) => t.is_complete === activeStatus.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const paginatedTickets = computed(() => {
|
const paginatedTickets = computed(() => {
|
||||||
const start = (page.value - 1) * limit.value;
|
const start = (page.value - 1) * limit.value;
|
||||||
return filteredTickets.value.slice(start, start + limit.value);
|
return filteredTickets.value.slice(start, start + limit.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const total = computed(() => filteredTickets.value.length);
|
const total = computed(() => filteredTickets.value.length);
|
||||||
|
|
||||||
// --- 4. 统计卡片数据定义 ---
|
const summaryStats = computed(() => [
|
||||||
const summaryStats = computed(() => {
|
{ label: "采集项目总数", value: baseTickets.value.length, color: "#6366f1", icon: Monitor, status: null },
|
||||||
// 基础权限过滤后的原始数组
|
{ label: "等待排期", value: baseTickets.value.filter((t) => Number(t.status) === 0).length, color: "#94a3b8", icon: Files, status: "未分配" },
|
||||||
const baseData = tickets.value.filter((t) =>
|
{ label: "正在采集", value: baseTickets.value.filter((t) => Number(t.status) === 1).length, color: "#f59e0b", icon: Timer, status: "进行中" },
|
||||||
["未分配", "进行中", "已完成"].includes(t.is_complete)
|
{ label: "已就绪", value: baseTickets.value.filter((t) => Number(t.status) === 2).length, color: "#10b981", icon: CircleCheck, status: "已完成" },
|
||||||
);
|
]);
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "项目总数",
|
|
||||||
value: baseData.length,
|
|
||||||
color: "#409EFF",
|
|
||||||
icon: Monitor,
|
|
||||||
status: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "等待排期",
|
|
||||||
value: baseData.filter((t) => t.is_complete === "未分配").length,
|
|
||||||
color: "#A8ABB2",
|
|
||||||
icon: Files,
|
|
||||||
status: "未分配",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "正在采集",
|
|
||||||
value: baseData.filter((t) => t.is_complete === "进行中").length,
|
|
||||||
color: "#E6A23C",
|
|
||||||
icon: Timer,
|
|
||||||
status: "进行中",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "已就绪",
|
|
||||||
value: baseData.filter((t) => t.is_complete === "已完成").length,
|
|
||||||
color: "#67C23A",
|
|
||||||
icon: CircleCheck,
|
|
||||||
status: "已完成",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 5. 交互方法 ---
|
|
||||||
const handleStatFilter = (status) => {
|
|
||||||
activeStatus.value = status;
|
|
||||||
page.value = 1; // 切换筛选时重置到第一页
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const handleStatFilter = (status) => { activeStatus.value = status; page.value = 1; };
|
||||||
const formatNumber = (val) => {
|
const formatNumber = (val) => {
|
||||||
if (!val || val === "0") return "0";
|
if (!val || val === "0") return "—";
|
||||||
const num = Number(val);
|
const n = Number(val);
|
||||||
return isNaN(num) ? val : num.toLocaleString();
|
return isNaN(n) ? val : n.toLocaleString();
|
||||||
};
|
};
|
||||||
|
const getStatusClass = (s) => ({ 0: "s-wait", 1: "s-run", 2: "s-done" })[Number(s)] || "";
|
||||||
const getStatusClass = (status) => {
|
const handlePageChange = (p) => { page.value = p; window.scrollTo({ top: 0, behavior: "smooth" }); };
|
||||||
const map = { 未分配: "is-waiting", 进行中: "is-running", 已完成: "is-success" };
|
|
||||||
return map[status] || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (p) => {
|
|
||||||
page.value = p;
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try { await userApi.logout(); } catch {}
|
||||||
await userApi.logout();
|
resetRouter();
|
||||||
} catch (e) {}
|
router.push({ name: "login" });
|
||||||
|
|
||||||
localStorage.removeItem("user_info");
|
|
||||||
sessionStorage.removeItem("user_info");
|
|
||||||
router.push("/login");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(loadData);
|
onMounted(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.client-view-container {
|
/* ── tokens ── */
|
||||||
padding: 24px 40px;
|
.cv-wrap {
|
||||||
background-color: #f6f8fb;
|
--cv-th-bg: #f8f9fb;
|
||||||
min-height: 100vh;
|
--cv-radius: 14px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
padding: 8px 4px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 头部样式 */
|
/* ── Header ── */
|
||||||
.client-header {
|
.cv-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.cv-brand { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.cv-logo {
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; font-size: 20px;
|
||||||
|
box-shadow: 0 4px 14px rgba(99,102,241,.32);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cv-title { font-size: 17px; font-weight: 700; color: var(--text-primary); line-height: 1.3; letter-spacing: -0.2px; }
|
||||||
|
.cv-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; letter-spacing: 0.2px; }
|
||||||
|
|
||||||
|
.cv-user { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.cv-avatar {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #a78bfa);
|
||||||
|
color: #fff; font-size: 13px; font-weight: 600;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(99,102,241,.25);
|
||||||
|
}
|
||||||
|
.cv-username { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.cv-logout-btn { border-radius: 8px !important; }
|
||||||
|
|
||||||
|
/* ── Stats ── */
|
||||||
|
.cv-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
.cv-stat-card {
|
||||||
.brand {
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--cv-radius);
|
||||||
|
padding: 20px 22px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
|
||||||
.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;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03);
|
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
|
||||||
border-left: 4px solid var(--theme-color);
|
|
||||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
.cv-stat-card:hover {
|
||||||
.stat-card-premium:hover {
|
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 8px 24px rgba(0,0,0,.08);
|
||||||
}
|
}
|
||||||
|
.cv-stat-card.is-active {
|
||||||
.stat-card-premium.is-active {
|
border-color: var(--c);
|
||||||
background: #f0f7ff;
|
background: color-mix(in srgb, var(--c) 5%, #fff);
|
||||||
border-left-width: 8px;
|
box-shadow: 0 6px 20px color-mix(in srgb, var(--c) 18%, transparent);
|
||||||
box-shadow: inset 0 0 0 1px rgba(64, 158, 255, 0.1), 0 8px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
.cv-stat-bar {
|
||||||
.active-dot {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
bottom: 0; left: 0; right: 0;
|
||||||
right: 10px;
|
height: 3px;
|
||||||
width: 6px;
|
background: var(--c);
|
||||||
height: 6px;
|
opacity: 0;
|
||||||
background: var(--theme-color);
|
transition: opacity 0.18s;
|
||||||
border-radius: 50%;
|
border-radius: 0 0 var(--cv-radius) var(--cv-radius);
|
||||||
box-shadow: 0 0 8px var(--theme-color);
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
}
|
||||||
|
.cv-stat-card.is-active .cv-stat-bar,
|
||||||
|
.cv-stat-card:hover .cv-stat-bar { opacity: 1; }
|
||||||
|
|
||||||
@keyframes pulse {
|
.cv-stat-icon {
|
||||||
0% {
|
width: 46px; height: 46px; border-radius: 12px;
|
||||||
opacity: 1;
|
background: color-mix(in srgb, var(--c) 10%, transparent);
|
||||||
|
color: var(--c);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.cv-stat-val { font-size: 28px; font-weight: 700; color: var(--text-primary); line-height: 1.1; letter-spacing: -0.5px; }
|
||||||
|
.cv-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; letter-spacing: 0.1px; }
|
||||||
|
|
||||||
50% {
|
/* ── Panel ── */
|
||||||
opacity: 0.3;
|
.cv-panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--cv-radius);
|
||||||
|
padding: 24px 28px 20px;
|
||||||
}
|
}
|
||||||
|
.cv-panel-header {
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
.cv-panel-title {
|
||||||
.header-left {
|
display: flex; align-items: center; gap: 10px;
|
||||||
display: flex;
|
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
.cv-title-dot {
|
||||||
.indicator {
|
width: 3px; height: 16px;
|
||||||
width: 3px;
|
background: linear-gradient(180deg, #6366f1, #a78bfa);
|
||||||
height: 16px;
|
|
||||||
background: #409eff;
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
.cv-filter-tag { border-radius: 6px; }
|
||||||
|
|
||||||
.section-title {
|
/* ── Table cells ── */
|
||||||
font-size: 16px;
|
.cv-table { width: 100%; }
|
||||||
font-weight: 600;
|
.cv-id { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
|
||||||
color: #303133;
|
|
||||||
|
.cv-site-cell { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.cv-site-favicon {
|
||||||
|
width: 28px; height: 28px; border-radius: 7px;
|
||||||
|
background: var(--cv-th-bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-muted); font-size: 13px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cv-site-name {
|
||||||
|
font-size: 13px; font-weight: 500; color: var(--text-primary);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tag {
|
.cv-progress-cell { display: flex; align-items: center; gap: 10px; }
|
||||||
margin-left: 10px;
|
.cv-progress-cell .el-progress { flex: 1; }
|
||||||
border-radius: 4px;
|
.cv-progress-pct { font-size: 11px; font-weight: 600; color: var(--text-muted); width: 32px; text-align: right; flex-shrink: 0; }
|
||||||
}
|
.cv-progress-pct.is-done { color: #10b981; }
|
||||||
|
|
||||||
.id-tag {
|
.cv-datanum { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||||
color: #909399;
|
.cv-date { font-size: 12px; color: var(--text-muted); }
|
||||||
font-family: "Consolas", monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-cell {
|
/* ── Status badge ── */
|
||||||
display: flex;
|
.cv-status-badge {
|
||||||
align-items: center;
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
gap: 8px;
|
padding: 4px 11px; border-radius: 20px;
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.cv-status-dot {
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: currentColor; display: inline-block;
|
||||||
|
}
|
||||||
|
.s-wait { background: #f1f5f9; color: #94a3b8; }
|
||||||
|
.s-run { background: #fffbeb; color: #f59e0b; }
|
||||||
|
.s-done { background: #ecfdf5; color: #10b981; }
|
||||||
|
|
||||||
.project-icon {
|
/* ── Footer ── */
|
||||||
color: #c0c4cc;
|
.cv-footer { margin-top: 24px; padding-bottom: 4px; display: flex; justify-content: center; }
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #2c3e50;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.percent-val {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #409eff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-num {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-footer {
|
|
||||||
margin-top: 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-table) {
|
|
||||||
--el-table-header-bg-color: #f8f9fb;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="customers-container">
|
<div class="customers-root">
|
||||||
<h2>运营人员管理</h2>
|
<!-- 主面板 -->
|
||||||
|
<div class="main-card">
|
||||||
<div class="action-bar">
|
<div class="card-header">
|
||||||
<div class="search-group">
|
<div class="header-left">
|
||||||
<el-input v-model="searchQuery" placeholder="搜索客户单位" style="width: 250px" clearable />
|
<span class="header-title">客户单位</span>
|
||||||
|
<span class="header-count">{{ filteredData.length }} 个</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-group">
|
<div class="header-right">
|
||||||
|
<el-input v-model="searchQuery" placeholder="搜索客户单位" style="width: 200px" size="default" clearable
|
||||||
|
:prefix-icon="Search" />
|
||||||
<el-button type="primary" :icon="Plus" @click="customerDialogVisible = true">
|
<el-button type="primary" :icon="Plus" @click="customerDialogVisible = true">
|
||||||
新增客户单位
|
新增客户单位
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -16,39 +19,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="companyAndOperators" border style="width: 100%; margin-top: 20px" v-loading="loading">
|
<el-table :data="filteredData" v-loading="loading" header-cell-class-name="table-head" row-class-name="table-row">
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty :image-size="100" description="暂无客户数据" />
|
<el-empty :image-size="80" description="暂无客户数据" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table-column prop="id" label="客户ID" width="100" />
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
<el-table-column prop="company_name" label="客户单位名称" />
|
<el-table-column prop="company_name" label="客户单位名称" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
<el-table-column label="员工管理" width="250" align="center">
|
<span class="company-name">{{ row.company_name }}</span>
|
||||||
<template #default="scope">
|
|
||||||
<span style="margin-right: 15px">
|
|
||||||
共 <b style="color: #409eff">{{ scope.row.companyoperators.length }}</b> 人
|
|
||||||
</span>
|
|
||||||
<el-button size="small" type="primary" link :icon="View" @click="showCompanyAndOperatorsDetails(scope.row)">
|
|
||||||
查看详情
|
|
||||||
</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
<el-table-column label="运营人员数量" width="140" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button size="small" type="danger" plain @click="handleDeleteCompany(scope.row)">
|
<b class="operator-count">{{ scope.row.companyoperators.length }}</b> 人
|
||||||
删除单位
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button size="small" type="primary" link @click="showCompanyAndOperatorsDetails(scope.row)">
|
||||||
|
查看
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" link @click="handleDeleteCompany(scope.row)">
|
||||||
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CompanyEditDialog v-model="customerDialogVisible" @success="fetchCompanyAndOperators" />
|
<CompanyEditDialog v-model="customerDialogVisible" @success="fetchCompanyAndOperators" />
|
||||||
|
|
||||||
<OperatorEditDialog v-model="operatorDialogVisible" :company-list="companyList"
|
<OperatorEditDialog v-model="operatorDialogVisible" :company-list="companyList"
|
||||||
@success="fetchCompanyAndOperators" />
|
@success="fetchCompanyAndOperators" />
|
||||||
|
|
||||||
<OperatorListDialog v-model="employeeDetailVisible" :company-name="activeCompanyName"
|
<OperatorListDialog v-model="employeeDetailVisible" :company-name="activeCompanyName"
|
||||||
:operators="currentCompanyOperators" @refresh="fetchCompanyAndOperators" />
|
:operators="currentCompanyOperators" @refresh="fetchCompanyAndOperators" />
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +60,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { Plus, User, View } from "@element-plus/icons-vue";
|
import { Plus, Search, User, View } from "@element-plus/icons-vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { customerApi, ticketApi } from "@/api/ticket";
|
import { customerApi, ticketApi } from "@/api/ticket";
|
||||||
|
|
||||||
@@ -66,7 +70,6 @@ import OperatorListDialog from "@/components/operators/OperatorListDialog.vue";
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const companyList = ref([]);
|
const companyList = ref([]);
|
||||||
const employeeList = ref([]);
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const companyAndOperators = ref([]);
|
const companyAndOperators = ref([]);
|
||||||
|
|
||||||
@@ -75,19 +78,23 @@ const operatorDialogVisible = ref(false);
|
|||||||
const employeeDetailVisible = ref(false);
|
const employeeDetailVisible = ref(false);
|
||||||
|
|
||||||
|
|
||||||
const activeCompanyName = ref(""); // 当前选中客户单位的名称
|
const activeCompanyName = ref("");
|
||||||
const currentCompanyOperators = ref([]); // 当前选中客户单位的员工列表
|
const currentCompanyOperators = ref([]);
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
if (!searchQuery.value) return companyAndOperators.value;
|
||||||
|
const q = searchQuery.value.toLowerCase();
|
||||||
|
return companyAndOperators.value.filter(c => c.company_name.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
const fetchCompanyAndOperators = async () => {
|
const fetchCompanyAndOperators = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await ticketApi.getOperatorsNew();
|
const response = await ticketApi.getOperatorsNew();
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
companyAndOperators.value = response.data
|
companyAndOperators.value = response.data.filter(item => item.id !== 0)
|
||||||
companyList.value = response.data.map(item => ({ company_id: item.id, company_name: item.company_name }))
|
companyList.value = response.data.map(item => ({ company_id: item.id, company_name: item.company_name }))
|
||||||
}
|
}
|
||||||
// console.log('获取数据', companyAndOperators.value);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error("获取客户数据失败,请检查网络");
|
ElMessage.error("获取客户数据失败,请检查网络");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -132,28 +139,87 @@ onMounted(fetchCompanyAndOperators);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.customers-container {
|
.customers-root {
|
||||||
padding: 30px;
|
padding: 0;
|
||||||
background-color: #f5f7fa;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
/* 圆角 */
|
||||||
margin-bottom: 25px;
|
.header-right :deep(.el-input__wrapper) {
|
||||||
color: #303133;
|
border-radius: 20px !important;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.header-right :deep(.el-button) {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 主面板 ---- */
|
||||||
|
.main-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: #fff;
|
align-items: center;
|
||||||
padding: 15px 20px;
|
padding: 16px 20px;
|
||||||
border-radius: 8px;
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
background: var(--color-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group .el-button {
|
.header-left {
|
||||||
margin-left: 10px;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 表格 ---- */
|
||||||
|
:deep(.table-head) {
|
||||||
|
background: #f3f5fb !important;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.table-row) {
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr:hover > td) {
|
||||||
|
background: #f6f8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-count {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,110 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ark-wrapper">
|
<div class="login-root">
|
||||||
<div class="grid-bg"></div>
|
<div class="login-card">
|
||||||
<div class="scan-line"></div>
|
<div class="card-head">
|
||||||
|
<img src="@/assets/logo.jpg" alt="" class="logo" />
|
||||||
<div class="login-container">
|
|
||||||
<transition name="ark-fade" appear>
|
|
||||||
<div class="ark-card">
|
|
||||||
<div class="corner-decorator top-left"></div>
|
|
||||||
<div class="corner-decorator bottom-right"></div>
|
|
||||||
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="brand-icon">
|
|
||||||
<el-icon>
|
|
||||||
<DataBoard />
|
|
||||||
</el-icon>
|
|
||||||
</div>
|
|
||||||
<div class="brand-text">
|
|
||||||
<h1 class="title">方舟工单</h1>
|
<h1 class="title">方舟工单</h1>
|
||||||
<span class="sub-text">ARK TICKET SYSTEM v4.0</span>
|
<p class="desc">登录以继续使用系统</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="system-monitor">
|
<el-form ref="formRef" :model="form" :rules="rules" class="login-form" @keyup.enter="handleLogin">
|
||||||
<span class="label">STATUS:</span>
|
|
||||||
<span class="value online">ENCRYPTED CONNECTION</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" class="ark-form">
|
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<div class="input-prefix">ID</div>
|
<el-input v-model="form.username" placeholder="员工编号" size="large" clearable>
|
||||||
<el-input v-model="loginForm.username" placeholder="员工编号" class="ark-input" />
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<User />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item prop="password">
|
<el-form-item prop="password">
|
||||||
<div class="input-prefix">KEY</div>
|
<el-input v-model="form.password" type="password" placeholder="访问密钥" size="large" show-password>
|
||||||
<el-input v-model="loginForm.password" type="password" placeholder="访问密钥" show-password class="ark-input"
|
<template #prefix>
|
||||||
@keyup.enter="handleLogin" />
|
<el-icon>
|
||||||
|
<Lock />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="options-bar">
|
<div class="form-row">
|
||||||
<el-checkbox v-model="rememberMe" @keyup.enter="handleLogin">保持认证状态</el-checkbox>
|
<el-checkbox v-model="rememberMe">保持登录</el-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--loading为true时候,会转圈圈-->
|
<el-button type="primary" size="large" class="submit-btn" :loading="loading" @click="handleLogin">
|
||||||
<el-button type="primary" class="ark-btn" @click="handleLogin" :loading="loading">
|
登 录
|
||||||
<div class="btn-content">
|
|
||||||
<span class="main-btn-text">INITIALIZE / 启动</span>
|
|
||||||
</div>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="card-footer">
|
|
||||||
<span class="code">SECURE PROTOCOL 801-ARK</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from "vue";
|
import { ref, reactive } from "vue";
|
||||||
import { DataBoard } from "@element-plus/icons-vue";
|
import { User, Lock } from "@element-plus/icons-vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { userApi } from "@/api/ticket";
|
import { userApi } from "@/api/ticket";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loginForm = reactive({ username: "", password: "" });
|
const form = reactive({ username: "", password: "" });
|
||||||
const rememberMe = ref(false);
|
const rememberMe = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loginFormRef = ref(null);
|
const formRef = ref(null);
|
||||||
|
|
||||||
const loginRules = reactive({
|
const rules = {
|
||||||
username: [{ required: true, message: "请输入员工编号", trigger: "blur" }],
|
username: [{ required: true, message: "请输入员工编号", trigger: "blur" }],
|
||||||
password: [{ required: true, message: "请输入密钥", trigger: "blur" }],
|
password: [{ required: true, message: "请输入密钥", trigger: "blur" }],
|
||||||
});
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!loginFormRef.value) return;
|
if (!formRef.value) return;
|
||||||
try {
|
try {
|
||||||
await loginFormRef.value.validate();
|
await formRef.value.validate();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
const res = await userApi.login(form.username, form.password);
|
||||||
const res = await userApi.login(loginForm.username, loginForm.password);
|
|
||||||
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
// ThinkPHP 会自动通过 Set-Cookie 下发 session,浏览器自动接管,无需手动存 token
|
const userInfo = JSON.stringify(res.data);
|
||||||
const userInfo = res.data;
|
|
||||||
const userInfoStr = JSON.stringify(userInfo);
|
|
||||||
|
|
||||||
// 用户信息存前端,仅用于显示用户名和角色路由判断
|
|
||||||
const storage = rememberMe.value ? localStorage : sessionStorage;
|
const storage = rememberMe.value ? localStorage : sessionStorage;
|
||||||
storage.setItem("user_info", userInfoStr);
|
storage.setItem("user_info", userInfo);
|
||||||
|
ElMessage({ message: "登录成功", type: "success", duration: 300 });
|
||||||
ElMessage({
|
|
||||||
message: "身份验证通过,方舟系统加载中...",
|
|
||||||
type: "success",
|
|
||||||
duration: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 触发路由守卫,自动处理跳转
|
|
||||||
await router.push(route.query.redirect || "/");
|
await router.push(route.query.redirect || "/");
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.message || "登录失败,请检查账号和密码");
|
ElMessage.error(res.message || "登录失败");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== "ValidationError") {
|
if (error.name !== "ValidationError") {
|
||||||
@@ -117,243 +85,143 @@ const handleLogin = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 1. 全局背景:工业网格风格 */
|
.login-root {
|
||||||
.ark-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: #0b111a;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(90% 60% at 85% -10%, rgba(147, 51, 234, 0.4) 0%, transparent 55%),
|
||||||
|
radial-gradient(90% 60% at -10% 110%, rgba(99, 102, 241, 0.45) 0%, transparent 55%),
|
||||||
|
linear-gradient(160deg, #1e2450 0%, #252a5e 45%, #1a1e40 100%);
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-bg {
|
.login-root::before,
|
||||||
|
.login-root::after {
|
||||||
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
border-radius: 50%;
|
||||||
background-image: linear-gradient(rgba(0, 242, 255, 0.05) 1px, transparent 1px),
|
filter: blur(90px);
|
||||||
linear-gradient(90deg, rgba(0, 242, 255, 0.05) 1px, transparent 1px);
|
opacity: 0.4;
|
||||||
background-size: 40px 40px;
|
|
||||||
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-line {
|
.login-root::before {
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(0, 242, 255, 0.2), transparent);
|
|
||||||
top: 0;
|
|
||||||
animation: scan 4s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scan {
|
|
||||||
0% {
|
|
||||||
top: -10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
top: 110%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. 方舟卡片 */
|
|
||||||
.ark-card {
|
|
||||||
position: relative;
|
|
||||||
width: 420px;
|
width: 420px;
|
||||||
background: rgba(13, 22, 34, 0.85);
|
height: 420px;
|
||||||
border: 1px solid rgba(0, 242, 255, 0.3);
|
left: -120px;
|
||||||
padding: 40px;
|
top: -120px;
|
||||||
box-shadow: 0 0 40px rgba(0, 242, 255, 0.1), inset 0 0 20px rgba(0, 242, 255, 0.05);
|
background: #4f46e5;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 装饰性角标 */
|
.login-root::after {
|
||||||
.corner-decorator {
|
width: 460px;
|
||||||
position: absolute;
|
height: 460px;
|
||||||
width: 20px;
|
right: -140px;
|
||||||
height: 20px;
|
bottom: -140px;
|
||||||
border-color: #00f2ff;
|
background: #9333ea;
|
||||||
border-style: solid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-left {
|
.login-card {
|
||||||
top: -2px;
|
width: 400px;
|
||||||
left: -2px;
|
background: #ffffff;
|
||||||
border-width: 3px 0 0 3px;
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 44px 38px 38px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.1),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.2),
|
||||||
|
0 30px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-right {
|
.card-head {
|
||||||
bottom: -2px;
|
text-align: center;
|
||||||
right: -2px;
|
margin-bottom: 34px;
|
||||||
border-width: 0 3px 3px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.logo {
|
||||||
display: flex;
|
width: 56px;
|
||||||
align-items: center;
|
height: 56px;
|
||||||
gap: 15px;
|
border-radius: 16px;
|
||||||
margin-bottom: 30px;
|
object-fit: cover;
|
||||||
}
|
margin-bottom: 18px;
|
||||||
|
box-shadow:
|
||||||
.brand-icon {
|
0 0 0 4px rgba(129, 140, 248, 0.28),
|
||||||
background: rgba(0, 242, 255, 0.1);
|
0 8px 20px rgba(79, 70, 229, 0.42);
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #00f2ff;
|
|
||||||
display: flex;
|
|
||||||
color: #00f2ff;
|
|
||||||
clip-path: polygon(20% 0%, 100% 0%, 100% 80%, 80% 100%, 0% 100%, 0% 20%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
color: #fff;
|
margin: 0 0 6px;
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 2px;
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-text {
|
.login-form :deep(.el-form-item) {
|
||||||
color: #00f2ff;
|
margin-bottom: 20px;
|
||||||
font-size: 10px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. 监控条 */
|
.login-form :deep(.el-input__wrapper) {
|
||||||
.system-monitor {
|
border-radius: 10px;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
padding: 3px 12px;
|
||||||
padding: 5px 12px;
|
background: #f7f8fd;
|
||||||
margin-bottom: 25px;
|
box-shadow: 0 0 0 1px var(--color-border) inset;
|
||||||
border-left: 3px solid #00f2ff;
|
transition: box-shadow 0.2s, background 0.2s;
|
||||||
font-family: monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor .label {
|
.login-form :deep(.el-input__wrapper:hover) {
|
||||||
color: #64748b;
|
background: #f3f5fd;
|
||||||
margin-right: 10px;
|
box-shadow: 0 0 0 1px rgba(79, 70, 229, 0.4) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor .value {
|
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||||
color: #22c55e;
|
background: #fff;
|
||||||
text-shadow: 0 0 5px #22c55e;
|
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.18), 0 0 0 1px rgba(79, 70, 229, 0.6) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4. 输入框定制 */
|
.form-row {
|
||||||
.input-prefix {
|
margin: -2px 0 24px;
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 10;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #00f2ff;
|
|
||||||
font-weight: bold;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ark-input .el-input__wrapper) {
|
|
||||||
background: rgba(0, 242, 255, 0.03) !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
border: 1px solid rgba(0, 242, 255, 0.2);
|
|
||||||
border-radius: 0;
|
|
||||||
/* 工业风通常使用直角 */
|
|
||||||
height: 50px;
|
|
||||||
padding-left: 45px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ark-input .is-focus) {
|
|
||||||
border-color: #00f2ff !important;
|
|
||||||
background: rgba(0, 242, 255, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 5. 启动按钮 */
|
|
||||||
.ark-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 50px;
|
|
||||||
background: transparent !important;
|
|
||||||
border: 1px solid #00f2ff !important;
|
|
||||||
color: #00f2ff !important;
|
|
||||||
border-radius: 0;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ark-btn:hover {
|
|
||||||
background: #00f2ff !important;
|
|
||||||
color: #000 !important;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-bar {
|
|
||||||
margin: 15px 0 25px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-checkbox__label) {
|
:deep(.el-checkbox__label) {
|
||||||
color: #64748b;
|
font-size: 13px;
|
||||||
font-size: 12px;
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-checkbox__inner) {
|
:deep(.el-checkbox__inner) {
|
||||||
background: transparent;
|
border-radius: 4px;
|
||||||
border-color: #334155;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
.submit-btn {
|
||||||
margin-top: 30px;
|
width: 100%;
|
||||||
text-align: right;
|
height: 48px;
|
||||||
border-top: 1px dashed rgba(0, 242, 255, 0.1);
|
border-radius: 12px;
|
||||||
padding-top: 15px;
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--brand-gradient) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.submit-btn:hover {
|
||||||
font-family: monospace;
|
transform: translateY(-1px);
|
||||||
font-size: 10px;
|
box-shadow: 0 10px 26px rgba(99, 102, 241, 0.52);
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 入场动画 */
|
|
||||||
.ark-fade-enter-active {
|
|
||||||
transition: all 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ark-fade-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(1.1);
|
|
||||||
filter: brightness(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 1. 核心修复:禁止卡片内非交互元素的文本选中 */
|
|
||||||
.ark-card,
|
|
||||||
.card-header,
|
|
||||||
.brand-text,
|
|
||||||
.title,
|
|
||||||
.sub-text,
|
|
||||||
.system-monitor,
|
|
||||||
.options-bar,
|
|
||||||
.card-footer,
|
|
||||||
.code,
|
|
||||||
.input-prefix {
|
|
||||||
user-select: none;
|
|
||||||
/* 禁止选中文字 */
|
|
||||||
-webkit-user-select: none;
|
|
||||||
/* 兼容 Safari */
|
|
||||||
-moz-user-select: none;
|
|
||||||
/* 兼容 Firefox */
|
|
||||||
-ms-user-select: none;
|
|
||||||
/* 兼容 IE */
|
|
||||||
cursor: default;
|
|
||||||
/* 强制鼠标指针为标准箭头,不变成 I 型 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. 确保输入框内部依然可以正常输入和选中(不受父级影响) */
|
|
||||||
:deep(.el-input__inner) {
|
|
||||||
user-select: text !important;
|
|
||||||
cursor: text !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. 确保复选框的 Label 也不会被选中(防止点击勾选时变蓝) */
|
|
||||||
:deep(.el-checkbox) {
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
<el-avatar :size="28">{{ staff.username.slice(0, 1) }}</el-avatar>
|
<el-avatar :size="28">{{ staff.username.slice(0, 1) }}</el-avatar>
|
||||||
<span class="staff-name">{{ staff.username }}</span>
|
<span class="staff-name">{{ staff.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag type="success" effect="dark" round>
|
<span class="count-badge">
|
||||||
已锁定 {{ staff.lockedTickets.length }} 个站点
|
已锁定 {{ staff.lockedTickets.length }} 个站点
|
||||||
</el-tag>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table :data="staff.lockedTickets" stripe border size="small" class="inner-table">
|
<el-table :data="staff.lockedTickets" stripe border size="small" class="inner-table">
|
||||||
@@ -41,34 +41,112 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="Number(row.status) === 2 ? 'success' : 'info'" size="small">
|
<span class="status-badge" :class="'s' + Number(row.status)">
|
||||||
{{ statusLabel(row.status) }}
|
{{ statusLabel(row.status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 数据记录导出 -->
|
||||||
|
<div class="export-bar">
|
||||||
|
<span class="export-label">数据记录导出</span>
|
||||||
|
<div class="export-actions">
|
||||||
|
<el-radio-group v-model="exportRange" size="small">
|
||||||
|
<el-radio value="week">本周</el-radio>
|
||||||
|
<el-radio value="month">本月</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button size="small" @click="handleExport">导出</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
import { ticketApi } from "@/api/ticket";
|
import { ticketApi, exportApi } from "@/api/ticket";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { formatDate } from "@/utils/format";
|
import { formatDate } from "@/utils/format";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const staffUserList = ref([]);
|
const staffUserList = ref([]);
|
||||||
const allTickets = ref([]);
|
const allTickets = ref([]);
|
||||||
const activeCollapse = ref("");
|
const activeCollapse = ref("");
|
||||||
const showOnlyThisMonth = ref(false);
|
const showOnlyThisMonth = ref(false);
|
||||||
|
const exportRange = ref("week");
|
||||||
|
|
||||||
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
||||||
const statusLabel = (status) => statusMap[Number(status)] || "未知";
|
const statusLabel = (status) => statusMap[Number(status)] || "未知";
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
// 使用接口获取需要导出的数据
|
||||||
|
exportApi.exportdata(exportRange.value).then((res) => {
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data || [];
|
||||||
|
exportToExcel(data, exportRange.value === "week" ? "本周" : "本月", exportRange.value);
|
||||||
|
} else {
|
||||||
|
ElMessage.error("导出失败,请稍后再试");
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error("导出失败,请稍后再试");
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToExcel = (data, label, range) => {
|
||||||
|
data.sort((a, b) => {
|
||||||
|
const timeA = new Date(a.create_time).getTime();
|
||||||
|
const timeB = new Date(b.create_time).getTime();
|
||||||
|
if (timeA !== timeB) return timeA - timeB;
|
||||||
|
return a.username.localeCompare(b.username, "zh-CN");
|
||||||
|
});
|
||||||
|
|
||||||
|
const excelData = data.map((item) => ({
|
||||||
|
域名: item.website,
|
||||||
|
数据量: item.datanum || 0,
|
||||||
|
运营人员: item.operator_name,
|
||||||
|
采集人员: item.username,
|
||||||
|
备注: item.remark,
|
||||||
|
开始时间: item.create_time,
|
||||||
|
结束时间: item.lock_time,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 生成工作表
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||||
|
|
||||||
|
// 自动调整列宽
|
||||||
|
const objectKeys = Object.keys(excelData[0] || {});
|
||||||
|
const max_widths = objectKeys.map((key) => {
|
||||||
|
let maxLen = key.replace(/[^\x00-\xff]/g, "00").length;
|
||||||
|
excelData.forEach((row) => {
|
||||||
|
const val = row[key] != null ? String(row[key]) : "";
|
||||||
|
const valLen = val.replace(/[^\x00-\xff]/g, "00").length;
|
||||||
|
if (valLen > maxLen) maxLen = valLen;
|
||||||
|
});
|
||||||
|
return { wch: maxLen + 3 };
|
||||||
|
});
|
||||||
|
worksheet["!cols"] = max_widths;
|
||||||
|
|
||||||
|
// 创建工作簿并导出
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "工单导出");
|
||||||
|
const now = new Date();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
const weekOfMonth = Math.ceil(now.getDate() / 7);
|
||||||
|
if (range === "week") {
|
||||||
|
XLSX.writeFile(workbook, `工单数据导出_${month}月第${weekOfMonth}周.xlsx`);
|
||||||
|
} else if (range === "month") {
|
||||||
|
XLSX.writeFile(workbook, `工单数据导出_${month}月.xlsx`);
|
||||||
|
}
|
||||||
|
ElMessage.success(`已导出${label}记录`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -144,12 +222,20 @@ const staffWithLockedTickets = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stats-view {
|
.stats-view {
|
||||||
padding: 20px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-card {
|
:deep(.el-card) {
|
||||||
border-radius: 12px;
|
background: var(--color-surface);
|
||||||
border: 1px solid #e4e7ed;
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__header) {
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
padding: 18px 22px;
|
||||||
|
background: var(--color-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -158,16 +244,17 @@ const staffWithLockedTickets = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header>span {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1c1c28;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header > span {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-title {
|
.collapse-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -179,12 +266,37 @@ const staffWithLockedTickets = computed(() => {
|
|||||||
.staff-info {
|
.staff-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-name {
|
.staff-name {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
color: #1c1c28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #15803d;
|
||||||
|
background: #dcf5e6;
|
||||||
|
border: 1px solid rgba(22, 163, 74, 0.22);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner-table {
|
.inner-table {
|
||||||
@@ -192,12 +304,105 @@ const staffWithLockedTickets = computed(() => {
|
|||||||
margin: 0 16px 16px 16px;
|
margin: 0 16px 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.inner-table) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse) {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-collapse-item__header) {
|
:deep(.el-collapse-item__header) {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
height: 60px;
|
height: 52px;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
color: #1c1c28;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__header:hover) {
|
||||||
|
background: rgba(0, 0, 0, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-collapse-item__wrap) {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-collapse-item__content) {
|
:deep(.el-collapse-item__content) {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
--el-table-bg-color: #fafbfc;
|
||||||
|
--el-table-header-bg-color: #f0f1f4;
|
||||||
|
--el-table-border-color: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b6d7a;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态徽章 */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s0 {
|
||||||
|
color: #64748b;
|
||||||
|
background: #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s1 {
|
||||||
|
color: #4f46e5;
|
||||||
|
background: #e7e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s2 {
|
||||||
|
color: #16a34a;
|
||||||
|
background: #dcf5e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s3 {
|
||||||
|
color: #d97706;
|
||||||
|
background: #ffefd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导出栏 */
|
||||||
|
.export-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--color-surface-soft);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1c1c28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
<div class="batch-bar" v-if="selectedTickets.length">
|
<div class="batch-bar" v-if="selectedTickets.length">
|
||||||
<span class="batch-info">已选 <strong>{{ selectedTickets.length }}</strong> 个工单</span>
|
<span class="batch-info">已选 <strong>{{ selectedTickets.length }}</strong> 个工单</span>
|
||||||
<el-button type="warning" @click="handleBatchFinish">一键已完结</el-button>
|
<el-button type="warning" @click="handleBatchFinish">一键已完结</el-button>
|
||||||
|
<el-button type="primary" @click="handleBatchExport">一键导出</el-button>
|
||||||
<el-button link type="info" @click="ticketListRef.clearSelection()">取消选择</el-button>
|
<el-button link type="info" @click="ticketListRef.clearSelection()">取消选择</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
Checked,
|
Checked,
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
// API 引入
|
// API 引入
|
||||||
import { ticketApi } from "@/api/ticket";
|
import { ticketApi } from "@/api/ticket";
|
||||||
@@ -272,6 +274,7 @@ const handleAdd = () => {
|
|||||||
operator_id: null,
|
operator_id: null,
|
||||||
user_id: null,
|
user_id: null,
|
||||||
status: 0,
|
status: 0,
|
||||||
|
progress: 0,
|
||||||
remark: "",
|
remark: "",
|
||||||
};
|
};
|
||||||
showAddDialog.value = true;
|
showAddDialog.value = true;
|
||||||
@@ -294,10 +297,34 @@ const handleFormSubmit = async (formData) => {
|
|||||||
response = await ticketApi.addTicket(formData);
|
response = await ticketApi.addTicket(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.code) {
|
if (response.code === 200) {
|
||||||
ElMessage.success(isEditing.value ? "修改成功 ✨" : "创建成功 🚀");
|
ElMessage.success(isEditing.value ? "修改成功 ✨" : "创建成功 🚀");
|
||||||
showAddDialog.value = false;
|
showAddDialog.value = false;
|
||||||
refreshAll();
|
refreshAll();
|
||||||
|
} else if (response.code === 409 && response.data?.confirm) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'已存在未分配或正在进行的相同工单,是否继续创建?',
|
||||||
|
'重复工单提示',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '继续创建',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// 用户确认继续创建
|
||||||
|
const res2 = await ticketApi.addTicket({ ...formData, confirm: 1 });
|
||||||
|
if (res2.code === 200) {
|
||||||
|
ElMessage.success("创建成功 🚀");
|
||||||
|
showAddDialog.value = false;
|
||||||
|
refreshAll();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res2.msg || "创建失败");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 用户点击取消操作,弹窗自动关闭
|
||||||
|
showAddDialog.value = false;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(response.msg || "操作失败");
|
ElMessage.error(response.msg || "操作失败");
|
||||||
}
|
}
|
||||||
@@ -356,6 +383,32 @@ const handleBatchFinish = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBatchExport = () => {
|
||||||
|
const data = selectedTickets.value.map((item) => ({
|
||||||
|
域名: item.website,
|
||||||
|
数据量: item.datanum || 0,
|
||||||
|
运营人员: item.operator_name,
|
||||||
|
采集人员: item.username,
|
||||||
|
备注: item.remark,
|
||||||
|
开始时间: item.create_time,
|
||||||
|
结束时间: item.lock_time,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||||
|
const keys = Object.keys(data[0] || {});
|
||||||
|
worksheet["!cols"] = keys.map((key) => ({
|
||||||
|
wch: Math.max(
|
||||||
|
key.replace(/[^\x00-\xff]/g, "00").length,
|
||||||
|
...data.map((r) => String(r[key] ?? "").replace(/[^\x00-\xff]/g, "00").length)
|
||||||
|
) + 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "工单导出");
|
||||||
|
XLSX.writeFile(workbook, `工单导出_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
|
ElMessage.success(`已导出 ${data.length} 条工单`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSizeChange = (val) => {
|
const handleSizeChange = (val) => {
|
||||||
pagination.limit = val;
|
pagination.limit = val;
|
||||||
loadTickets();
|
loadTickets();
|
||||||
@@ -376,119 +429,123 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 样式部分保持不变 */
|
|
||||||
.ticket-management {
|
.ticket-management {
|
||||||
padding: 20px;
|
padding: 0;
|
||||||
background-color: #f8fafc;
|
min-height: 0;
|
||||||
min-height: 100vh;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-container {
|
.stats-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(150px, 1fr));
|
grid-template-columns: repeat(5, minmax(140px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: var(--color-surface);
|
||||||
padding: 20px;
|
padding: 18px 18px;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-left: 5px solid var(--accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: all 0.3s ease;
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
/* 禁止用户选中文字 */
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
position: relative;
|
||||||
/* 兼容 Safari */
|
overflow: hidden;
|
||||||
-moz-user-select: none;
|
|
||||||
/* 兼容 Firefox */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card::before {
|
||||||
transform: translateY(-5px);
|
content: "";
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--tint);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total {
|
.total {
|
||||||
border-bottom-color: #409eff;
|
--accent: #4f46e5;
|
||||||
}
|
--tint: linear-gradient(135deg, rgba(79, 70, 229, 0.10) 0%, rgba(99, 102, 241, 0.02) 70%);
|
||||||
|
|
||||||
.total .stat-icon {
|
|
||||||
color: #409eff;
|
|
||||||
background: #ecf5ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending {
|
.pending {
|
||||||
border-bottom-color: #909399;
|
--accent: #64748b;
|
||||||
}
|
--tint: linear-gradient(135deg, rgba(100, 116, 139, 0.10) 0%, rgba(100, 116, 139, 0.02) 70%);
|
||||||
|
|
||||||
.pending .stat-icon {
|
|
||||||
color: #909399;
|
|
||||||
background: #f4f4f5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ongoing {
|
.ongoing {
|
||||||
border-bottom-color: #e6a23c;
|
--accent: #f59e0b;
|
||||||
}
|
--tint: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(245, 158, 11, 0.02) 70%);
|
||||||
|
|
||||||
.ongoing .stat-icon {
|
|
||||||
color: #e6a23c;
|
|
||||||
background: #fdf6ec;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
border-bottom-color: #67c23a;
|
--accent: #16a34a;
|
||||||
}
|
--tint: linear-gradient(135deg, rgba(22, 163, 74, 0.12) 0%, rgba(22, 163, 74, 0.02) 70%);
|
||||||
|
|
||||||
.success .stat-icon {
|
|
||||||
color: #67c23a;
|
|
||||||
background: #f0f9eb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.finished {
|
.finished {
|
||||||
border-bottom-color: #009688;
|
--accent: #0ea5e9;
|
||||||
|
--tint: linear-gradient(135deg, rgba(14, 165, 233, 0.12) 0%, rgba(14, 165, 233, 0.02) 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finished .stat-icon {
|
.stat-card:hover {
|
||||||
color: #009688;
|
transform: translateY(-3px);
|
||||||
background: #e6f6f5;
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-card .stat-info,
|
||||||
font-size: 13px;
|
.stat-card .stat-icon {
|
||||||
color: #64748b;
|
position: relative;
|
||||||
margin-bottom: 8px;
|
z-index: 1;
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 14%, #fff);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
background: white;
|
background: var(--color-surface);
|
||||||
padding: 20px;
|
padding: 22px 24px;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
border: 1px solid var(--color-border-light);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-bar {
|
.batch-bar {
|
||||||
@@ -496,19 +553,24 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
background: #fdf6ec;
|
background: var(--color-warning-soft);
|
||||||
border: 1px solid #faecd8;
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-info {
|
.batch-info {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #606266;
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-info strong {
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-container {
|
.pagination-container {
|
||||||
margin-top: 20px;
|
margin-top: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user