Compare commits

..

10 Commits

Author SHA1 Message Date
f0fd784eeb wip 2026-08-10 14:37:47 +08:00
2c983e826d wip 2026-08-08 18:29:00 +08:00
fcd797a1fd wip 2026-08-08 16:28:32 +08:00
9d53d7deb0 wip 2026-08-08 15:06:28 +08:00
97c7efb81f 增加侧边栏持久化记忆 2026-08-05 13:53:32 +08:00
826d140717 增加侧边栏折叠图标 2026-08-05 13:49:45 +08:00
94e0c718e2 wip 2026-08-05 13:46:42 +08:00
f081928187 AI初步修改整体布局风格以及样式 2026-08-05 11:14:17 +08:00
17505b5c22 wip 2026-07-28 16:24:31 +08:00
f54c58c366 最新修改 2026-05-26 14:08:13 +08:00
24 changed files with 3549 additions and 1787 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__list_pages"
]
}
}

30
.github/agents/vue-expert.agent.md vendored Normal file
View 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 侧最佳方案,再补充必要说明。

View File

@@ -0,0 +1,4 @@
{
"tokens": {},
"components": {}
}

106
package-lock.json generated
View File

@@ -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",

View File

@@ -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
View 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')
}
}

View File

@@ -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 })
} }
} }

View File

@@ -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;
} }

View 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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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')

View File

@@ -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
View 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
}

View File

@@ -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) {
@@ -220,50 +267,318 @@ const handleToggleStatus = (row) => {
ElMessage.error(res.msg || `${action}失败`); ElMessage.error(res.msg || `${action}失败`);
} }
}) })
.catch(() => {}); .catch(() => { });
}; };
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 {
ElMessage.error(res.msg || "删除失败"); ElMessage.error(res.msg || "删除失败");
} }
}) })
.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>

View File

@@ -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>&nbsp;同步最新数据
</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;
50% { font-size: 22px;
opacity: 0.3; flex-shrink: 0;
}
100% {
opacity: 1;
}
} }
.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; }
.s-label { /* ── Panel ── */
font-size: 13px; .cv-panel {
color: #86868b; background: var(--color-surface);
display: block; border: 1px solid var(--color-border-light);
margin-bottom: 6px; border-radius: var(--cv-radius);
padding: 24px 28px 20px;
} }
.cv-panel-header {
.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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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