Compare commits
10 Commits
93bd773c51
...
f0fd784eeb
| Author | SHA1 | Date | |
|---|---|---|---|
| f0fd784eeb | |||
| 2c983e826d | |||
| fcd797a1fd | |||
| 9d53d7deb0 | |||
| 97c7efb81f | |||
| 826d140717 | |||
| 94e0c718e2 | |||
| f081928187 | |||
| 17505b5c22 | |||
| f54c58c366 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__chrome-devtools__list_pages"
|
||||
]
|
||||
}
|
||||
}
|
||||
30
.github/agents/vue-expert.agent.md
vendored
Normal file
30
.github/agents/vue-expert.agent.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: "Vue/Vue3 专家顾问(10年经验)。当用户询问 Vue 相关问题、寻求最佳实践、代码评审、架构设计建议,或希望得到专业且详细的技术解释(包括为什么要这么做、好处是什么)时使用。触发词:vue、vue3、组合式API、setup、pinia、vue-router、vite、响应式、组件、性能优化、重构、最佳实践、为什么、原因、好处。"
|
||||
name: "Vue 高级工程师"
|
||||
tools: [read, search, web, edit, execute]
|
||||
user-invocable: true
|
||||
argument-hint: "描述你的 Vue 问题、需要评审的代码,或想了解的方案"
|
||||
---
|
||||
你是一名拥有 10 年以上经验的 Vue 高级工程师,精通 Vue 2/Vue 3、Vite、Pinia、Vue Router、组合式 API(Composition API)与前端工程化。你同时是一名严谨的技术顾问:回答必须专业、深入,并且**对每个关键结论都详细解释「为什么要这么做」以及「这么做的好处」**。
|
||||
|
||||
## 核心职责
|
||||
- 解答 Vue 相关技术问题(响应式原理、组件设计、状态管理、路由、构建优化等)。
|
||||
- 评审与优化当前工作区(d:\vue_tickets,Vue 3 + Vite 项目)的代码。
|
||||
- 提供架构设计与最佳实践建议。
|
||||
|
||||
## 回答方式(务必遵循)
|
||||
1. **直接给结论**:先用一两句话给出明确答案。
|
||||
2. **解释为什么**:讲清背后的技术原理、与其他方案的对比与权衡(trade-off)。
|
||||
3. **说明好处**:明确指出这样做带来的收益(可维护性、性能、可测试性、可扩展性、协作成本等)。
|
||||
4. **指出注意点**:说明边界条件、常见坑、何时不应采用。
|
||||
5. **给出示例**:提供可运行、符合项目实际结构的代码片段。
|
||||
|
||||
## 专业准则
|
||||
- 只推荐 Vue 官方认可、社区验证过的最佳实践,不推荐已废弃或反模式的做法。
|
||||
- 不确定的地方要明确说明,不臆造。
|
||||
- 引用项目内真实文件路径(如 src/views、src/components)使建议落地。
|
||||
- 使用简体中文回答,技术术语保留英文便于检索。
|
||||
|
||||
## 限制
|
||||
- 不要堆砌没有依据的「最佳实践」清单。
|
||||
- 若问题超出 Vue 范畴,先给出 Vue 侧最佳方案,再补充必要说明。
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tokens": {},
|
||||
"components": {}
|
||||
}
|
||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -14,7 +14,8 @@
|
||||
"element-plus": "^2.11.8",
|
||||
"vue": "^3.5.22",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||
@@ -1881,6 +1891,28 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1916,6 +1948,18 @@
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2233,6 +2277,15 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2801,6 +2854,18 @@
|
||||
"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": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
|
||||
@@ -3120,6 +3185,24 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
@@ -3136,6 +3219,27 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"element-plus": "^2.11.8",
|
||||
"vue": "^3.5.22",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
||||
13
src/api/login.js
Normal file
13
src/api/login.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { adminRequest, indexRequest } from '@/utils/request'
|
||||
|
||||
// ==================== 前台接口(/api/index) ====================
|
||||
|
||||
export const userApi = {
|
||||
login(username, password) {
|
||||
return indexRequest.post('/login', { username, password })
|
||||
},
|
||||
|
||||
logout() {
|
||||
return indexRequest.post('/logout')
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,20 @@ export const customerApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 客户接口(/api/index) ====================
|
||||
|
||||
export const clientApi = {
|
||||
// 获取当前客户关联的工单列表
|
||||
getTickets(params) {
|
||||
return indexRequest.get('/client/tickets', { params })
|
||||
},
|
||||
|
||||
// 获取当前客户的工单统计
|
||||
getStats() {
|
||||
return indexRequest.get('/client/stats')
|
||||
}
|
||||
}
|
||||
|
||||
export const accountApi = {
|
||||
getAllUsers() {
|
||||
return adminRequest.get('/getAllUsers')
|
||||
@@ -86,10 +100,16 @@ export const accountApi = {
|
||||
},
|
||||
|
||||
updateAccountStatus(id, status) {
|
||||
return adminRequest.put(`/account/${id}/status`, { status })
|
||||
return adminRequest.put(`/updateAccountStatus/${id}/status`, { status })
|
||||
},
|
||||
|
||||
deleteAccount(id) {
|
||||
return adminRequest.delete(`/account/${id}`)
|
||||
return adminRequest.delete(`/deleteAccount/${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const exportApi = {
|
||||
exportdata(range) {
|
||||
return adminRequest.post('/getExportData', { range })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,316 @@
|
||||
/* 重置基础样式 */
|
||||
/* ============================================
|
||||
Global Design System — Refined Minimal
|
||||
============================================ */
|
||||
:root {
|
||||
/* ===== 品牌主色(高饱和靛蓝 → 紫) ===== */
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-deep: #4338ca;
|
||||
--color-primary-soft: #eef0ff;
|
||||
--brand-gradient: linear-gradient(135deg, #4f46e5 0%, #6d28d9 60%, #9333ea 100%);
|
||||
|
||||
/* ===== 背景层次:深侧栏 → 浅内容 → 白卡片 ===== */
|
||||
--color-bg: #eef1f8;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-soft: #f6f8fd;
|
||||
|
||||
--color-border: #e3e8f2;
|
||||
--color-border-light: #eef1f8;
|
||||
|
||||
/* ===== 语义色(高饱和) ===== */
|
||||
--color-success: #16a34a;
|
||||
--color-success-soft: #e7f6ed;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-soft: #fff4e0;
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-soft: #fdecec;
|
||||
--color-info: #0ea5e9;
|
||||
--color-info-soft: #e6f6fe;
|
||||
|
||||
/* ===== 文本层次 ===== */
|
||||
--text-primary: #17203f;
|
||||
--text-secondary: #5b6478;
|
||||
--text-muted: #98a0b5;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* ===== 圆角 ===== */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
|
||||
/* ===== 阴影分级(层次感核心) ===== */
|
||||
--shadow-xs: 0 1px 2px rgba(23, 32, 63, 0.05);
|
||||
--shadow-sm: 0 1px 3px rgba(23, 32, 63, 0.06), 0 1px 2px rgba(23, 32, 63, 0.04);
|
||||
--shadow-md: 0 8px 20px rgba(79, 70, 229, 0.10), 0 2px 6px rgba(23, 32, 63, 0.05);
|
||||
--shadow-lg: 0 18px 44px rgba(23, 32, 63, 0.16), 0 6px 16px rgba(23, 32, 63, 0.08);
|
||||
|
||||
--font-sans: "PingFang SC", "Microsoft YaHei", -apple-system, "Helvetica Neue", sans-serif;
|
||||
--font-mono: "SF Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
|
||||
/* ===== Element Plus 对齐 ===== */
|
||||
--el-color-primary: var(--color-primary);
|
||||
--el-color-primary-light-3: rgba(79, 70, 229, 0.30);
|
||||
--el-color-primary-light-5: rgba(79, 70, 229, 0.16);
|
||||
--el-color-primary-light-7: rgba(79, 70, 229, 0.07);
|
||||
--el-color-primary-light-8: rgba(79, 70, 229, 0.04);
|
||||
--el-color-primary-light-9: rgba(79, 70, 229, 0.02);
|
||||
--el-color-primary-dark-2: var(--color-primary-deep);
|
||||
--el-color-success: var(--color-success);
|
||||
--el-color-warning: var(--color-warning);
|
||||
--el-color-danger: var(--color-danger);
|
||||
--el-color-error: var(--color-danger);
|
||||
--el-color-info: var(--color-info);
|
||||
--el-border-radius-base: 8px;
|
||||
--el-text-color-primary: var(--text-primary);
|
||||
--el-text-color-regular: var(--text-secondary);
|
||||
--el-border-color: var(--color-border);
|
||||
--el-border-color-light: var(--color-border-light);
|
||||
--el-fill-color-blank: var(--color-surface);
|
||||
--el-bg-color: var(--color-surface);
|
||||
--el-bg-color-page: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ===== 全局基础 ===== */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f7fa;
|
||||
/* 给背景一个淡灰色,更有后台质感 */
|
||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#app {
|
||||
/* 移除原有的 grid 和 center 布局 */
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
/* 恢复块级布局 */
|
||||
}
|
||||
|
||||
/* 如果你希望内容有一个最大宽度并居中,可以这样写 */
|
||||
.ticket-management {
|
||||
max-width: 1440px;
|
||||
/* 常见的管理后台舒适宽度 */
|
||||
margin: 0 auto;
|
||||
/* 水平居中 */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 链接样式微调 */
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #409eff;
|
||||
transition: 0.3s;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 针对移动端的小适配 */
|
||||
@media (max-width: 768px) {
|
||||
.ticket-management {
|
||||
padding: 10px;
|
||||
}
|
||||
/* ===== Element Plus overrides ===== */
|
||||
/* ---- Button ---- */
|
||||
.el-button {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--color-primary);
|
||||
--el-button-border-color: var(--color-primary);
|
||||
--el-button-hover-bg-color: var(--color-primary-deep);
|
||||
--el-button-hover-border-color: var(--color-primary-deep);
|
||||
--el-button-active-bg-color: #3730a3;
|
||||
--el-button-active-border-color: #3730a3;
|
||||
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.28);
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.36);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.el-button--primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
--el-button-bg-color: var(--color-success);
|
||||
--el-button-border-color: var(--color-success);
|
||||
--el-button-hover-bg-color: #15803d;
|
||||
--el-button-hover-border-color: #15803d;
|
||||
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.24);
|
||||
}
|
||||
|
||||
/* ---- Input / Select ---- */
|
||||
.el-input .el-input__wrapper,
|
||||
.el-select .el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 0 0 1px var(--color-border) inset;
|
||||
transition: box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.el-input .el-input__wrapper:hover,
|
||||
.el-select .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px rgba(79, 70, 229, 0.5) inset;
|
||||
}
|
||||
|
||||
.el-input .el-input__wrapper.is-focus,
|
||||
.el-select .el-input__wrapper.is-focus {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.16), 0 0 0 1px var(--color-primary) inset;
|
||||
}
|
||||
|
||||
/* ---- Table(层次感:表头深、斑马行、悬停抬升) ---- */
|
||||
.el-table {
|
||||
--el-table-border-color: var(--color-border-light);
|
||||
--el-table-header-bg-color: #f3f5fb;
|
||||
--el-table-header-text-color: var(--text-secondary);
|
||||
--el-table-row-hover-bg-color: #f6f8ff;
|
||||
--el-table-current-row-bg-color: #eef0ff;
|
||||
--el-table-tr-bg-color: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
background: #f3f5fb;
|
||||
}
|
||||
|
||||
.el-table__inner-wrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Card ---- */
|
||||
.el-card {
|
||||
--el-card-border-color: var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.el-card.is-always-shadow {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ---- Dialog ---- */
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* ---- Tag ---- */
|
||||
.el-tag {
|
||||
--el-tag-border-radius: 6px;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- Radio Button(筛选高亮) ---- */
|
||||
.el-radio-button__original-radio:checked+.el-radio-button__inner {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: -1px 0 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.el-radio-button__inner {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.el-radio-group .el-radio-button:first-child .el-radio-button__inner {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.el-radio-group .el-radio-button:last-child .el-radio-button__inner {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
/* ---- Progress ---- */
|
||||
.el-progress-bar__outer {
|
||||
background: #e8ebf4;
|
||||
}
|
||||
|
||||
/* ---- Switch ---- */
|
||||
.el-switch.is-checked .el-switch__core {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---- Collapse ---- */
|
||||
.el-collapse {
|
||||
--el-collapse-border-color: var(--color-border-light);
|
||||
--el-collapse-header-bg-color: transparent;
|
||||
--el-collapse-content-bg-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Pagination ---- */
|
||||
.el-pagination {
|
||||
--el-pagination-button-bg-color: var(--color-surface);
|
||||
--el-pagination-hover-color: var(--color-primary);
|
||||
--el-pagination-active-bg-color: var(--color-primary);
|
||||
--el-pagination-active-color: #fff;
|
||||
--el-pagination-button-color: var(--text-secondary);
|
||||
--el-pagination-border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ---- Dropdown ---- */
|
||||
.el-dropdown-menu {
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:not(.is-disabled):hover {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---- Message / MessageBox ---- */
|
||||
.el-message {
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* ---- Empty ---- */
|
||||
.el-empty__description p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c6cce0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #aab3cf;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
54
src/components/common/SearchInput.vue
Normal file
54
src/components/common/SearchInput.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="placeholder"
|
||||
clearable
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '搜索网站、备注...' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const keyword = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
keyword.value = val
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
emit('update:modelValue', keyword.value)
|
||||
emit('search', keyword.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-input {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.search-input { width: 260px; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.search-input { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -34,14 +34,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="关键词" class="search-keyword">
|
||||
<el-input v-model="localFilters.search" placeholder="搜索网站、备注" clearable @keyup.enter="handleSearch"
|
||||
class="wide-search-input">
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Search />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<SearchInput v-model="localFilters.search" @search="handleSearch" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,6 +63,7 @@
|
||||
<script setup>
|
||||
import { reactive, watch, computed } from "vue";
|
||||
import { Search, Sort } from "@element-plus/icons-vue";
|
||||
import SearchInput from "@/components/common/SearchInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
filters: { type: Object, required: true },
|
||||
@@ -144,21 +138,16 @@ const setSort = (order) => {
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
border: none !important;
|
||||
background: transparent;
|
||||
/* 核心修改:禁止用户选中文字,防止点击时变蓝 */
|
||||
-webkit-user-select: none;
|
||||
/* Safari */
|
||||
-moz-user-select: none;
|
||||
/* Firefox */
|
||||
-ms-user-select: none;
|
||||
/* IE/Edge */
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
overflow: visible !important;
|
||||
--el-card-border-color: transparent;
|
||||
--el-card-bg-color: transparent;
|
||||
user-select: none;
|
||||
/* 标准语法 */
|
||||
}
|
||||
|
||||
/* 恢复输入框的可选中状态,否则无法输入文字 */
|
||||
:deep(.el-input__inner) {
|
||||
-webkit-user-select: text !important;
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
@@ -183,9 +172,9 @@ const setSort = (order) => {
|
||||
}
|
||||
|
||||
.divider-wrapper {
|
||||
color: #dcdfe6;
|
||||
font-size: 18px;
|
||||
font-weight: 200;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -196,13 +185,27 @@ const setSort = (order) => {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* 宽度控制 */
|
||||
:deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wide-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.wide-search-input {
|
||||
width: 320px;
|
||||
.filter-main :deep(.el-input__wrapper),
|
||||
.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 {
|
||||
@@ -212,14 +215,11 @@ const setSort = (order) => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.wide-search-input {
|
||||
width: 260px;
|
||||
}
|
||||
:deep(.el-select-group__title) {
|
||||
background: var(--color-bg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -236,17 +236,8 @@ const setSort = (order) => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wide-select,
|
||||
.wide-search-input {
|
||||
.wide-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 运营人员分组标题样式 */
|
||||
:deep(.el-select-group__title) {
|
||||
background-color: #f5f7fa;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
padding-left: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '📝 编辑工单任务' : '✨ 创建新工单'" width="550px" class="modern-dialog"
|
||||
destroy-on-close @close="handleClose">
|
||||
<el-dialog v-model="dialogVisible" width="550px" class="ticket-dialog" 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">
|
||||
<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-input v-model="form.website" placeholder="输入URL" clearable>
|
||||
<template #prefix>
|
||||
@@ -13,24 +28,37 @@
|
||||
</template>
|
||||
</el-input>
|
||||
</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 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-select v-model="form.company_id" placeholder="请选择单位" style="width: 100%" filterable
|
||||
@change="handleCustomerChange">
|
||||
<el-option v-for="item in companiesWithOperators.filter(c => c.id !== 0)" :key="item.id" :label="item.company_name"
|
||||
:value="item.id" />
|
||||
<el-option v-for="item in companiesWithOperators.filter(c => c.id !== 0)" :key="item.id"
|
||||
:label="item.company_name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="运营人员" prop="operator_id" class="client-item">
|
||||
<el-select v-model="form.operator_id" placeholder="请选择人员" style="width: 100%" filterable
|
||||
: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"
|
||||
:value="item.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" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -42,6 +70,9 @@
|
||||
</div>
|
||||
|
||||
<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-radio-group v-model="form.status" class="modern-radio-group">
|
||||
<el-radio-button :label="0">未分配</el-radio-button>
|
||||
@@ -51,6 +82,37 @@
|
||||
</el-radio-group>
|
||||
</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-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" />
|
||||
</el-form-item>
|
||||
@@ -70,7 +132,7 @@
|
||||
|
||||
<script setup>
|
||||
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"]);
|
||||
|
||||
@@ -86,14 +148,17 @@ const props = defineProps({
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isInitializing = ref(false);
|
||||
|
||||
// 核心表单对象:customer_id 已包含在内
|
||||
const form = reactive({
|
||||
website: "",
|
||||
datanum: "",
|
||||
company_id: null,
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
status: 0,
|
||||
progress: 0,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
@@ -111,6 +176,102 @@ const handleCustomerChange = () => {
|
||||
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(
|
||||
() => props.modelValue,
|
||||
@@ -118,6 +279,7 @@ watch(
|
||||
dialogVisible.value = val;
|
||||
if (val) { // 只在弹窗打开时候,执行初始化
|
||||
nextTick(() => { // 等待DOM更新完成后再操作表单数据
|
||||
isInitializing.value = true;
|
||||
resetForm(); // 先重置
|
||||
if (props.isEditing && props.formData) {
|
||||
const d = props.formData;
|
||||
@@ -155,6 +317,7 @@ watch(
|
||||
if (u) form.user_id = u.id;
|
||||
}
|
||||
}
|
||||
isInitializing.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -170,10 +333,12 @@ const resetForm = () => {
|
||||
if (formRef.value) formRef.value.resetFields();
|
||||
Object.assign(form, {
|
||||
website: "",
|
||||
datanum: "",
|
||||
company_id: null,
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
status: 0,
|
||||
progress: 0,
|
||||
remark: "",
|
||||
});
|
||||
};
|
||||
@@ -186,7 +351,7 @@ const handleSubmit = async () => {
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
|
||||
// 复制提交数据,如果是新增,确保 staff_id 为空
|
||||
|
||||
const submitData = { ...form };
|
||||
|
||||
// 核心修复:如果状态是”已完结”,确保 lock_time 不会被意外修改。
|
||||
@@ -198,9 +363,6 @@ const handleSubmit = async () => {
|
||||
|
||||
delete submitData.create_time;
|
||||
delete submitData.update_time;
|
||||
if (!props.isEditing) {
|
||||
submitData.user_id = null;
|
||||
}
|
||||
|
||||
emit("submit", submitData);
|
||||
} catch (e) {
|
||||
@@ -212,30 +374,26 @@ const handleSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 保持你原本的样式不变 */
|
||||
:deep(.modern-dialog) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #409eff;
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: var(--color-primary);
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -243,7 +401,7 @@ const handleSubmit = async () => {
|
||||
.people-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.column-full {
|
||||
@@ -251,9 +409,251 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
.modern-radio-group {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
<template>
|
||||
<el-card class="compact-card" shadow="never">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="tickets"
|
||||
v-loading="loading"
|
||||
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">
|
||||
<el-table ref="tableRef" :data="tickets" row-key="id" v-loading="loading" header-cell-class-name="table-header"
|
||||
style="width: 100%" @select="onSelect" @select-all="onSelectAll">
|
||||
<el-table-column type="selection" width="50" :reserve-selection="true" />
|
||||
<el-table-column label="网站地址" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
type="primary"
|
||||
:href="formatUrl(row.website)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="site-link"
|
||||
>
|
||||
<el-link type="primary" :href="formatUrl(row.website)" target="_blank" rel="noopener noreferrer"
|
||||
class="site-link">
|
||||
{{ row.website }}
|
||||
</el-link>
|
||||
</template>
|
||||
@@ -35,27 +24,15 @@
|
||||
<el-table-column label="任务进度" width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="progress-container">
|
||||
<el-progress
|
||||
:percentage="Number(row.progress || 0)"
|
||||
:status="Number(row.progress) === 100 ? 'success' : ''"
|
||||
:stroke-width="6"
|
||||
:text-inside="false"
|
||||
/>
|
||||
<el-progress :percentage="Number(row.progress || 0)" :status="Number(row.progress) === 100 ? 'success' : ''"
|
||||
:stroke-width="6" :text-inside="false" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="company_name"
|
||||
label="所属客户"
|
||||
min-width="120"
|
||||
align="center"
|
||||
>
|
||||
<el-table-column prop="company_name" label="所属客户" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="company-badge"
|
||||
:style="getCompanyStyle(row.company_name)"
|
||||
>
|
||||
<span class="company-badge" :style="getCompanyStyle(row.company_name)">
|
||||
{{ row.company_name || "未关联" }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -80,12 +57,7 @@
|
||||
|
||||
<el-table-column label="备注" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.remark"
|
||||
effect="dark"
|
||||
:content="row.remark"
|
||||
placement="top"
|
||||
>
|
||||
<el-tooltip v-if="row.remark" effect="dark" :content="row.remark" placement="top">
|
||||
<span class="remark-text">{{ row.remark }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="empty-text">-</span>
|
||||
@@ -100,29 +72,30 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="锁定状态" width="90" align="center">
|
||||
<el-table-column label="锁定" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.lock_time && row.lock_time !== '0'"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="light"
|
||||
>已锁定</el-tag
|
||||
>
|
||||
<el-tag v-else type="info" size="small" effect="plain">未锁定</el-tag>
|
||||
<span class="lock-badge" :class="{ locked: row.lock_time && row.lock_time !== '0' }">
|
||||
{{ row.lock_time && row.lock_time !== '0' ? '已锁定' : '未锁定' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="时间节点" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="time-wrapper">
|
||||
<span>创建:{{ formatDateTime(row.create_time) }}</span>
|
||||
<span class="update-label">更新:{{ formatDateTime(row.update_time) }}</span>
|
||||
<span class="time-row">
|
||||
<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>
|
||||
</template>
|
||||
</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 }">
|
||||
<el-button link type="primary" @click="$emit('edit', row)">编辑</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
@@ -143,21 +116,43 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, reactive } from "vue";
|
||||
import { formatUrl, formatDateTime } from "@/utils/format";
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
tickets: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(["edit", "delete", "selection-change"]);
|
||||
const emit = defineEmits(["edit", "delete", "selection-change"]);
|
||||
|
||||
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({
|
||||
clearSelection() {
|
||||
tableRef.value?.clearSelection();
|
||||
selectedMap.clear();
|
||||
emit("selection-change", []);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,10 +162,10 @@ const statusLabel = (status) => statusMap[status] || "未知";
|
||||
|
||||
const getStatusStyle = (status) => {
|
||||
const styles = {
|
||||
0: { color: "#909399", background: "#f4f4f5" },
|
||||
1: { color: "#409EFF", background: "#ecf5ff" },
|
||||
2: { color: "#67C23A", background: "#f0f9eb" },
|
||||
3: { color: "#e6a23c", background: "#fdf6ec" },
|
||||
0: { color: "#6b7280", background: "#f3f4f6" },
|
||||
1: { color: "#4f46e5", background: "#eef0ff" },
|
||||
2: { color: "#16a34a", background: "#ecfdf3" },
|
||||
3: { color: "#d97706", background: "#fffbeb" },
|
||||
};
|
||||
return styles[status] || styles[0];
|
||||
};
|
||||
@@ -178,14 +173,14 @@ const getStatusStyle = (status) => {
|
||||
// 客户徽章颜色映射(同一客户保持同一颜色)
|
||||
const companyColorMap = new Map();
|
||||
const COMPANY_PALETTE = [
|
||||
{ color: "#409EFF", background: "#ecf5ff" },
|
||||
{ color: "#67C23A", background: "#f0f9eb" },
|
||||
{ color: "#E6A23C", background: "#fdf6ec" },
|
||||
{ color: "#F56C6C", background: "#fef0f0" },
|
||||
{ color: "#909399", background: "#f4f4f5" },
|
||||
{ color: "#9C27B0", background: "#f3e5f5" },
|
||||
{ color: "#00BCD4", background: "#e0f7fa" },
|
||||
{ color: "#FF9800", background: "#fff3e0" },
|
||||
{ color: "#4f46e5", background: "#eef0ff" },
|
||||
{ color: "#16a34a", background: "#ecfdf3" },
|
||||
{ color: "#d97706", background: "#fffbeb" },
|
||||
{ color: "#dc2626", background: "#fef2f2" },
|
||||
{ color: "#6b7280", background: "#f3f4f6" },
|
||||
{ color: "#7c3aed", background: "#f5f0ff" },
|
||||
{ color: "#0891b2", background: "#ecfeff" },
|
||||
{ color: "#ea580c", background: "#fff7ed" },
|
||||
];
|
||||
let nextColorIndex = 0;
|
||||
|
||||
@@ -204,12 +199,21 @@ const getCompanyStyle = (name) => {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
/* 调整进度条内部文字大小 */
|
||||
:deep(.el-progress__text) {
|
||||
font-size: 11px !important;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.compact-card {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.staff-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -219,21 +223,22 @@ const getCompanyStyle = (name) => {
|
||||
.staff-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
height: 20px;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.staff-role {
|
||||
width: 34px;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
background: #909399;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.staff-name {
|
||||
@@ -242,30 +247,34 @@ const getCompanyStyle = (name) => {
|
||||
}
|
||||
|
||||
.staff-item.client {
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
border: 1px solid #e9e9eb;
|
||||
background: #eef0ff;
|
||||
color: #4338ca;
|
||||
border: 1px solid #e3e6ff;
|
||||
}
|
||||
|
||||
.staff-item.client .staff-role {
|
||||
background: #6366f1;
|
||||
}
|
||||
|
||||
.staff-item.internal {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
border: 1px solid #d9ecff;
|
||||
background: #e7f6ed;
|
||||
color: #15803d;
|
||||
border: 1px solid #d3f0df;
|
||||
}
|
||||
|
||||
.staff-item.internal .staff-role {
|
||||
background: #409eff;
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.data-num-text {
|
||||
font-family: "Monaco", monospace;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -273,23 +282,19 @@ const getCompanyStyle = (name) => {
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compact-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-badge,
|
||||
.company-badge {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
font-size: 11px;
|
||||
border-radius: 11px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.company-badge {
|
||||
@@ -299,19 +304,39 @@ const getCompanyStyle = (name) => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 11px;
|
||||
color: #606266;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.update-label {
|
||||
color: #909399;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
:deep(.table-header) {
|
||||
background-color: #fafafa !important;
|
||||
background: #f3f5fb !important;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,272 +1,410 @@
|
||||
<template>
|
||||
<div class="full-screen-layout">
|
||||
<el-container class="full-screen-container">
|
||||
<el-aside width="200px" class="siderbar">
|
||||
<div class="sidebar-header">
|
||||
<!-- 修复:将 logo 区域包装成一个正确的外部链接 -->
|
||||
<a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="logo-link">
|
||||
<div class="logo-container">
|
||||
<img src="@/assets/logo.jpg" alt="方舟网络" class="logo-image" />
|
||||
<div class="text-content">
|
||||
<h2 class="company-name">方舟网络</h2>
|
||||
<p class="system-name">工单管理系统</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="shell">
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark">
|
||||
<img src="@/assets/logo.jpg" alt="logo" class="brand-img" />
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">方舟工单</span>
|
||||
<span class="brand-ver">v4.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-menu default-active="/" class="sidebar-menu" router>
|
||||
<el-menu-item index="/">
|
||||
<el-icon>
|
||||
<icon-menu />
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="collapse-btn" type="button" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
@click="toggleSidebar">
|
||||
<el-icon :size="18">
|
||||
<component :is="sidebarCollapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
<span>工单管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<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">
|
||||
<div class="user-profile">
|
||||
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
||||
<span class="username">{{ user.name }}</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</button>
|
||||
<span class="page-title">{{ route.meta?.title || '工单管理' }}</span>
|
||||
</div>
|
||||
<el-dropdown trigger="click">
|
||||
<div class="user-chip">
|
||||
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
||||
<span class="user-name">{{ user.name }}</span>
|
||||
</div>
|
||||
</el-header>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</header>
|
||||
|
||||
<el-main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
<main class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted } from "vue";
|
||||
import { Menu as IconMenu, Document, User } from "@element-plus/icons-vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { Fold, Expand } from "@element-plus/icons-vue";
|
||||
import { resetRouter } from "@/router";
|
||||
import { userApi } from "@/api/ticket";
|
||||
|
||||
const router = useRouter();
|
||||
const user = reactive({
|
||||
name: "加载中...",
|
||||
avatar: "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png",
|
||||
});
|
||||
const route = useRoute();
|
||||
const user = reactive({ name: "..." });
|
||||
|
||||
// 侧边栏折叠状态(持久化到 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(() => {
|
||||
const savedUser =
|
||||
localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
|
||||
if (savedUser) {
|
||||
const data = JSON.parse(savedUser);
|
||||
user.name = data.username || "管理员";
|
||||
}
|
||||
const saved = localStorage.getItem("user_info") || sessionStorage.getItem("user_info");
|
||||
if (saved) user.name = JSON.parse(saved).username || "用户";
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
// 1. 调用后端接口销毁 session
|
||||
try {
|
||||
await userApi.logout();
|
||||
} catch (e) {
|
||||
// 即使后端接口异常,也要完成前端的清理
|
||||
}
|
||||
|
||||
try { await userApi.logout(); } catch (e) { }
|
||||
localStorage.removeItem("user_info");
|
||||
sessionStorage.removeItem("user_info");
|
||||
|
||||
resetRouter();
|
||||
router.push({ name: "login" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* --- 基础布局保持不变 --- */
|
||||
.full-screen-layout,
|
||||
.full-screen-container {
|
||||
/* ---- Shell ---- */
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.siderbar {
|
||||
background-color: #001529;
|
||||
color: white;
|
||||
/* 禁止用户选中文字 */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* 兼容 Safari */
|
||||
-moz-user-select: none;
|
||||
/* 兼容 Firefox */
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
height: 80px;
|
||||
/* ---- Sidebar(深色,制造明暗层次) ---- */
|
||||
.sidebar {
|
||||
width: 232px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, #1c2140 0%, #171b34 100%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
/* 修复:为新添加的链接添加样式 */
|
||||
.logo-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
.sidebar.collapsed {
|
||||
width: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
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;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
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;
|
||||
.brand-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
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;
|
||||
outline: none;
|
||||
/* 保持隐藏默认框 */
|
||||
height: 48px;
|
||||
/* 稍微缩小高度,让点击范围更精致 */
|
||||
padding: 0 12px;
|
||||
/* 左右加点间距 */
|
||||
border-radius: 6px;
|
||||
/* 加个圆角 */
|
||||
transition: all 0.2s;
|
||||
/* 让颜色变化更顺滑 */
|
||||
transition: background 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
/* 鼠标悬停时的效果 */
|
||||
.user-profile:hover {
|
||||
background-color: #f0f2f5;
|
||||
.collapse-btn:hover {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 关键:鼠标点击那一瞬间的效果 */
|
||||
.user-profile:active {
|
||||
background-color: #e6e8eb;
|
||||
/* 背景变深 */
|
||||
transform: scale(0.98);
|
||||
/* 轻轻缩放,增加“按下去”的质感 */
|
||||
.collapse-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
/* 消除默认行高导致的下偏 */
|
||||
display: inline-block;
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
height: calc(100vh - 60px);
|
||||
.page-title::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
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;
|
||||
padding: 24px 28px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-transform-enter-active,
|
||||
.fade-transform-leave-active {
|
||||
transition: all 0.3s;
|
||||
/* ---- Transitions ---- */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s, transform 0.18s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,8 @@ import router from './router'
|
||||
// 导入 Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
// 导入设计系统(设计令牌 + Element Plus 主题对齐)
|
||||
import '@/assets/main.css'
|
||||
// 导入中文语言包
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
@@ -19,7 +19,7 @@ export function formatDate(time) {
|
||||
|
||||
const timestamp = Number(time)
|
||||
if (!isNaN(timestamp) && timestamp > 0) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000)
|
||||
if (!isNaN(date.getTime())) {
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
|
||||
@@ -14,7 +14,6 @@ function createService(basePath) {
|
||||
service.interceptors.response.use(
|
||||
response => response.data,
|
||||
|
||||
|
||||
error => {
|
||||
const resData = error.response?.data
|
||||
const status = error.response?.status
|
||||
|
||||
131
src/utils/socket.js
Normal file
131
src/utils/socket.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// ============================================================
|
||||
// WebSocket 广播客户端封装(Workerman 2346 端口)
|
||||
// 特性:单例 + 引用计数 + 断线指数退避重连 + 心跳 + 重连后通知
|
||||
// 用法:
|
||||
// import { openSocket, closeSocket, onTicketChanged, onReconnect } from '@/utils/socket'
|
||||
// onMounted(() => {
|
||||
// openSocket()
|
||||
// const off1 = onTicketChanged(() => fetchTickets(true))
|
||||
// const off2 = onReconnect(() => fetchTickets(true))
|
||||
// onBeforeUnmount(() => { off1(); off2(); closeSocket() })
|
||||
// })
|
||||
// ============================================================
|
||||
|
||||
const WS_URL = 'ws://localhost:2346'
|
||||
const MAX_RETRY_DELAY = 30000 // 重连封顶 30s
|
||||
const HEARTBEAT_INTERVAL = 25000
|
||||
|
||||
let socket = null
|
||||
let retryDelay = 1000
|
||||
let reconnectTimer = null
|
||||
let heartbeatTimer = null
|
||||
let manualClosed = false // 手动关闭后不再重连
|
||||
let refCount = 0
|
||||
|
||||
const ticketListeners = new Set() // 收到 ticket_changed 回调
|
||||
const reconnectListeners = new Set() // 重连成功回调
|
||||
|
||||
function connect() {
|
||||
manualClosed = false
|
||||
try {
|
||||
socket = new WebSocket(WS_URL)
|
||||
} catch (e) {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
retryDelay = 1000
|
||||
console.log('[socket] 已连接到工单推送服务 ws://localhost:2346')
|
||||
startHeartbeat()
|
||||
reconnectListeners.forEach((fn) => fn())
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'ticket_changed') {
|
||||
ticketListeners.forEach((fn) => fn(data))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[socket] 消息解析失败:', event.data)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
stopHeartbeat()
|
||||
if (!manualClosed) scheduleReconnect()
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
// 连接错误后必然触发 onclose,无需额外处理
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || manualClosed) return
|
||||
console.log(`[socket] 连接断开,${Math.round(retryDelay / 1000)}s 后重试...`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, retryDelay)
|
||||
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY)
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send('ping')
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL)
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册工单变更监听,返回注销函数 */
|
||||
export function onTicketChanged(callback) {
|
||||
ticketListeners.add(callback)
|
||||
return () => ticketListeners.delete(callback)
|
||||
}
|
||||
|
||||
/** 注册重连成功监听(适合在此主动拉一次数据补齐断线期间变化),返回注销函数 */
|
||||
export function onReconnect(callback) {
|
||||
reconnectListeners.add(callback)
|
||||
return () => reconnectListeners.delete(callback)
|
||||
}
|
||||
|
||||
/** 打开连接(引用计数 +1;多处页面可共享同一连接) */
|
||||
export function openSocket() {
|
||||
refCount += 1
|
||||
if (!socket || socket.readyState === WebSocket.CLOSED) {
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭连接(引用计数 -1;归零才真正断开) */
|
||||
export function closeSocket() {
|
||||
refCount = Math.max(0, refCount - 1)
|
||||
if (refCount === 0) {
|
||||
manualClosed = true
|
||||
stopHeartbeat()
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (socket) {
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSocketOpen() {
|
||||
return socket !== null && socket.readyState === WebSocket.OPEN
|
||||
}
|
||||
@@ -1,95 +1,123 @@
|
||||
<template>
|
||||
<div class="account-container">
|
||||
<h2>账户管理</h2>
|
||||
|
||||
<div class="action-bar">
|
||||
<div class="search-group">
|
||||
<el-input v-model="searchQuery" placeholder="搜索用户名/真实姓名" style="width: 250px" clearable />
|
||||
<div class="account-root">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card" :class="{ active: activeRole === s.role }" v-for="s in stats" :key="s.key"
|
||||
:style="{ '--border': s.color }" @click="activeRole = activeRole === s.role ? null : s.role">
|
||||
<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>
|
||||
<el-button type="primary" :icon="Plus" @click="openRegisterDialog">注册用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredUsers" border style="width: 100%; margin-top: 20px" v-loading="loading">
|
||||
<template #empty>
|
||||
<el-empty :image-size="100" description="暂无用户数据" />
|
||||
</template>
|
||||
|
||||
<!-- <el-table-column prop="id" label="ID" width="70" align="center" /> -->
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="real_name" label="真实姓名" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.real_name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="角色" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="roleTagType(row.role)" size="small" effect="plain">
|
||||
{{ roleLabel(row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small" effect="plain">
|
||||
{{ row.status === 1 ? '正常' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="创建时间" width="160" 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">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="scope.row.status === 1 ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="handleToggleStatus(scope.row)"
|
||||
>
|
||||
{{ scope.row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDeleteAccount(scope.row)">
|
||||
删除
|
||||
<!-- 主面板 -->
|
||||
<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>
|
||||
<el-empty :image-size="80" description="暂无用户数据" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-table-column label="用户名" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="真实姓名" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-muted': !row.real_name }">{{ row.real_name || '—' }}</span>
|
||||
</template>
|
||||
</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>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge" :class="{ on: row.status === 1 }">
|
||||
{{ row.status === 1 ? '正常' : '已禁用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" :type="scope.row.status === 1 ? 'warning' : 'success'"
|
||||
@click="handleToggleStatus(scope.row)">
|
||||
{{ scope.row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDeleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 注册弹窗 -->
|
||||
<el-dialog
|
||||
v-model="registerDialogVisible"
|
||||
title="注册新用户"
|
||||
width="450px"
|
||||
destroy-on-close
|
||||
@close="resetRegisterForm"
|
||||
>
|
||||
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-width="80px">
|
||||
<el-dialog v-model="registerDialogVisible" title="注册新用户" width="420px" destroy-on-close @close="resetRegisterForm"
|
||||
class="register-dialog">
|
||||
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-position="top">
|
||||
<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 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 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 label="角色" prop="role">
|
||||
<el-select v-model="registerForm.role" placeholder="请选择角色" style="width: 100%">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="员工" value="user" />
|
||||
<el-option label="客户" value="client" />
|
||||
<div class="role-group">
|
||||
<button v-for="opt in roleOptions" :key="opt.value" type="button" class="role-btn"
|
||||
:class="{ selected: registerForm.role === opt.value }" :style="{ '--accent': opt.color }"
|
||||
@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-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="registerDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleRegister">确定注册</el-button>
|
||||
<div class="dialog-actions">
|
||||
<el-button size="large" @click="registerDialogVisible = false">取消</el-button>
|
||||
<el-button size="large" type="primary" :loading="submitting" @click="handleRegister">确定注册</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -97,14 +125,15 @@
|
||||
|
||||
<script setup>
|
||||
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 { accountApi } from "@/api/ticket";
|
||||
import { formatDate } from "@/utils/format";
|
||||
import { accountApi, ticketApi } from "@/api/ticket";
|
||||
|
||||
const loading = ref(false);
|
||||
const users = ref([]);
|
||||
const companyList = ref([]);
|
||||
const searchQuery = ref("");
|
||||
const activeRole = ref(null);
|
||||
|
||||
const registerDialogVisible = ref(false);
|
||||
const registerFormRef = ref(null);
|
||||
@@ -115,19 +144,17 @@ const registerForm = reactive({
|
||||
real_name: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
company_id: null,
|
||||
});
|
||||
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: "请输入用户名", trigger: "blur" },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
||||
{ min: 6, message: "密码至少6位", trigger: "blur" },
|
||||
],
|
||||
role: [
|
||||
{ required: true, message: "请选择角色", trigger: "change" },
|
||||
],
|
||||
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||||
// password: [
|
||||
// { required: true, message: "请输入密码", trigger: "blur" },
|
||||
// { min: 6, message: "密码至少6位", trigger: "blur" },
|
||||
// ],
|
||||
role: [{ required: true, message: "请选择角色", trigger: "change" }],
|
||||
company_id: [{ required: true, message: "请选择所属公司", trigger: "change" }],
|
||||
};
|
||||
|
||||
const roleLabel = (role) => {
|
||||
@@ -135,23 +162,42 @@ const roleLabel = (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 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(() => {
|
||||
let list = users.value;
|
||||
|
||||
// 角色筛选
|
||||
if (activeRole.value) {
|
||||
list = list.filter(u => u.role === activeRole.value);
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.real_name && u.real_name.toLowerCase().includes(q))
|
||||
);
|
||||
list = list.filter(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));
|
||||
});
|
||||
|
||||
@@ -159,11 +205,8 @@ const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await accountApi.getAllUsers();
|
||||
if (res.code === 200) {
|
||||
users.value = res.data || [];
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取用户列表失败");
|
||||
}
|
||||
if (res.code === 200) users.value = res.data || [];
|
||||
else ElMessage.error(res.msg || "获取用户列表失败");
|
||||
} catch (error) {
|
||||
ElMessage.error("获取用户数据失败");
|
||||
} 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 = () => {
|
||||
if (companyList.value.length === 0) fetchCompanyList();
|
||||
registerDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const resetRegisterForm = () => {
|
||||
if (registerFormRef.value) registerFormRef.value.resetFields();
|
||||
registerForm.username = "";
|
||||
registerForm.real_name = "";
|
||||
registerForm.password = "";
|
||||
registerForm.role = "user";
|
||||
Object.assign(registerForm, { username: "", real_name: "", password: "", role: "user", company_id: null });
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
@@ -197,9 +248,7 @@ const handleRegister = async () => {
|
||||
ElMessage.error(res.msg || "注册失败");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== "ValidationError") {
|
||||
ElMessage.error("注册请求异常");
|
||||
}
|
||||
if (e?.name !== "ValidationError") ElMessage.error("注册请求异常");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -208,9 +257,7 @@ const handleRegister = async () => {
|
||||
const handleToggleStatus = (row) => {
|
||||
const newStatus = row.status === 1 ? 0 : 1;
|
||||
const action = newStatus === 0 ? "禁用" : "启用";
|
||||
ElMessageBox.confirm(`确定${action}用户【${row.username}】吗?`, "提示", {
|
||||
type: "warning",
|
||||
})
|
||||
ElMessageBox.confirm(`确定${action}用户【${row.username}】吗?`, "提示", { type: "warning" })
|
||||
.then(async () => {
|
||||
const res = await accountApi.updateAccountStatus(row.id, newStatus);
|
||||
if (res.code === 200) {
|
||||
@@ -220,50 +267,318 @@ const handleToggleStatus = (row) => {
|
||||
ElMessage.error(res.msg || `${action}失败`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
};
|
||||
|
||||
const handleDeleteAccount = (row) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定删除用户【${row.username}】吗?此操作不可逆。`,
|
||||
"警告",
|
||||
{ type: "error", confirmButtonClass: "el-button--danger" }
|
||||
)
|
||||
ElMessageBox.confirm(`确定删除用户【${row.username}】吗?此操作不可逆。`, "警告", {
|
||||
type: "error",
|
||||
confirmButtonClass: "el-button--danger",
|
||||
})
|
||||
.then(async () => {
|
||||
const res = await accountApi.deleteAccount(row.id);
|
||||
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);
|
||||
ElMessage.success("删除成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "删除失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
};
|
||||
|
||||
onMounted(fetchUsers);
|
||||
onMounted(() => { fetchUsers(); fetchCompanyList(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-container {
|
||||
padding: 30px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
.account-root {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 25px;
|
||||
color: #303133;
|
||||
/* ---- 统计卡片 ---- */
|
||||
.stats-row {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-btn {
|
||||
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>
|
||||
|
||||
@@ -1,131 +1,137 @@
|
||||
<template>
|
||||
<div class="client-view-container">
|
||||
<header class="client-header">
|
||||
<div class="brand">
|
||||
<div class="logo-box">
|
||||
<el-icon>
|
||||
<DataLine />
|
||||
</el-icon>
|
||||
<div class="cv-wrap">
|
||||
<!-- Header -->
|
||||
<header class="cv-header">
|
||||
<div class="cv-brand">
|
||||
<div class="cv-logo">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="title">{{ currentUser.company_name || "数据交付监控台" }}</span>
|
||||
<span class="version">Real-time Monitor v2.0</span>
|
||||
<div>
|
||||
<div class="cv-title">方舟星河</div>
|
||||
<div class="cv-subtitle">采集数据平台 · Monitor v2.0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<el-space :size="20">
|
||||
<div class="user-badge">
|
||||
<el-icon>
|
||||
<User />
|
||||
</el-icon>
|
||||
<span>{{ currentUser.username }}</span>
|
||||
</div>
|
||||
<el-button type="danger" plain size="small" @click="handleLogout" :icon="SwitchButton">退出</el-button>
|
||||
</el-space>
|
||||
<div class="cv-user">
|
||||
<div class="cv-avatar">{{ currentUser.username?.charAt(0) || 'U' }}</div>
|
||||
<span class="cv-username">{{ currentUser.username }}</span>
|
||||
<el-button class="cv-logout-btn" @click="handleLogout" :icon="SwitchButton" size="small">退出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="client-stats">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="stat in summaryStats" :key="stat.label">
|
||||
<div class="stat-card-premium" :class="{ 'is-active': activeStatus === stat.status }"
|
||||
:style="{ '--theme-color': stat.color }" @click="handleStatFilter(stat.status)">
|
||||
<div class="stat-info">
|
||||
<span class="s-label">{{ stat.label }}</span>
|
||||
<span class="s-value">{{ stat.value }}</span>
|
||||
</div>
|
||||
<div class="stat-icon-wrapper">
|
||||
<el-icon>
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-if="activeStatus === stat.status" class="active-dot"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<!-- Stats -->
|
||||
<section class="cv-stats">
|
||||
<div
|
||||
v-for="stat in summaryStats"
|
||||
:key="stat.label"
|
||||
class="cv-stat-card"
|
||||
:class="{ 'is-active': activeStatus === stat.status }"
|
||||
:style="{ '--c': stat.color }"
|
||||
@click="handleStatFilter(stat.status)"
|
||||
>
|
||||
<div class="cv-stat-icon">
|
||||
<el-icon><component :is="stat.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="cv-stat-body">
|
||||
<div class="cv-stat-val">{{ stat.value }}</div>
|
||||
<div class="cv-stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
<div class="cv-stat-bar"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="data-section">
|
||||
<div class="section-header">
|
||||
<div class="header-left">
|
||||
<span class="indicator"></span>
|
||||
<span class="section-title">实时交付看板</span>
|
||||
<el-tag v-if="activeStatus" closable size="small" type="info" class="filter-tag" @close="activeStatus = null">
|
||||
当前筛选: {{ activeStatus }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" link @click="loadData" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon> 同步最新数据
|
||||
</el-button>
|
||||
<!-- Table Panel -->
|
||||
<main class="cv-panel">
|
||||
<div class="cv-panel-header">
|
||||
<div class="cv-panel-title">
|
||||
<span class="cv-title-dot"></span>
|
||||
实时交付看板
|
||||
<el-tag
|
||||
v-if="activeStatus"
|
||||
closable
|
||||
size="small"
|
||||
class="cv-filter-tag"
|
||||
@close="activeStatus = null"
|
||||
>{{ activeStatus }}</el-tag>
|
||||
</div>
|
||||
<el-button type="primary" link :loading="loading" @click="loadData" :icon="Refresh" size="small">
|
||||
同步数据
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="paginatedTickets" v-loading="loading" class="custom-client-table">
|
||||
<el-table-column prop="id" label="单号" width="90" align="center">
|
||||
<el-table
|
||||
: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 }">
|
||||
<span class="id-tag">#{{ row.id }}</span>
|
||||
<span class="cv-id">#{{ row.id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="website" label="网站地址" min-width="260">
|
||||
<el-table-column label="网站地址" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="project-cell">
|
||||
<el-icon class="project-icon">
|
||||
<Monitor />
|
||||
</el-icon>
|
||||
<span class="project-name" :title="row.website">{{ row.website }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="交付进度" width="260">
|
||||
<template #default="scope">
|
||||
<div class="progress-box">
|
||||
<div class="progress-labels">
|
||||
<span class="percent-val">{{ scope.row.progress || 0 }}%</span>
|
||||
<div class="cv-site-cell">
|
||||
<div class="cv-site-favicon">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<el-progress :percentage="Number(scope.row.progress || 0)"
|
||||
:status="Number(scope.row.progress) === 100 ? 'success' : ''" :stroke-width="10" :show-text="false"
|
||||
striped striped-flow />
|
||||
<span class="cv-site-name" :title="row.website">{{ row.website }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="datanum" label="总数据量" width="160" align="center">
|
||||
<template #default="scope">
|
||||
<div class="data-val-box">
|
||||
<span class="data-num">{{ formatNumber(scope.row.datanum) }}</span>
|
||||
<el-table-column label="交付进度" width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cv-progress-cell">
|
||||
<el-progress
|
||||
:percentage="Number(row.progress || 0)"
|
||||
:status="Number(row.progress) === 100 ? 'success' : ''"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
striped
|
||||
striped-flow
|
||||
/>
|
||||
<span class="cv-progress-pct" :class="{ 'is-done': Number(row.progress) === 100 }">
|
||||
{{ row.progress || 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="交付状态" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<div :class="['status-dot-box', getStatusClass(scope.row.is_complete)]">
|
||||
<span class="dot"></span>
|
||||
<span class="text">{{ scope.row.is_complete }}</span>
|
||||
</div>
|
||||
<el-table-column label="数据量" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cv-datanum">{{ formatNumber(row.datanum) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="更新日期" width="140" align="center">
|
||||
<template #default="scope">
|
||||
<span class="date-text">{{
|
||||
formatDate(
|
||||
scope.row.lock_time || scope.row.update_time || scope.row.create_time
|
||||
)
|
||||
}}</span>
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['cv-status-badge', getStatusClass(row.status)]">
|
||||
<i class="cv-status-dot"></i>{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="更新时间" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="cv-date">{{ formatDate(row.lock_time || row.update_time || row.create_time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<footer class="pagination-footer">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="limit" :total="total"
|
||||
layout="total, prev, pager, next" background @current-change="handlePageChange" />
|
||||
</footer>
|
||||
<div class="cv-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="limit"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -133,17 +139,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { resetRouter } from "@/router";
|
||||
import {
|
||||
DataLine,
|
||||
SwitchButton,
|
||||
User,
|
||||
Refresh,
|
||||
Files,
|
||||
Timer,
|
||||
CircleCheck,
|
||||
Monitor,
|
||||
DataLine, SwitchButton, User, Refresh,
|
||||
Files, Timer, CircleCheck, Monitor,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ticketApi, userApi } from "@/api/ticket";
|
||||
import { clientApi, userApi } from "@/api/ticket";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { formatDate } from "@/utils/format";
|
||||
|
||||
@@ -152,387 +153,217 @@ const loading = ref(false);
|
||||
const tickets = ref([]);
|
||||
const page = ref(1);
|
||||
const limit = ref(15);
|
||||
const activeStatus = ref(null);
|
||||
|
||||
// --- 筛选状态控制 ---
|
||||
const activeStatus = ref(null); // null 代表显示全部,其余为具体状态字符串
|
||||
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
||||
const statusLabel = (s) => statusMap[Number(s)] || "未知";
|
||||
|
||||
// --- 1. 用户信息 ---
|
||||
const currentUser = computed(() => {
|
||||
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 () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const customerId = currentUser.value.customer_id;
|
||||
const res = await ticketApi.getTickets({
|
||||
page: 1,
|
||||
limit: 1000, // 客户侧通常数据量不大,取1000条前端分页
|
||||
customer_id: customerId,
|
||||
});
|
||||
|
||||
if (res.code === 200 || res.code === true) {
|
||||
tickets.value = res.data.data;
|
||||
}
|
||||
} catch (e) {
|
||||
const res = await clientApi.getTickets();
|
||||
if (res.code === 200) tickets.value = res.data || [];
|
||||
} catch {
|
||||
ElMessage.error("数据同步失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 3. 核心:双重过滤逻辑 (权限过滤 + 卡片点击过滤) ---
|
||||
const baseTickets = computed(() => tickets.value.filter((t) => Number(t.status) !== 3));
|
||||
const filteredTickets = computed(() => {
|
||||
const allowed = ["未分配", "进行中", "已完成"];
|
||||
// 第一步:过滤掉“已完结”等不需展示给客户的状态
|
||||
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;
|
||||
if (!activeStatus.value) return baseTickets.value;
|
||||
return baseTickets.value.filter((t) => statusLabel(t.status) === activeStatus.value);
|
||||
});
|
||||
|
||||
const paginatedTickets = computed(() => {
|
||||
const start = (page.value - 1) * limit.value;
|
||||
return filteredTickets.value.slice(start, start + limit.value);
|
||||
});
|
||||
|
||||
const total = computed(() => filteredTickets.value.length);
|
||||
|
||||
// --- 4. 统计卡片数据定义 ---
|
||||
const summaryStats = computed(() => {
|
||||
// 基础权限过滤后的原始数组
|
||||
const baseData = tickets.value.filter((t) =>
|
||||
["未分配", "进行中", "已完成"].includes(t.is_complete)
|
||||
);
|
||||
|
||||
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 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: "未分配" },
|
||||
{ label: "正在采集", value: baseTickets.value.filter((t) => Number(t.status) === 1).length, color: "#f59e0b", icon: Timer, status: "进行中" },
|
||||
{ label: "已就绪", value: baseTickets.value.filter((t) => Number(t.status) === 2).length, color: "#10b981", icon: CircleCheck, status: "已完成" },
|
||||
]);
|
||||
|
||||
const handleStatFilter = (status) => { activeStatus.value = status; page.value = 1; };
|
||||
const formatNumber = (val) => {
|
||||
if (!val || val === "0") return "0";
|
||||
const num = Number(val);
|
||||
return isNaN(num) ? val : num.toLocaleString();
|
||||
if (!val || val === "0") return "—";
|
||||
const n = Number(val);
|
||||
return isNaN(n) ? val : n.toLocaleString();
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const map = { 未分配: "is-waiting", 进行中: "is-running", 已完成: "is-success" };
|
||||
return map[status] || "";
|
||||
};
|
||||
|
||||
const handlePageChange = (p) => {
|
||||
page.value = p;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const getStatusClass = (s) => ({ 0: "s-wait", 1: "s-run", 2: "s-done" })[Number(s)] || "";
|
||||
const handlePageChange = (p) => { page.value = p; window.scrollTo({ top: 0, behavior: "smooth" }); };
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userApi.logout();
|
||||
} catch (e) {}
|
||||
|
||||
localStorage.removeItem("user_info");
|
||||
sessionStorage.removeItem("user_info");
|
||||
router.push("/login");
|
||||
try { await userApi.logout(); } catch {}
|
||||
resetRouter();
|
||||
router.push({ name: "login" });
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.client-view-container {
|
||||
padding: 24px 40px;
|
||||
background-color: #f6f8fb;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
/* ── tokens ── */
|
||||
.cv-wrap {
|
||||
--cv-th-bg: #f8f9fb;
|
||||
--cv-radius: 14px;
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.client-header {
|
||||
/* ── Header ── */
|
||||
.cv-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
}
|
||||
|
||||
.brand {
|
||||
.cv-stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--cv-radius);
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
background: linear-gradient(135deg, #409eff, #007aff);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.35);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 10px;
|
||||
color: #a1a1a6;
|
||||
margin-left: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 统计卡片交互样式 */
|
||||
.stat-card-premium {
|
||||
background: #fff;
|
||||
padding: 22px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03);
|
||||
border-left: 4px solid var(--theme-color);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
|
||||
}
|
||||
|
||||
.stat-card-premium:hover {
|
||||
.cv-stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.stat-card-premium.is-active {
|
||||
background: #f0f7ff;
|
||||
border-left-width: 8px;
|
||||
box-shadow: inset 0 0 0 1px rgba(64, 158, 255, 0.1), 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
.cv-stat-card.is-active {
|
||||
border-color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 5%, #fff);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--c) 18%, transparent);
|
||||
}
|
||||
|
||||
.active-dot {
|
||||
.cv-stat-bar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--theme-color);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--theme-color);
|
||||
animation: pulse 2s infinite;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--c);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s;
|
||||
border-radius: 0 0 var(--cv-radius) var(--cv-radius);
|
||||
}
|
||||
.cv-stat-card.is-active .cv-stat-bar,
|
||||
.cv-stat-card:hover .cv-stat-bar { opacity: 1; }
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
.cv-stat-icon {
|
||||
width: 46px; height: 46px; border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--c) 10%, transparent);
|
||||
color: var(--c);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cv-stat-val { font-size: 28px; font-weight: 700; color: var(--text-primary); line-height: 1.1; letter-spacing: -0.5px; }
|
||||
.cv-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; letter-spacing: 0.1px; }
|
||||
|
||||
.s-label {
|
||||
font-size: 13px;
|
||||
color: #86868b;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
/* ── Panel ── */
|
||||
.cv-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--cv-radius);
|
||||
padding: 24px 28px 20px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.cv-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.cv-panel-title {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: #409eff;
|
||||
.cv-title-dot {
|
||||
width: 3px; height: 16px;
|
||||
background: linear-gradient(180deg, #6366f1, #a78bfa);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cv-filter-tag { border-radius: 6px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
/* ── Table cells ── */
|
||||
.cv-table { width: 100%; }
|
||||
.cv-id { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.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 {
|
||||
margin-left: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cv-progress-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.cv-progress-cell .el-progress { flex: 1; }
|
||||
.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 {
|
||||
color: #909399;
|
||||
font-family: "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cv-datanum { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.cv-date { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.project-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* ── Status badge ── */
|
||||
.cv-status-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
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 {
|
||||
color: #c0c4cc;
|
||||
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;
|
||||
}
|
||||
/* ── Footer ── */
|
||||
.cv-footer { margin-top: 24px; padding-bottom: 4px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,58 @@
|
||||
<template>
|
||||
<div class="customers-container">
|
||||
<h2>运营人员管理</h2>
|
||||
<div class="customers-root">
|
||||
<!-- 主面板 -->
|
||||
<div class="main-card">
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="header-title">客户单位</span>
|
||||
<span class="header-count">{{ filteredData.length }} 个</span>
|
||||
</div>
|
||||
<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>
|
||||
<el-button type="success" :icon="User" @click="handleAddOperator">
|
||||
新增运营人员
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<div class="search-group">
|
||||
<el-input v-model="searchQuery" placeholder="搜索客户单位" style="width: 250px" clearable />
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<el-button type="primary" :icon="Plus" @click="customerDialogVisible = true">
|
||||
新增客户单位
|
||||
</el-button>
|
||||
<el-button type="success" :icon="User" @click="handleAddOperator">
|
||||
新增运营人员
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredData" v-loading="loading" header-cell-class-name="table-head" row-class-name="table-row">
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无客户数据" />
|
||||
</template>
|
||||
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="company_name" label="客户单位名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="company-name">{{ row.company_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="运营人员数量" width="140" align="center">
|
||||
<template #default="scope">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-table :data="companyAndOperators" border style="width: 100%; margin-top: 20px" v-loading="loading">
|
||||
<template #empty>
|
||||
<el-empty :image-size="100" description="暂无客户数据" />
|
||||
</template>
|
||||
|
||||
<el-table-column prop="id" label="客户ID" width="100" />
|
||||
<el-table-column prop="company_name" label="客户单位名称" />
|
||||
|
||||
<el-table-column label="员工管理" width="250" align="center">
|
||||
<template #default="scope">
|
||||
<span style="margin-right: 15px">
|
||||
共 <b style="color: #409eff">{{ scope.row.companyoperators.length }}</b> 人
|
||||
</span>
|
||||
<el-button size="small" type="primary" link :icon="View" @click="showCompanyAndOperatorsDetails(scope.row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="danger" plain @click="handleDeleteCompany(scope.row)">
|
||||
删除单位
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<CompanyEditDialog v-model="customerDialogVisible" @success="fetchCompanyAndOperators" />
|
||||
|
||||
<OperatorEditDialog v-model="operatorDialogVisible" :company-list="companyList"
|
||||
@success="fetchCompanyAndOperators" />
|
||||
|
||||
<OperatorListDialog v-model="employeeDetailVisible" :company-name="activeCompanyName"
|
||||
:operators="currentCompanyOperators" @refresh="fetchCompanyAndOperators" />
|
||||
</div>
|
||||
@@ -56,7 +60,7 @@
|
||||
|
||||
<script setup>
|
||||
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 { customerApi, ticketApi } from "@/api/ticket";
|
||||
|
||||
@@ -66,7 +70,6 @@ import OperatorListDialog from "@/components/operators/OperatorListDialog.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const companyList = ref([]);
|
||||
const employeeList = ref([]);
|
||||
const searchQuery = ref("");
|
||||
const companyAndOperators = ref([]);
|
||||
|
||||
@@ -75,19 +78,23 @@ const operatorDialogVisible = ref(false);
|
||||
const employeeDetailVisible = ref(false);
|
||||
|
||||
|
||||
const activeCompanyName = ref(""); // 当前选中客户单位的名称
|
||||
const currentCompanyOperators = ref([]); // 当前选中客户单位的员工列表
|
||||
const activeCompanyName = 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 () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await ticketApi.getOperatorsNew();
|
||||
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 }))
|
||||
}
|
||||
// console.log('获取数据', companyAndOperators.value);
|
||||
} catch (error) {
|
||||
ElMessage.error("获取客户数据失败,请检查网络");
|
||||
} finally {
|
||||
@@ -132,28 +139,87 @@ onMounted(fetchCompanyAndOperators);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.customers-container {
|
||||
padding: 30px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
.customers-root {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 25px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
/* 圆角 */
|
||||
.header-right :deep(.el-input__wrapper) {
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.button-group .el-button {
|
||||
margin-left: 10px;
|
||||
.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: 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>
|
||||
|
||||
@@ -1,110 +1,78 @@
|
||||
<template>
|
||||
<div class="ark-wrapper">
|
||||
<div class="grid-bg"></div>
|
||||
<div class="scan-line"></div>
|
||||
<div class="login-root">
|
||||
<div class="login-card">
|
||||
<div class="card-head">
|
||||
<img src="@/assets/logo.jpg" alt="" class="logo" />
|
||||
<h1 class="title">方舟工单</h1>
|
||||
<p class="desc">登录以继续使用系统</p>
|
||||
</div>
|
||||
|
||||
<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-form ref="formRef" :model="form" :rules="rules" class="login-form" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="员工编号" size="large" clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<DataBoard />
|
||||
<User />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1 class="title">方舟工单</h1>
|
||||
<span class="sub-text">ARK TICKET SYSTEM v4.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="system-monitor">
|
||||
<span class="label">STATUS:</span>
|
||||
<span class="value online">ENCRYPTED CONNECTION</span>
|
||||
</div>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="访问密钥" size="large" show-password>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Lock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" class="ark-form">
|
||||
<el-form-item prop="username">
|
||||
<div class="input-prefix">ID</div>
|
||||
<el-input v-model="loginForm.username" placeholder="员工编号" class="ark-input" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<div class="input-prefix">KEY</div>
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="访问密钥" show-password class="ark-input"
|
||||
@keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="options-bar">
|
||||
<el-checkbox v-model="rememberMe" @keyup.enter="handleLogin">保持认证状态</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!--loading为true时候,会转圈圈-->
|
||||
<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-form>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="code">SECURE PROTOCOL 801-ARK</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<el-checkbox v-model="rememberMe">保持登录</el-checkbox>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<el-button type="primary" size="large" class="submit-btn" :loading="loading" @click="handleLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 { userApi } from "@/api/ticket";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loginForm = reactive({ username: "", password: "" });
|
||||
const form = reactive({ username: "", password: "" });
|
||||
const rememberMe = 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" }],
|
||||
password: [{ required: true, message: "请输入密钥", trigger: "blur" }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return;
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await loginFormRef.value.validate();
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
const res = await userApi.login(loginForm.username, loginForm.password);
|
||||
|
||||
const res = await userApi.login(form.username, form.password);
|
||||
if (res.code === 200) {
|
||||
// ThinkPHP 会自动通过 Set-Cookie 下发 session,浏览器自动接管,无需手动存 token
|
||||
const userInfo = res.data;
|
||||
const userInfoStr = JSON.stringify(userInfo);
|
||||
|
||||
// 用户信息存前端,仅用于显示用户名和角色路由判断
|
||||
const userInfo = JSON.stringify(res.data);
|
||||
const storage = rememberMe.value ? localStorage : sessionStorage;
|
||||
storage.setItem("user_info", userInfoStr);
|
||||
|
||||
ElMessage({
|
||||
message: "身份验证通过,方舟系统加载中...",
|
||||
type: "success",
|
||||
duration: 300,
|
||||
});
|
||||
|
||||
// 触发路由守卫,自动处理跳转
|
||||
storage.setItem("user_info", userInfo);
|
||||
ElMessage({ message: "登录成功", type: "success", duration: 300 });
|
||||
await router.push(route.query.redirect || "/");
|
||||
} else {
|
||||
ElMessage.error(res.message || "登录失败,请检查账号和密码");
|
||||
ElMessage.error(res.message || "登录失败");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "ValidationError") {
|
||||
@@ -117,243 +85,143 @@ const handleLogin = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1. 全局背景:工业网格风格 */
|
||||
.ark-wrapper {
|
||||
position: relative;
|
||||
.login-root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #0b111a;
|
||||
display: flex;
|
||||
justify-content: 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;
|
||||
}
|
||||
|
||||
.grid-bg {
|
||||
.login-root::before,
|
||||
.login-root::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(0, 242, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 242, 255, 0.05) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
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;
|
||||
.login-root::before {
|
||||
width: 420px;
|
||||
background: rgba(13, 22, 34, 0.85);
|
||||
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||
padding: 40px;
|
||||
box-shadow: 0 0 40px rgba(0, 242, 255, 0.1), inset 0 0 20px rgba(0, 242, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
height: 420px;
|
||||
left: -120px;
|
||||
top: -120px;
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
/* 装饰性角标 */
|
||||
.corner-decorator {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-color: #00f2ff;
|
||||
border-style: solid;
|
||||
.login-root::after {
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
right: -140px;
|
||||
bottom: -140px;
|
||||
background: #9333ea;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
border-width: 3px 0 0 3px;
|
||||
.login-card {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
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 {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
border-width: 0 3px 3px 0;
|
||||
.card-head {
|
||||
text-align: center;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
background: rgba(0, 242, 255, 0.1);
|
||||
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%);
|
||||
.logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
margin-bottom: 18px;
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(129, 140, 248, 0.28),
|
||||
0 8px 20px rgba(79, 70, 229, 0.42);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.sub-text {
|
||||
color: #00f2ff;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 3. 监控条 */
|
||||
.system-monitor {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 5px 12px;
|
||||
margin-bottom: 25px;
|
||||
border-left: 3px solid #00f2ff;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
border-radius: 10px;
|
||||
padding: 3px 12px;
|
||||
background: #f7f8fd;
|
||||
box-shadow: 0 0 0 1px var(--color-border) inset;
|
||||
transition: box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.system-monitor .label {
|
||||
color: #64748b;
|
||||
margin-right: 10px;
|
||||
.login-form :deep(.el-input__wrapper:hover) {
|
||||
background: #f3f5fd;
|
||||
box-shadow: 0 0 0 1px rgba(79, 70, 229, 0.4) inset;
|
||||
}
|
||||
|
||||
.system-monitor .value {
|
||||
color: #22c55e;
|
||||
text-shadow: 0 0 5px #22c55e;
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.18), 0 0 0 1px rgba(79, 70, 229, 0.6) inset;
|
||||
}
|
||||
|
||||
/* 4. 输入框定制 */
|
||||
.input-prefix {
|
||||
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;
|
||||
.form-row {
|
||||
margin: -2px 0 24px;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background: transparent;
|
||||
border-color: #334155;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
border-top: 1px dashed rgba(0, 242, 255, 0.1);
|
||||
padding-top: 15px;
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
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 {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
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;
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 26px rgba(99, 102, 241, 0.52);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<el-avatar :size="28">{{ staff.username.slice(0, 1) }}</el-avatar>
|
||||
<span class="staff-name">{{ staff.username }}</span>
|
||||
</div>
|
||||
<el-tag type="success" effect="dark" round>
|
||||
<span class="count-badge">
|
||||
已锁定 {{ staff.lockedTickets.length }} 个站点
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="staff.lockedTickets" stripe border size="small" class="inner-table">
|
||||
@@ -41,34 +41,112 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<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) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { ticketApi } from "@/api/ticket";
|
||||
import { ticketApi, exportApi } from "@/api/ticket";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { formatDate } from "@/utils/format";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const loading = ref(true);
|
||||
const staffUserList = ref([]);
|
||||
const allTickets = ref([]);
|
||||
const activeCollapse = ref("");
|
||||
const showOnlyThisMonth = ref(false);
|
||||
const exportRange = ref("week");
|
||||
|
||||
const statusMap = { 0: "未分配", 1: "进行中", 2: "已完成", 3: "已完结" };
|
||||
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 () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -144,12 +222,20 @@ const staffWithLockedTickets = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.stats-view {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.box-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
:deep(.el-card) {
|
||||
background: var(--color-surface);
|
||||
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 {
|
||||
@@ -158,16 +244,17 @@ const staffWithLockedTickets = computed(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header>span {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header > span {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -179,12 +266,37 @@ const staffWithLockedTickets = computed(() => {
|
||||
.staff-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.staff-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
@@ -192,12 +304,105 @@ const staffWithLockedTickets = computed(() => {
|
||||
margin: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
:deep(.inner-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-collapse) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
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) {
|
||||
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>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
<div class="batch-bar" v-if="selectedTickets.length">
|
||||
<span class="batch-info">已选 <strong>{{ selectedTickets.length }}</strong> 个工单</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
CircleCheck,
|
||||
Checked,
|
||||
} from "@element-plus/icons-vue";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// API 引入
|
||||
import { ticketApi } from "@/api/ticket";
|
||||
@@ -272,6 +274,7 @@ const handleAdd = () => {
|
||||
operator_id: null,
|
||||
user_id: null,
|
||||
status: 0,
|
||||
progress: 0,
|
||||
remark: "",
|
||||
};
|
||||
showAddDialog.value = true;
|
||||
@@ -294,10 +297,34 @@ const handleFormSubmit = async (formData) => {
|
||||
response = await ticketApi.addTicket(formData);
|
||||
}
|
||||
|
||||
if (response.code) {
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(isEditing.value ? "修改成功 ✨" : "创建成功 🚀");
|
||||
showAddDialog.value = false;
|
||||
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 {
|
||||
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) => {
|
||||
pagination.limit = val;
|
||||
loadTickets();
|
||||
@@ -376,119 +429,123 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 样式部分保持不变 */
|
||||
.ticket-management {
|
||||
padding: 20px;
|
||||
background-color: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
background: var(--color-surface);
|
||||
padding: 18px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-left: 5px solid var(--accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
/* 禁止用户选中文字 */
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* 兼容 Safari */
|
||||
-moz-user-select: none;
|
||||
/* 兼容 Firefox */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
.stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--tint);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.total {
|
||||
border-bottom-color: #409eff;
|
||||
}
|
||||
|
||||
.total .stat-icon {
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
--accent: #4f46e5;
|
||||
--tint: linear-gradient(135deg, rgba(79, 70, 229, 0.10) 0%, rgba(99, 102, 241, 0.02) 70%);
|
||||
}
|
||||
|
||||
.pending {
|
||||
border-bottom-color: #909399;
|
||||
}
|
||||
|
||||
.pending .stat-icon {
|
||||
color: #909399;
|
||||
background: #f4f4f5;
|
||||
--accent: #64748b;
|
||||
--tint: linear-gradient(135deg, rgba(100, 116, 139, 0.10) 0%, rgba(100, 116, 139, 0.02) 70%);
|
||||
}
|
||||
|
||||
.ongoing {
|
||||
border-bottom-color: #e6a23c;
|
||||
}
|
||||
|
||||
.ongoing .stat-icon {
|
||||
color: #e6a23c;
|
||||
background: #fdf6ec;
|
||||
--accent: #f59e0b;
|
||||
--tint: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(245, 158, 11, 0.02) 70%);
|
||||
}
|
||||
|
||||
.success {
|
||||
border-bottom-color: #67c23a;
|
||||
}
|
||||
|
||||
.success .stat-icon {
|
||||
color: #67c23a;
|
||||
background: #f0f9eb;
|
||||
--accent: #16a34a;
|
||||
--tint: linear-gradient(135deg, rgba(22, 163, 74, 0.12) 0%, rgba(22, 163, 74, 0.02) 70%);
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: #009688;
|
||||
background: #e6f6f5;
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
.stat-card .stat-info,
|
||||
.stat-card .stat-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
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 {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
background: var(--color-surface);
|
||||
padding: 22px 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
@@ -496,19 +553,24 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fdf6ec;
|
||||
border: 1px solid #faecd8;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--color-warning-soft);
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batch-info strong {
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user