Squashed commit of the following:

commit 0e84bedb67
Author: 浅念 <email@sample.com>
Date:   Fri Aug 15 16:31:55 2025 +0800

    v1.0.2 更新内容

commit f5deb7b8f7
Author: 浅念 <email@sample.com>
Date:   Wed Aug 13 19:27:49 2025 +0800

    添加菜单 权限 等页面

commit e797cb24de
Author: 浅念 <email@sample.com>
Date:   Mon Aug 11 19:24:24 2025 +0800

    添加工单附件  及样式

commit 619311dfbe
Author: chenlan <suya123456>
Date:   Fri Aug 8 22:13:25 2025 +0800

    添加crm  归属客户

commit b402e522bf
Author: 浅念 <email@sample.com>
Date:   Thu Aug 7 10:51:17 2025 +0800

    询单权限校验

commit f950f665a9
Author: 浅念 <email@sample.com>
Date:   Thu Aug 7 10:10:45 2025 +0800

    添加 询单图片预览

commit 8deecc8197
Author: 浅念 <email@sample.com>
Date:   Wed Aug 6 18:36:42 2025 +0800

    修改用户页面 询单

commit 3e24d1905f
Author: 浅念 <email@sample.com>
Date:   Tue Aug 5 18:25:32 2025 +0800

    添加用户页面 客户页面接口 等

commit 7542d80e38
Author: 浅念 <email@sample.com>
Date:   Mon Aug 4 18:05:07 2025 +0800

    修改 仪表盘页面样式

commit e5548f95e4
Author: 浅念 <email@sample.com>
Date:   Mon Aug 4 14:50:55 2025 +0800

    修改询单字段  仪表盘页面  及添加管理端客户页面

commit d30ea5b7bf
Author: 浅念 <email@sample.com>
Date:   Mon Aug 4 14:48:58 2025 +0800

    添加echarts 插件

commit 0e4160c5ca
Author: 浅念 <email@sample.com>
Date:   Fri Aug 1 18:27:22 2025 +0800

    仪表盘接口

commit bd23288acd
Author: 浅念 <email@sample.com>
Date:   Fri Aug 1 18:26:46 2025 +0800

    修改 仪表盘

commit 6bfb940881
Author: 浅念 <email@sample.com>
Date:   Fri Aug 1 10:01:35 2025 +0800

    客户列表 添加公海详情展示

commit b50f650565
Author: 浅念 <email@sample.com>
Date:   Thu Jul 31 17:26:36 2025 +0800

    修改 客户标签样式  个人信息添加邮箱 修复关闭全部页面404

commit ea1dbb7af4
Author: 浅念 <email@sample.com>
Date:   Wed Jul 30 18:50:31 2025 +0800

    修改公海和询单页面

commit 1bfbb95bd9
Author: 浅念 <email@sample.com>
Date:   Wed Jul 30 17:23:09 2025 +0800

    修改样式和添加用户数据显示

commit a9f5f76b35
Author: 浅念 <email@sample.com>
Date:   Wed Jul 30 15:06:03 2025 +0800

    设置默认页面

commit 3ea9d3d5df
Author: 浅念 <email@sample.com>
Date:   Wed Jul 30 11:46:31 2025 +0800

    修改飞书登录 公司id 系统id 获取不到问题  客户及公海添加用户信息显示

commit 0cef82279f
Author: 浅念 <email@sample.com>
Date:   Tue Jul 29 16:42:36 2025 +0800

    修改工单 客户等页面

commit 695ebbcc71
Author: 浅念 <email@sample.com>
Date:   Fri Jul 25 17:50:04 2025 +0800

    修改默认值

commit ffc194a1c1
Author: 浅念 <email@sample.com>
Date:   Fri Jul 25 17:05:33 2025 +0800

    飞书授权添加系统码  及登录进入页面路由

commit 48b95e252f
Author: 浅念 <email@sample.com>
Date:   Fri Jul 25 15:48:49 2025 +0800

    回收站添加占位

commit 382011d592
Author: 浅念 <email@sample.com>
Date:   Fri Jul 25 15:11:50 2025 +0800

    修改登录页样式公海数据 个人信息默认图

commit 0f790269db
Author: 浅念 <email@sample.com>
Date:   Fri Jul 25 09:16:55 2025 +0800

    修改字段名称

commit f18cfdb69e
Author: 浅念 <email@sample.com>
Date:   Thu Jul 24 17:28:51 2025 +0800

    调整代码格式

commit 858147fbbd
Author: 浅念 <email@sample.com>
Date:   Thu Jul 24 10:17:22 2025 +0800

    修改token刷新 及修改询单详情展示

commit c333b907e6
Author: 浅念 <email@sample.com>
Date:   Wed Jul 23 18:22:56 2025 +0800

    对接询单接口

commit 7979a31e36
Author: 浅念 <email@sample.com>
Date:   Wed Jul 23 14:40:02 2025 +0800

    修改代码格式

commit 4a39387865
Author: 浅念 <email@sample.com>
Date:   Wed Jul 23 14:07:13 2025 +0800

    设置客户列表权限

commit fc4de02582
Author: 浅念 <email@sample.com>
Date:   Tue Jul 22 17:08:13 2025 +0800

    feat(router): 调整菜单

commit 2ffe0c9753
Author: 浅念 <email@sample.com>
Date:   Tue Jul 22 17:04:16 2025 +0800

    feat(crmApi.ts): 调整结构

commit c3fb2c48d3
Author: 浅念 <email@sample.com>
Date:   Tue Jul 22 17:00:25 2025 +0800

    feat(router): 路由调整

commit c5b0a8265a
Author: 浅念 <email@sample.com>
Date:   Tue Jul 22 16:54:06 2025 +0800

    chore: 添加询单页面及调整菜单

commit ca06c9bc46
Author: 浅念 <email@sample.com>
Date:   Tue Jul 22 16:35:43 2025 +0800

    询单页面及菜单

commit 3b6de4c7fa
Merge: d3bb4ec 85e8b9b
Author: 浅念 <email@sample.com>
Date:   Tue Jul 22 09:22:20 2025 +0800

    Merge branch 'master' into feature/crm_pc

commit d3bb4ec6e5
Merge: c7d8908 d7ab6f1
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 18:22:55 2025 +0800

    Merge branch 'master' into feature/crm_pc

    # Conflicts:
    #	.gitignore
    #	src/api/crmApi.ts
    #	src/api/usersApi.ts
    #	src/store/modules/user.ts
    #	src/utils/http/index.ts
    #	src/views/auth/login/index.vue
    #	src/views/crmpc/customer/Show.vue
    #	src/views/crmpc/customer/index.vue

commit c7d890809c
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 18:12:16 2025 +0800

    添加系统码

commit 31cd18fade
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 18:02:48 2025 +0800

    Merge branch 'master' into feature/crm_pc

    # Conflicts:
    #	.gitignore

commit 59c9c97c08
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 17:56:52 2025 +0800

    Merge branch 'master' into feature/crm_pc

    # Conflicts:
    #	.gitignore

commit d4b577c42e
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 13:03:56 2025 +0800

    feat(crm): 添加相关图片

commit e4967760b7
Merge: 0c67666 85102fc
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 11:25:49 2025 +0800

    Finish crm_pc

commit 85102fcf32
Author: 浅念 <email@sample.com>
Date:   Mon Jul 21 11:24:06 2025 +0800

    crm 接口对接

commit 0c67666793
Author: 浅念 <email@sample.com>
Date:   Wed Jul 16 17:18:14 2025 +0800

    添加上传头部信息

commit a41c75f703
Author: 浅念 <email@sample.com>
Date:   Tue Jul 15 09:40:53 2025 +0800

    修改页面

commit 22ae0e86c8
Author: 浅念 <email@sample.com>
Date:   Mon Jul 14 17:59:02 2025 +0800

    修改页面

commit 812e3c4b0c
Author: 浅念 <email@sample.com>
Date:   Mon Jul 14 10:09:10 2025 +0800

    修改菜单问题

commit 2ad479b006
Author: 浅念 <email@sample.com>
Date:   Fri Jul 11 19:45:57 2025 +0800

    修改样式

commit 5802e157bf
Author: 浅念 <email@sample.com>
Date:   Fri Jul 11 19:43:34 2025 +0800

    修改工单

commit 713d43f7b4
Author: 浅念 <email@sample.com>
Date:   Fri Jul 11 18:37:16 2025 +0800

    添加登录

commit 719911918f
Author: 浅念 <email@sample.com>
Date:   Fri Jul 4 17:16:38 2025 +0800

    项目创建

commit ce47b39fa5
Author: 浅念 <email@sample.com>
Date:   Wed Jul 2 09:37:51 2025 +0800

    删除
This commit is contained in:
浅念
2025-08-15 16:37:41 +08:00
parent 85e8b9b6b4
commit 5d906087d3
66 changed files with 13539 additions and 2803 deletions
+1
View File
@@ -3,6 +3,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
*.env
.cursorrules .cursorrules
src/types/components.d.ts src/types/components.d.ts
src/types/auto-imports.d.ts src/types/auto-imports.d.ts
-1
View File
@@ -1 +0,0 @@
pnpm dlx commitlint --edit $1
-1
View File
@@ -1 +0,0 @@
pnpm run lint:lint-staged
+34
View File
@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@types/echarts": "^4.9.22",
"@vue/reactivity": "^3.4.35", "@vue/reactivity": "^3.4.35",
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
@@ -16,6 +17,7 @@
"axios": "^1.7.5", "axios": "^1.7.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.4.0", "echarts": "^5.4.0",
"echarts-gl": "^2.0.9",
"element-plus": "^2.8.0", "element-plus": "^2.8.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
@@ -2982,6 +2984,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/echarts": {
"version": "4.9.22",
"resolved": "https://registry.npmjs.org/@types/echarts/-/echarts-4.9.22.tgz",
"integrity": "sha512-7Fo6XdWpoi8jxkwP7BARUOM7riq8bMhmsCtSG8gzUcJmFhLo387tihoBYS/y5j7jl3PENT5RxeWZdN9RiwO7HQ==",
"license": "MIT",
"dependencies": {
"@types/zrender": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3138,6 +3149,12 @@
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
}, },
"node_modules/@types/zrender": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/zrender/-/zrender-4.0.6.tgz",
"integrity": "sha512-1jZ9bJn2BsfmYFPBHtl5o3uV+ILejAtGrDcYSpT4qaVKEI/0YY+arw3XHU04Ebd8Nca3SQ7uNcLaqiL+tTFVMg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.1", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
@@ -5103,6 +5120,11 @@
"consola": "^3.2.3" "consola": "^3.2.3"
} }
}, },
"node_modules/claygl": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
"integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -6399,6 +6421,18 @@
"zrender": "5.6.1" "zrender": "5.6.1"
} }
}, },
"node_modules/echarts-gl": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
"integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
"dependencies": {
"claygl": "^1.2.1",
"zrender": "^5.1.1"
},
"peerDependencies": {
"echarts": "^5.1.2"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.170", "version": "1.5.170",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz",
+6
View File
@@ -20,6 +20,10 @@
} }
}, },
"lint-staged": { "lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix --quiet",
"prettier --write"
],
"*.{js,ts,mjs,mts,tsx}": [ "*.{js,ts,mjs,mts,tsx}": [
"eslint --fix", "eslint --fix",
"prettier --write" "prettier --write"
@@ -48,6 +52,7 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@types/echarts": "^4.9.22",
"@vue/reactivity": "^3.4.35", "@vue/reactivity": "^3.4.35",
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
@@ -55,6 +60,7 @@
"axios": "^1.7.5", "axios": "^1.7.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.4.0", "echarts": "^5.4.0",
"echarts-gl": "^2.0.9",
"element-plus": "^2.8.0", "element-plus": "^2.8.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
+47 -46
View File
@@ -20,8 +20,11 @@
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const companyid = params.get('corp_id') const companyid = params.get('corp_id')
const companyabbr = params.get('corp_abbr') const companyabbr = params.get('corp_abbr')
// const systemid = params.get('system_id')
// const system_code = params.get('system_code')
if (companyid) { if (companyid) {
userStore.setCompany(companyid, companyabbr || '') userStore.setCompany(companyid, companyabbr || '')
// userStore.setSystems(systemid || '', system_code || '')
// 你也可以加 localStorage/sessionStorage 备份 // 你也可以加 localStorage/sessionStorage 备份
} }
} }
@@ -29,11 +32,9 @@
zh: zh, zh: zh,
en: en en: en
} }
onBeforeMount(() => { onBeforeMount(() => {
setThemeTransitionClass(true) setThemeTransitionClass(true)
setCompanyFromUrl() setCompanyFromUrl()
useUserStore().restoreTokenTimer()
}) })
onMounted(() => { onMounted(() => {
@@ -60,59 +61,59 @@
} }
</script> </script>
<style> <style>
.boxshadow { .boxshadow {
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1); box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1);
background: var(--art-main-bg-color); background: var(--art-main-bg-color);
padding: 10px; padding: 10px 20px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.border{ .border {
width: 6px; width: 6px;
height: 22px; height: 22px;
border-radius: 3px; border-radius: 3px;
background: #3471FF; background: #3471FF;
display: inline-block; display: inline-block;
vertical-align: bottom; vertical-align: bottom;
} }
.el-dialog { .el-dialog {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.el-dialog::before { .el-dialog::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
background: linear-gradient(180deg, #E4ECFF 0%, #FFFFFF 50%); background: linear-gradient(180deg, #E4ECFF 0%, #FFFFFF 50%);
pointer-events: none; pointer-events: none;
} }
.el-dialog__header, .el-dialog__header,
.el-dialog__body, .el-dialog__body,
.el-dialog__footer { .el-dialog__footer {
background: transparent !important; background: transparent !important;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.el-dialog__header{ .el-dialog__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
padding-right: 40px; padding-right: 40px;
} }
.el-dialog__title{ .el-dialog__title {
flex: 1; flex: 1;
text-align: center; text-align: center;
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
} }
.el-dialog__headerbtn{ .el-dialog__headerbtn {
position: absolute; position: absolute;
right: 16px; right: 16px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
} }
</style> </style>
+201
View File
@@ -0,0 +1,201 @@
import request from '@/utils/http'
export class manageService {
//仪表盘 总
static statisticswhole() {
return request.get<any>({
url: '/admin/inquiry/statistics/whole'
})
}
//各渠道统计
static channelcount() {
return request.get<any>({
url: '/admin/inquiry/statistics/channel/count'
})
}
//各国家统计
static countrycount() {
return request.get<any>({
url: '/admin/inquiry/statistics/country/count'
})
}
//跟单人统计
static trackcount() {
return request.get<any>({
url: '/admin/inquiry/statistics/track/count'
})
}
//每日录单统计
static daycount() {
return request.get<any>({
url: '/admin/inquiry/statistics/day/count'
})
}
//每日录单统计
static inquirycount(params: any) {
return request.get<any>({
url: '/admin/inquiry/statistics/inquiry/count',
params
})
}
//询单列表
static getinquiryList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/inquiry/list',
params
})
}
// 获取详情
static inquirydetail(params: any) {
return request.get<any>({
url: '/admin/inquiry/show',
params
})
}
//添加询单
static inquiryAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inquiry/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//修改询单
static inquiryedit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/inquiry/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//删除询单
static inquirydelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/inquiry/destroy',
params
})
}
//分配询单
static inquiryallocation(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/inquiry/allocation',
params
// showErrorMessage: false // 不显示错误消息
})
}
//重新分配询单
static inquiryreallocation(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/inquiry/reallocation',
params
// showErrorMessage: false // 不显示错误消息
})
}
//发布询单
static inquiryrelease(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/inquiry/release',
params
// showErrorMessage: false // 不显示错误消息
})
}
//获取用户下拉框
static presetusers() {
return request.get<any>({
url: '/common/preset/users'
})
}
//crm 客户管理
//客户列表
static getcrmCustomerList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListDatad>({
url: '/admin/crm/customer/list',
params
})
}
//客户导出
static crmCustomerExport(params: any) {
return request.get<any>({
url: '/admin/crm/customer/export',
params,
responseType: 'blob' // 设置响应类型为 blob
})
}
//客户审核
static crmCustomerAudit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/crm/customer/audit',
params
// showErrorMessage: false // 不显示错误消息
})
}
//客户转移
static crmCustomerTransfer(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/crm/customer/transfer',
params
// showErrorMessage: false // 不显示错误消息
})
}
//用户列表
static getUserList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/user/list',
params
})
}
//用户编辑
static userEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//用户设置角色
static userSetRole(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/authorize',
params
// showErrorMessage: false // 不显示错误消息
})
}
//用户菜单权限设置
static userMenuAuth(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/user/set/resources',
params
// showErrorMessage: false // 不显示错误消息
})
}
//清除缓存
static clearCache() {
return request.get<any>({
url: '/common/preset/cache/clear'
})
}
//公海客户分配
static crmCustomerAllocation(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/crm/customer/cede',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公海客户跟进记录
static crmCustomerFollow(params: any) {
return request.get<any>({
url: '/admin/crm/customer/follow',
params: params
})
}
//公海客户删除
static crmCustomerDelete(params: any) {
return request.get<Api.Common.AddResponse>({
url: '/admin/crm/customer/delete',
params
// showErrorMessage: false // 不显示错误消息
})
}
}
+52 -14
View File
@@ -26,11 +26,19 @@ export class CrmService {
} }
//删除客户 //删除客户
static getcustomerdelete(params: any) { static getcustomerdelete(params: any) {
return request.get<Api.Crm.CrmListData>({ return request.get<any>({
url: '/crm/customer/delete', url: '/crm/customer/delete',
params params
}) })
} }
//标记无效
static customerReport(params: any) {
return request.get<any>({
url: '/crm/customer/report',
params
// showErrorMessage: false // 不显示错误消息
})
}
//切换等级crm/customer/grade //切换等级crm/customer/grade
static customergrade(params: any) { static customergrade(params: any) {
return request.post<Api.Work.AddResponse>({ return request.post<Api.Work.AddResponse>({
@@ -39,10 +47,9 @@ export class CrmService {
// showErrorMessage: false // 不显示错误消息 // showErrorMessage: false // 不显示错误消息
}) })
} }
//渠道下拉 //渠道下拉
static getChannel() { static getChannel() {
return request.get<Api.Brand.BrandInfo>({ return request.get<any>({
url: '/crm/customer/getChannel' url: '/crm/customer/getChannel'
}) })
} }
@@ -65,9 +72,10 @@ export class CrmService {
}) })
} }
//标签列表/crm/tagGroup/list //标签列表/crm/tagGroup/list
static tagGrouplist() { static tagGrouplist(params: any) {
return request.get<any>({ return request.get<any>({
url: '/crm/tagGroup/list' url: '/crm/tagGroup/list',
params
}) })
} }
//更新客户标签 /crm/tagGroup/updatePivot //更新客户标签 /crm/tagGroup/updatePivot
@@ -99,16 +107,16 @@ export class CrmService {
params params
}) })
} }
//设置下次跟进时间 followNext crm/customer/followNext //设置下次跟进时间 followNext crm/customer/followNext
static followNext(params: any) { static followNext(params: any) {
return request.post<Api.Work.AddResponse>({ return request.post<Api.Work.AddResponse>({
url: '/crm/customer/followNext', url: '/crm/customer/followNext',
params params
// showErrorMessage: false // 不显示错误消息 // showErrorMessage: false // 不显示错误消息
}) })
} }
//crm 个人信息修改 //crm 个人信息修改
static crmuseredit(params: any) { static crmuseredit(params: any) {
return request.post<Api.Work.AddResponse>({ return request.post<Api.Work.AddResponse>({
url: '/crm/user/edit', url: '/crm/user/edit',
@@ -153,4 +161,34 @@ static followNext(params: any) {
params params
}) })
} }
//从属客户列表
static getCustomercsList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/crm/customer/index',
params
})
}
//从属客户转移
static cedeCustomer(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/crm/customer/cede',
params
// showErrorMessage: false // 不显示错误消息
})
}
//所属员工
static getCustomerUserList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/common/user/team',
params
})
}
//标记离职
static markLeave(params: any) {
return request.get<Api.Work.AddResponse>({
url: '/common/user/resign',
params
// showErrorMessage: false // 不显示错误消息
})
}
} }
+6 -31
View File
@@ -1,32 +1,10 @@
import { asyncRoutes } from '@/router/routes/asyncRoutes'
import { menuDataToRouter } from '@/router/utils/menuToRouter' import { menuDataToRouter } from '@/router/utils/menuToRouter'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { UserService } from '@/api/usersApi'
interface MenuResponse { interface MenuResponse {
menuList: AppRouteRecord[] menuList: AppRouteRecord[]
} }
// 合并方法
function mergeRoutes(dynamicRoutes: AppRouteRecord[] = [], staticRoutes: AppRouteRecord[] = []): AppRouteRecord[] {
// 深度合并逻辑
const merged = [...staticRoutes]
dynamicRoutes.forEach(dynamicRoute => {
const existingIndex = merged.findIndex(r => r.path === dynamicRoute.path)
if (existingIndex >= 0) {
// 合并children(如果存在)
if (dynamicRoute.children) {
merged[existingIndex].children = [
...(merged[existingIndex].children || []),
...dynamicRoute.children
]
}
} else {
merged.push(dynamicRoute)
}
})
return merged
}
// 菜单接口 // 菜单接口
export const menuService = { export const menuService = {
async getMenuList(delay = 300): Promise<MenuResponse> { async getMenuList(delay = 300): Promise<MenuResponse> {
@@ -36,30 +14,27 @@ export const menuService = {
if (!userStore.isLogin) { if (!userStore.isLogin) {
throw new Error('用户未登录') throw new Error('用户未登录')
} }
const { menu } = await UserService.getMenuList()
const menuData = menu
// const userStore = useUserStore()
// 模拟接口返回的菜单数据 // 模拟接口返回的菜单数据
const menuData = userStore.info.menu
// 2. 合并静态菜单
const finalRoutes = mergeRoutes(menuData, asyncRoutes)
// 验证菜单数据 // 验证菜单数据
if (!menuData || !Array.isArray(menuData) || menuData.length === 0) { if (!menuData || !Array.isArray(menuData) || menuData.length === 0) {
ElMessage.error('暂无系统菜单权限') ElMessage.error('暂无系统菜单权限')
return { menuList: [] } return { menuList: [] }
} }
console.log('menuData', menuData)
// 处理菜单数据 // 处理菜单数据
const menuList = finalRoutes.map((route: any) => menuDataToRouter(route)) const menuList = menuData.map((route: any) => menuDataToRouter(route))
// 验证处理后的菜单列表 // 验证处理后的菜单列表
if (!menuList || menuList.length === 0) { if (!menuList || menuList.length === 0) {
ElMessage.error('菜单数据处理失败') ElMessage.error('菜单数据处理失败')
return { menuList: [] } return { menuList: [] }
} }
// 模拟接口延迟 // 模拟接口延迟
await new Promise((resolve) => setTimeout(resolve, delay)) await new Promise((resolve) => setTimeout(resolve, delay))
return { menuList } return { menuList }
} catch (error) { } catch (error) {
console.error('获取菜单失败:', error) console.error('获取菜单失败:', error)
throw error instanceof Error ? error : new Error('获取菜单失败') throw error instanceof Error ? error : new Error('获取菜单失败')
+220
View File
@@ -0,0 +1,220 @@
import request from '@/utils/http'
export class systemService {
//系统列表
static getSystemList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/systems/list',
params
})
}
//系统添加
static systemAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/systems/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//系统详情
static systemDetail(params: any) {
return request.get<any>({
url: '/admin/systems/show',
params
})
}
//系统编辑
static systemEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/systems/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//系统删除
static systemDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/systems/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//飞书应用列表
static getFeishuAppList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/corp/feishu/list',
params
})
}
//飞书应用添加
static feishuAppAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/feishu/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//飞书应用详情
static feishuAppDetail(params: any) {
return request.get<any>({
url: '/admin/corp/feishu/show',
params
})
}
//飞书应用编辑
static feishuAppEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/feishu/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//飞书应用删除
static feishuAppDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/feishu/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公司列表
static getCompanyList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/corp/list',
params
})
}
//公司添加
static companyAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公司详情
static companyDetail(params: any) {
return request.get<any>({
url: '/admin/corp/show',
params
})
}
//公司编辑
static companyEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//公司删除
static companyDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/corp/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色列表
static getRoleList(params: Api.Common.PaginatingParams) {
return request.get<Api.Common.CommonListData>({
url: '/admin/role/list',
params
})
}
//角色添加
static roleAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色详情
static roleDetail(params: any) {
return request.get<any>({
url: '/admin/role/show',
params
})
}
//角色编辑
static roleEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色删除
static roleDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色权限设置
static roleAuth(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/role/menu/resource',
params
// showErrorMessage: false // 不显示错误消息
})
}
//角色下拉框
static getRoleSelectList() {
return request.get<Api.Common.CommonListData>({
url: '/common/preset/roles'
})
}
//菜单列表
static getMenutreeList(params: any) {
return request.get<any>({
url: '/admin/resources/list/tree',
params
})
}
static getMenuList() {
return request.get<Api.Common.CommonListData>({
url: '/admin/resources/list/menu'
})
}
//菜单添加
static menuAdd(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/resources/store',
params
// showErrorMessage: false // 不显示错误消息
})
}
//菜单详情
static menuDetail(params: any) {
return request.get<any>({
url: '/admin/resources/show',
params
})
}
//菜单编辑
static menuEdit(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/resources/update',
params
// showErrorMessage: false // 不显示错误消息
})
}
//菜单删除
static menuDelete(params: any) {
return request.post<Api.Common.AddResponse>({
url: '/admin/resources/destroy',
params
// showErrorMessage: false // 不显示错误消息
})
}
//权限菜单
static getPermissionMenuList(params: any) {
return request.get<Api.Common.CommonListData>({
url: '/admin/resources/list/menu/tree',
params
})
}
}
+21 -2
View File
@@ -7,6 +7,12 @@ export class UserService {
url: '/common/preset/corps' url: '/common/preset/corps'
}) })
} }
//获取系统
static getsystemsList() {
return request.get<Api.Brand.BrandInfo>({
url: '/common/preset/systems'
})
}
//注册 //注册
static register(params: any) { static register(params: any) {
return request.post<Api.Auth.registerResponse>({ return request.post<Api.Auth.registerResponse>({
@@ -16,7 +22,7 @@ export class UserService {
}) })
} }
//获取 飞书信息 //获取 飞书信息
static getfeishuAppList(params: { corp_id: string }) { static getfeishuAppList(params: any) {
return request.get<Api.Brand.BrandInfo>({ return request.get<Api.Brand.BrandInfo>({
url: '/common/preset/feishu/app', url: '/common/preset/feishu/app',
params params
@@ -37,7 +43,7 @@ export class UserService {
params params
}) })
} }
//飞书凭证 //飞书凭证
static getfeishuscope() { static getfeishuscope() {
return request.get<Api.Auth.Authfeishuscope>({ return request.get<Api.Auth.Authfeishuscope>({
url: '/common/auth/feishu/scope' url: '/common/auth/feishu/scope'
@@ -66,4 +72,17 @@ export class UserService {
params params
}) })
} }
//获取菜单
static getMenuList() {
return request.get<Api.User.UserInfo>({
url: '/common/preset/menus'
})
}
//个人信息编辑
static editUserInfo(params: any) {
return request.post<Api.Work.AddResponse>({
url: '/admin/user/edit',
params
})
}
} }
+6 -5
View File
@@ -39,13 +39,14 @@ export class WorkService {
// showErrorMessage: false // 不显示错误消息 // showErrorMessage: false // 不显示错误消息
}) })
} }
//工单状态下拉选择框 //工单状态下拉选择框
static getStatus() { static getStatus(params: any) {
return request.get<Api.Brand.BrandInfo>({ return request.get<Api.Brand.BrandInfo>({
url: '/ticket/ticket/getStatus' url: '/ticket/ticket/getStatus',
params
}) })
} }
//工单沟通记录 //工单沟通记录
static getconversationList(params: any) { static getconversationList(params: any) {
return request.get<Api.Work.WorkListData>({ return request.get<Api.Work.WorkListData>({
url: '/ticket/conversation/list', url: '/ticket/conversation/list',
@@ -53,7 +54,7 @@ export class WorkService {
}) })
} }
//添加工单记录 ticket/conversation/add //添加工单记录 ticket/conversation/add
static conversationAdd(params:any) { static conversationAdd(params: any) {
return request.post<Api.Work.AddResponse>({ return request.post<Api.Work.AddResponse>({
url: '/ticket/conversation/add', url: '/ticket/conversation/add',
params params
+426
View File
@@ -0,0 +1,426 @@
{
"wordGeoCoordMap":{
"阿富汗": [67.709953, 33.93911],
"安哥拉": [17.873887, -11.202692],
"阿尔巴尼亚": [20.168331, 41.153332],
"阿拉伯联合酋长国": [53.847818, 23.424076],
"阿根廷": [-63.61667199999999, -38.416097],
"亚美尼亚": [45.038189, 40.069099],
"法属南半球和南极领地": [69.348557, -49.280366],
"澳大利亚": [133.775136, -25.274398],
"奥地利": [14.550072, 47.516231],
"阿塞拜疆": [47.576927, 40.143105],
"布隆迪": [29.918886, -3.373056],
"比利时": [4.469936, 50.503887],
"贝宁": [2.315834, 9.30769],
"布基纳法索": [-1.561593, 12.238333],
"孟加拉人民共和国": [90.356331, 23.684994],
"保加利亚": [25.48583, 42.733883],
"巴哈马": [-77.39627999999999, 25.03428],
"波斯尼亚和黑塞哥维那": [17.679076, 43.915886],
"白俄罗斯": [27.953389, 53.709807],
"伯利兹": [-88.49765, 17.189877],
"百慕大": [-64.7505, 32.3078],
"玻利维亚": [-63.58865299999999, -16.290154],
"巴西": [-51.92528, -14.235004],
"文莱": [114.727669, 4.535277],
"不丹": [90.433601, 27.514162],
"博茨瓦纳": [24.684866, -22.328474],
"中非共和国": [20.939444, 6.611110999999999],
"加拿大": [-106.346771, 56.130366],
"瑞士": [8.227511999999999, 46.818188],
"智利": [-71.542969, -35.675147],
"中华人民共和国": [104.195397, 35.86166],
"象牙海岸": [-5.547079999999999, 7.539988999999999],
"喀麦隆": [12.354722, 7.369721999999999],
"刚果民主共和国": [21.758664, -4.038333],
"刚果共和国": [15.827659, -0.228021],
"哥伦比亚": [-74.297333, 4.570868],
"哥斯达黎加共和国": [-83.753428, 9.748916999999999],
"古巴": [-77.781167, 21.521757],
"北塞浦路斯": [33.429859, 35.126413],
"塞浦路斯": [33.429859, 35.126413],
"捷克共和国": [15.472962, 49.81749199999999],
"德国": [10.451526, 51.165691],
"吉布提": [42.590275, 11.825138],
"丹麦": [9.501785, 56.26392],
"多明尼加共和国": [-70.162651, 18.735693],
"阿尔及利亚": [1.659626, 28.033886],
"厄瓜多尔": [-78.18340599999999, -1.831239],
"埃及": [30.802498, 26.820553],
"厄立特里亚": [39.782334, 15.179384],
"西班牙": [-3.74922, 40.46366700000001],
"爱沙尼亚": [25.013607, 58.595272],
"埃塞俄比亚": [40.489673, 9.145000000000001],
"芬兰": [25.748151, 61.92410999999999],
"斐": [178.065032, -17.713371],
"福克兰群岛": [-59.523613, -51.796253],
"法国": [2.213749, 46.227638],
"加蓬共和国": [11.609444, -0.803689],
"英国": [-3.435973, 55.378051],
"格鲁吉亚": [-82.9000751, 32.1656221],
"加纳": [-1.023194, 7.946527],
"几内亚": [-9.696645, 9.945587],
"冈比亚": [-15.310139, 13.443182],
"几内亚比绍": [-15.180413, 11.803749],
"赤道几内亚": [10.267895, 1.650801],
"希腊": [21.824312, 39.074208],
"格陵兰": [-42.604303, 71.706936],
"危地马拉": [-90.23075899999999, 15.783471],
"法属圭亚那": [-53.125782, 3.933889],
"圭亚那": [-58.93018, 4.860416],
"洪都拉斯": [-86.241905, 15.199999],
"克罗地亚": [15.2, 45.1],
"海地": [-72.285215, 18.971187],
"匈牙利共和国": [19.503304, 47.162494],
"印尼": [113.921327, -0.789275],
"爱尔兰": [-8.24389, 53.41291],
"伊朗": [53.688046, 32.427908],
"伊拉克": [43.679291, 33.223191],
"冰岛": [-19.020835, 64.963051],
"以色列": [34.851612, 31.046051],
"意大利": [12.56738, 41.87194],
"牙买加": [-77.297508, 18.109581],
"约旦": [36.238414, 30.585164],
"日本": [138.252924, 36.204824],
"哈萨克斯坦": [66.923684, 48.019573],
"肯尼亚": [37.906193, -0.023559],
"吉尔吉斯斯坦": [74.766098, 41.20438],
"柬埔寨": [104.990963, 12.565679],
"韩国": [127.766922, 35.907757],
"科索沃": [20.902977, 42.6026359],
"科威特": [47.481766, 29.31166],
"老挝": [102.495496, 19.85627],
"黎巴嫩": [35.862285, 33.854721],
"利比里亚共和国": [-9.429499000000002, 6.428055],
"利比亚": [17.228331, 26.3351],
"斯里兰卡": [80.77179699999999, 7.873053999999999],
"莱索托": [28.233608, -29.609988],
"立陶宛": [23.881275, 55.169438],
"巴林": [50.30, 26.10],
"卢森堡": [6.129582999999999, 49.815273],
"拉脱维亚共和国": [24.603189, 56.879635],
"摩洛哥": [-7.092619999999999, 31.791702],
"摩尔多瓦": [28.369885, 47.411631],
"马达加斯加": [46.869107, -18.766947],
"墨西哥": [-102.552784, 23.634501],
"马其顿": [21.745275, 41.608635],
"马里": [-3.996166, 17.570692],
"缅甸": [95.956223, 21.913965],
"黑山共和国": [19.37439, 42.708678],
"蒙古": [103.846656, 46.862496],
"莫桑比克": [35.529562, -18.665695],
"毛里塔尼亚": [-10.940835, 21.00789],
"马拉维": [34.301525, -13.254308],
"马来西亚": [101.975766, 4.210484],
"纳米比亚": [18.49041, -22.95764],
"新喀里多尼亚": [165.618042, -20.904305],
"尼日尔": [8.081666, 17.607789],
"尼日利亚": [8.675277, 9.081999],
"尼加拉瓜": [-85.207229, 12.865416],
"荷兰": [5.291265999999999, 52.132633],
"挪威": [8.468945999999999, 60.47202399999999],
"尼泊尔": [84.12400799999999, 28.394857],
"新西兰": [174.885971, -40.900557],
"阿曼": [55.923255, 21.512583],
"巴基斯坦": [69.34511599999999, 30.375321],
"巴拿马共和国": [-80.782127, 8.537981],
"秘鲁": [-75.015152, -9.189967],
"菲律宾": [121.774017, 12.879721],
"巴布亚新几内亚": [143.95555, -6.314992999999999],
"波兰": [19.145136, 51.919438],
"波多黎各": [-66.590149, 18.220833],
"北朝鲜": [127.510093, 40.339852],
"葡萄牙": [-8.224454, 39.39987199999999],
"巴拉圭": [-58.443832, -23.442503],
"卡塔尔": [51.183884, 25.354826],
"罗马尼亚": [24.96676, 45.943161],
"俄罗斯": [105.318756, 61.52401],
"俄罗斯联邦": [105.318756, 61.52401],
"卢旺达": [29.873888, -1.940278],
"西撒哈拉": [-12.885834, 24.215527],
"沙特阿拉伯": [45.079162, 23.885942],
"苏丹": [30.217636, 12.862807],
"南苏丹": [31.3069788, 6.876991899999999],
"塞内加尔": [-14.452362, 14.497401],
"所罗门群岛": [160.156194, -9.64571],
"塞拉利昂": [-11.779889, 8.460555],
"萨尔瓦多": [-88.89653, 13.794185],
"索马里兰": [46.8252838, 9.411743399999999],
"索马里": [46.199616, 5.152149],
"塞尔维亚": [21.005859, 44.016521],
"苏里南": [-56.027783, 3.919305],
"斯洛伐克共和国": [19.699024, 48.669026],
"斯洛文尼亚": [14.995463, 46.151241],
"瑞典": [18.643501, 60.12816100000001],
"斯威士兰": [31.465866, -26.522503],
"叙利亚": [38.996815, 34.80207499999999],
"乍得": [18.732207, 15.454166],
"多哥": [0.824782, 8.619543],
"泰国": [100.992541, 15.870032],
"塔吉克斯坦共和国": [71.276093, 38.861034],
"土库曼斯坦": [59.556278, 38.969719],
"东帝汶": [125.727539, -8.874217],
"特里尼达和多巴哥": [-61.222503, 10.691803],
"突尼斯": [9.537499, 33.886917],
"土耳其": [35.243322, 38.963745],
"坦桑尼亚": [34.888822, -6.369028],
"乌干达": [32.290275, 1.373333],
"乌克兰": [31.16558, 48.379433],
"乌拉圭": [-55.765835, -32.522779],
"美国": [-95.712891, 37.09024],
"乌兹别克斯坦": [64.585262, 41.377491],
"委内瑞拉": [-66.58973, 6.42375],
"越南": [108.277199, 14.058324],
"瓦努阿图": [166.959158, -15.376706],
"西岸": [35.3027226, 31.9465703],
"也门": [48.516388, 15.552727],
"南非": [22.937506, -30.559482],
"赞比亚": [27.849332, -13.133897],
"津巴布韦": [29.154857, -19.015438],
"科摩罗": [43.872219, -11.875001],
"印度尼西亚": [106.515414, -6.103040],
"印度": [78.96288, 20.593684],
"刚果": [15.12, -4.09],
"萨摩亚独立国": [-171.46, 13.5],
"朝鲜": [125.30, 39.09],
"新加坡": [103.87546, 1.30243],
"中非": [18.33, 4.21],
"巴勒斯坦": [35.25, 31.79],
"圣卢西亚": [-60.58, 14.02],
"安提瓜和巴布达": [-61.48, 17.20],
"巴巴多斯": [-59.30, 13.05],
"开曼群岛": [-81.24, 19.20],
"法罗群岛": [-6.56, 62.05],
"圣文森特和格林纳丁斯": [-61.10, 13.10],
"特立尼达和多巴哥": [-60.32, 11.18],
"台澎金马单独关税区": [121.21, 25.02],
"中国香港": [114.12, 22.26],
"中国澳门": [113.5, 22.2],
"格林纳达": [-61.4, 12.7],
"塞舌尔共和国": [55.28, -4.4],
"多米尼克": [61.23, 15.18],
"斐济": [178.30, -18.06],
"科特迪瓦": [-5.17, 6.49],
"欧盟": [4.47, 50.50]
},
"nameMap":{
"Afghanistan": "阿富汗",
"Singapore": "新加坡",
"Angola": "安哥拉",
"Albania": "阿尔巴尼亚",
"United Arab Emirates": "阿拉伯联合酋长国",
"Argentina": "阿根廷",
"Armenia": "亚美尼亚",
"French Southern and Antarctic Lands": "法属南半球和南极领地",
"Australia": "澳大利亚",
"Austria": "奥地利",
"Azerbaijan": "阿塞拜疆",
"Seychelles": "塞舌尔共和国",
"Burundi": "布隆迪",
"Belgium": "比利时",
"Benin": "贝宁",
"Burkina Faso": "布基纳法索",
"Côte d Ivoire": "科特迪瓦",
"Bangladesh": "孟加拉人民共和国",
"Bulgaria": "保加利亚",
"The Bahamas": "巴哈马",
"Bosnia and Herzegovina": "波斯尼亚和黑塞哥维那",
"Belarus": "白俄罗斯",
"Belize": "伯利兹",
"Bermuda": "百慕大",
"Bolivia": "玻利维亚",
"Brazil": "巴西",
"Brunei": "文莱",
"Bhutan": "不丹",
"Botswana": "博茨瓦纳",
"Central African Republic": "中非共和国",
"Canada": "加拿大",
"Switzerland": "瑞士",
"Chile": "智利",
"China": "中华人民共和国",
"Ivory Coast": "象牙海岸",
"Cameroon": "喀麦隆",
"Dem. Rep. Congo": "刚果民主共和国",
"Republic of the Congo": "刚果共和国",
"Colombia": "哥伦比亚",
"Costa Rica": "哥斯达黎加共和国",
"Cuba": "古巴",
"Northern Cyprus": "北塞浦路斯",
"Cyprus": "塞浦路斯",
"Czech Republic": "捷克共和国",
"Germany": "德国",
"Djibouti": "吉布提",
"Denmark": "丹麦",
"Dominican Republic": "多明尼加共和国",
"Algeria": "阿尔及利亚",
"Ecuador": "厄瓜多尔",
"Egypt": "埃及",
"Eritrea": "厄立特里亚",
"Spain": "西班牙",
"Estonia": "爱沙尼亚",
"Ethiopia": "埃塞俄比亚",
"Bahrain": "巴林",
"Finland": "芬兰",
"Fiji": "斐济",
"Falkland Islands": "福克兰群岛",
"France": "法国",
"Gabon": "加蓬共和国",
"United Kingdom": "英国",
"Georgia": "格鲁吉亚",
"Ghana": "加纳",
"Guinea": "几内亚",
"Gambia": "冈比亚",
"Guinea Bissau": "几内亚比绍",
"Greece": "希腊",
"Greenland": "格陵兰",
"Guatemala": "危地马拉",
"French Guiana": "法属圭亚那",
"Guyana": "圭亚那",
"Honduras": "洪都拉斯",
"Croatia": "克罗地亚",
"Haiti": "海地",
"Hungary": "匈牙利共和国",
"Indonesia": "印度尼西亚",
"India": "印度",
"Ireland": "爱尔兰",
"Iran": "伊朗",
"Iraq": "伊拉克",
"Iceland": "冰岛",
"Israel": "以色列",
"Italy": "意大利",
"Jamaica": "牙买加",
"Jordan": "约旦",
"Japan": "日本",
"Kazakhstan": "哈萨克斯坦",
"Kenya": "肯尼亚",
"Kyrgyzstan": "吉尔吉斯斯坦",
"Cambodia": "柬埔寨",
"Kosovo": "科索沃",
"Kuwait": "科威特",
"Laos": "老挝",
"Lebanon": "黎巴嫩",
"Liberia": "利比里亚共和国",
"Libya": "利比亚",
"Sri Lanka": "斯里兰卡",
"Lesotho": "莱索托",
"Lithuania": "立陶宛",
"Luxembourg": "卢森堡",
"Latvia": "拉脱维亚共和国",
"Morocco": "摩洛哥",
"Moldova": "摩尔多瓦",
"Madagascar": "马达加斯加",
"Mexico": "墨西哥",
"Macedonia": "马其顿",
"Mali": "马里",
"Myanmar": "缅甸",
"Montenegro": "黑山共和国",
"Mongolia": "蒙古",
"Mozambique": "莫桑比克",
"Mauritania": "毛里塔尼亚",
"Malawi": "马拉维",
"Malaysia": "马来西亚",
"Namibia": "纳米比亚",
"New Caledonia": "新喀里多尼亚",
"Niger": "尼日尔",
"Nigeria": "尼日利亚",
"Nicaragua": "尼加拉瓜",
"Netherlands": "荷兰",
"Norway": "挪威",
"Nepal": "尼泊尔",
"New Zealand": "新西兰",
"Oman": "阿曼",
"Pakistan": "巴基斯坦",
"Panama": "巴拿马共和国",
"Peru": "秘鲁",
"Philippines": "菲律宾",
"Papua New Guinea": "巴布亚新几内亚",
"Poland": "波兰",
"Puerto Rico": "波多黎各",
"North Korea": "北朝鲜",
"Portugal": "葡萄牙",
"Paraguay": "巴拉圭",
"Qatar": "卡塔尔",
"Romania": "罗马尼亚",
"Russia": "俄罗斯",
"Rwanda": "卢旺达",
"Western Sahara": "西撒哈拉",
"Saudi Arabia": "沙特阿拉伯",
"Sudan": "苏丹",
"S. Sudan": "南苏丹",
"Senegal": "塞内加尔",
"Solomon Islands": "所罗门群岛",
"Sierra Leone": "塞拉利昂",
"El Salvador": "萨尔瓦多",
"Somaliland": "索马里兰",
"Somalia": "索马里",
"Republic of Serbia": "塞尔维亚",
"Suriname": "苏里南",
"Slovakia": "斯洛伐克共和国",
"Slovenia": "斯洛文尼亚",
"Sweden": "瑞典",
"Swaziland": "斯威士兰",
"Syria": "叙利亚",
"Chad": "乍得",
"Togo": "多哥",
"Thailand": "泰国",
"Tajikistan": "塔吉克斯坦共和国",
"Turkmenistan": "土库曼斯坦",
"East Timor": "东帝汶",
"Trinidad and Tobago": "特立尼达和多巴哥",
"Tunisia": "突尼斯",
"Turkey": "土耳其",
"Uganda": "乌干达",
"Ukraine": "乌克兰",
"Uruguay": "乌拉圭",
"United States": "美国",
"Uzbekistan": "乌兹别克斯坦",
"Venezuela": "委内瑞拉",
"Vietnam": "越南",
"Vanuatu": "瓦努阿图",
"West Bank": "西岸",
"Yemen": "也门",
"South Africa": "南非",
"Zambia": "赞比亚",
"Korea": "韩国",
"Tanzania": "坦桑尼亚",
"Zimbabwe": "津巴布韦",
"Congo": "刚果",
"Central African Rep.": "中非",
"Serbia": "塞尔维亚",
"Bosnia and Herz.": "波黑",
"Czech Rep.": "捷克",
"W. Sahara": "西撒哈拉",
"Lao PDR": "老挝",
"Dem. Rep. Korea": "朝鲜",
"Falkland Is.": "福克兰群岛",
"Timor-Leste": "东帝汶",
"Solomon Is.": "所罗门群岛",
"Palestine": "巴勒斯坦",
"N. Cyprus": "北塞浦路斯",
"Aland": "奥兰群岛",
"Fr. S. Antarctic Lands": "法属南半球和南极陆地",
"Mauritius": "毛里求斯",
"Comoros": "科摩罗",
"Eq. Guinea": "赤道几内亚",
"Guinea-Bissau": "几内亚比绍",
"Dominican Rep.": "多米尼加共和国",
"Saint Lucia": "圣卢西亚",
"Dominica": "多米尼克",
"Antigua and Barb.": "安提瓜和巴布达",
"U.S. Virgin Is.": "美国原始岛屿",
"Montserrat": "蒙塞拉特",
"Grenada": "格林纳达",
"Barbados": "巴巴多斯",
"Samoa": "萨摩亚独立国",
"Bahamas": "巴哈马",
"Cayman Is.": "开曼群岛",
"Faeroe Is.": "法罗群岛",
"IsIe of Man": "马恩岛",
"Malta": "马耳他共和国",
"Jersey": "泽西",
"Cape Verde": "佛得角共和国",
"Turks and Caicos Is.": "特克斯和凯科斯群岛",
"St. Vin. and Gren.": "圣文森特和格林纳丁斯"
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -54,7 +54,11 @@
</div> </div>
</div> </div>
</div> --> </div> -->
<div class="btn-box screen-box" @click="clearCachefn">
<div class="btn">
<i class="iconfont-sys">&#xe847;</i>
</div>
</div>
<!-- 全屏按钮 --> <!-- 全屏按钮 -->
<div class="btn-box screen-box" @click="toggleFullScreen"> <div class="btn-box screen-box" @click="toggleFullScreen">
<div <div
@@ -79,7 +83,7 @@
</div> </div>
</div> --> </div> -->
<!-- 语言 --> <!-- 语言 -->
<div class="btn-box" v-if="showLanguage"> <!-- <div class="btn-box" v-if="showLanguage">
<el-dropdown @command="changeLanguage" popper-class="langDropDownStyle"> <el-dropdown @command="changeLanguage" popper-class="langDropDownStyle">
<div class="btn language-btn"> <div class="btn language-btn">
<i class="iconfont-sys">&#xe611;</i> <i class="iconfont-sys">&#xe611;</i>
@@ -98,7 +102,7 @@
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
</div> </div> -->
<!-- 设置 --> <!-- 设置 -->
<!-- <div class="btn-box" @click="openSetting"> <!-- <div class="btn-box" @click="openSetting">
<el-popover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0"> <el-popover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
@@ -138,7 +142,10 @@
popper-style="border: 1px solid var(--art-border-dashed-color); border-radius: calc(var(--custom-radius) / 2 + 4px); padding: 5px 16px; 5px 16px;" popper-style="border: 1px solid var(--art-border-dashed-color); border-radius: calc(var(--custom-radius) / 2 + 4px); padding: 5px 16px; 5px 16px;"
> >
<template #reference> <template #reference>
<img class="cover" src="@imgs/user/avatar.webp" /> <img
class="cover"
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
/>
</template> </template>
<template #default> <template #default>
<div class="user-menu-box"> <div class="user-menu-box">
@@ -155,15 +162,19 @@
<span class="email">{{ userInfo.user.email }}</span> <span class="email">{{ userInfo.user.email }}</span>
</div> </div>
</div> --> </div> -->
<ul class="user-menu"> <ul class="user-menu" style="border: none">
<li @click="goPage('/system/user-center')"> <li @click="gouserInfo()">
<i class="menu-icon iconfont-sys">&#xe734;</i> <i class="menu-icon iconfont-sys">&#xe734;</i>
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span> <span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
</li> </li>
<li @click="goPage('/crmpc/recycle')"> <li @click="goPage('/work/myOrder')">
<i class="menu-icon iconfont-sys">&#xe828;</i>
<span class="menu-txt">我的工单</span>
</li>
<!-- <li @click="goPage('/crmpc/recycle')">
<i class="menu-icon iconfont-sys">&#xe828;</i> <i class="menu-icon iconfont-sys">&#xe828;</i>
<span class="menu-txt">回收站</span> <span class="menu-txt">回收站</span>
</li> </li> -->
<!-- <li @click="goPage('/system/user-center')"> <!-- <li @click="goPage('/system/user-center')">
<i class="menu-icon iconfont-sys">&#xe734;</i> <i class="menu-icon iconfont-sys">&#xe734;</i>
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span> <span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
@@ -176,10 +187,10 @@
<i class="menu-icon iconfont-sys">&#xe8d6;</i> <i class="menu-icon iconfont-sys">&#xe8d6;</i>
<span class="menu-txt">{{ $t('topBar.user.github') }}</span> <span class="menu-txt">{{ $t('topBar.user.github') }}</span>
</li> --> </li> -->
<li @click="lockScreen()"> <!-- <li @click="lockScreen()">
<i class="menu-icon iconfont-sys">&#xe817;</i> <i class="menu-icon iconfont-sys">&#xe817;</i>
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span> <span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
</li> </li> -->
<div class="line"></div> <div class="line"></div>
<div class="logout-btn" @click="loginOut"> <div class="logout-btn" @click="loginOut">
{{ $t('topBar.user.logout') }} {{ $t('topBar.user.logout') }}
@@ -208,14 +219,14 @@
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu' import { useMenuStore } from '@/store/modules/menu'
import AppConfig from '@/config' import AppConfig from '@/config'
import { manageService } from '@/api/adminApi'
import { languageOptions } from '@/locales' import { languageOptions } from '@/locales'
const isWindows = navigator.userAgent.includes('Windows') const isWindows = navigator.userAgent.includes('Windows')
const { locale } = useI18n() const { locale } = useI18n()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const { getUserInfo, home_page } = useUserStore()
const { const {
showMenuButton, showMenuButton,
showRefreshButton, showRefreshButton,
@@ -229,7 +240,7 @@
tabStyle tabStyle
} = storeToRefs(settingStore) } = storeToRefs(settingStore)
const { language, getUserInfo: userInfo } = storeToRefs(userStore) const { language } = storeToRefs(userStore)
const { menuList } = storeToRefs(useMenuStore()) const { menuList } = storeToRefs(useMenuStore())
@@ -268,7 +279,14 @@
const toggleFullScreen = () => { const toggleFullScreen = () => {
toggleFullscreen() toggleFullscreen()
} }
const clearCachefn = () => {
manageService.clearCache().then((res) => {
ElMessage.success(res.data?.message || '成功!')
// setTimeout(() => {
window.location.reload()
// }, 5000)
})
}
const topBarWidth = (): string => { const topBarWidth = (): string => {
const { TOP, DUAL_MENU, TOP_LEFT } = MenuTypeEnum const { TOP, DUAL_MENU, TOP_LEFT } = MenuTypeEnum
const { getMenuOpenWidth } = settingStore const { getMenuOpenWidth } = settingStore
@@ -296,17 +314,25 @@
const goPage = (path: string) => { const goPage = (path: string) => {
router.push(path) router.push(path)
} }
const { systemsid } = useUserStore()
const toDocs = () => { const gouserInfo = () => {
window.open(WEB_LINKS.DOCS) if (systemsid == '1') {
router.push('/admin/user/user_info')
} else {
router.push('/system/user-center')
}
} }
const toGithub = () => { // const toDocs = () => {
window.open(WEB_LINKS.GITHUB) // window.open(WEB_LINKS.DOCS)
} // }
// const toGithub = () => {
// window.open(WEB_LINKS.GITHUB)
// }
const toHome = () => { const toHome = () => {
router.push(HOME_PAGE) router.push(home_page)
} }
const loginOut = () => { const loginOut = () => {
@@ -112,11 +112,11 @@
import { isIframe } from '@/utils/navigation' import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation' import { handleMenuJump } from '@/utils/navigation'
import SidebarSubmenu from './widget/SidebarSubmenu.vue' import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useUserStore } from '@/store/modules/user'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { home_page } = useUserStore()
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } = const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
storeToRefs(settingStore) storeToRefs(settingStore)
@@ -210,7 +210,7 @@
) )
const toHome = () => { const toHome = () => {
router.push(HOME_PAGE) router.push(home_page)
} }
let screenWidth = 0 let screenWidth = 0
@@ -6,6 +6,7 @@
<RouterView <RouterView
v-if="isRefresh" v-if="isRefresh"
:key="$route.fullPath"
v-slot="{ Component, route }" v-slot="{ Component, route }"
:style="{ minHeight: containerMinHeight }" :style="{ minHeight: containerMinHeight }"
> >
@@ -32,7 +33,7 @@
import { useWorktabStore } from '@/store/modules/worktab' import { useWorktabStore } from '@/store/modules/worktab'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
// Store refs // Store refs1
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore()) const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
const { keepAliveExclude } = storeToRefs(useWorktabStore()) const { keepAliveExclude } = storeToRefs(useWorktabStore())
@@ -3,7 +3,10 @@
<div v-if="!isLock"> <div v-if="!isLock">
<el-dialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen"> <el-dialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
<div class="lock-content"> <div class="lock-content">
<img class="cover" src="@imgs/user/avatar.webp" /> <img
class="cover"
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
/>
<div class="username">{{ userInfo.userName }}</div> <div class="username">{{ userInfo.userName }}</div>
<el-form ref="formRef" :model="formData" :rules="rules" @submit.prevent="handleLock"> <el-form ref="formRef" :model="formData" :rules="rules" @submit.prevent="handleLock">
<el-form-item prop="password"> <el-form-item prop="password">
@@ -32,7 +35,10 @@
<div class="unlock-content" v-else> <div class="unlock-content" v-else>
<div class="box"> <div class="box">
<img class="cover" src="@imgs/user/avatar.webp" /> <img
class="cover"
:src="getUserInfo.user.avatar ? getUserInfo.user.avatar : '@imgs/user/avatar.webp'"
/>
<div class="username">{{ userInfo.userName }}</div> <div class="username">{{ userInfo.userName }}</div>
<el-form <el-form
ref="unlockFormRef" ref="unlockFormRef"
@@ -77,7 +83,7 @@
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
const { getUserInfo } = useUserStore()
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
const userStore = useUserStore() const userStore = useUserStore()
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore) const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
@@ -11,9 +11,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { HOME_PAGE } from '@/router/routesAlias' // import { HOME_PAGE } from '@/router/routesAlias'
import { useUserStore } from '@/store/modules/user'
const { home_page } = useUserStore()
const router = useRouter() const router = useRouter()
interface ExceptionData { interface ExceptionData {
/** 标题 */ /** 标题 */
title: string title: string
@@ -33,7 +34,7 @@
) )
const backHome = () => { const backHome = () => {
router.push(HOME_PAGE) router.push(home_page)
} }
</script> </script>
+13 -7
View File
@@ -1,7 +1,5 @@
import { RoutesAlias } from '../routesAlias' import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { WEB_LINKS } from '@/utils/constants'
/** /**
* 菜单列表、异步路由 * 菜单列表、异步路由
* *
@@ -13,13 +11,21 @@ import { WEB_LINKS } from '@/utils/constants'
* 可以是 i18n 的 key,也可以是字符串,比如:'用户列表' * 可以是 i18n 的 key,也可以是字符串,比如:'用户列表'
*/ */
export const asyncRoutes: AppRouteRecord[] = [ export const asyncRoutes: AppRouteRecord[] = [
{
path: '/admin/inquiry_system',
name: 'Inquiry_system',
component: RoutesAlias.inquirySystem,
meta: {
title: '询单系统'
}
},
{ {
path: '/crmpc/customer/add', path: '/crmpc/customer/add',
name: 'CrmpcustomerAdd', name: 'CrmpcustomerAdd',
component: RoutesAlias.CrmpcustomerAdd, component: RoutesAlias.CrmpcustomerAdd,
meta: { meta: {
title: '新增客户资料', title: '新增客户资料',
isHide: true, isHide: true
// isHideTab: true // isHideTab: true
} }
}, },
@@ -29,7 +35,7 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.CrmpcustomerShow, component: RoutesAlias.CrmpcustomerShow,
meta: { meta: {
title: '客户资料', title: '客户资料',
isHide: true, isHide: true
// isHideTab: true // isHideTab: true
} }
}, },
@@ -39,7 +45,7 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.CrmpcustomerEdit, component: RoutesAlias.CrmpcustomerEdit,
meta: { meta: {
title: '编辑客户资料', title: '编辑客户资料',
isHide: true, isHide: true
// isHideTab: true // isHideTab: true
} }
}, },
@@ -49,7 +55,7 @@ export const asyncRoutes: AppRouteRecord[] = [
component: RoutesAlias.CrmpcustomerFollowadd, component: RoutesAlias.CrmpcustomerFollowadd,
meta: { meta: {
title: '添加跟进', title: '添加跟进',
isHide: true, isHide: true
// isHideTab: true // isHideTab: true
} }
}, },
@@ -61,5 +67,5 @@ export const asyncRoutes: AppRouteRecord[] = [
title: '回收站', title: '回收站',
isHide: true isHide: true
} }
}, }
] ]
+8 -5
View File
@@ -54,12 +54,15 @@ export enum RoutesAlias {
Crmpchome = '/crmpc/home', // crm首页 Crmpchome = '/crmpc/home', // crm首页
Crmphighseas = '/crmpc/highseas', // crm公海 Crmphighseas = '/crmpc/highseas', // crm公海
Crmpcustomer = '/crmpc/customer', // crm客户 Crmpcustomer = '/crmpc/customer', // crm客户
CrmpcustomerAdd = '/crmpc/customer/Add',//客户添加 CrmpcustomerAdd = '/crmpc/customer/Add', //客户添加
CrmpcustomerEdit = '/crmpc/customer/Edit',//客户编辑 CrmpcustomerEdit = '/crmpc/customer/Edit', //客户编辑
CrmpcustomerShow = '/crmpc/customer/Show',//客户详情 CrmpcustomerShow = '/crmpc/customer/Show', //客户详情
CrmpcustomerFollowadd = '/crmpc/customer/Followadd',//添加跟进 CrmpcustomerFollowadd = '/crmpc/customer/Followadd', //添加跟进
recycle = '/crmpc/recycle',//回收站 recycle = '/crmpc/recycle', //回收站
UserCenter = '/system/user-center', // 用户中心 UserCenter = '/system/user-center', // 用户中心
inquirySystem = '/admin/inquiry_system', // 询单系统
customer = '/admin/customer', // 管理端客户列表
customeraudit = '/admin/customer/customeraudit' // 管理端待审客户列表
} }
// 主页路由 // 主页路由
+59 -16
View File
@@ -25,6 +25,9 @@ export const useUserStore = defineStore(
const refreshToken = ref('') const refreshToken = ref('')
const companyid = ref('') const companyid = ref('')
const companyabbr = ref('') const companyabbr = ref('')
const systemsid = ref('')
const systemsabbr = ref('')
const home_page = ref('')
const loginType = ref<'feishu' | 'upwd'>('upwd') const loginType = ref<'feishu' | 'upwd'>('upwd')
const tokenExpireAt = ref<number>(0) const tokenExpireAt = ref<number>(0)
@@ -65,17 +68,28 @@ export const useUserStore = defineStore(
refreshToken.value = newRefreshToken refreshToken.value = newRefreshToken
} }
if (expiresIn) { if (expiresIn) {
// 记录绝对过期时间
const expireAt = Date.now() + expiresIn * 1000 const expireAt = Date.now() + expiresIn * 1000
tokenExpireAt.value = expireAt tokenExpireAt.value = expireAt
localStorage.setItem('token_expire_at', String(expireAt)) localStorage.setItem('token_expire_at', String(expireAt))
scheduleTokenRefresh(expiresIn) // 检查是否需要重新设置定时器
const currentLeftSeconds = Math.floor((expireAt - Date.now()) / 1000)
if (currentLeftSeconds > 0) {
scheduleTokenRefresh(currentLeftSeconds)
}
} }
} }
const setCompany= (id: string, abbr: string) => { const setCompany = (id: string, abbr: string) => {
companyid.value = id companyid.value = id
companyabbr.value = abbr companyabbr.value = abbr
} }
const setSystems = (id: string, abbr: string) => {
systemsid.value = id
systemsabbr.value = abbr
}
// 设置首页
const sethome_page = (page: string) => {
home_page.value = page
}
function restoreTokenTimer() { function restoreTokenTimer() {
const expireAt = Number(localStorage.getItem('token_expire_at') || '0') const expireAt = Number(localStorage.getItem('token_expire_at') || '0')
if (accessToken.value && expireAt > Date.now()) { if (accessToken.value && expireAt > Date.now()) {
@@ -84,31 +98,56 @@ export const useUserStore = defineStore(
} }
} }
let isScheduled = false
function scheduleTokenRefresh(expiresIn: number) { function scheduleTokenRefresh(expiresIn: number) {
if (refreshTimeout) clearTimeout(refreshTimeout) if (refreshTimeout) {
clearTimeout(refreshTimeout)
refreshTimeout = null
}
// 提前1分钟刷新 // 提前1分钟刷新
const refreshTime = (expiresIn - 100) * 1000 const refreshTime = (expiresIn - 60) * 1000
if (refreshTime > 0) { if (refreshTime > 0) {
refreshTimeout = setTimeout(refreshTokenAction, refreshTime) if (!isScheduled) {
isScheduled = true
refreshTimeout = setTimeout(() => {
refreshTokenAction().finally(() => {
isScheduled = false // 重置标志位
})
}, refreshTime)
} else {
console.log('scheduleTokenRefresh is already scheduled, skipping...')
}
} else {
console.error('Invalid refreshTime:', refreshTime)
} }
} }
async function refreshTokenAction() { async function refreshTokenAction() {
try { try {
const { data } = await UserService.refreshToken({ refresh_token: refreshToken.value }) const { data } = await UserService.refreshToken({ refresh_token: refreshToken.value })
setToken(data.access_token, data.refresh_token, data.expires_in) // 动态计算剩余时间
const currentTime = Date.now()
const expireAt = currentTime + data.expires_in * 1000
const leftSeconds = Math.floor((expireAt - currentTime) / 1000)
setToken(data.access_token, data.refresh_token, leftSeconds)
} catch (e) { } catch (e) {
console.error('Token refresh failed:', e)
logOut() logOut()
} }
} }
const logOut = () => { const logOut = () => {
// 清空所有本地缓存 // 清空所有本地缓存
localStorage.clear(); localStorage.clear()
sessionStorage.clear(); sessionStorage.clear()
systemsabbr.value = ''
systemsid.value = ''
companyabbr.value = '' companyabbr.value = ''
companyid.value = '' companyid.value = ''
home_page.value = ''
info.value = {} info.value = {}
isLogin.value = false isLogin.value = false
isLock.value = false isLock.value = false
@@ -116,17 +155,13 @@ export const useUserStore = defineStore(
accessToken.value = '' accessToken.value = ''
refreshToken.value = '' refreshToken.value = ''
if (refreshTimeout) clearTimeout(refreshTimeout) // 清理定时器 if (refreshTimeout) clearTimeout(refreshTimeout) // 清理定时器
// 清理工作台标签页 // 清理工作台标签页
useWorktabStore().clearAll() useWorktabStore().clearAll()
// 清理sessionStorage中的用户信息缓存 // 清理sessionStorage中的用户信息缓存
sessionStorage.removeItem('lastUserInfo') sessionStorage.removeItem('lastUserInfo')
sessionStorage.removeItem('iframeRoutes') sessionStorage.removeItem('iframeRoutes')
// 重置路由状态 // 重置路由状态
resetRouterState(router) resetRouterState(router)
// 跳转到登录页 // 跳转到登录页
router.push(RoutesAlias.Login) router.push(RoutesAlias.Login)
} }
@@ -139,6 +174,9 @@ export const useUserStore = defineStore(
info, info,
companyid, companyid,
companyabbr, companyabbr,
systemsid,
systemsabbr,
home_page,
searchHistory, searchHistory,
loginType, loginType,
accessToken, accessToken,
@@ -155,7 +193,9 @@ export const useUserStore = defineStore(
setLockPassword, setLockPassword,
setToken, setToken,
setCompany, setCompany,
setSystems,
setLoginType, setLoginType,
sethome_page,
logOut, logOut,
restoreTokenTimer restoreTokenTimer
} }
@@ -163,7 +203,10 @@ export const useUserStore = defineStore(
{ {
persist: { persist: {
key: 'user', key: 'user',
storage: localStorage storage: localStorage,
} afterRestore: (ctx: { store: { restoreTokenTimer: () => void } }) => {
ctx.store.restoreTokenTimer()
}
} as any
} }
) )
+9 -8
View File
@@ -4,7 +4,7 @@ import { HOME_PAGE } from '@/router/routesAlias'
import { router } from '@/router' import { router } from '@/router'
import { LocationQueryRaw, Router } from 'vue-router' import { LocationQueryRaw, Router } from 'vue-router'
import { WorkTab } from '@/types' import { WorkTab } from '@/types'
import { useUserStore } from '@/store/modules/user'
interface WorktabState { interface WorktabState {
current: Partial<WorkTab> current: Partial<WorkTab>
opened: WorkTab[] opened: WorkTab[]
@@ -144,6 +144,7 @@ export const useWorktabStore = defineStore(
* 关闭指定的选项卡 * 关闭指定的选项卡
*/ */
const removeTab = (path: string): void => { const removeTab = (path: string): void => {
const { home_page } = useUserStore()
const targetTab = getTab(path) const targetTab = getTab(path)
const targetIndex = findTabIndex(path) const targetIndex = findTabIndex(path)
@@ -167,9 +168,9 @@ export const useWorktabStore = defineStore(
// 如果关闭后无标签页,跳转首页 // 如果关闭后无标签页,跳转首页
if (!hasOpenedTabs.value) { if (!hasOpenedTabs.value) {
if (path !== HOME_PAGE) { if (path !== home_page) {
current.value = {} current.value = {}
safeRouterPush({ path: HOME_PAGE }) safeRouterPush({ path: home_page })
} }
return return
} }
@@ -287,12 +288,12 @@ export const useWorktabStore = defineStore(
*/ */
const removeAll = (): void => { const removeAll = (): void => {
const hasFixedTabs = opened.value.some((tab) => tab.fixedTab) const hasFixedTabs = opened.value.some((tab) => tab.fixedTab)
const { home_page } = useUserStore()
// 获取可关闭的标签页 // 获取可关闭的标签页
const closableTabs = opened.value.filter((tab) => { const closableTabs = opened.value.filter((tab) => {
if (!isTabClosable(tab)) return false if (!isTabClosable(tab)) return false
// 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页 // 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页
return hasFixedTabs || tab.path !== HOME_PAGE return hasFixedTabs || tab.path !== home_page
}) })
if (closableTabs.length === 0) { if (closableTabs.length === 0) {
@@ -305,18 +306,18 @@ export const useWorktabStore = defineStore(
// 保留不可关闭的标签页和首页(当没有固定标签时) // 保留不可关闭的标签页和首页(当没有固定标签时)
opened.value = opened.value.filter((tab) => { opened.value = opened.value.filter((tab) => {
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === HOME_PAGE) return !isTabClosable(tab) || (!hasFixedTabs && tab.path === home_page)
}) })
// 处理激活状态 // 处理激活状态
if (!hasOpenedTabs.value) { if (!hasOpenedTabs.value) {
current.value = {} current.value = {}
safeRouterPush({ path: HOME_PAGE }) safeRouterPush({ path: home_page })
return return
} }
// 选择激活的标签页:优先首页,其次第一个可用标签 // 选择激活的标签页:优先首页,其次第一个可用标签
const homeTab = opened.value.find((tab) => tab.path === HOME_PAGE) const homeTab = opened.value.find((tab) => tab.path === home_page)
const targetTab = homeTab || opened.value[0] const targetTab = homeTab || opened.value[0]
current.value = targetTab current.value = targetTab
+1
View File
@@ -28,6 +28,7 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
authList?: Array<{ authList?: Array<{
title: string title: string
auth_mark: string auth_mark: string
authMark?: string // 兼容旧版
}> }>
/** 是否为一级菜单 */ /** 是否为一级菜单 */
isFirstLevel?: boolean isFirstLevel?: boolean
+30 -2
View File
@@ -29,6 +29,32 @@ declare namespace Api {
/** 启用状态 */ /** 启用状态 */
type EnableStatus = '1' | '2' type EnableStatus = '1' | '2'
interface CommonInfo {
data: never[]
}
/** 列表数据 */
interface CommonListData {
records: CommonInfo[]
current: number
size: number
total: number
}
interface CommonListDatad {
data: CommonInfo[]
current: number
size: number
total: number
}
interface AddResponse {
code: string
message: string
}
interface CommonListDatad1 {
data: never[]
message: string
}
} }
/** 认证类型 */ /** 认证类型 */
@@ -116,7 +142,7 @@ declare namespace Api {
interface AddParams { interface AddParams {
categorie_id: any categorie_id: any
content: any content: any
attachment:any attachment: any
} }
interface AddResponse { interface AddResponse {
code: string code: string
@@ -126,7 +152,7 @@ declare namespace Api {
interface StatusParams { interface StatusParams {
id: any id: any
msg: any msg: any
status:any status: any
} }
//分类 //分类
interface CategorieInfo { interface CategorieInfo {
@@ -165,6 +191,8 @@ declare namespace Api {
/** 列表数据 */ /** 列表数据 */
interface CrmListData { interface CrmListData {
country_id: any
channel_id: any
data: CrmInfo[] data: CrmInfo[]
current: number current: number
size: number size: number
+2 -1
View File
@@ -47,9 +47,10 @@ axiosInstance.interceptors.request.use(
const { language } = useUserStore() const { language } = useUserStore()
const { loginType } = useUserStore() const { loginType } = useUserStore()
const { companyabbr } = useUserStore() const { companyabbr } = useUserStore()
const { systemsabbr } = useUserStore()
request.headers.set('Fdesk-Language', language) request.headers.set('Fdesk-Language', language)
request.headers.set('Fdesk-Corp-Abbr', companyabbr) request.headers.set('Fdesk-Corp-Abbr', companyabbr)
request.headers.set('Fdesk-System-Code', 'fdesk') request.headers.set('Fdesk-System-Code', systemsabbr)
request.headers.set('Fdesk-Login-Type', loginType) request.headers.set('Fdesk-Login-Type', loginType)
// 设置 token 和 请求头 // 设置 token 和 请求头
if (accessToken) { if (accessToken) {
+345
View File
@@ -0,0 +1,345 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column prop="code" label="编号" align="center" />
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="grade_txt" label="等级" align="center" />
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column>
<el-table-column prop="user_name" label="标记人"></el-table-column>
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('crm_customer@show')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="goaudit(scope.row)"
v-if="hasAuth('customer_audit@audet')"
>
<i class="iconfont-sys">&#xe6bf;</i> 审核
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<el-dialog v-model="detailDialogVisible" title="详情">
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
<el-dialog v-model="audetDialogVisible" title="审核" width="360px">
<el-form :model="audetForm" label-width="0">
<el-form-item>
<el-select v-model="audetForm.scene" filterable placeholder="请选择" style="width: 100%">
<el-option label="彻底删除" value="delete" />
<el-option label="恢复至公海" value="report" />
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="audetDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleAudetSubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { manageService } from '@/api/adminApi'
const { hasAuth } = useAuth()
const keyword = ref<string>('')
const loading = ref(false)
const detailDialogVisible = ref(false)
const audetDialogVisible = ref(false)
const detailRow = ref<any>(null)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const audetForm = reactive({
id: '',
scene: ''
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
})
watch([keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
scene: 'audit'
}
if (keyword.value) {
params.keyword = keyword.value
}
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
function statusClass(status: any) {
if (status === 0) return 'status-following'
if (status === 1) return 'status-dealed'
if (status === 2) return 'status-lost'
return ''
}
//审核
const goaudit = async (row: any) => {
audetForm.id = row.id
audetDialogVisible.value = true
}
const handleAudetSubmit = async () => {
const res = await manageService.crmCustomerAudit(audetForm)
ElMessage.success(res?.message || '设置成功!')
audetDialogVisible.value = false
gettableList()
}
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
</script>
<style scoped lang="scss">
:deep(.el-badge) {
--el-badge-size: 16px;
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
</style>
+655
View File
@@ -0,0 +1,655 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<el-button
type="primary"
@click="plzyDialogVisible = true"
v-if="hasAuth('highseas@allocation')"
:disabled="selectedRows.length === 0"
>
<i class="iconfont-sys">&#xe794;</i> 批量分配
</el-button>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column prop="code" label="编号" align="center" />
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="grade_txt" label="等级" align="center" />
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column>
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<!-- <el-table-column prop="follow_next" label="下次跟进"></el-table-column> -->
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('highseas@detail')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="gotransfer(scope.row)"
v-if="hasAuth('highseas@allocation')"
>
<i class="iconfont-sys"> &#xe794;</i>分配
</el-link>
<el-link
type="warning"
underline="never"
style="margin-right: 8px"
@click="gofollowdetail(scope.row)"
v-if="hasAuth('highseas@follow_detail')"
>
<i class="iconfont-sys"> &#xe64e;</i>跟进记录
</el-link>
<el-link
type="danger"
underline="never"
style="margin-right: 8px"
@click="onDelete(scope.row)"
v-if="hasAuth('highseas@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i>删除
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详情" width="800px">
<div class="detail-info-table">
<div class="row">
<div>客户名称:{{ detailRow.contacts }}</div>
<div>电话:{{ detailRow.phone }}</div>
<div>国家:{{ detailRow.country_name }}</div>
</div>
<div class="row">
<div>渠道:{{ detailRow.channel_name }}</div>
<div>邮箱:{{ detailRow.email }}</div>
<div>WhatsApp:{{ detailRow.WhatsApp }}</div>
</div>
<div class="row">
<div>发布人:{{ detailRow.enter_user_name }}</div>
<div>录单时间:{{ detailRow.created_at }}</div>
<div>
等级:
<span>
{{ detailRow.magnitude_txt }}
</span>
</div>
</div>
<div class="row">
<div>
状态:<span>{{ detailRow.grade_txt }}</span>
</div>
<div>
跟进状态:<span>{{ detailRow.follow_txt }}</span>
</div>
</div>
</div>
<div class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
<!-- 分配跟单对话框 -->
<el-dialog v-model="zyDialogVisible" title="分配" width="360px">
<el-form :model="zyForm" label-width="0">
<el-form-item>
<el-select
v-model="zyForm.user_id"
filterable
placeholder="请选择分配人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="zyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlezySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<!-- 批量分配对话框 -->
<el-dialog v-model="plzyDialogVisible" title="批量分配" width="360px">
<el-form :model="plzyForm" label-width="0">
<el-form-item>
<el-select
v-model="plzyForm.user_id"
filterable
placeholder="请选择分配人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="plzyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleplzySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<!-- 跟进记录对话框 -->
<el-dialog v-model="followRecordDialogVisible" title="跟进记录" width="800px">
<!-- 跟进记录列表 -->
<div class="follow-record-list">
<ElScrollbar>
<template v-if="followRecords.length > 0">
<ElTimeline>
<ElTimelineItem
v-for="item in followRecords"
:key="item.time"
:placement="TIMELINE_PLACEMENT"
:color="activecolor"
:center="true"
>
<div class="timeline-item">
<div class="timeline-content-row">
<div class="timeline-content">
<p class="timeline-text"
>{{ item.type_txt }}
<span class="timeline-mom">{{ item.created_at }}</span></p
>
<p class="timeline-mom"
><span v-if="item.type == 1">跟进描述</span><span v-else>拜访内容</span>
{{ item.audio_text }}</p
>
</div>
<img
v-if="item.picture_full"
:src="item.picture_full"
class="timeline-img"
alt="图片"
@click="handleImageClick(item.picture_full)"
style="cursor: pointer"
/>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</template>
<template v-else>
<!-- 使用 Element Plus 的空状态组件 -->
<el-empty :image="emptyimg" description="暂无跟进记录" />
</template>
</ElScrollbar>
</div>
</el-dialog>
<!-- 图片预览对话框 -->
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageList"
:initial-index="imageViewerIndex"
@close="closeImageViewer"
/>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import emptyimg from '@/assets/img/crm/empty.png'
const TIMELINE_PLACEMENT = 'top'
const activecolor = '#3471ff'
const { hasAuth } = useAuth()
const loading = ref(false)
const detailDialogVisible = ref(false)
const zyDialogVisible = ref(false)
const plzyDialogVisible = ref(false)
const followRecordDialogVisible = ref(false)
const detailRow = ref<any>(null)
const currentCustomer = ref<any>(null)
const followRecords = ref<any[]>([])
const keyword = ref<string>('')
// 图片预览相关
const imageViewerVisible = ref(false)
const imageList = ref<string[]>([])
const imageViewerIndex = ref(0)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const plzyForm = reactive({
customer_id: '',
user_id: ''
})
const zyForm = reactive({
customer_id: '',
user_id: ''
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
})
watch([keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
scene: 'seas'
}
if (keyword.value) {
params.keyword = keyword.value
}
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
function statusClass(status: any) {
if (status === 0) return 'status-following'
if (status === 1) return 'status-dealed'
if (status === 2) return 'status-lost'
return ''
}
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
const gotransfer = (row: any) => {
zyForm.customer_id = row.id
zyDialogVisible.value = true
}
// 跟进记录
const gofollowdetail = async (row: any) => {
currentCustomer.value = row
followRecordDialogVisible.value = true
await manageService.crmCustomerFollow({ id: row.id }).then((res) => {
followRecords.value = res
})
}
// 图片预览相关函数
const handleImageClick = (url: string) => {
imageList.value = [url]
imageViewerIndex.value = 0
imageViewerVisible.value = true
}
const closeImageViewer = () => {
imageViewerVisible.value = false
imageList.value = []
imageViewerIndex.value = 0
}
// 分配
const handlezySubmit = async () => {
const res = await manageService.crmCustomerAllocation({
customer_id: zyForm.customer_id,
user_id: zyForm.user_id
})
ElMessage.success(res?.message || '分配成功!')
zyDialogVisible.value = false
gettableList()
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 批量分配
const handleplzySubmit = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
plzyForm.customer_id = selectedRows.value.map((item) => item.id).join(',')
const res = await manageService.crmCustomerAllocation(plzyForm)
ElMessage.success(res?.message || '批量分配成功!')
plzyDialogVisible.value = false
gettableList()
}
//删除
const onDelete = async (row: any) => {
try {
const res = await manageService.crmCustomerDelete({ customer_id: row.id })
ElMessage.success(res?.message || '删除成功!')
gettableList()
} catch (error) {
ElMessage.error('删除失败,请稍后再试')
}
}
</script>
<style scoped lang="scss">
:deep(.el-badge) {
--el-badge-size: 16px;
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
.detail-info-table {
padding-left: 15px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.dtitle {
line-height: 40px;
color: #333;
font-size: 16px;
font-weight: bold;
padding-left: 15px;
}
.detils-content {
padding-left: 15px;
img {
max-width: 100% !important;
}
}
.fenpei {
color: #3471ff !important;
}
.detil {
color: #00a43f !important;
}
.edit {
color: #008477 !important;
}
.delete {
color: #f00 !important;
}
.timeline-img {
width: 180px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.follow-record-list {
padding: 15px;
max-height: 500px;
overflow-y: auto;
.timeline-item {
.timeline-content-row {
display: flex;
align-items: flex-start;
gap: 15px;
.timeline-content {
flex: 1;
.timeline-text {
font-size: 14px;
color: #303133;
margin-bottom: 5px;
.timeline-mom {
color: #909399;
font-size: 12px;
margin-left: 10px;
}
}
.timeline-mom {
font-size: 14px;
color: #606266;
line-height: 1.6;
}
}
}
}
}
</style>
+828
View File
@@ -0,0 +1,828 @@
<template>
<div class="customer-page">
<!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
<el-form-item label="跟单人" label-width="70px">
<el-select
v-model="selectValue"
style="min-width: 120px"
filterable
multiple
collapse-tags
placeholder="请选择跟单人"
>
<el-option v-for="v in userOptions" :key="v.id" :label="v.name" :value="v.id">
</el-option>
</el-select>
</el-form-item>
</div>
<el-button type="primary" @click="exportfn" v-if="hasAuth('customer@export')">
<i class="iconfont-sys">&#xe89f;</i> 导出
</el-button>
</div>
<!-- 筛选标签 -->
<div class="customer-tags boxshadow">
<!-- 前3组 -->
<div class="filter-row" v-for="item in visibleTagGroups" :key="item.id">
<span class="filter-label">{{ item.name }}</span>
<div class="filter-tags">
<span
:class="['tag-itemall', { 'tag-active': tagActiveMap[item.id] === '' }]"
@click="handleTagClick(item.id, '')"
>全部</span
>
<span
class="tag-items"
v-for="item1 in item.tags"
:key="item1.id"
@click="handleTagClick(item.id, item1.id)"
>
<span :class="['tag-item', { 'tag-active': tagActiveMap[item.id] === item1.id }]">
{{ item1.name }}
</span>
<!-- <el-badge :value="item1.count" :max="99" class="itemtag" type="primary"> </el-badge> -->
</span>
</div>
</div>
<!-- 更多 -->
<transition name="fade">
<div v-if="showMore">
<div class="filter-row" v-for="item in moreTagGroups" :key="item.id">
<span class="filter-label">{{ item.name }}</span>
<div class="filter-tags">
<span
:class="['tag-itemall', { 'tag-active': tagActiveMap[item.id] === '' }]"
@click="handleTagClick(item.id, '')"
>全部</span
>
<span
class="tag-items"
v-for="item1 in item.tags"
:key="item1.id"
@click="handleTagClick(item.id, item1.id)"
>
<span :class="['tag-item', { 'tag-active': tagActiveMap[item.id] === item1.id }]">
{{ item1.name }}
</span>
<!-- <el-badge :value="item1.count" :max="99" class="itemtag" type="primary"> </el-badge> -->
</span>
</div>
</div>
</div>
</transition>
<div class="filter-more-btn" v-if="moreTagGroups.length">
<el-link underline="never" @click="showMore = !showMore">
{{ showMore ? '收起' : '更多' }}
<i v-if="showMore" class="iconfont-sys"> &#xe864;</i>
<i v-else class="iconfont-sys">&#xe867;</i>
</el-link>
</div>
</div>
<div class="boxshadow">
<el-button
type="primary"
@click="plzyDialogVisible = true"
v-if="hasAuth('customer@transfer')"
:disabled="selectedRows.length === 0"
>
<i class="iconfont-sys">&#xe794;</i> 批量转移
</el-button>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column prop="code" label="编号" align="center" />
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="grade_txt" label="等级" align="center" />
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column>
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="user_name" label="跟单人"></el-table-column>
<el-table-column prop="follow_next" label="下次跟进"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('customer@detail')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="gotransfer(scope.row)"
v-if="hasAuth('customer@transfer')"
>
<i class="iconfont-sys"> &#xe794;</i>转移
</el-link>
<el-link
type="warning"
underline="never"
style="margin-right: 8px"
@click="gofollowdetail(scope.row)"
v-if="hasAuth('customer@follow_detail')"
>
<i class="iconfont-sys"> &#xe64e;</i>跟进记录
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详情" width="800px">
<div class="detail-info-table">
<div class="row">
<div>客户名称:{{ detailRow.contacts }}</div>
<div>电话:{{ detailRow.phone }}</div>
<div>国家:{{ detailRow.country_name }}</div>
</div>
<div class="row">
<div>渠道:{{ detailRow.channel_name }}</div>
<div>邮箱:{{ detailRow.email }}</div>
<div>WhatsApp:{{ detailRow.WhatsApp }}</div>
</div>
<div class="row">
<div>发布人:{{ detailRow.enter_user_name }}</div>
<div>跟单人:{{ detailRow.user_name }}</div>
<div>录单时间:{{ detailRow.created_at }}</div>
</div>
<div class="row">
<div>
等级:
<span>
{{ detailRow.magnitude_txt }}
</span>
</div>
<div>
状态:<span>{{ detailRow.grade_txt }}</span>
</div>
<div>
跟进状态:<span>{{ detailRow.follow_txt }}</span>
</div>
</div>
</div>
<div class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
<!-- 转移跟单对话框 -->
<el-dialog v-model="zyDialogVisible" title="转移" width="360px">
<el-form :model="zyForm" label-width="0">
<el-form-item>
<el-select
v-model="zyForm.user_id"
filterable
placeholder="请选择转移人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="zyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlezySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<!-- 批量转移对话框 -->
<el-dialog v-model="plzyDialogVisible" title="批量转移" width="360px">
<el-form :model="plzyForm" label-width="0">
<el-form-item>
<el-select
v-model="plzyForm.user_id"
filterable
placeholder="请选择转移人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="plzyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleplzySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<!-- 跟进记录对话框 -->
<el-dialog v-model="followRecordDialogVisible" title="跟进记录" width="800px">
<!-- 跟进记录列表 -->
<div class="follow-record-list">
<ElScrollbar>
<template v-if="followRecords.length > 0">
<ElTimeline>
<ElTimelineItem
v-for="item in followRecords"
:key="item.time"
:placement="TIMELINE_PLACEMENT"
:color="activecolor"
:center="true"
>
<div class="timeline-item">
<div class="timeline-content-row">
<div class="timeline-content">
<p class="timeline-text"
>{{ item.type_txt }}
<span class="timeline-mom">{{ item.created_at }}</span></p
>
<p class="timeline-mom"
><span v-if="item.type == 1">跟进描述</span><span v-else>拜访内容</span>
{{ item.audio_text }}</p
>
</div>
<img
v-if="item.picture_full"
:src="item.picture_full"
class="timeline-img"
alt="图片"
@click="handleImageClick(item.picture_full)"
style="cursor: pointer;"
/>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</template>
<template v-else>
<!-- 使用 Element Plus 的空状态组件 -->
<el-empty :image="emptyimg" description="暂无跟进记录" />
<!-- <el-empty /> -->
<!-- 或者自定义图片 -->
<!-- <img src="你的空图片地址.png" alt="暂无数据" class="empty-img" /> -->
</template>
</ElScrollbar>
</div>
</el-dialog>
<!-- 图片预览对话框 -->
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageList"
:initial-index="imageViewerIndex"
@close="closeImageViewer"
/>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { CrmService } from '@/api/crmApi'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import emptyimg from '@/assets/img/crm/empty.png'
const TIMELINE_PLACEMENT = 'top'
const activecolor = '#3471ff'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const showMore = ref(false)
const loading = ref(false)
const detailDialogVisible = ref(false)
const zyDialogVisible = ref(false)
const plzyDialogVisible = ref(false)
const followRecordDialogVisible = ref(false)
const detailRow = ref<any>(null)
const currentCustomer = ref<any>(null)
const followRecords = ref<any[]>([])
const keyword = ref<string>('')
// 图片预览相关
const imageViewerVisible = ref(false)
const imageList = ref<string[]>([])
const imageViewerIndex = ref(0)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const plzyForm = reactive({
customer_id: '',
user_id: ''
})
const zyForm = reactive({
customer_id: '',
user_id: ''
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 获取列表数据
const tagActiveMap = reactive<{ [groupId: string]: string }>({})
//获取标签
type TagGroup = { id: string; name: string; tags: { id: string; name: string; count: number }[] }
const tagGroups = ref<TagGroup[]>([])
const tagGrouplistfn = async () => {
const res = await CrmService.tagGrouplist({ scene: 'customers' })
tagGroups.value = res || []
tagGroups.value.forEach((group) => {
if (group.tags && Array.isArray(group.tags)) {
tagActiveMap[group.id] = ''
}
})
}
// 计算前3组和更多组
// eslint-disable-next-line vue/valid-define-props
defineProps()
const visibleTagGroups = computed(() => tagGroups.value.slice(0, 3))
const moreTagGroups = computed(() => tagGroups.value.slice(3))
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
tagGrouplistfn()
})
// tabs 切换联动
watch([selectValue, keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (selectValue.value && selectValue.value.length) {
params.user_id = selectValue.value.join(',')
}
if (keyword.value) {
params.keyword = keyword.value
}
// 标签参数,所有选中的标签id组成数组
const selectedTagIds = Object.values(tagActiveMap).filter(Boolean)
if (selectedTagIds.length) {
params.tag_id = selectedTagIds
}
console.log(params)
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
function statusClass(status: any) {
if (status === 0) return 'status-following'
if (status === 1) return 'status-dealed'
if (status === 2) return 'status-lost'
return ''
}
//导出
const exportfn = () => {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
user_id: selectValue.value.join(','),
tag_id: Object.values(tagActiveMap).filter(Boolean)
}
// manageService.crmCustomerExport(params)
try {
// 1. 调用接口,获取blob
// const res = await manageService.crmCustomerExport(params)
const urls =
import.meta.env.VITE_API_URL +
`/admin/crm/customer/export?current=${params.currentPage}&size=${params.pageSize}&user_id=${params.user_id}&tag_id=${params.tag_id}`
// 2. 创建blob对象
// const blob = new Blob([res], { type: 'application/vnd.ms-excel' }) // 或根据后端实际类型调整
// 3. 创建下载链接
const url = urls
const link = document.createElement('a')
link.href = url
link.download = '客户数据.xlsx' // 可自定义文件名
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error('导出失败')
}
}
// 标签选中状态,key为组id,值为选中的标签id
function handleTagClick(groupId: string, tagId: string) {
// if (tagActiveMap[groupId] === tagId) {
// tagActiveMap[groupId] = '' // 取消选中
// } else {
tagActiveMap[groupId] = tagId // 选中
// }
pagination.currentPage = 1
gettableList()
}
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
const gotransfer = (row: any) => {
zyForm.customer_id = row.id
zyDialogVisible.value = true
}
// 跟进记录
const gofollowdetail = async (row: any) => {
currentCustomer.value = row
followRecordDialogVisible.value = true
await manageService.crmCustomerFollow({ id: row.id }).then((res) => {
followRecords.value = res
})
}
// 图片预览相关函数
const handleImageClick = (url: string) => {
imageList.value = [url]
imageViewerIndex.value = 0
imageViewerVisible.value = true
}
const closeImageViewer = () => {
imageViewerVisible.value = false
imageList.value = []
imageViewerIndex.value = 0
}
// 转移
const handlezySubmit = async () => {
const res = await manageService.crmCustomerTransfer({
customer_id: zyForm.customer_id,
user_id: zyForm.user_id
})
ElMessage.success(res?.message || '转移成功!')
zyDialogVisible.value = false
gettableList()
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 批量转移
const handleplzySubmit = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
plzyForm.customer_id = selectedRows.value.map((item) => item.id).join(',')
const res = await manageService.crmCustomerTransfer(plzyForm)
ElMessage.success(res?.message || '批量转移成功!')
plzyDialogVisible.value = false
gettableList()
}
</script>
<style scoped lang="scss">
.timeline-img {
width: 180px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
:deep(.el-badge) {
--el-badge-size: 16px;
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
.detail-info-table {
padding-left: 15px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.dtitle {
line-height: 40px;
color: #333;
font-size: 16px;
font-weight: bold;
padding-left: 15px;
}
.detils-content {
padding-left: 15px;
img {
max-width: 100% !important;
}
}
.follow-record-header {
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 15px;
.customer-info {
display: flex;
align-items: center;
.label {
font-size: 14px;
color: #606266;
margin-right: 5px;
}
.value {
font-size: 14px;
color: #303133;
font-weight: bold;
}
}
}
.follow-record-list {
padding: 15px;
max-height: 500px;
overflow-y: auto;
.list-title {
font-size: 16px;
font-weight: bold;
color: #303133;
margin-bottom: 15px;
padding-left: 10px;
}
.record-item {
background-color: #f5f7fa;
border-radius: 4px;
padding: 10px 15px;
margin-bottom: 10px;
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.record-type {
font-size: 14px;
color: #3471ff;
font-weight: bold;
background-color: #e6f7ff;
padding: 2px 8px;
border-radius: 4px;
}
.record-time {
font-size: 14px;
color: #909399;
}
.record-user {
font-size: 14px;
color: #3471ff;
font-weight: bold;
}
}
.record-content {
font-size: 14px;
color: #303133;
line-height: 1.6;
margin-bottom: 5px;
}
.record-footer {
.next-follow {
font-size: 14px;
color: #909399;
}
}
}
.no-records {
text-align: center;
color: #909399;
padding: 20px 0;
}
}
</style>
+906
View File
@@ -0,0 +1,906 @@
<!-- eslint-disable vue/no-parsing-error -->
<!-- eslint-disable vue/valid-attribute-name -->
<!-- eslint-disable prettier/prettier -->
<template>
<div class="boxshadow">
<section class="search-bar art-custom-card">
<el-form v-model:filter="formFilters" label-position="left">
<el-row class="search-form-row" :gutter="12">
<el-col :xs="24" :sm="12" :md="4" :lg="4" :xl="4">
<el-form-item label="关键字" label-width="70px">
<el-input v-model="filter.keyword" placeholder="请输入关键字" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4" :xl="4">
<el-form-item label="状态" label-width="70px">
<el-select v-model="filter.status">
<el-option label="全部" value="" />
<el-option label="待发布" value="0" />
<el-option label="待跟踪" value="1" />
<el-option label="已跟踪" value="2" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4" :xl="4">
<el-form-item label="渠道" label-width="70px">
<el-select v-model="filter.channel">
<el-option v-for="v in channelOptions" :key="v.id" :label="v.name" :value="v.id">
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="6" :lg="6" :xl="6">
<el-form-item label="时间" label-width="70px">
<el-date-picker
v-model="filter.time"
type="daterange"
format="YYYY-MM-DD"
valueFormat="YYYY-MM-DD"
rangeSeparator="至"
startPlaceholder="开始日期"
endPlaceholder="结束日期"
:style="{ width: '100%' }"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="4" :lg="4" :xl="4" class="action-column">
<div
class="action-buttons-wrapper"
:style="{
'justify-content': isMobile ? 'flex-end' : 3 <= 2 ? 'flex-start' : 'flex-end'
}"
>
<div class="form-buttons">
<el-button class="reset-button" @click="handleReset">{{
$t('table.searchBar.reset')
}}</el-button>
<el-button type="primary" class="search-button" @click="handleSearch">{{
$t('table.searchBar.search')
}}</el-button>
</div>
<div class="form-btn-right">
</div>
</div>
</el-col>
</el-row>
</el-form>
</section>
<div class="table">
<el-button class="reset-button" @click="addfn()" v-if="hasAuth('inquiry#store')"><i class="iconfont-sys">&#xe602;</i> 录单</el-button>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<el-table-column prop="order_no" label="SN"></el-table-column>
<el-table-column prop="contacts" label="客户名称"></el-table-column>
<el-table-column prop="tel" label="电话"></el-table-column>
<el-table-column prop="phone" label="国家">
<template #default="scope">
<span>
{{scope.row.countryInfo? scope.row.countryInfo.cn_addr : ''}}
</span>
<span v-if="scope.row.countryInfo">
<el-tag class="steer" type="warning" v-if="scope.row.countryInfo.steering=='左舵'">{{ scope.row.countryInfo.steering }}</el-tag>
<el-tag class="steer" type="success" v-else-if="scope.row.countryInfo.steering=='右舵'">{{ scope.row.countryInfo.steering }}</el-tag>
</span>
<!-- <span class="steer" v-if="scope.row.countryInfo">{{ scope.row.countryInfo.steering }}</span> -->
</template>
</el-table-column>
<el-table-column prop="phone" label="渠道">
<template #default="scope">
<span>
{{scope.row.channelInfo? scope.row.channelInfo.name : ''}}
</span>
</template>
</el-table-column>
<el-table-column prop="quality" label="客户商机">
<template #default="scope">
<span :style="scope.row.quality==1 ? { color: '#F5AB00' } : scope.row.quality==2 ? { color: '#8495B8' }: { color: '#9B7200' }">
{{scope.row.quality==1 ? '金牌商机' : scope.row.quality==2 ? '银牌商机' : '铜牌商机'}}
</span>
</template>
</el-table-column>
<el-table-column prop="magnitude" label="发布人">
<template #default="scope">
<span>
{{scope.row.enter? scope.row.enter.name : ''}}
</span>
</template>
</el-table-column>
<el-table-column prop="recipient" label="跟单人">
<template #default="scope">
<span style="color:#008477;cursor: pointer;" v-if="scope.row.recipientInfo && hasAuth('inquiry#reallocation')" @click="onFenpei(scope.row,'edit')">
{{scope.row.recipientInfo? scope.row.recipientInfo.name : ''}}
<i class="iconfont-sys">&#xe626;</i>
</span>
</template>
</el-table-column>
<el-table-column label="状态">
<template #default="scope">
<span :style="scope.row.status== 0 ? { color: '#ccc' } :scope.row.status== 1 ? { color: '#00A43F' }: { color: '#3471FF' }">
{{ scope.row.status== 0 ? '未发布' : scope.row.status== 1 ? '待跟踪' : '已跟踪' }}
</span>
</template>
</el-table-column>
<!-- <el-table-column label="倒计时" min-width="140">
<template #default="scope">
<span :class="getCountdownClass(scope.row.countdown)" v-if="scope.row.status==1">
<span class="custom-clock-icon" style="margin-right: 4px; vertical-align: middle"
><i class="iconfont-sys">&#xe7b1;</i></span
>
{{ getCountdown(scope.row.countdown) }}
</span>
</template>
</el-table-column> -->
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="260px">
<template #default="scope">
<div class="follow-action fenpei" @click="onFenpei(scope.row,'add')" v-if="scope.row.status!= 2&&hasAuth('inquiry#allocation')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe852;</i></span
>
分配
</div>
<div class="follow-action detil" @click="onDetail(scope.row)" v-if="hasAuth('inquiry#show')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe70f;</i></span
>
详情
</div>
<div class="follow-action" @click="onFollow(scope.row)" v-if="scope.row.status==0&&hasAuth('inquiry#release')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe712;</i></span
>
发布
</div>
<div class="follow-action edit" @click="onEdit(scope.row)" v-if="hasAuth('inquiry#update')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe6bf;</i></span
>
编辑
</div>
<div class="follow-action delete" @click="onDelete(scope.row)" v-if="hasAuth('inquiry#destroy')">
<span class="custom-clock-icon" style="margin-right: 2px; vertical-align: middle"
><i class="iconfont-sys">&#xe6d4;</i></span
>
删除
</div>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<el-dialog v-model="detailDialogVisible" title="详情" >
<div class="detail-info-table">
<div class="row">
<div>客户名称:{{ detailRow.contacts }}</div>
<div>电话:{{ detailRow.tel }}</div>
<div>国家:{{ detailRow.countryInfo? detailRow.countryInfo.cn_addr:'' }}</div>
</div>
<div class="row">
<div>渠道:{{ detailRow.channelInfo?detailRow.channelInfo.name:'' }}</div>
<div>邮箱:{{ detailRow.email }}</div>
<div>WhatsApp:{{ detailRow.whatsapp }}</div>
</div>
<div class="row">
<div>发布人:{{ detailRow.enter? detailRow.enter.name : ''}}</div>
<div>跟单人:{{ detailRow.recipientInfo? detailRow.recipientInfo.name : ''}}</div>
<div>录单时间:{{ detailRow.created_at }}</div>
</div>
<div class="row">
<div>
等级:
<span :style="detailRow.quality==1 ? { color: '#F5AB00' } : detailRow.quality==2 ? { color: '#8495B8' }: { color: '#9B7200' }">
{{detailRow.quality==1 ? '金牌商机' : detailRow.quality==2 ? '银牌商机' : '铜牌商机'}}
</span>
</div>
<div
>状态:<span>{{ detailRow.status== 0 ? '未发布' : detailRow.status== 1 ? '待跟踪' : '已跟踪' }}</span></div
>
<div>SN:{{ detailRow.order_no }}</div>
</div>
</div>
<div class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.content" class="detils-content" @click="handleImageClick" style="cursor: pointer;">
</div>
<!-- 图片预览组件 -->
<ElImageViewer
v-if="showImageViewer"
:url-list="imageList"
:initial-index="currentImageIndex"
:hide-on-click-modal="true"
@close="showImageViewer = false"
/>
</el-dialog>
<el-dialog
:title="dialogType === 'add' ? '录单' : '编辑'"
v-model="AddDialogVisible"
width="800px"
:close-on-click-modal="false"
>
<el-form :model="addForm" ref="addFormRef" label-width="80px" class="add-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="渠道" prop="channel">
<el-select v-model="addForm.channel" placeholder="请选择渠道">
<el-option
v-for="item in channelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="等级" prop="quality">
<el-select v-model="addForm.quality" placeholder="请选择等级">
<el-option
v-for="item in opportunityOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="国家" prop="country">
<el-select v-model="addForm.country" filterable placeholder="请选择国家">
<el-option
v-for="item in countryOptions"
:key="item.id"
:label="item.cn_addr"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Email" prop="email">
<el-input v-model="addForm.email" placeholder="请输入Email" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="联系方式" prop="tel">
<el-input v-model="addForm.tel" placeholder="请输入联系方式" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系人" prop="contacts">
<el-input v-model="addForm.contacts" placeholder="请输入联系人" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="WhatsApp" prop="whatsapp">
<el-input v-model="addForm.whatsapp" placeholder="请输入WhatsApp" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="跟单人" prop="country">
<el-select v-model="addForm.recipient" filterable placeholder="请选择跟单人">
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="详情内容" prop="content">
<ArtWangEditor class="el-top" v-model="editorHtml" />
</el-form-item>
<div class="form-submit-row">
<el-button type="primary" size="large" style="width: 50%" @click="handleAddSubmit"
>提交</el-button
>
</div>
</el-form>
</el-dialog>
<!-- 分配 -->
<el-dialog v-model="fenpeiDialogVisible" :title="dialogTypefp === 'add' ? '分配跟单' : '重新分配跟单'" width="360px">
<el-form :model="fenpeiForm" label-width="0">
<el-form-item>
<el-select v-model="fenpeiForm.recipient" filterable placeholder="请选择" style="width: 100%">
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="fenpeiDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleFenpeiSubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import dayjs from 'dayjs'
import { ElMessageBox, ElImageViewer } from 'element-plus'
import { CrmService } from '@/api/crmApi'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
const loading = ref(false)
const dialogType = ref('add')
const dialogTypefp = ref('add')
const { width } = useWindowSize()
const isMobile = computed(() => width.value < 500)
const filter = ref({
status: '',
time: '',
channel: '',
keyword: ''
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const editorHtml = ref('') // 编辑器内容
const timer = ref(0)
const refreshKey = ref(0)
const fenpeiDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const AddDialogVisible = ref(false)
const detailRow = ref<any>(null)
const countryOptions = ref<any>()
const tableData = ref<any[]>([])
const addFormRef = ref()
let addForm = reactive({
channel: '',
quality: '',
country: '',
email: '',
tel: '',
contacts: '',
whatsapp: '',
content: '',
recipient: ''
})
// 响应式表单数据
const formFilters = reactive({ ...filter })
// 重置表单
const handleReset = () => {
filter.value = {
status: '',
time: '',
channel: '',
keyword: ''
}
Object.assign(formFilters, { ...filter })
pagination.currentPage = 1 // 重置到第一页
gettableList()
}
// 搜索处理
const handleSearch = () => {
console.log('搜索参数:', formFilters)
pagination.currentPage = 1 // 搜索时重置到第一页
gettableList()
}
const opportunityOptions = ref<any>()
//商机
const getMagnitude = async () => {
// 这里调用获取品牌列表的接口
const res = await CrmService.getMagnitude()
opportunityOptions.value = res || []
}
const channelOptions = ref<any>()
//渠道
const getgetChannel = async () => {
const res = await CrmService.getChannel()
channelOptions.value = res
}
//国家
const getarea = async () => {
const res = await CrmService.getCountry()
countryOptions.value = res || []
}
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 计算属性:将时间格式化为 "开始日期 - 结束日期"
const formattedTimeRange = () => {
if (!filter.value.time || filter.value.time.length !== 2) {
return '' // 如果时间范围无效,返回空字符串
}
const [startDate, endDate] = filter.value.time
return `${startDate} - ${endDate}`
}
// 获取列表数据
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
channel: filter.value.channel,
status: filter.value.status,
keyword: filter.value.keyword,
time: formattedTimeRange()
}
const { records, current, size, total } = await manageService.getinquiryList(params)
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
onMounted(() => {
getgetChannel()
gettableList()
getuserList()
timer.value = window.setInterval(() => {
refreshKey.value++
}, 1000)
})
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
function getCountdown(targetTimestamp: number) {
// 让 refreshKey 成为依赖,确保每秒刷新
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
refreshKey.value
const now = dayjs()
const target = dayjs.unix(targetTimestamp) // 使用时间戳创建 dayjs 对象
if (!target.isValid()) return '时间格式错误'
const diff = target.diff(now)
if (diff <= 0) return '已超时'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((diff / (1000 * 60)) % 60)
const seconds = Math.floor((diff / 1000) % 60)
return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
}
// function getCountdown(targetDate: string) {
// // 让 refreshKey 成为依赖,确保每秒刷新
// // eslint-disable-next-line @typescript-eslint/no-unused-expressions
// refreshKey.value
// const now = dayjs()
// const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
// if (!target.isValid()) return '时间格式错误'
// const diff = target.diff(now)
// if (diff <= 0) return '已超时'
// const days = Math.floor(diff / (1000 * 60 * 60 * 24))
// const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
// const minutes = Math.floor((diff / (1000 * 60)) % 60)
// const seconds = Math.floor((diff / 1000) % 60)
// return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
// }
function getCountdownClass(targetDate: number) {
const now = dayjs()
// const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
const target = dayjs.unix(targetDate)
if (!target.isValid() || target.diff(now) <= 0) {
return 'countdown-danger'
}
return 'countdown-success'
}
function reactiveForm() {
Object.assign(addForm, {
channel: '',
quality: '',
country: '',
email: '',
tel: '',
contacts: '',
whatsapp: '',
content: '',
recipient: ''
})
editorHtml.value = ''
}
const addfn = () => {
dialogType.value = 'add'
reactiveForm()
getMagnitude()
getarea()
AddDialogVisible.value = true
}
const onFollow = async (row: any) => {
ElMessageBox.confirm('确定要发布此询单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.inquiryrelease({ id: row.id, status: 1 })
ElMessage.success(res?.message || '请求成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
function onDetail(row: any) {
row.content = processImages(row.content) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
//编辑
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
const carrid = ref('')
const onEdit = async (row: any) => {
dialogType.value = 'edit'
getMagnitude()
getarea()
AddDialogVisible.value = true
carrid.value = row.id
addForm = reactive({
channel: row.channelInfo ? row.channelInfo.id : '',
quality: row.quality,
country: row.countryInfo ? row.countryInfo.id : '',
email: row.email,
tel: row.tel,
contacts: row.contacts,
whatsapp: row.whatsapp,
content: row.content,
recipient: row.recipientInfo ? row.recipientInfo.id : ''
})
editorHtml.value = row.content
// 这里可以添加编辑逻辑
// 例如打开编辑对话框或跳转到编辑页面
}
const delCodeTrim = (content: string): string => {
return content.replace(/(\s*)<\/code>/g, '</code>')
}
const handleAddSubmit = async () => {
// 提交表单逻辑
// 这里可以调用 API 提交 addForm 数据
if (dialogType.value === 'add') {
editorHtml.value = delCodeTrim(editorHtml.value)
addForm.content = editorHtml.value
const res = await manageService.inquiryAdd(addForm)
ElMessage.success(res?.message || '录单成功!')
} else {
editorHtml.value = delCodeTrim(editorHtml.value)
addForm.content = editorHtml.value
const forms = { ...addForm, id: carrid.value }
const res = await manageService.inquiryedit(forms)
ElMessage.success(res?.message || '录单成功!')
}
AddDialogVisible.value = false
gettableList()
}
//删除
const onDelete = (row: any) => {
ElMessageBox.confirm('确定要删除此条询单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.inquirydelete({ id: row.id })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
const fenpeiForm = reactive({
id: '',
recipient: ''
})
//分配
const onFenpei = (row: any, edits: any) => {
dialogTypefp.value = edits
fenpeiForm.id = row.id
fenpeiDialogVisible.value = true
}
const handleFenpeiSubmit = async () => {
if (!fenpeiForm.recipient) {
ElMessage.warning('请选择跟单人')
return
}
if (dialogTypefp.value === 'add') {
const res = await manageService.inquiryallocation({
id: fenpeiForm.id,
recipient: fenpeiForm.recipient,
status: 2
})
ElMessage.success(res?.message || '分配成功!')
} else {
const res = await manageService.inquiryreallocation({
id: fenpeiForm.id,
recipient: fenpeiForm.recipient,
status: 2
})
ElMessage.success(res?.message || '分配成功!')
}
fenpeiDialogVisible.value = false
gettableList()
}
const imageList = ref<string[]>([])
const currentImageIndex = ref(0)
const showImageViewer = ref(false)
const handleImageClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.tagName === 'IMG') {
const imgElement = target as HTMLImageElement
// 获取所有图片并设置预览列表
const contentDiv = event.currentTarget as HTMLElement
const allImages = contentDiv.querySelectorAll('img')
const imageUrls = Array.from(allImages).map(img => img.src)
imageList.value = imageUrls
currentImageIndex.value = imageUrls.indexOf(imgElement.src)
showImageViewer.value = true
}
}
</script>
<style lang="scss" scoped>
.search-bar {
padding: 20px 20px 0;
background-color: var(--art-main-bg-color);
border-radius: calc(var(--custom-radius) / 2 + 2px);
:deep(.el-form-item__label) {
display: flex;
align-items: center;
line-height: 20px;
}
.search-form-row {
display: flex;
flex-wrap: wrap;
}
.action-column {
flex: 1;
max-width: 100%;
.action-buttons-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
// justify-content: flex-end;
margin-bottom: 12px;
}
.filter-toggle {
display: flex;
align-items: center;
margin-left: 10px;
line-height: 32px;
color: var(--main-color);
cursor: pointer;
span {
font-size: 14px;
user-select: none;
}
.icon-wrapper {
display: flex;
align-items: center;
margin-left: 4px;
font-size: 14px;
}
}
.form-btn-right {
flex: 1;
text-align: right;
}
}
}
.dark {
.card-list {
.art-custom-card {
.iconfont-sys {
background-color: #232323 !important;
}
}
}
}
.table {
margin-top: 10px;
:deep(.el-table__empty-block) {
height: 300px !important;
}
:deep(.el-table__header th) {
font-weight: bold;
color: #333;
background: #f7f9fa !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-right: 1px solid #f7f9fa !important;
border-bottom: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-right: 1px solid #f7f9fa !important;
border-bottom: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
:deep(.el-table th),
:deep(.el-table td) {
text-align: center !important;
}
}
.countdown-danger {
color: #f00 !important;
}
.countdown-success {
color: #00a43f !important;
}
.fenpei {
color: #3471ff !important;
}
.detil {
color: #00a43f !important;
}
.edit {
color: #008477 !important;
}
.delete {
color: #f00 !important;
}
.follow-action {
display: inline-flex;
align-items: center;
padding: 4px;
color: #409eff;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
.detail-info-table {
padding-left: 25px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.form-submit-row {
text-align: center;
}
.steer {
margin-left: 5px;
// display: inline-block;
// border: 1px solid #00a43f;
// padding: 0px 4px;
// color: #00a43f;
}
.dtitle {
line-height: 40px;
color: #333;
font-size: 16px;
font-weight: bold;
padding-left: 25px;
}
.detils-content {
padding-left: 25px;
img {
max-width: 100% !important;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
}
</style>
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="page-content">
<div v-if="!isMobile">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@company_add')">新增</ElButton>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
>
<template #default>
<ElTableColumn label="ID" prop="id" />
<ElTableColumn label="公司名称" prop="name" />
<ElTableColumn label="公司标识" prop="abbr" />
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status ? 'primary' : 'info'">
{{ scope.row.status ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作" width="200px">
<template #default="scope">
<ElRow>
<el-button
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('system@company_update')"
>
编辑
</el-button>
<el-button
type="danger"
link
@click="deleteRole(scope.row)"
v-if="hasAuth('system@company_destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增' : '编辑'"
width="40%"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" class="form">
<ElFormItem label="公司名称" prop="name">
<ElInput v-model="form.name" />
</ElFormItem>
<ElFormItem label="公司标识" prop="abbr">
<ElInput v-model="form.abbr" />
</ElFormItem>
<!-- <ElFormItem label="飞书标识" prop="tenant_key">
<ElInput v-model="form.tenant_key" />
</ElFormItem> -->
<ElFormItem label="状态">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
<div v-else class="mobile-hide-tip">
<p>该页面暂不支持移动端访问</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { systemService } from '@/api/systemApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'Type ' })
const loading = ref(false)
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const { width } = useWindowSize()
const isMobile = computed(() => width.value < 500)
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
abbr: [{ required: true, message: '请输入公司简称', trigger: 'blur' }]
// tenant_key: [{ required: true, message: '请输入飞书标识', trigger: 'blur' }]
})
const form = reactive<any>({
name: '',
id: '',
abbr: '',
status: 1
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await systemService.getCompanyList({
current: currentPage,
size: pageSize
})
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const dialogType = ref('add')
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
form.name = row.name
form.id = row.id
form.status = row.status
form.abbr = row.abbr
// form.tenant_key = row.tenant_key
} else {
form.name = ''
form.id = ''
form.status = 1
form.abbr = ''
// form.tenant_key = ''
}
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await systemService.companyDelete({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
if (dialogType.value === 'add') {
const { message } = await systemService.companyAdd(form)
ElMessage.success(message)
dialogVisible.value = false
getWorkList()
} else {
const { message } = await systemService.companyEdit(form)
ElMessage.success(message)
dialogVisible.value = false
getWorkList()
}
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="page-content">
<div v-if="!isMobile">
<ElRow>
<!-- <ElCol :xs="24" :sm="12" :lg="6">
<ElInput placeholder="名称"></ElInput>
</ElCol> -->
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<!-- <ElButton v-ripple>搜索</ElButton> -->
<ElButton @click="showDialog('add')" v-if="hasAuth('system@feishu_app_add')" v-ripple
>新增</ElButton
>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
<ElTableColumn label="公司" prop="corp_id" width="140">
<template #default="scope">
{{ scope.row.corpInfo.name }}
</template>
</ElTableColumn>
<ElTableColumn label="系统" prop="system_id" width="140">
<template #default="scope">
{{ scope.row.systemInfo.name }}
</template>
</ElTableColumn>
<ElTableColumn label="名称" prop="name" width="160" />
<ElTableColumn label="应用ID" prop="app_id" width="180" show-overflow-tooltip />
<ElTableColumn label="基础域" prop="base_uri" width="220" show-overflow-tooltip />
<ElTableColumn label="授权地址" prop="r_url" width="260" show-overflow-tooltip />
<ElTableColumn label="授权范围" prop="scopes" width="240" show-overflow-tooltip />
<ElTableColumn label="携带参数" prop="state" width="140" show-overflow-tooltip />
<ElTableColumn label="排序" prop="sort" width="90" />
<ElTableColumn label="状态" prop="status" width="100">
<template #default="scope">
<ElTag :type="scope.row.status ? 'primary' : 'info'">
{{ scope.row.status ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" width="180" />
<ElTableColumn fixed="right" label="操作" width="180px">
<template #default="scope">
<ElRow>
<el-button
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('system@feishu_app_update')"
>
编辑
</el-button>
<el-button
type="danger"
link
@click="deleteRole(scope.row)"
v-if="hasAuth('system@feishu_app_destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增' : '编辑'"
align-center
>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
style="padding: 5px 20px"
label-width="100px"
>
<ElFormItem label="公司" prop="corp_id">
<ElSelect v-model="form.corp_id" placeholder="请选择公司" filterable style="width: 100%">
<ElOption v-for="item in companyList" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="系统" prop="system_id">
<ElSelect v-model="form.system_id" placeholder="请选择系统" filterable style="width: 100%">
<ElOption v-for="item in systemsList" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入名称" />
</ElFormItem>
<!-- <ElFormItem label="企业标识" prop="tenant_key">
<ElInput v-model="form.tenant_key" placeholder="请输入企业标识" />
</ElFormItem> -->
<ElFormItem label="应用ID" prop="app_id">
<ElInput v-model="form.app_id" placeholder="请输入应用ID" />
</ElFormItem>
<ElFormItem label="应用秘钥" prop="app_secret">
<ElInput v-model="form.app_secret" placeholder="请输入应用秘钥" show-password />
</ElFormItem>
<ElFormItem label="基础域" prop="base_uri">
<ElInput v-model="form.base_uri" placeholder="请输入基础域" />
</ElFormItem>
<ElFormItem label="授权地址" prop="r_url">
<ElInput v-model="form.r_url" placeholder="请输入授权地址" />
</ElFormItem>
<ElFormItem label="授权范围" prop="scopes">
<ElInput v-model="form.scopes" type="textarea" :rows="3" placeholder="请输入授权范围" />
</ElFormItem>
<ElFormItem label="携带参数" prop="state">
<ElInput v-model="form.state" placeholder="请输入携带参数" />
</ElFormItem>
<ElFormItem label="排序" prop="sort">
<ElInputNumber v-model="form.sort" :min="0" placeholder="请输入排序" />
</ElFormItem>
<ElFormItem label="状态" prop="status">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
<div v-else class="mobile-hide-tip">
<p>该页面暂不支持移动端访问</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { systemService } from '@/api/systemApi'
import { UserService } from '@/api/usersApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'Type ' })
const loading = ref(false)
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const { width } = useWindowSize()
const isMobile = computed(() => width.value < 500)
const rules = reactive<FormRules>({
corp_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
system_id: [{ required: true, message: '请选择系统', trigger: 'change' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
// app_id: [{ required: true, message: '请输入应用ID', trigger: 'blur' }],
// app_secret: [{ required: true, message: '请输入应用秘钥', trigger: 'blur' }],
// base_uri: [{ required: true, message: '请输入基础域', trigger: 'blur' }],
// r_url: [{ required: true, message: '请输入授权地址', trigger: 'blur' }],
// scopes: [{ required: true, message: '请输入授权范围', trigger: 'blur' }],
// state: [{ required: true, message: '请输入携带参数', trigger: 'blur' }],
// sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
// status: [{ required: true, message: '请选择状态', trigger: 'change' }]
})
const form = reactive({
corp_id: '',
system_id: '',
name: '',
// tenant_key: '',
app_id: '',
app_secret: '',
base_uri: '',
r_url: '',
scopes: '',
state: '',
sort: 0,
status: 1,
id: ''
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const companyList = ref<any[]>([])
const systemsList = ref<any[]>([])
const getcompanyList = async () => {
const response = await UserService.getCompanyList()
const list = (response as any)?.data ?? (response as any)?.records ?? response
companyList.value = Array.isArray(list) ? list : []
}
const getsystemsList = async () => {
const response = await UserService.getsystemsList()
const list = (response as any)?.data ?? (response as any)?.records ?? response
systemsList.value = Array.isArray(list) ? list : []
}
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
getcompanyList()
getsystemsList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await systemService.getFeishuAppList({
current: currentPage,
size: pageSize
})
tableData.value = records.map((item: any, index: number) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const dialogType = ref('add')
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
// 编辑模式,填充所有字段
Object.assign(form, {
corp_id: row.corp_id || '',
system_id: row.system_id || '',
name: row.name || '',
// tenant_key: row.tenant_key || '',
app_id: row.app_id || '',
app_secret: row.app_secret || '',
base_uri: row.base_uri || '',
r_url: row.r_url || '',
scopes: row.scopes || '',
state: row.state || '',
sort: row.sort || 0,
status: row.status !== undefined ? row.status : 1,
id: row.id || ''
})
} else {
// 新增模式,重置所有字段
Object.assign(form, {
corp_id: '',
system_id: '',
name: '',
// tenant_key: '',
app_id: '',
app_secret: '',
base_uri: '',
r_url: '',
scopes: '',
state: '',
sort: 0,
status: 1,
id: ''
})
}
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await systemService.feishuAppDelete({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (valid) {
if (dialogType.value === 'add') {
const { message } = await systemService.feishuAppAdd({
corp_id: form.corp_id,
system_id: form.system_id,
name: form.name,
app_id: form.app_id,
app_secret: form.app_secret,
base_uri: form.base_uri,
r_url: form.r_url,
scopes: form.scopes,
state: form.state,
sort: form.sort,
status: form.status
})
ElMessage.success(message)
} else {
const { message } = await systemService.feishuAppEdit({
id: form.id,
corp_id: form.corp_id,
system_id: form.system_id,
name: form.name,
app_id: form.app_id,
app_secret: form.app_secret,
base_uri: form.base_uri,
r_url: form.r_url,
scopes: form.scopes,
state: form.state,
sort: form.sort,
status: form.status
})
ElMessage.success(message)
}
dialogVisible.value = false
getWorkList()
}
})
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
</style>
+751
View File
@@ -0,0 +1,751 @@
<template>
<ArtTableFullScreen>
<div class="menu-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader :showZebra="false" v-model:columns="columnChecks" @refresh="handleRefresh">
<template #left>
<el-segmented
v-model="yearVal"
size="large"
:options="segmentedOptions"
@change="searchArticleByYear"
>
<!-- <template #default="scope">
<div class="flex flex-col items-center gap-2 p-2">
<div>{{ scope.item.label }}</div>
</div>
</template> -->
</el-segmented>
<!-- 按钮权限:后端控制模式,使用自定义指令 -->
<ElButton
v-auth="'system@menu_add'"
@click="showModel('menu', null)"
v-ripple
style="margin-left: 20px"
>
添加菜单
</ElButton>
<ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
<!-- 按钮权限:前端控制模式,使用 hasAuth 方法 -->
<!-- <ElButton v-if="hasAuth('B_CODE1')" @click="showModel('menu', null, true)" v-ripple>
添加菜单
</ElButton> -->
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
rowKey="path"
ref="tableRef"
:loading="loading"
:data="filteredTableData"
:marginTop="10"
:stripe="false"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<ElDialog :title="dialogTitle" v-model="dialogVisible" width="700px" align-center>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="85px">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="资源类型" prop="type">
<el-select v-model="form.type">
<el-option label="目录" value="directory" />
<el-option label="菜单" value="menu" />
<el-option label="权限" value="permission" />
</el-select>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="资源名称" prop="name">
<ElInput v-model="form.name" placeholder="资源名称"></ElInput>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="权限标识" prop="code">
<ElInput v-model="form.code" placeholder="权限标识"></ElInput>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="图标" prop="icon">
<ArtIconSelector v-model="form.icon" :iconType="iconType" width="100%" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20" v-if="form.type !== 'permission'">
<ElCol :span="12" v-if="form.type !== 'permission'">
<ElFormItem label="路由地址" prop="url">
<ElInput v-model="form.url" placeholder="路由地址"></ElInput>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="打开方式" prop="open_type">
<ElSelect v-model="form.open_type" class="account-select">
<el-option label="内部" value="internal" />
<el-option label="外部" value="external" />
<el-option label="嵌入" value="iframe" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20" v-if="form.type !== 'permission'">
<ElCol :span="12">
<ElFormItem label="资源排序" prop="sort" style="width: 100%">
<ElInputNumber
v-model="form.sort"
style="width: 100%"
@change="handleChange"
:min="1"
controls-position="right"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="外部链接" prop="link">
<ElInput
v-model="form.link"
placeholder="外部链接/内嵌地址(https://www.baidu.com)"
></ElInput>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="系统" prop="system_id">
<ElSelect v-model="form.system_id" class="account-select">
<ElOption
v-for="account in systemsList"
:key="account.id"
:label="account.name"
:value="account.id"
>
<span>{{ account.name }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="父级" prop="parent_id">
<el-tree-select
v-model="form.parent_id"
:data="parentList"
:props="{
value: 'id',
label: 'name',
children: 'children'
}"
check-strictly
:render-after-expand="false"
style="width: 240px"
/>
<!-- <ElSelect v-model="form.parent_id" class="account-select">
<ElOption
v-for="account in parentList"
:key="account.id"
:label="account.name"
:value="account.id"
>
<span>{{ account.name }}</span>
</ElOption>
</ElSelect> -->
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20" v-if="form.type !== 'permission'">
<ElCol :span="5">
<ElFormItem label="是否启用" prop="status">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0"></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="5">
<ElFormItem label="页面缓存" prop="keep_alive">
<ElSwitch
v-model="form.keep_alive"
:active-value="1"
:inactive-value="0"
></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="5">
<ElFormItem label="是否隐藏" prop="is_hide">
<ElSwitch v-model="form.is_hide" :active-value="1" :inactive-value="0"></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="5">
<ElFormItem label="标签页是否隐藏" prop="is_hide_tab" label-width="120px">
<ElSwitch
v-model="form.is_hide_tab"
:active-value="1"
:inactive-value="0"
></ElSwitch>
</ElFormItem>
</ElCol>
<!-- <ElCol :span="5">
<ElFormItem label="是否内嵌" prop="isMenu">
<ElSwitch v-model="form.isIframe"></ElSwitch>
</ElFormItem>
</ElCol> -->
</ElRow>
<ElRow :gutter="20" v-if="form.type !== 'permission'">
<ElCol :span="5">
<ElFormItem label="是否首页" prop="is_home">
<ElSwitch v-model="form.is_home" :active-value="1" :inactive-value="0"></ElSwitch>
</ElFormItem>
</ElCol>
<ElCol :span="10">
<ElFormItem label="不需要认证" prop="no_auth" label-width="120px">
<ElSwitch v-model="form.no_auth" :active-value="1" :inactive-value="0"></ElSwitch>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="dialogVisible = false">取 消</ElButton>
<ElButton type="primary" @click="submitForm()">确 定</ElButton>
</span>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { IconTypeEnum } from '@/enums/appEnum'
import { formatMenuTitle } from '@/router/utils/utils'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { ElPopover, ElButton } from 'element-plus'
import { AppRouteRecord } from '@/types/router'
import { useAuth } from '@/composables/useAuth'
import { SearchFormItem } from '@/types'
import { systemService } from '@/api/systemApi'
import { UserService } from '@/api/usersApi'
const yearVal = ref<any>('')
defineOptions({ name: 'Menus' })
const { hasAuth } = useAuth()
// const { menuList } = storeToRefs(useMenuStore())
const menuList = ref<any[]>([])
const loading = ref(false)
const systemsListtab = ref<any>([])
const segmentedOptions = computed(() =>
Array.isArray(systemsListtab.value)
? systemsListtab.value.map((item: any) => ({ label: item.name, value: item.id }))
: []
)
const getsystemsListtab = async () => {
const response = await UserService.getsystemsList()
const list = (response as any)?.data?.records ?? (response as any)?.records ?? response ?? []
systemsListtab.value = Array.isArray(list) ? list : []
if (!yearVal.value && systemsListtab.value.length > 0) {
yearVal.value = systemsListtab.value[0].id
}
}
//系统
const systemsList = ref<any>()
const getsystemsList = async () => {
const response = await UserService.getsystemsList()
systemsList.value = response || []
}
//父级
const parentList = ref<any>()
const getparentList = async () => {
const response = await systemService.getMenuList()
parentList.value = response || []
}
// 定义表单搜索初始值
const initialSearchState = {
name: '',
route: ''
}
// 响应式表单数据
const formFilters = reactive({ ...initialSearchState })
// 增加实际应用的搜索条件状态
const appliedFilters = reactive({ ...initialSearchState })
// 重置表单
const handleReset = () => {
Object.assign(formFilters, { ...initialSearchState })
Object.assign(appliedFilters, { ...initialSearchState })
getTableData()
}
// 搜索处理
const handleSearch = () => {
// 将当前输入的筛选条件应用到实际搜索
Object.assign(appliedFilters, { ...formFilters })
getTableData()
}
// 表单配置项
const formItems: SearchFormItem[] = [
{
label: '菜单名称',
prop: 'name',
type: 'input',
config: {
clearable: true
}
},
{
label: '路由地址',
prop: 'route',
type: 'input',
config: {
clearable: true
}
}
]
// 构建菜单类型标签
const buildMenuTypeTag = (row: AppRouteRecord) => {
if (row.children && row.children.length > 0) {
return 'info'
} else if (row.meta?.link && row.meta?.isIframe) {
return 'success'
} else if (row.path) {
return 'primary'
} else if (row.meta?.link) {
return 'warning'
}
}
// 构建菜单类型文本
const buildMenuTypeText = (row: AppRouteRecord) => {
if (row.children && row.children.length > 0) {
return '目录'
} else if (row.meta?.link && row.meta?.isIframe) {
return '内嵌'
} else if (row.path) {
return '菜单'
} else if (row.meta?.link) {
return '外链'
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'meta.title',
label: '菜单名称',
minWidth: 120,
formatter: (row: AppRouteRecord) => {
return formatMenuTitle(row.meta?.title)
}
},
{
prop: 'type',
label: '菜单类型',
formatter: (row: AppRouteRecord) => {
return h(ElTag, { type: buildMenuTypeTag(row) }, () => buildMenuTypeText(row))
}
},
{
prop: 'path',
label: '路由',
formatter: (row: AppRouteRecord) => {
return row.meta?.link || row.path || ''
}
},
{
prop: 'meta.authList',
label: '可操作权限',
formatter: (row: AppRouteRecord) => {
return h(
'div',
{},
row.meta.authList?.map((item: { title: string; auth_mark: string }, index: number) => {
return h(
ElPopover,
{
placement: 'top-start',
title: '操作',
width: 200,
trigger: 'click',
key: index
},
{
default: () =>
h('div', { style: 'margin: 0; text-align: right' }, [
h(
ElButton,
{
size: 'small',
type: 'primary',
onClick: () => showModel('button', item, false, row)
},
{ default: () => '编辑' }
),
h(
ElButton,
{
size: 'small',
type: 'danger',
onClick: () => deleteAuth(item)
},
{ default: () => '删除' }
)
]),
reference: () => h(ElButton, { class: 'small-btn' }, { default: () => item.title })
}
)
})
)
}
},
{
prop: 'date',
label: '编辑时间',
formatter: () => '2022-3-12 12:00:00'
},
{
prop: 'status',
label: '隐藏菜单',
formatter: (row) => {
return h(ElTag, { type: row.meta.isHide ? 'danger' : 'info' }, () =>
row.meta.isHide ? '是' : '否'
)
}
},
{
prop: 'operation',
label: '操作',
width: 180,
formatter: (row: AppRouteRecord) => {
return h('div', [
// 后端模式权限标识
hasAuth('system@menu_add') &&
h(ArtButtonTable, {
type: 'add',
onClick: () => showModel('menu', row, true)
}),
hasAuth('system@menu_update') &&
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
hasAuth('system@menu_destroy') &&
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteMenu(row)
})
])
}
}
])
const handleRefresh = () => {
getTableData()
}
const dialogVisible = ref(false)
const form = reactive({
// 菜单
system_id: '',
type: 'menu', // 资源类型
parent_id: '',
name: '',
url: '',
code: '',
icon: '',
status: 1,
sort: 1,
is_home: 0,
keep_alive: 1,
is_hide: 0,
is_hide_tab: 0,
no_auth: 0,
link: '',
open_type: 'internal'
})
const iconType = ref(IconTypeEnum.UNICODE)
// const labelPosition = ref('menu')
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
system_id: [{ required: true, message: '请选择系统', trigger: 'blur' }],
type: [{ required: true, message: '请选择资源类型', trigger: 'blur' }],
code: [{ required: true, message: '输入权限标识', trigger: 'blur' }]
})
const tableData = ref<AppRouteRecord[]>([])
onMounted(() => {
getsystemsListtab()
getTableData()
})
const searchArticleByYear = () => {
getTableData()
}
// const handleSegmentedClick = (item: any) => {
// // 若点击的是当前项也强制刷新
// yearVal.value = item?.value ?? yearVal.value
// getTableData()
// }
const getTableData = async () => {
loading.value = true
// 清空旧数据,避免视觉上出现追加
menuList.value = []
tableData.value = []
// 并发安全:本次请求标识
requestCounter += 1
const reqId = requestCounter
const params: any = {
system_id: yearVal.value || '1'
}
try {
const res = await systemService.getMenutreeList(params)
// 若在等待过程中又发起了新请求,则丢弃本次结果
if (reqId !== requestCounter) return
menuList.value = Array.isArray(res) ? res : []
} catch (e) {
console.error(e)
menuList.value = []
}
// console.log(res)
// menuList.value = data
// 基于 appliedFilters 在这里进行过滤,并将结果赋值给 tableData
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
const filterMenus = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items.reduce((acc: AppRouteRecord[], item: AppRouteRecord) => {
// 浅拷贝节点,避免直接修改原数据
const copy: AppRouteRecord = { ...item }
if (copy.children && copy.children.length > 0) {
copy.children = filterMenus(copy.children)
}
const menuTitle = formatMenuTitle(copy.meta?.title || '').toLowerCase()
const menuPath = (copy.path || '').toLowerCase()
const nameMatch = !searchName || menuTitle.includes(searchName)
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
const hasChildren = Array.isArray(copy.children) && copy.children.length > 0
if (hasChildren || (nameMatch && routeMatch)) {
acc.push(copy)
}
return acc
}, [])
}
tableData.value = filterMenus(menuList.value)
loading.value = false
}
// 过滤后的表格数据改为来源于 getTableData 的结果
const filteredTableData = computed(() => tableData.value)
const isEdit = ref(false)
const formRef = ref<FormInstance>()
const dialogTitle = computed(() => {
// const type = labelPosition.value === 'menu' ? '菜单' : '权限'
return isEdit.value ? `编辑资源` : `新建资源`
})
const showDialog = (type: string, row: AppRouteRecord) => {
showModel('menu', row)
}
const handleChange = () => {}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
if (isEdit.value) {
const payload = { ...form, id: itemid.value }
await systemService.menuEdit(payload as any)
} else {
await systemService.menuAdd(form)
}
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
dialogVisible.value = false
// 刷新表格数据
getTableData()
} catch {
ElMessage.error(`${isEdit.value ? '编辑' : '新增'}失败`)
}
}
})
}
const itemid = ref('')
const showModel = (type: string, row?: any, padd: boolean = false, data?: any) => {
getsystemsList()
getparentList()
dialogVisible.value = true
// labelPosition.value = type
isEdit.value = false
// lockMenuType.value = lock
resetForm()
itemid.value = ''
if (data) {
isEdit.value = true
form.type = 'permission'
form.system_id = data.meta.systemId
form.parent_id = data.id
form.name = row.title
form.code = row.authMark
itemid.value = row.id
} else if (padd) {
nextTick(() => {
form.parent_id = row.id
form.system_id = row.meta.systemId
})
} else if (row && !padd) {
isEdit.value = true
nextTick(() => {
// 回显数据
itemid.value = row.id
form.system_id = row.meta.systemId
form.parent_id = row.meta.parentId == 0 ? '' : row.meta.parentId
form.name = row.name
form.type = row.meta.type
form.url = row.path
form.code = row.meta.code
form.icon = row.meta.icon
form.sort = row.meta.sort
form.is_home = row.meta.isHome
form.keep_alive = row.meta.keepAlive
form.is_hide = row.meta.isHide
form.is_hide_tab = row.meta.isHideTab
form.no_auth = row.meta.noAuth
form.status = row.meta.status
form.link = row.meta.link
form.open_type = row.meta.openType
})
}
}
const resetForm = () => {
formRef.value?.resetFields()
Object.assign(form, {
// 菜单
system_id: '',
type: 'menu', // 资源类型
parent_id: '',
name: '',
url: '',
code: '',
icon: '',
status: 1,
sort: 1,
is_home: 0,
keep_alive: 1,
is_hide: 0,
is_hide_tab: 0,
no_auth: 0,
link: '',
open_type: 'internal'
})
}
const deleteMenu = async (item: any) => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await systemService.menuDelete({ id: item.id })
ElMessage.success(res?.message || '删除成功!')
// 刷新表格数据
getTableData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const deleteAuth = async (item: any) => {
try {
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await systemService.menuDelete({ id: item.id })
ElMessage.success(res?.message || '删除成功!')
// 刷新表格数据
getTableData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 添加一个控制变量
// const lockMenuType = ref(false)
const isExpanded = ref(false)
const tableRef = ref()
// 数据请求并发保护计数器
let requestCounter = 0
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value) {
tableRef.value[isExpanded.value ? 'expandAll' : 'collapseAll']()
}
})
}
</script>
<style lang="scss" scoped>
.menu-page {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
:deep(.small-btn) {
height: 22px !important;
padding: 0 5px !important;
font-size: 10px !important;
margin-bottom: 5px;
}
:deep(.el-dialog__footer) {
z-index: inherit;
.el-button > span {
z-index: inherit;
}
}
}
</style>
+446
View File
@@ -0,0 +1,446 @@
<template>
<div class="page-content">
<ElRow>
<ElCol :xs="24" :sm="12" :lg="6">
<ElInput placeholder="角色名称"></ElInput>
</ElCol>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton v-ripple>搜索</ElButton>
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@role_add')">新增角色</ElButton>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="roleList"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="角色名称" prop="name" />
<ElTableColumn label="描述" prop="desc" />
<ElTableColumn label="启用" prop="enable">
<template #default="scope">
<ElTag :type="scope.row.status == 1 ? 'primary' : 'info'">
{{ scope.row.status == 1 ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at"></ElTableColumn>
<ElTableColumn fixed="right" label="操作" width="200px">
<template #default="scope">
<ElRow>
<el-button
link
@click="showPermissionDialog(scope.row)"
v-if="hasAuth('system@role_perm')"
>
菜单权限
</el-button>
<el-button
type="success"
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('system@role_update')"
>
编辑
</el-button>
<el-button
type="danger"
link
@click="deleteRole(scope.row)"
v-if="hasAuth('system@role_destroy')"
>删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
width="30%"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="角色名称" prop="name">
<ElInput v-model="form.name" />
</ElFormItem>
<ElFormItem label="描述" prop="desc">
<ElInput v-model="form.desc" type="textarea" :rows="3" />
</ElFormItem>
<ElFormItem label="排序" prop="sort">
<ElInput type="number" v-model="form.sort" />
</ElFormItem>
<ElFormItem label="启用">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<ElDialog
v-model="permissionDialog"
title="菜单权限"
width="520px"
align-center
class="el-dialog-border"
>
<ElScrollbar height="70vh">
<ElTree
ref="treeRef"
:data="processedMenuList"
show-checkbox
node-key="id"
:default-expand-all="isExpandAll"
:props="defaultProps"
@check="handleTreeCheck"
>
<template #default="{ data }">
<div style="display: flex; align-items: center">
<span v-if="data.isAuth">
{{ data.label }}
</span>
<span v-else>{{ defaultProps.label(data) }}</span>
</div>
</template>
</ElTree>
</ElScrollbar>
<template #footer>
<div class="dialog-footer">
<ElButton @click="toggleExpandAll">{{ isExpandAll ? '全部收起' : '全部展开' }}</ElButton>
<ElButton @click="toggleSelectAll" style="margin-left: 8px">{{
isSelectAll ? '取消全选' : '全部选择'
}}</ElButton>
<ElButton type="primary" @click="savePermission">保存</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { formatMenuTitle } from '@/router/utils/utils'
import { systemService } from '@/api/systemApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
// import { ButtonMoreItem } from '@/components/core/forms/ArtButtonMore.vue'
// import { Role, ROLE_LIST_DATA } from '@/mock/temp/formData'
const loading = ref(false)
defineOptions({ name: 'Role' })
const dialogVisible = ref(false)
const permissionDialog = ref(false)
// 权限树数据(来自接口 getPermissionMenuList)
const permissionMenuList = ref<any[]>([])
const treeRef = ref()
const isExpandAll = ref(true)
const isSelectAll = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const formRef = ref<FormInstance>()
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
des: [{ required: true, message: '请输入角色描述', trigger: 'blur' }]
})
const form = reactive<any>({
id: '',
name: '',
desc: '',
sort: 0,
status: 1
})
const roleList = ref<any[]>([])
onMounted(() => {
getTableData()
})
// const getTableData = () => {
// roleList.value = ROLE_LIST_DATA
// }
// 获取列表数据
const getTableData = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await systemService.getRoleList({
current: currentPage,
size: pageSize
})
roleList.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getTableData()
}
const dialogType = ref('add')
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
form.id = row.id
form.name = row.name
form.desc = row.desc
form.sort = row.sort
form.status = row.status
} else {
form.name = ''
form.desc = ''
form.sort = 0
form.status = 1
}
}
const roleId = ref('')
const showPermissionDialog = async (row: any) => {
permissionDialog.value = true
roleId.value = row.id
try {
const res = await systemService.getPermissionMenuList({ role_id: row.id })
// 兼容不同返回结构(data/records/直接数组)
const list = (res as any)?.data ?? (res as any)?.records ?? (res as any)
permissionMenuList.value = Array.isArray(list) ? list : []
// 根据接口返回的 selected 字段设置默认选中
nextTick(() => {
const keys = buildDefaultCheckedKeys(permissionMenuList.value)
treeRef.value?.setCheckedKeys(keys)
handleTreeCheck()
})
} catch (e) {
permissionMenuList.value = []
}
}
// 构建默认选中的 key 列表(与 node-key="id" 一致)
const buildDefaultCheckedKeys = (source: any[]): Array<string | number> => {
const keys: Array<string | number> = []
const walk = (node: any) => {
if (node?.meta?.selected) {
keys.push(node.id)
}
if (Array.isArray(node?.meta?.authList)) {
node.meta.authList.forEach((auth: any) => {
if (auth?.selected) {
keys.push(`${node.id}__auth__${auth.authMark}`)
}
})
}
if (Array.isArray(node?.children)) node.children.forEach(walk)
}
source.forEach(walk)
return keys
}
// 处理菜单数据,将 authList 转换为子节点
const processedMenuList = computed(() => {
const processNode = (node: any) => {
// 规范化 id,确保树节点唯一键与 node-key 对应
const processed: any = { ...node, id: node.id }
// 如果有 authList,将其转换为子节点
if (node.meta && node.meta.authList && node.meta.authList.length) {
const authNodes = node.meta.authList.map((auth: any) => ({
// 为权限子节点提供稳定唯一 id(数字+字符串),与 node-key="id" 匹配
id: `${node.id}__auth__${auth.authMark}`,
name: `${node.name}_${auth.authMark}`,
label: auth.title,
auth_mark: auth.authMark,
auth_id: auth.id,
isAuth: true,
checked: auth.selected || false
}))
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
}
// 递归处理子节点
if (processed.children) {
processed.children = processed.children.map(processNode)
}
return processed
}
const source = permissionMenuList.value || []
return source.map(processNode)
})
const defaultProps = {
children: 'children',
label: (data: any) => formatMenuTitle(data.meta?.title) || ''
}
const deleteRole = async (row: any) => {
ElMessageBox.confirm('确定删除该角色吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const res = await systemService.roleDelete({ id: row.id })
ElMessage.success(res?.message || '删除成功!')
getTableData()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
if (dialogType.value === 'add') {
// 新增角色逻辑
const res = await systemService.roleAdd(form)
ElMessage.success(res?.message || '提交成功!')
getTableData()
} else {
// 编辑角色逻辑
const res = await systemService.roleEdit(form)
ElMessage.success(res?.message || '提交成功!')
getTableData()
}
dialogVisible.value = false
formEl.resetFields()
}
const getSelectedIds = () => {
const tree = treeRef.value
if (!tree) return { menuIds: [], authIds: [] }
const checkedNodes = tree.getCheckedNodes?.(false) || []
const halfCheckedNodes = tree.getHalfCheckedNodes?.() || []
const allNodes = [...checkedNodes, ...halfCheckedNodes]
const menuIds = Array.from(
new Set(
allNodes
.filter((n: any) => !n.isAuth && n.id !== undefined && n.id !== null)
.map((n: any) => n.id)
)
)
const authIds = Array.from(
new Set(
checkedNodes.filter((n: any) => n.isAuth && n.auth_id != null).map((n: any) => n.auth_id)
)
)
const ids = Array.from(new Set([...(menuIds as any[]), ...(authIds as any[])]))
return { menuIds, authIds, ids }
}
const savePermission = async () => {
const { ids } = getSelectedIds()
// console.log('选中的菜单ID:', menuIds)
// console.log('选中的权限ID:', authIds)
// console.log('合并后的全部ID:', ids)
const res = await systemService.roleAuth({
role_id: roleId.value,
resources: ids
})
ElMessage.success(res?.message || '权限保存成功')
permissionDialog.value = false
}
const toggleExpandAll = () => {
const tree = treeRef.value
if (!tree) return
// 使用store.nodesMap直接控制所有节点的展开状态
const nodes = tree.store.nodesMap
for (const node in nodes) {
nodes[node].expanded = !isExpandAll.value
}
isExpandAll.value = !isExpandAll.value
}
const toggleSelectAll = () => {
const tree = treeRef.value
if (!tree) return
if (!isSelectAll.value) {
// 全选:获取所有节点的key并设置为选中
const allKeys = getAllNodeKeys(processedMenuList.value)
tree.setCheckedKeys(allKeys)
} else {
// 取消全选:清空所有选中
tree.setCheckedKeys([])
}
isSelectAll.value = !isSelectAll.value
}
const getAllNodeKeys = (nodes: any[]): Array<string | number> => {
const keys: Array<string | number> = []
const traverse = (nodeList: any[]) => {
nodeList.forEach((node) => {
if (node.id !== undefined && node.id !== null) {
keys.push(node.id)
}
if (node.children && node.children.length > 0) {
traverse(node.children)
}
})
}
traverse(nodes)
return keys
}
const handleTreeCheck = () => {
const tree = treeRef.value
if (!tree) return
// 使用树组件的getCheckedKeys方法获取选中的节点
const checkedKeys = tree.getCheckedKeys()
const allKeys = getAllNodeKeys(processedMenuList.value)
// 判断是否全选:选中的节点数量等于总节点数量
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
}
</script>
<style lang="scss" scoped>
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
</style>
+471
View File
@@ -0,0 +1,471 @@
<template>
<div class="page-content">
<div v-if="!isMobile">
<ElRow>
<!-- <ElCol :xs="24" :sm="12" :lg="6">
<ElInput placeholder="名称"></ElInput>
</ElCol> -->
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<!-- <ElButton v-ripple>搜索</ElButton> -->
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@systems_add')">新增</ElButton>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
<ElTableColumn label="系统编码" prop="code" />
<ElTableColumn label="系统名称" prop="name" />
<ElTableColumn label="系统类型" prop="type">
<template #default="scope">
<ElTag :type="getTypeTagType(scope.row.type)">
{{ getTypeText(scope.row.type) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="系统描述" prop="desc" show-overflow-tooltip />
<ElTableColumn label="域名URL" prop="domain_url" show-overflow-tooltip />
<ElTableColumn label="区分角色" prop="is_role">
<template #default="scope">
<ElTag :type="scope.row.is_role ? 'success' : 'info'">
{{ scope.row.is_role ? '是' : '否' }}
</ElTag>
</template>
</ElTableColumn>
<!-- <ElTableColumn label="排序" prop="sort" width="80" /> -->
<ElTableColumn label="状态" prop="status">
<template #default="scope">
<ElTag :type="scope.row.status ? 'primary' : 'info'">
{{ scope.row.status ? '启用' : '禁用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作">
<template #default="scope">
<ElRow>
<el-button
link
@click="showDialog('edit', scope.row)"
v-if="hasAuth('system@systems_update')"
>
编辑
</el-button>
<el-button
type="danger"
link
@click="deleteRole(scope.row)"
v-if="hasAuth('system@systems_destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增' : '编辑'"
width="60%"
align-center
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="160px">
<ElFormItem label="系统编码" prop="code">
<ElInput v-model="form.code" placeholder="请输入系统编码" />
</ElFormItem>
<ElFormItem label="系统名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入系统名称" />
</ElFormItem>
<ElFormItem label="系统类型" prop="type">
<ElSelect v-model="form.type" placeholder="请选择系统类型" style="width: 100%">
<ElOption label="管理后台" value="admin" />
<ElOption label="Web端" value="web" />
<ElOption label="小程序" value="miniapp" />
<ElOption label="工具" value="tool" />
<ElOption label="其他" value="other" />
</ElSelect>
</ElFormItem>
<ElFormItem label="系统描述" prop="desc">
<ElInput v-model="form.desc" type="textarea" :rows="3" placeholder="请输入系统描述" />
</ElFormItem>
<ElFormItem label="系统图标" prop="icon">
<ElUpload
v-model:file-list="iconFileList"
:show-file-list="false"
:headers="uploadHeaders"
:action="actionImageUplode"
:before-upload="beforeIconUpload"
:on-success="handleIconSuccess"
:on-error="handleIconError"
accept="image/*"
class="icon-uploader"
>
<div v-if="form.icon" class="icon-preview">
<img :src="file_url" alt="系统图标" class="icon-image" />
<div class="icon-overlay">
<el-icon><Plus /></el-icon>
</div>
</div>
<el-icon v-else class="icon-uploader-icon"><Plus /></el-icon>
</ElUpload>
</ElFormItem>
<ElFormItem label="域名URL" prop="domain_url">
<ElInput v-model="form.domain_url" placeholder="请输入域名URL" />
</ElFormItem>
<ElFormItem label="排序" prop="sort">
<ElInputNumber v-model="form.sort" :min="0" placeholder="请输入排序" />
</ElFormItem>
<ElFormItem label="是否区分角色权限" prop="is_role">
<ElSwitch v-model="form.is_role" :active-value="1" :inactive-value="0" />
</ElFormItem>
<ElFormItem label="状态" prop="status">
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
</div>
<div v-else class="mobile-hide-tip">
<p>该页面暂不支持移动端访问</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { systemService } from '@/api/systemApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'Type ' })
const loading = ref(false)
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const iconFileList = ref<any[]>([])
const typeList = ref<any[]>([])
const { width } = useWindowSize()
const isMobile = computed(() => width.value < 500)
import { useUploadHeaders } from '@/composables/useUploadHeaders'
const { uploadHeaders } = useUploadHeaders()
let actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload`
const rules = reactive<FormRules>({
code: [{ required: true, message: '请输入系统编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入系统名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择系统类型', trigger: 'change' }]
// desc: [
// { required: true, message: '请输入系统描述', trigger: 'blur' }
// ],
// icon: [
// { required: true, message: '请输入系统图标', trigger: 'blur' }
// ],
// domain_url: [
// { required: true, message: '请输入域名URL', trigger: 'blur' }
// ],
// is_role: [
// { required: true, message: '请选择是否区分角色权限', trigger: 'change' }
// ],
// sort: [
// { required: true, message: '请输入排序', trigger: 'blur' }
// ],
// status: [
// { required: true, message: '请选择状态', trigger: 'change' }
// ]
})
const file_url = ref('')
const form = reactive({
code: '',
name: '',
type: '',
desc: '',
icon: '',
domain_url: '',
is_role: false,
sort: 0,
status: true,
id: ''
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await systemService.getSystemList({
current: currentPage,
size: pageSize
})
tableData.value = records.map((item: any, index: number) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
// 系统类型相关辅助函数
const getTypeText = (type: string) => {
const typeMap: { [key: string]: string } = {
'admin': '管理后台',
'web': 'Web端',
'miniapp': '小程序',
'tool': '工具',
'other': '其他'
}
return typeMap[type] || type
}
const getTypeTagType = (type: string) => {
const typeMap: { [key: string]: 'primary' | 'success' | 'warning' | 'info' | 'danger' } = {
'admin': 'danger',
'web': 'primary',
'miniapp': 'success',
'tool': 'warning',
'other': 'info'
}
return typeMap[type] || 'info'
}
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type as 'add' | 'edit'
if (type === 'edit' && row) {
file_url.value = row.icon
// 编辑模式,填充所有字段
Object.assign(form, {
code: row.code || '',
name: row.name || '',
type: row.type || 'web',
desc: row.desc || '',
icon: row.icon || row.icon_url || '', // 优先使用 icon,其次 icon_url
domain_url: row.domain_url || '',
is_role: row.is_role !== undefined ? row.is_role : 0,
sort: row.sort || 0,
status: row.status !== undefined ? row.status : 1,
id: row.id || '' // 编辑时填充文件名,如果没有则使用 icon
})
} else {
file_url.value = ''
// 新增模式,重置所有字段
Object.assign(form, {
code: '',
name: '',
type: 'web',
desc: '',
icon: '',
domain_url: '',
is_role: 0,
sort: 0,
status: 1,
id: ''
})
}
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await systemService.systemDelete({
id: row.id,
})
ElMessage.success(message)
getWorkList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (valid) {
if (dialogType.value === 'add') {
const { message } = await systemService.systemAdd({
code: form.code,
name: form.name,
type: form.type,
desc: form.desc,
icon: form.icon,
domain_url: form.domain_url,
is_role: form.is_role,
sort: form.sort,
status: form.status
})
ElMessage.success(message)
} else {
const { message } = await systemService.systemEdit({
id: form.id,
code: form.code,
name: form.name,
type: form.type,
desc: form.desc,
icon: form.icon,
domain_url: form.domain_url,
is_role: form.is_role,
sort: form.sort,
status: form.status
})
ElMessage.success(message)
}
dialogVisible.value = false
getWorkList()
}
})
}
// 图标上传相关函数
const beforeIconUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return false
}
return true
}
const handleIconSuccess = (response: any, file: any) => {
// 接口返回 file_url 用于回显,file_name 用于传参
if (response.data) {
form.icon = response.data.file_url || response.data.file_name // 回显用 file_url
file_url.value = response.data.file_url
} else {
form.icon = response.file_url || response.file_name
file_url.value = response.file_url
}
ElMessage.success('图标上传成功')
}
const handleIconError = () => {
ElMessage.error('图标上传失败')
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.el-table {
margin-top: 20px;
}
.icon-uploader {
text-align: center;
}
.icon-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 64px;
height: 64px;
line-height: 64px;
text-align: center;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.3s;
}
.icon-uploader-icon:hover {
border-color: #409eff;
}
.icon-preview {
position: relative;
width: 64px;
height: 64px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
overflow: hidden;
}
.icon-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.icon-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.icon-overlay .el-icon {
color: white;
font-size: 20px;
}
.icon-preview:hover .icon-overlay {
opacity: 1;
}
.icon-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
text-align: center;
}
</style>
+860
View File
@@ -0,0 +1,860 @@
<template>
<div class="customer-page">
<!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
<el-form-item label="状态:" label-width="50px">
<el-select v-model="selectValue" style="min-width: 120px">
<el-option label="全部" value="" />
<el-option label="正常" value="1" />
<el-option label="离职" value="2" />
</el-select>
</el-form-item>
</div>
</div>
<div class="boxshadow">
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<!-- <el-table-column prop="id" label="用户ID" align="center" /> -->
<el-table-column prop="sn" label="SN" align="center" />
<el-table-column prop="follow" label="头像" align="center">
<template #default="scope">
<img
:src="scope.row.avatar"
alt="头像"
class="avatar"
@click="handleAvatarClick(scope.row.avatar)"
style="cursor: pointer"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="名字" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="corp_name" label="所属公司" align="center" />
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.status)">
{{ scope.row.status_txt }}
</span>
</template>
</el-table-column>
<el-table-column prop="customer_count" label="客户数量" align="center" />
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="showPermissionDialog(scope.row)"
v-if="hasAuth('user@direct_perm')"
>
<i class="iconfont-sys">&#xe723;</i>直接授权
</el-link>
<el-link
type="warning"
underline="never"
style="margin-right: 8px"
@click="showroleDialog(scope.row)"
v-if="hasAuth('user@role_system')"
>
<i class="iconfont-sys">&#xe830;</i>角色&系统
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="gotransfer(scope.row)"
v-if="hasAuth('user@customer_transfer')"
>
<i class="iconfont-sys"> &#xe794;</i>客户转移
</el-link>
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onEdit(scope.row)"
v-if="hasAuth('user@edit')"
>
<i class="iconfont-sys"> &#xe6bf;</i>编辑
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详情" width="75%">
<div>
<el-button type="primary" @click="plzyDialogVisiblefn">
<i class="iconfont-sys">&#xe794;</i> 批量转移
</el-button>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loadingkh"
:data="tableDatakh"
:currentPage="paginationkh.currentPage"
:pageSize="paginationkh.pageSize"
:total="paginationkh.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChangekh"
@current-change="handleCurrentChangekh"
lass="customer-table"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="grade_txt" label="等级" align="center" />
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</el-dialog>
<!-- 批量转移对话框 -->
<el-dialog v-model="plzyDialogVisible" title="批量转移" width="360px">
<el-form :model="plzyForm" label-width="0">
<el-form-item>
<el-select
v-model="plzyForm.user_id"
filterable
placeholder="请选择转移人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="plzyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleplzySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<el-dialog
title="编辑用户"
v-model="AddDialogVisible"
width="800px"
:close-on-click-modal="false"
>
<el-form :model="addForm" ref="addFormRef" label-width="80px" class="add-form">
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="用户名" prop="country">
<el-input v-model="addForm.name" placeholder="请输入用户名" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Email" prop="email">
<el-input v-model="addForm.email" placeholder="请输入Email" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="电话" prop="tel">
<el-input v-model="addForm.phone" placeholder="请输入电话" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="上级" prop="report_to">
<el-select
v-model="addForm.report_to"
filterable
placeholder="请选择上级"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="状态" prop="status">
<el-select v-model="addForm.status">
<el-option label="正常" :value="1" />
<el-option label="离职" :value="2" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="form-submit-row">
<el-button type="primary" size="large" style="width: 50%" @click="handleAddSubmit"
>提交</el-button
>
</div>
</el-form>
</el-dialog>
<!-- 图片预览组件 -->
<ElImageViewer
v-if="showImageViewer"
:url-list="[currentImageUrl]"
:hide-on-click-modal="true"
@close="showImageViewer = false"
/>
<ElDialog
v-model="permissionDialog"
title="菜单权限"
width="520px"
align-center
class="el-dialog-border"
>
<ElScrollbar height="70vh">
<ElTree
ref="treeRef"
:data="processedMenuList"
show-checkbox
node-key="id"
:default-expand-all="isExpandAll"
:props="defaultProps"
@check="handleTreeCheck"
>
<template #default="{ data }">
<div style="display: flex; align-items: center">
<span v-if="data.isAuth">
{{ data.label }}
</span>
<span v-else>{{ defaultProps.label(data) }}</span>
</div>
</template>
</ElTree>
</ElScrollbar>
<template #footer>
<div class="dialog-footer">
<ElButton @click="toggleExpandAll">{{ isExpandAll ? '全部收起' : '全部展开' }}</ElButton>
<ElButton @click="toggleSelectAll" style="margin-left: 8px">{{
isSelectAll ? '取消全选' : '全部选择'
}}</ElButton>
<ElButton type="primary" @click="savePermission">保存</ElButton>
</div>
</template>
</ElDialog>
<el-dialog
title="角色&系统设置"
v-model="roleDialogVisible"
width="700px"
:close-on-click-modal="false"
>
<el-form :model="roleForm" ref="addFormRef" label-width="80px" class="add-form">
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="角色" prop="status">
<ElSelect
v-model="roleForm.role_ids"
multiple
class="account-select"
placeholder="请选择角色"
>
<ElOption
v-for="account in roleOptions"
:key="account.id"
:label="account.name"
:value="account.id"
>
<span>{{ account.name }}</span>
</ElOption>
</ElSelect>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="系统" prop="status">
<ElSelect
v-model="roleForm.system_ids"
multiple
class="account-select"
placeholder="请选择系统"
>
<ElOption
v-for="account in systemsList"
:key="account.id"
:label="account.name"
:value="account.id"
>
<span>{{ account.name }}</span>
</ElOption>
</ElSelect>
</el-form-item>
</el-col>
</el-row>
<div class="form-submit-row">
<el-button type="primary" size="large" style="width: 70%" @click="handleRoleSubmit"
>提交</el-button
>
</div>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElImageViewer } from 'element-plus'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import { systemService } from '@/api/systemApi'
import { formatMenuTitle } from '@/router/utils/utils'
import { UserService } from '@/api/usersApi'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const keyword = ref<string>('')
const loading = ref(false)
const loadingkh = ref(false)
const detailDialogVisible = ref(false)
const plzyDialogVisible = ref(false)
const AddDialogVisible = ref(false)
const permissionDialog = ref(false)
const roleDialogVisible = ref(false)
// 权限树数据(来自接口 getPermissionMenuList)
const permissionMenuList = ref<any[]>([])
const treeRef = ref()
const isExpandAll = ref(true)
const isSelectAll = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const paginationkh = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const plzyForm = reactive({
customer_id: '',
user_id: ''
})
const zyForm = reactive({
customer_id: ''
})
const addForm = reactive({
id: '',
name: '',
email: '',
phone: '',
report_to: '',
status: ''
})
const roleForm = reactive({
user_id: '',
role_ids: '',
system_ids: ''
})
const userOptions = ref<any>()
//系统
const systemsList = ref<any>()
const getsystemsList = async () => {
const response = await UserService.getsystemsList()
systemsList.value = response || []
}
//角色下拉框
const roleOptions = ref<any>()
const getroleOptions = async () => {
const response = await systemService.getRoleSelectList()
roleOptions.value = response || []
}
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 表格数据
const tableData = ref<any[]>([])
const tableDatakh = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
})
// tabs 切换联动
watch([selectValue, keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (selectValue.value) {
params.status = selectValue.value
}
if (keyword.value) {
params.keyword = keyword.value
}
const { records, current, size, total } = await manageService.getUserList(params)
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
const gettableListkh = async (user_id: any) => {
loadingkh.value = true
try {
const { currentPage, pageSize } = paginationkh
const params: any = {
current: currentPage,
size: pageSize,
user_id: user_id
}
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableDatakh.value = data.map((item: any) => ({
...item
}))
Object.assign(paginationkh, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loadingkh.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
// 处理表格分页变化
const handleSizeChangekh = (newPageSize: number) => {
paginationkh.pageSize = newPageSize
gettableListkh(zyForm.customer_id)
}
const handleCurrentChangekh = (newCurrentPage: number) => {
paginationkh.currentPage = newCurrentPage
gettableListkh(zyForm.customer_id)
}
function statusClass(status: any) {
if (status === 1) return 'status-following'
if (status === 0) return 'status-lost'
return ''
}
const gotransfer = (row: any) => {
paginationkh.currentPage = 1
paginationkh.pageSize = 10
paginationkh.total = 0
gettableListkh(row.id)
zyForm.customer_id = row.id
detailDialogVisible.value = true
}
// 显示角色&系统对话框
const showroleDialog = (row: any) => {
getsystemsList()
getroleOptions()
roleDialogVisible.value = true
roleForm.user_id = row.id
roleForm.role_ids = row.role_ids || []
roleForm.system_ids = row.system_ids || []
}
// 提交角色&系统
const handleRoleSubmit = async () => {
const res = await manageService.userSetRole(roleForm)
ElMessage.success(res?.message || '角色&系统设置成功!')
roleDialogVisible.value = false
gettableList()
}
// 显示权限对话框
const userId = ref('')
const showPermissionDialog = async (row: any) => {
permissionDialog.value = true
userId.value = row.id
try {
const res = await systemService.getPermissionMenuList({ user_id: row.id })
// 兼容不同返回结构(data/records/直接数组)
const list = (res as any)?.data ?? (res as any)?.records ?? (res as any)
permissionMenuList.value = Array.isArray(list) ? list : []
// 根据接口返回的 selected 字段设置默认选中
nextTick(() => {
const keys = buildDefaultCheckedKeys(permissionMenuList.value)
treeRef.value?.setCheckedKeys(keys)
handleTreeCheck()
})
} catch (e) {
permissionMenuList.value = []
}
}
// 构建默认选中的 key 列表(与 node-key="id" 一致)
const buildDefaultCheckedKeys = (source: any[]): Array<string | number> => {
const keys: Array<string | number> = []
const walk = (node: any) => {
if (node?.meta?.selected) {
keys.push(node.id)
}
if (Array.isArray(node?.meta?.authList)) {
node.meta.authList.forEach((auth: any) => {
if (auth?.selected) {
keys.push(`${node.id}__auth__${auth.authMark}`)
}
})
}
if (Array.isArray(node?.children)) node.children.forEach(walk)
}
source.forEach(walk)
return keys
}
// 处理菜单数据,将 authList 转换为子节点
const processedMenuList = computed(() => {
const processNode = (node: any) => {
// 规范化 id,确保树节点唯一键与 node-key 对应
const processed: any = { ...node, id: node.id }
// 如果有 authList,将其转换为子节点
if (node.meta && node.meta.authList && node.meta.authList.length) {
const authNodes = node.meta.authList.map((auth: any) => ({
// 为权限子节点提供稳定唯一 id(数字+字符串),与 node-key="id" 匹配
id: `${node.id}__auth__${auth.authMark}`,
name: `${node.name}_${auth.authMark}`,
label: auth.title,
auth_mark: auth.authMark,
auth_id: auth.id,
isAuth: true,
checked: auth.selected || false
}))
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
}
// 递归处理子节点
if (processed.children) {
processed.children = processed.children.map(processNode)
}
return processed
}
const source = permissionMenuList.value || []
return source.map(processNode)
})
const defaultProps = {
children: 'children',
label: (data: any) => formatMenuTitle(data.meta?.title) || ''
}
const toggleExpandAll = () => {
const tree = treeRef.value
if (!tree) return
// 使用store.nodesMap直接控制所有节点的展开状态
const nodes = tree.store.nodesMap
for (const node in nodes) {
nodes[node].expanded = !isExpandAll.value
}
isExpandAll.value = !isExpandAll.value
}
const toggleSelectAll = () => {
const tree = treeRef.value
if (!tree) return
if (!isSelectAll.value) {
// 全选:获取所有节点的key并设置为选中
const allKeys = getAllNodeKeys(processedMenuList.value)
tree.setCheckedKeys(allKeys)
} else {
// 取消全选:清空所有选中
tree.setCheckedKeys([])
}
isSelectAll.value = !isSelectAll.value
}
const getAllNodeKeys = (nodes: any[]): Array<string | number> => {
const keys: Array<string | number> = []
const traverse = (nodeList: any[]) => {
nodeList.forEach((node) => {
if (node.id !== undefined && node.id !== null) {
keys.push(node.id)
}
if (node.children && node.children.length > 0) {
traverse(node.children)
}
})
}
traverse(nodes)
return keys
}
const handleTreeCheck = () => {
const tree = treeRef.value
if (!tree) return
// 使用树组件的getCheckedKeys方法获取选中的节点
const checkedKeys = tree.getCheckedKeys()
const allKeys = getAllNodeKeys(processedMenuList.value)
// 判断是否全选:选中的节点数量等于总节点数量
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
}
const getSelectedIds = () => {
const tree = treeRef.value
if (!tree) return { menuIds: [], authIds: [] }
const checkedNodes = tree.getCheckedNodes?.(false) || []
const halfCheckedNodes = tree.getHalfCheckedNodes?.() || []
const allNodes = [...checkedNodes, ...halfCheckedNodes]
const menuIds = Array.from(
new Set(
allNodes
.filter((n: any) => !n.isAuth && n.id !== undefined && n.id !== null)
.map((n: any) => n.id)
)
)
const authIds = Array.from(
new Set(
checkedNodes.filter((n: any) => n.isAuth && n.auth_id != null).map((n: any) => n.auth_id)
)
)
const ids = Array.from(new Set([...(menuIds as any[]), ...(authIds as any[])]))
return { menuIds, authIds, ids }
}
const savePermission = async () => {
const { ids } = getSelectedIds()
const res = await manageService.userMenuAuth({
user_id: userId.value,
resources: ids
})
ElMessage.success(res?.message || '权限保存成功')
permissionDialog.value = false
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
const plzyDialogVisiblefn = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
plzyDialogVisible.value = true
}
// 批量转移
const handleplzySubmit = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
plzyForm.customer_id = selectedRows.value.map((item) => item.id).join(',')
const res = await manageService.crmCustomerTransfer(plzyForm)
ElMessage.success(res?.message || '批量转移成功!')
plzyDialogVisible.value = false
detailDialogVisible.value = false
gettableList()
}
// 编辑用户
const onEdit = (row: any) => {
AddDialogVisible.value = true
addForm.id = row.id
addForm.name = row.name
addForm.email = row.email
addForm.phone = row.phone
addForm.status = row.status
addForm.report_to = row.report_to || ''
}
const handleAddSubmit = async () => {
const res = await manageService.userEdit(addForm)
ElMessage.success(res?.message || '编辑成功!')
AddDialogVisible.value = false
gettableList()
}
// 图片预览相关
const showImageViewer = ref(false)
const currentImageUrl = ref('')
const handleAvatarClick = (avatarUrl: string) => {
if (avatarUrl) {
currentImageUrl.value = avatarUrl
showImageViewer.value = true
}
}
</script>
<style scoped lang="scss">
:deep(.el-badge) {
--el-badge-size: 16px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.form-submit-row {
text-align: center;
}
</style>
+533
View File
@@ -0,0 +1,533 @@
<template>
<div class="page-content user">
<div class="content">
<div class="left-wrap">
<div class="user-wrap box-style">
<!-- <img class="bg" src="@imgs/user/bg.webp" /> -->
<img
class="avatar"
:src="userInfo.avatar != '' ? userInfo.avatar : '/src/assets/img/user/avatar.webp'"
/>
<h2 class="name">{{ userInfo.name }}</h2>
<p class="des">SN:{{ userInfo.sn }}</p>
<div class="outer-info">
<!-- <div class="info-row">
<span class="info-label">昵 称:</span>
<span class="info-value">{{ userInfo.phone }}</span>
</div> -->
<div class="info-row">
<span class="info-label">电 话:</span>
<span class="info-value">{{ userInfo.phone }}</span>
</div>
<div class="info-row">
<span class="info-label">邮 箱:</span>
<span class="info-value">{{ userInfo.email }}</span>
</div>
<div class="info-row">
<span class="info-label">公 司:</span>
<span class="info-value">{{ userInfo.corp_name }}</span>
</div>
<!-- <div class="info-row">
<span class="info-label">国 家:</span>
<span class="info-value">{{ userInfo.country_txt }}</span>
</div> -->
</div>
</div>
</div>
<div class="right-wrap">
<div class="info box-style">
<h1 class="title"><span class="border" style="margin-right: 5px"></span>基本信息</h1>
<ElForm
:model="form"
class="form"
ref="ruleFormRef"
label-width="86px"
label-position="left"
>
<ElFormItem label="头像" prop="avatar">
<el-upload
class="qrcode-upload"
:action="actionImageUplode"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadavatar"
:disabled="!isEdit"
>
<div class="qrcode-upload-box">
<img
class="avatar"
:src="
imageUrlavatar != '' ? imageUrlavatar : '/src/assets/img/user/avatar.webp'
"
/>
</div>
</el-upload>
</ElFormItem>
<ElRow>
<ElFormItem label="昵称" prop="name">
<el-input v-model="form.name" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="性别" prop="sex" class="right-input">
<ElSelect v-model="form.sex" placeholder="未知" :disabled="!isEdit">
<ElOption
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem label="联系方式" prop="phone">
<ElInput v-model="form.phone" disabled />
</ElFormItem>
<ElFormItem label="公司" prop="company" class="right-input">
<ElInput v-model="form.corp_name" disabled />
</ElFormItem>
<!-- <ElFormItem label="职务" prop="position" class="right-input">
<ElInput v-model="form.position" :disabled="!isEdit" />
</ElFormItem> -->
</ElRow>
<!-- <ElRow>
<ElFormItem label="公司" prop="company">
<ElInput v-model="form.corp_name" disabled />
</ElFormItem>
<ElFormItem label="国家" prop="address" class="right-input">
<el-select
v-model="form.country_id"
filterable
placeholder="请选择国家"
:disabled="!isEdit"
>
<el-option
v-for="item in countryOptions"
:key="item.id"
:label="item.cn_addr"
:value="item.id"
/>
</el-select>
</ElFormItem>
</ElRow> -->
<ElRow>
<ElFormItem label="邮箱" prop="email">
<ElInput v-model="form.email" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<!-- <ElRow>
<ElFormItem
label="二维码"
prop="socialize"
:class="isEdit ? 'qrcode-radio-group' : ''"
>
<el-radio-group v-model="form.socialize" v-if="isEdit">
<el-radio label="微信">微信</el-radio>
<el-radio label="WhatsApp">WhatsApp</el-radio>
</el-radio-group>
<template v-else>
<img
v-if="form.socialize_pic_full"
:src="form.socialize_pic_full"
class="qrcode-img"
/>
<span v-else class="qrcode-upload-text">暂无二维码</span>
</template>
</ElFormItem>
<ElFormItem v-if="isEdit" label="上传二维码" prop="address" class="right-input">
<el-upload
class="qrcode-upload"
:action="actionImageUplode"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadavatarqrcode"
>
<div class="qrcode-upload-box">
<img
v-if="form.socialize_pic_full"
:src="socialize_pic_full"
class="qrcode-img"
/>
<span v-else class="qrcode-upload-text">
<img :src="imageUrls" class="qrcode-img" />
</span>
</div>
</el-upload>
</ElFormItem>
</ElRow> -->
<div class="el-form-item-right">
<ElButton type="primary" style="width: 90px" v-ripple @click="edit">
<i v-if="!isEdit" class="iconfont-sys"> &#xe6bf;</i>
{{ isEdit ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
<div class="info box-style" style="margin-top: 20px">
<h1 class="title">更改密码</h1>
<ElForm :model="pwdForm" class="form" label-width="86px" label-position="left">
<ElFormItem label="当前密码" prop="password">
<ElInput
v-model="pwdForm.password"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="新密码" prop="password_new">
<ElInput
v-model="pwdForm.password_new"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="确认新密码" prop="confirmPassword">
<ElInput
v-model="pwdForm.confirmPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<div class="el-form-item-right">
<ElButton type="primary" style="width: 90px" v-ripple @click="editPwd">
<i v-if="!isEditPwd" class="iconfont-sys"> &#xe6bf;</i>
{{ isEditPwd ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ElForm, FormInstance } from 'element-plus'
// import imageUrl from '@/assets/img/crm/upimg.png'
import { CrmService } from '@/api/crmApi'
import { UserService } from '@/api/usersApi'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
import { useUserStore } from '@/store/modules/user'
const { uploadHeaders } = useUploadHeaders()
let actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload`
defineOptions({ name: 'UserCenter' })
// 定义客户详情类型
interface CustomerInfo {
[key: string]: any
}
// const imageUrls = ref(imageUrl)
const imageUrlavatar = ref('')
// const socialize_pic_full = ref('')
// const countryOptions = ref<any>()
const form = ref<CustomerInfo>({})
//获取公司 getCompanyList
// const companyList = ref<any>()
//国家
// const getarea = async () => {
// try {
// // 这里调用获取品牌列表的接口
// const res = await CrmService.getCountry()
// countryOptions.value = res||[]
// } catch (error) { countryOptions.value = [] }
// }
const { getUserInfo } = useUserStore()
const userInfo = ref<CustomerInfo>({})
const getuserInfo = async () => {
// const data = await CrmService.userget()
userInfo.value = getUserInfo.user
form.value = getUserInfo.user
imageUrlavatar.value = getUserInfo.user.avatar
// socialize_pic_full.value = data.socialize_pic_full
}
const isEdit = ref(false)
const isEditPwd = ref(false)
const pwdForm = reactive({
password: '',
password_new: '',
confirmPassword: ''
})
const ruleFormRef = ref<FormInstance>()
const options = [
{
value: '',
label: '未知'
},
{
value: '1',
label: '男'
},
{
value: '2',
label: '女'
}
]
onMounted(async () => {
// await getarea()
await getuserInfo()
})
const edit = async () => {
if (isEdit.value) {
const res = await UserService.editUserInfo(form.value)
ElMessage.success(res?.message || '操作成功')
getuserInfo()
}
isEdit.value = !isEdit.value
}
const editPwd = async () => {
if (isEditPwd.value) {
// 判断新密码和确认密码是否一致
if (pwdForm.password_new !== pwdForm.confirmPassword) {
ElMessage.error('新密码和确认密码不一致')
return
}
const res = await CrmService.savePassword(pwdForm)
ElMessage.success(res?.message || '操作成功')
}
isEditPwd.value = !isEditPwd.value
}
//头像上传
function handleUploadavatar(response: any) {
// 假设返回图片地址字段为 response.url 或 response.data.url
imageUrlavatar.value = response.url || (response.data && response.data.file_url) || ''
// 如需同步到 form.picture
form.value.avatar = response.url || (response.data && response.data.file_name) || ''
}
//二维码上传
// function handleUploadavatarqrcode(response: any) {
// // 假设返回图片地址字段为 response.url 或 response.data.url
// socialize_pic_full.value = response.url || (response.data && response.data.file_url) || ''
// imageUrls.value = response.url || (response.data && response.data.file_url) || ''
// // 如需同步到 form.picture
// form.value.socialize_pic = response.url || (response.data && response.data.file_name) || ''
// }
</script>
<style lang="scss">
.user {
.icon {
width: 1.4em;
height: 1.4em;
overflow: hidden;
vertical-align: -0.15em;
fill: currentcolor;
}
}
</style>
<style lang="scss" scoped>
:deep(.qrcode-radio-group .el-form-item__content){
height: 40px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.qrcode-img{
width: 80px;
height: 80px;
}
.page-content {
width: 100%;
height: 100%;
padding: 0 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
$box-radius: calc(var(--custom-radius) + 4px);
.box-style {
border: 1px solid var(--art-border-color);
}
.content {
position: relative;
display: flex;
justify-content: space-between;
margin-top: 10px;
.left-wrap {
width: 350px;
margin-right: 25px;
.user-wrap {
position: relative;
height: 400px;
padding: 35px 30px;
overflow: hidden;
text-align: center;
// background: var(--art-main-bg-color);
background: linear-gradient(180deg, #E9F0FF 0%, #FFFFFF 67.9%);
border-radius: $box-radius;
.avatar {
position: relative;
z-index: 10;
width: 80px;
height: 80px;
// margin-top: 120px;
object-fit: cover;
border: 2px solid #fff;
border-radius: 50%;
}
.name {
margin-top: 20px;
font-size: 22px;
font-weight: 400;
}
.des {
margin-top: 20px;
font-size: 14px;
}
.outer-info {
// width: 260px;
margin: 0 auto;
margin-top: 30px;
}
.info-row {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 10px;
font-size: 16px;
}
.info-label {
display: inline-block;
width: 100px;
color: #222;
text-align: right;
letter-spacing: 2px;
}
.info-value {
color: #333;
margin-left: 8px;
word-break: break-all;
}
.lables {
margin-top: 40px;
h3 {
font-size: 15px;
font-weight: 500;
}
> div {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-top: 15px;
> div {
padding: 3px 6px;
margin: 0 10px 10px 0;
font-size: 12px;
background: var(--art-main-bg-color);
border: 1px solid var(--art-border-color);
border-radius: 2px;
}
}
}
}
.gallery {
margin-top: 25px;
border-radius: 10px;
.item {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
}
.right-wrap {
flex: 1;
overflow: hidden;
border-radius: $box-radius;
.info {
background: var(--art-main-bg-color);
border-radius: $box-radius;
.title {
padding: 15px 25px;
font-size: 20px;
font-weight: 400;
color: var(--art-text-gray-800);
// border-bottom: 1px solid var(--art-border-color);
}
.form {
box-sizing: border-box;
padding: 30px 25px;
> .el-row {
.el-form-item {
width: calc(50% - 10px);
}
.el-input,
.el-select {
width: 100%;
}
}
.right-input {
margin-left: 20px;
}
.el-form-item-right {
display: flex;
align-items: center;
justify-content: end;
.el-button {
width: 110px !important;
}
}
}
}
}
}
}
@media only screen and (max-width: $device-ipad-vertical) {
.page-content {
.content {
display: block;
margin-top: 5px;
.left-wrap {
width: 100%;
}
.right-wrap {
width: 100%;
margin-top: 15px;
}
}
}
}
</style>
+4 -1
View File
@@ -35,7 +35,10 @@ onMounted(async () => {
const userInfo = await UserService.getUserInfo() const userInfo = await UserService.getUserInfo()
userStore.setUserInfo(userInfo) userStore.setUserInfo(userInfo)
userStore.setLoginStatus(true) userStore.setLoginStatus(true)
router.replace('/') userStore.sethome_page(data.home_url)
localStorage.setItem('home_page', data.home_url)
router.push(data.home_url)
// router.replace('/')
} catch (e: any) { } catch (e: any) {
error.value = e?.response?.data?.message || '登录失败,请重试' error.value = e?.response?.data?.message || '登录失败,请重试'
router.replace('/') router.replace('/')
+128 -59
View File
@@ -9,7 +9,7 @@
{{ isDark ? '&#xe6b5;' : '&#xe725;' }} {{ isDark ? '&#xe6b5;' : '&#xe725;' }}
</i> </i>
</div> --> </div> -->
<ElDropdown @command="changeLanguage" popper-class="langDropDownStyle"> <!-- <ElDropdown @command="changeLanguage" popper-class="langDropDownStyle">
<div class="btn language-btn"> <div class="btn language-btn">
<i class="iconfont-sys icon-language">&#xe611;</i> <i class="iconfont-sys icon-language">&#xe611;</i>
</div> </div>
@@ -26,7 +26,7 @@
</div> </div>
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
</ElDropdown> </ElDropdown> -->
</div> </div>
<div class="header"> <div class="header">
<ArtLogo class="icon" /> <ArtLogo class="icon" />
@@ -36,8 +36,16 @@
<div class="form"> <div class="form">
<!-- 登录方式切换 tab --> <!-- 登录方式切换 tab -->
<div class="login-type-tabs"> <div class="login-type-tabs">
<span :class="{active: activeLoginType === 'password'}" @click="activeLoginType = 'password'">账号密码登录</span> <!-- <span
<span :class="{active: activeLoginType === 'qrcode'}" @click="activeLoginType = 'qrcode'">飞书登录</span> :class="{ active: activeLoginType === 'password' }"
@click="activeLoginType = 'password'"
>账号密码登录</span
> -->
<span
:class="{ active: activeLoginType === 'qrcode' }"
@click="activeLoginType = 'qrcode'"
>飞书登录</span
>
</div> </div>
<div v-if="activeLoginType === 'password'"> <div v-if="activeLoginType === 'password'">
<ElForm <ElForm
@@ -47,8 +55,11 @@
@keyup.enter="handleSubmit" @keyup.enter="handleSubmit"
style="margin-top: 25px" style="margin-top: 25px"
> >
<ElFormItem prop="username" label="账号" > <ElFormItem prop="username" label="账号">
<ElInput :placeholder="$t('login.placeholder[0]')" v-model.trim="formData.username" /> <ElInput
:placeholder="$t('login.placeholder[0]')"
v-model.trim="formData.username"
/>
</ElFormItem> </ElFormItem>
<ElFormItem prop="password" label="密码"> <ElFormItem prop="password" label="密码">
<ElInput <ElInput
@@ -72,12 +83,24 @@
</ElOption> </ElOption>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem prop="company" label="系统">
<ElSelect v-model="formData.systems" class="account-select" @change="changesystems">
<ElOption
v-for="account in systemsList"
:key="account.id"
:label="account.name"
:value="account.id"
>
<span>{{ account.name }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<div class="drag-verify"> <div class="drag-verify">
<div class="drag-verify-content" :class="{ error: !isPassing && isClickPass }"> <div class="drag-verify-content" :class="{ error: !isPassing && isClickPass }">
<ArtDragVerify <ArtDragVerify
ref="dragVerify" ref="dragVerify"
v-model:value="isPassing" v-model:value="isPassing"
:width="width < 500 ? 260: 380" :width="width < 500 ? 260 : 380"
:text="$t('login.sliderText')" :text="$t('login.sliderText')"
textColor="var(--art-gray-800)" textColor="var(--art-gray-800)"
:successText="$t('login.sliderSuccessText')" :successText="$t('login.sliderSuccessText')"
@@ -93,9 +116,13 @@
<div class="login-bottom-row"> <div class="login-bottom-row">
<p> <p>
{{ $t('login.noAccount') }} {{ $t('login.noAccount') }}
<RouterLink :to="RoutesAlias.Register" style="color:#5D87FF;">{{ $t('login.register') }}</RouterLink> <RouterLink :to="RoutesAlias.Register" style="color: #5d87ff">{{
$t('login.register')
}}</RouterLink>
</p> </p>
<ElCheckbox v-model="formData.rememberPassword">{{ $t('login.rememberPwd') }}</ElCheckbox> <ElCheckbox v-model="formData.rememberPassword">{{
$t('login.rememberPwd')
}}</ElCheckbox>
</div> </div>
<div style="margin-top: 30px"> <div style="margin-top: 30px">
<ElButton <ElButton
@@ -111,10 +138,7 @@
</ElForm> </ElForm>
</div> </div>
<div v-else> <div v-else>
<ElForm <ElForm :model="formData" style="margin-top: 25px">
:model="formData"
style="margin-top: 25px"
>
<ElFormItem prop="company" label="公司"> <ElFormItem prop="company" label="公司">
<ElSelect v-model="formData.company" class="account-select" @change="changeCompany"> <ElSelect v-model="formData.company" class="account-select" @change="changeCompany">
<ElOption <ElOption
@@ -126,11 +150,23 @@
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem prop="company" label="系统">
<ElSelect v-model="formData.systems" class="account-select" @change="changesystems">
<ElOption
v-for="account in systemsList"
:key="account.id"
:label="account.name"
:value="account.id"
>
<span>{{ account.name }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
</ElForm> </ElForm>
<div class="qrcode-login" v-if="!isInFeishu"> <div class="qrcode-login" v-if="!isInFeishu">
<div id="login_container"></div> <div id="login_container"></div>
<p class="qrcode-tip">请使用飞书App扫码登录</p> <p class="qrcode-tip">请使用飞书App扫码登录</p>
</div> </div>
</div> </div>
</div> </div>
@@ -160,7 +196,7 @@
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isDark, systemThemeType } = storeToRefs(settingStore) const { systemThemeType } = storeToRefs(settingStore)
const dragVerify = ref() const dragVerify = ref()
@@ -169,25 +205,29 @@
const isPassing = ref(false) const isPassing = ref(false)
const isClickPass = ref(false) const isClickPass = ref(false)
const activeLoginType = ref<'password' | 'qrcode'>('qrcode') const activeLoginType = ref<'password' | 'qrcode'>('qrcode')
const isInFeishu = /Lark|Feishu/i.test(navigator.userAgent); const isInFeishu = /Lark|Feishu/i.test(navigator.userAgent)
// const isInFeishu = true; // const isInFeishu = true;
const systemName = AppConfig.systemInfo.name const systemName = AppConfig.systemInfo.name
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const companyid = ref('') const companyid = ref('')
companyid.value = useUserStore().companyid companyid.value = useUserStore().companyid
const iscompany = ref(false) const systemsid = ref('')
const { companyabbr } = useUserStore() systemsid.value = useUserStore().systemsid
//获取公司 getCompanyList const iscompany = ref(false)
const { companyabbr, systemsabbr } = useUserStore()
//获取公司 getCompanyList
const companyList = ref<any>() const companyList = ref<any>()
const feishuresponse=ref() //系统
const systemsList = ref<any>()
const feishuresponse = ref()
const formData = reactive({ const formData = reactive({
account: '', account: '',
company: useUserStore().companyid?Number(useUserStore().companyid):'', systems: useUserStore().systemsid ? Number(useUserStore().systemsid) : '',
company: useUserStore().companyid ? Number(useUserStore().companyid) : '',
username: '', username: '',
password: '', password: '',
rememberPassword: true rememberPassword: true
}) })
const rules = computed<FormRules>(() => ({ const rules = computed<FormRules>(() => ({
username: [{ required: true, message: t('login.placeholder[0]'), trigger: 'blur' }], username: [{ required: true, message: t('login.placeholder[0]'), trigger: 'blur' }],
password: [{ required: true, message: t('login.placeholder[1]'), trigger: 'blur' }], password: [{ required: true, message: t('login.placeholder[1]'), trigger: 'blur' }],
@@ -197,14 +237,15 @@
const loading = ref(false) const loading = ref(false)
const { width } = useWindowSize() const { width } = useWindowSize()
const getcompanyList = async () => { const getcompanyList = async () => {
try { const response = await UserService.getCompanyList()
// 这里调用获取品牌列表的接口 companyList.value = response || []
const response = await UserService.getCompanyList()
companyList.value = response || []
} catch (error) { }
} }
// 切换语言 const getsystemsList = async () => {
const { locale } = useI18n() const response = await UserService.getsystemsList()
systemsList.value = response || []
}
// 切换语言
const { locale } = useI18n()
const changeLanguage = (lang: LanguageEnum) => { const changeLanguage = (lang: LanguageEnum) => {
if (locale.value === lang) return if (locale.value === lang) return
@@ -213,30 +254,39 @@
} }
const getfeishuAppList = async () => { const getfeishuAppList = async () => {
try { feishuresponse.value = await UserService.getfeishuAppList({
// 这里调用获取品牌列表的接口 corp_id: useUserStore().companyid,
feishuresponse.value = await UserService.getfeishuAppList({ corp_id: companyid.value }) system_id: useUserStore().systemsid
if(activeLoginType.value=='qrcode'){ })
feishulogin() if (activeLoginType.value == 'qrcode') {
} feishulogin()
console.log(feishuresponse) }
} catch (error) { } }
//切换系统
const changesystems = (systems: any) => {
const systemsdata = systemsList.value.find((c: any) => c.id === systems)
systemsid.value = systems
userStore.setSystems(systems, systemsdata?.code)
if (useUserStore().companyid && systemsid.value) {
getfeishuAppList()
}
} }
//切换公司 //切换公司
const changeCompany = (company: any) => { const changeCompany = (company: any) => {
// if (companyid.value === company) return // if (companyid.value === company) return
const companyt = companyList.value.find((c: any) => c.id === company) const companyt = companyList.value.find((c: any) => c.id === company)
companyid.value = company companyid.value = company
iscompany.value = company iscompany.value = company
userStore.setCompany(company,companyt?.abbr) userStore.setCompany(company, companyt?.abbr)
getfeishuAppList() if (companyid.value && systemsid.value) {
getfeishuAppList()
}
} }
if(companyabbr!=''){ if (companyabbr != '' && systemsabbr != '') {
iscompany.value = true iscompany.value = true
getfeishuAppList() getfeishuAppList()
} }
const feishulogin = async () => { const feishulogin = async () => {
userStore.setLoginType('feishu') userStore.setLoginType('feishu')
if (isInFeishu) { if (isInFeishu) {
@@ -249,7 +299,7 @@
const waitForQRLogin = () => const waitForQRLogin = () =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
const check = () => { const check = () => {
// @ts-ignore // @ts-expect-error
if (window.QRLogin) resolve() if (window.QRLogin) resolve()
else setTimeout(check, 100) else setTimeout(check, 100)
} }
@@ -271,7 +321,7 @@
goto: feishuGotoUrl, goto: feishuGotoUrl,
width: '250', width: '250',
height: '250', height: '250',
style: 'width:350px;height:350px;border:none' style: 'width:350px;height:270px;border:none'
}) })
const handleMessage = function (event: MessageEvent) { const handleMessage = function (event: MessageEvent) {
if (QRLoginObj.matchOrigin(event.origin) && QRLoginObj.matchData(event.data)) { if (QRLoginObj.matchOrigin(event.origin) && QRLoginObj.matchData(event.data)) {
@@ -282,25 +332,25 @@
window.addEventListener('message', handleMessage, false) window.addEventListener('message', handleMessage, false)
} }
} }
watch( watch(
(activeLoginType as import('vue').Ref<string>), activeLoginType as import('vue').Ref<string>,
async (val) => { async (val) => {
if (val === 'qrcode') { if (val === 'qrcode') {
userStore.setLoginType('feishu') userStore.setLoginType('feishu')
if(companyid.value!=''){ if (companyid.value != '') {
feishulogin() feishulogin()
} }
}else{ } else {
userStore.setLoginType('upwd') userStore.setLoginType('upwd')
} }
}, },
{ immediate: true } { immediate: true }
) )
//飞书登录 //飞书登录
const feishuSubmit = async () => { const feishuSubmit = async () => {
try { try {
const { auth_url } = await UserService.getfeishuscope() const { auth_url } = await UserService.getfeishuscope()
window.location.href = auth_url window.location.href = auth_url
} catch (error) { } catch (error) {
ElMessage.error('登录失败,请稍后重试') ElMessage.error('登录失败,请稍后重试')
} finally { } finally {
@@ -328,7 +378,7 @@
// 登录请求 // 登录请求
const { username, password } = formData const { username, password } = formData
const { data } = await UserService.login({ const { data } = await UserService.login({
account: username, account: username,
password password
}) })
@@ -343,10 +393,11 @@
const userInfo = await UserService.getUserInfo() const userInfo = await UserService.getUserInfo()
userStore.setUserInfo(userInfo) userStore.setUserInfo(userInfo)
userStore.setLoginStatus(true) userStore.setLoginStatus(true)
userStore.sethome_page(data.home_url)
localStorage.setItem('home_page', data.home_url)
// 登录成功处理 // 登录成功处理
showLoginSuccessNotice() showLoginSuccessNotice()
router.push(HOME_PAGE) router.push(data.home_url)
} catch (error) { } catch (error) {
// 处理 HttpError // 处理 HttpError
if (error instanceof HttpError) { if (error instanceof HttpError) {
@@ -383,15 +434,33 @@
onMounted(() => { onMounted(() => {
getcompanyList() getcompanyList()
getsystemsList()
// 自动读取 query 参数并存入 userStore // 自动读取 query 参数并存入 userStore
const companyid = Array.isArray(route.query.corp_id) ? route.query.corp_id[0] : route.query.corp_id const companyid = Array.isArray(route.query.corp_id)
const companyabbr = Array.isArray(route.query.corp_abbr) ? route.query.corp_abbr[0] : route.query.corp_abbr ? route.query.corp_id[0]
: route.query.corp_id
const companyabbr = Array.isArray(route.query.corp_abbr)
? route.query.corp_abbr[0]
: route.query.corp_abbr
// const system_id = Array.isArray(route.query.system_id)
// ? route.query.system_id[0]
// : route.query.system_id
// const system_code = Array.isArray(route.query.system_code)
// ? route.query.system_code[0]
// : route.query.system_code
if (companyid) { if (companyid) {
userStore.setCompany(companyid as string, (companyabbr as string) || '') userStore.setCompany(companyid as string, (companyabbr as string) || '')
formData.company = Number(companyid) formData.company = Number(companyid)
} }
// if (system_id) {
// userStore.setSystems(companyid as string, (system_code as string) || '')
// formData.systems = Number(system_id)
// }
// 只有两个 id 都有值时才调用
if (companyid && systemsid.value) {
getfeishuAppList()
}
}) })
// 切换主题 // 切换主题
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
+222 -211
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="customer-form-page"> <div class="customer-form-page">
<div class="form-header"> <div class="form-header">
<el-link :underline="false" @click="goBack"> <el-link underline="never" @click="goBack">
<i class="iconfont-sys icon-reverse">&#xe772;</i> 返回 <i class="iconfont-sys icon-reverse">&#xe772;</i> 返回
</el-link> </el-link>
<span class="border"></span><span class="form-title">新增客户资料</span> <span class="border"></span><span class="form-title">新增客户资料</span>
</div> </div>
@@ -18,19 +18,29 @@
<el-col :span="24"> <el-col :span="24">
<el-form-item label="客户渠道" prop="channel_id"> <el-form-item label="客户渠道" prop="channel_id">
<el-select v-model="form.channel_id" filterable placeholder="请选择客户渠道"> <el-select v-model="form.channel_id" filterable placeholder="请选择客户渠道">
<el-option v-for="item in channelOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in channelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="客户商机" prop="magnitude"> <el-form-item label="客户商机" prop="magnitude">
<el-select v-model="form.magnitude" placeholder="请选择客户商机"> <el-select v-model="form.magnitude" placeholder="请选择客户商机">
<el-option v-for="item in opportunityOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in opportunityOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="客户名称" prop="contacts"> <el-form-item label="客户名称" prop="contacts">
<el-input v-model="form.contacts" placeholder="请输入客户名称" /> <el-input v-model="form.contacts" placeholder="请输入客户名称" />
</el-form-item> </el-form-item>
<el-form-item label="客户性别" prop="sex" > <el-form-item label="客户性别" prop="sex">
<el-radio-group v-model="form.sex" style="justify-content: flex-end;width: 100%;"> <el-radio-group v-model="form.sex">
<el-radio label="0">未知</el-radio> <el-radio label="0">未知</el-radio>
<el-radio label="1">男</el-radio> <el-radio label="1">男</el-radio>
<el-radio label="2">女</el-radio> <el-radio label="2">女</el-radio>
@@ -52,16 +62,31 @@
</el-form-item> --> </el-form-item> -->
<el-form-item label="客户分级" prop="grade"> <el-form-item label="客户分级" prop="grade">
<el-select v-model="form.grade" placeholder="请选择客户分级"> <el-select v-model="form.grade" placeholder="请选择客户分级">
<el-option v-for="item in levelOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in levelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="国家" prop="country_id"> <el-form-item label="国家" prop="country_id">
<el-select v-model="form.country_id" filterable placeholder="请选择国家"> <el-select v-model="form.country_id" filterable placeholder="请选择国家">
<el-option v-for="item in countryOptions" :key="item.id" :label="item.cn_addr" :value="item.id" /> <el-option
v-for="item in countryOptions"
:key="item.id"
:label="item.cn_addr"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="公司" prop="company"> <el-form-item label="公司" prop="company">
<el-input v-model="form.company" placeholder="请输入公司" @click="showMapDialog = true" readonly /> <el-input
v-model="form.company"
placeholder="请输入公司"
@click="showMapDialog = true"
readonly
/>
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="describe"> <el-form-item label="备注" prop="describe">
<el-input v-model="form.describe" type="textarea" :rows="3" placeholder="请输入备注" /> <el-input v-model="form.describe" type="textarea" :rows="3" placeholder="请输入备注" />
@@ -69,15 +94,15 @@
</el-col> </el-col>
</el-row> </el-row>
<el-form-item class="submit-row"> <el-form-item class="submit-row">
<el-button type="primary" style="width:100%" @click="submitForm">提交</el-button> <el-button type="primary" style="width: 100%" @click="submitForm">提交</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<!-- 谷歌地图选点弹窗 --> <!-- 谷歌地图选点弹窗 -->
<el-dialog v-model="showMapDialog" title="选择公司地址" width="800px"> <el-dialog v-model="showMapDialog" title="选择公司地址" width="800px">
<div style="height:400px;position:relative;"> <div style="height: 400px; position: relative">
<!-- <input id="map-search" type="text" placeholder="搜索地点" style="position:absolute;z-index:2;top:10px;left:10px;width:300px;" /> --> <!-- <input id="map-search" type="text" placeholder="搜索地点" style="position:absolute;z-index:2;top:10px;left:10px;width:300px;" /> -->
<div id="google-map" style="width:100%;height:100%;"></div> <div id="google-map" style="width: 100%; height: 100%"></div>
</div> </div>
<template #footer> <template #footer>
<el-button @click="showMapDialog = false">取消</el-button> <el-button @click="showMapDialog = false">取消</el-button>
@@ -87,221 +112,207 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
const router = useRouter() const router = useRouter()
const formRef = ref() const formRef = ref()
const form = ref({ const form = ref({
channel_id: '', channel_id: '',
magnitude: '', magnitude: '',
contacts: '', contacts: '',
sex: '0', sex: '0',
phone: '', phone: '',
email: '', email: '',
WhatsApp: '', WhatsApp: '',
grade: '', grade: '',
country_id: '', country_id: '',
company: '', company: '',
describe: '' describe: ''
})
const rules = {
channel: [{ required: true, message: '请选择客户渠道', trigger: 'change' }],
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
// car_type_id: [{ required: true, message: '请选择客户偏好', trigger: 'change' }]
}
const channelOptions = ref<any>()
const opportunityOptions = ref<any>()
const intentionOptions = ref<any>()
const levelOptions = ref<any>()
const countryOptions = ref<any>()
onMounted(() => {
getgetChannel()
getMagnitude()
getGrade()
getarea()
})
//渠道
const getgetChannel = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getChannel()
channelOptions.value = res||[]
} catch (error) { channelOptions.value = [] }
}
//商机
const getMagnitude = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getMagnitude()
opportunityOptions.value = res||[]
} catch (error) { opportunityOptions.value = [] }
}
//客户等级
const getGrade = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getGrade()
levelOptions.value = res||[]
} catch (error) { levelOptions.value = [] }
}
//国家
const getarea = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getCountry()
countryOptions.value = res||[]
} catch (error) { countryOptions.value = [] }
}
function goBack() {
router.back()
}
function submitForm() {
formRef.value.validate(async (valid: boolean) => {
if (valid) {
try {
const res = await CrmService.customerAdd(form.value)
ElMessage.success(res?.message || '提交成功!')
router.push('/crmpc/customer')
} catch (e: any) {
ElMessage.error(e?.message || '提交失败')
}
}
}) })
}
const showMapDialog = ref(false) const rules = {
const selectedAddress = ref('') channel: [{ required: true, message: '请选择客户渠道', trigger: 'change' }],
const selectedLatLng = ref({ lat: null, lng: null }) name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }]
// car_type_id: [{ required: true, message: '请选择客户偏好', trigger: 'change' }]
function confirmMapSelect() {
form.value.company = selectedAddress.value
// 你也可以保存 selectedLatLng.value 到 form
showMapDialog.value = false
}
let map: any, marker: any, autocomplete: any
function initMap() {
const google = (window as any).google;
const input = document.getElementById('map-search') as HTMLInputElement;
if (!input) {
setTimeout(initMap, 100);
return;
} }
map = new google.maps.Map(document.getElementById('google-map'), {
center: { lat: 39.9042, lng: 116.4074 }, // 默认北京
zoom: 12
})
marker = new google.maps.Marker({ const channelOptions = ref<any>()
map, const opportunityOptions = ref<any>()
draggable: true const levelOptions = ref<any>()
const countryOptions = ref<any>()
onMounted(() => {
getgetChannel()
getMagnitude()
getGrade()
getarea()
}) })
//渠道
const getgetChannel = async () => {
const res = await CrmService.getChannel()
channelOptions.value = res || []
}
//商机
const getMagnitude = async () => {
const res = await CrmService.getMagnitude()
opportunityOptions.value = res || []
}
//客户等级
const getGrade = async () => {
const res = await CrmService.getGrade()
levelOptions.value = res || []
}
//国家
const getarea = async () => {
const res = await CrmService.getCountry()
countryOptions.value = res || []
}
function goBack() {
router.back()
}
map.addListener('click', (e: any) => { function submitForm() {
marker.setPosition(e.latLng) formRef.value.validate(async (valid: boolean) => {
getAddressByLatLng(e.latLng) if (valid) {
}) try {
const res = await CrmService.customerAdd(form.value)
marker.addListener('dragend', (e: any) => { ElMessage.success(res?.message || '提交成功!')
getAddressByLatLng(e.latLng) router.push('/crmpc/customer')
}) } catch (e: any) {
ElMessage.error(e?.message || '提交失败')
autocomplete = new google.maps.places.Autocomplete(input)
autocomplete.bindTo('bounds', map)
autocomplete.addListener('place_changed', () => {
const place = autocomplete.getPlace()
if (!place.geometry) return
map.panTo(place.geometry.location)
marker.setPosition(place.geometry.location)
getAddressByLatLng(place.geometry.location)
})
}
function getAddressByLatLng(latlng: any) {
const google = (window as any).google;
const geocoder = new google.maps.Geocoder()
geocoder.geocode({ location: latlng }, (results: any, status: any) => {
if (status === 'OK' && results[0]) {
selectedAddress.value = results[0].formatted_address
selectedLatLng.value = {
lat: latlng.lat(),
lng: latlng.lng()
}
}
})
}
watch(showMapDialog, (val) => {
if (val) {
nextTick(() => {
setTimeout(() => {
const google = (window as any).google;
if (!google || !google.maps) {
const script = document.createElement('script')
script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyCiQdUlA2QrEBEaZthoj7B18HTz6sxU_3I&libraries=places`
script.async = true
script.onload = initMap
document.head.appendChild(script)
} else {
initMap()
} }
}, 100) }
}) })
} }
})
const showMapDialog = ref(false)
const selectedAddress = ref('')
const selectedLatLng = ref({ lat: null, lng: null })
function confirmMapSelect() {
form.value.company = selectedAddress.value
// 你也可以保存 selectedLatLng.value 到 form
showMapDialog.value = false
}
let map: any, marker: any, autocomplete: any
function initMap() {
const google = (window as any).google
const input = document.getElementById('map-search') as HTMLInputElement
if (!input) {
setTimeout(initMap, 100)
return
}
map = new google.maps.Map(document.getElementById('google-map'), {
center: { lat: 39.9042, lng: 116.4074 }, // 默认北京
zoom: 12
})
marker = new google.maps.Marker({
map,
draggable: true
})
map.addListener('click', (e: any) => {
marker.setPosition(e.latLng)
getAddressByLatLng(e.latLng)
})
marker.addListener('dragend', (e: any) => {
getAddressByLatLng(e.latLng)
})
autocomplete = new google.maps.places.Autocomplete(input)
autocomplete.bindTo('bounds', map)
autocomplete.addListener('place_changed', () => {
const place = autocomplete.getPlace()
if (!place.geometry) return
map.panTo(place.geometry.location)
marker.setPosition(place.geometry.location)
getAddressByLatLng(place.geometry.location)
})
}
function getAddressByLatLng(latlng: any) {
const google = (window as any).google
const geocoder = new google.maps.Geocoder()
geocoder.geocode({ location: latlng }, (results: any, status: any) => {
if (status === 'OK' && results[0]) {
selectedAddress.value = results[0].formatted_address
selectedLatLng.value = {
lat: latlng.lat(),
lng: latlng.lng()
}
}
})
}
watch(showMapDialog, (val) => {
if (val) {
nextTick(() => {
setTimeout(() => {
const google = (window as any).google
if (!google || !google.maps) {
const script = document.createElement('script')
script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyCiQdUlA2QrEBEaZthoj7B18HTz6sxU_3I&libraries=places`
script.async = true
script.onload = initMap
document.head.appendChild(script)
} else {
initMap()
}
}, 100)
})
}
})
</script> </script>
<style scoped> <style scoped>
.border{ .border {
margin: 0px 10px 0px 40px;
margin: 0px 10px 0px 40px; }
} .submit-row .el-form-item__content {
.submit-row .el-form-item__content { display: flex;
display: flex; justify-content: center;
justify-content: center; }
} .icon-reverse {
.icon-reverse { display: inline-block;
display: inline-block; transform: rotate(270deg); /* 旋转180度 */
transform: rotate(270deg); /* 旋转180度 */ }
} .customer-form-page {
.customer-form-page { min-height: 100vh;
min-height: 100vh; background: #fff;
background: #fff; display: flex;
display: flex; flex-direction: column;
flex-direction: column; align-items: center;
align-items: center; padding-top: 18px;
padding-top: 18px; }
} .form-header {
.form-header {
width: 98%; width: 98%;
margin-bottom: 24px; margin-bottom: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 18px; font-size: 18px;
justify-content: flex-start; /* 新增,左对齐 */ justify-content: flex-start; /* 新增,左对齐 */
} }
.form-title{ .form-title {
font-weight: bold; font-weight: bold;
color: #222; color: #222;
} }
.customer-form { .customer-form {
width: 600px; width: 600px;
background: #fff; background: #fff;
padding: 10px 24px 16px 24px; padding: 10px 24px 16px 24px;
} }
.el-form-item { .el-form-item {
margin-bottom: 18px; margin-bottom: 18px;
} }
.el-form-item__label { .el-form-item__label {
color: #222; color: #222;
font-weight: 500; font-weight: 500;
} }
</style> </style>
+170 -140
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="customer-form-page"> <div class="customer-form-page">
<div class="form-header"> <div class="form-header">
<el-link :underline="false" @click="goBack"> <el-link underline="never" @click="goBack">
<i class="iconfont-sys icon-reverse">&#xe772;</i> 返回 <i class="iconfont-sys icon-reverse">&#xe772;</i> 返回
</el-link> </el-link>
<span class="border"></span><span class="form-title">编辑客户资料</span> <span class="border"></span><span class="form-title">编辑客户资料</span>
</div> </div>
@@ -18,19 +18,29 @@
<el-col :span="24"> <el-col :span="24">
<el-form-item label="客户渠道" prop="channel_id"> <el-form-item label="客户渠道" prop="channel_id">
<el-select v-model="form.channel_id" filterable placeholder="请选择客户渠道"> <el-select v-model="form.channel_id" filterable placeholder="请选择客户渠道">
<el-option v-for="item in channelOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in channelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="客户商机" prop="magnitude"> <el-form-item label="客户商机" prop="magnitude">
<el-select v-model="form.magnitude" placeholder="请选择客户商机"> <el-select v-model="form.magnitude" placeholder="请选择客户商机">
<el-option v-for="item in opportunityOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in opportunityOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="客户名称" prop="contacts"> <el-form-item label="客户名称" prop="contacts">
<el-input v-model="form.contacts" placeholder="请输入客户名称" /> <el-input v-model="form.contacts" placeholder="请输入客户名称" />
</el-form-item> </el-form-item>
<el-form-item label="客户性别" prop="sex" > <el-form-item label="客户性别" prop="sex">
<el-radio-group v-model="form.sex" style="justify-content: flex-end;width: 100%;"> <el-radio-group v-model="form.sex">
<el-radio :label="0">未知</el-radio> <el-radio :label="0">未知</el-radio>
<el-radio :label="1">男</el-radio> <el-radio :label="1">男</el-radio>
<el-radio :label="2">女</el-radio> <el-radio :label="2">女</el-radio>
@@ -52,12 +62,22 @@
</el-form-item> --> </el-form-item> -->
<el-form-item label="客户分级" prop="grade"> <el-form-item label="客户分级" prop="grade">
<el-select v-model="form.grade" placeholder="请选择客户分级"> <el-select v-model="form.grade" placeholder="请选择客户分级">
<el-option v-for="item in levelOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in levelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="国家" prop="country_id"> <el-form-item label="国家" prop="country_id">
<el-select v-model="form.country_id" filterable placeholder="请选择国家"> <el-select v-model="form.country_id" filterable placeholder="请选择国家">
<el-option v-for="item in countryOptions" :key="item.id" :label="item.cn_addr" :value="item.id" /> <el-option
v-for="item in countryOptions"
:key="item.id"
:label="item.cn_addr"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="公司" prop="company"> <el-form-item label="公司" prop="company">
@@ -69,153 +89,163 @@
</el-col> </el-col>
</el-row> </el-row>
<el-form-item class="submit-row"> <el-form-item class="submit-row">
<el-button type="primary" style="width:100%" @click="submitForm">提交</el-button> <el-button type="primary" style="width: 100%" @click="submitForm">提交</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
const route = useRoute() const route = useRoute()
const customerId = route.query.id const customerId = route.query.id
const router = useRouter() const router = useRouter()
const formRef = ref() const formRef = ref()
const rules = {
channel: [{ required: true, message: '请选择客户渠道', trigger: 'change' }],
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }]
// car_type_id: [{ required: true, message: '请选择客户偏好', trigger: 'change' }]
}
const rules = { const channelOptions = ref<any>()
channel: [{ required: true, message: '请选择客户渠道', trigger: 'change' }], const opportunityOptions = ref<any>()
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }], const levelOptions = ref<any>()
// car_type_id: [{ required: true, message: '请选择客户偏好', trigger: 'change' }] const countryOptions = ref<any>()
} onMounted(async () => {
await getgetChannel()
const channelOptions = ref<any>() await getMagnitude()
const opportunityOptions = ref<any>() await getGrade()
const intentionOptions = ref<any>() await getarea()
const levelOptions = ref<any>() await getdetilfn()
const countryOptions = ref<any>()
onMounted(async () => {
await getgetChannel()
await getMagnitude()
await getGrade()
await getarea()
await getdetilfn()
})
// 定义客户详情类型
interface CustomerInfo {
contacts?: string
// 可根据实际接口补充更多字段
[key: string]: any
}
const form = ref<CustomerInfo>({})
const customerInfo = ref<CustomerInfo>({})
//获取详情
const getdetilfn = async() => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getcustomerdetail({id:customerId})
customerInfo.value = res
form.value = res
} catch (error) { }
}
//渠道
const getgetChannel = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getChannel()
channelOptions.value = res||[]
} catch (error) { channelOptions.value = [] }
}
//商机
const getMagnitude = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getMagnitude()
opportunityOptions.value = res||[]
} catch (error) { opportunityOptions.value = [] }
}
//客户等级
const getGrade = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getGrade()
levelOptions.value = res||[]
} catch (error) { levelOptions.value = [] }
}
//国家
const getarea = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getCountry()
countryOptions.value = res||[]
} catch (error) { countryOptions.value = [] }
}
function goBack() {
router.back()
}
function submitForm() {
formRef.value.validate(async (valid: boolean) => {
if (valid) {
try {
const res = await CrmService.customerAdd(form.value)
ElMessage.success(res?.message || '提交成功!')
router.push('/crmpc/customer')
} catch (e: any) {
ElMessage.error(e?.message || '提交失败')
}
}
}) })
} // 定义客户详情类型
interface CustomerInfo {
contacts?: string
// 可根据实际接口补充更多字段
[key: string]: any
}
const form = ref<CustomerInfo>({})
const customerInfo = ref<CustomerInfo>({})
//获取详情
const getdetilfn = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getcustomerdetail({ id: customerId })
res.channel_id = res.channel_id==0 ? '' : res.channel_id
res.country_id = res.country_id==0 ? '' : res.country_id
customerInfo.value = res
form.value = res
} catch (error) {
console.log(error)
}
}
//渠道
const getgetChannel = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getChannel()
channelOptions.value = res || []
} catch (error) {
console.log(error)
}
}
//商机
const getMagnitude = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getMagnitude()
opportunityOptions.value = res || []
} catch (error) {
console.log(error)
}
}
//客户等级
const getGrade = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getGrade()
levelOptions.value = res || []
} catch (error) {
console.log(error)
}
}
//国家
const getarea = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getCountry()
countryOptions.value = res || []
} catch (error) {
console.log(error)
}
}
function goBack() {
router.back()
}
function submitForm() {
formRef.value.validate(async (valid: boolean) => {
if (valid) {
try {
const res = await CrmService.customerAdd(form.value)
ElMessage.success(res?.message || '提交成功!')
router.push('/crmpc/customer')
} catch (e: any) {
ElMessage.error(e?.message || '提交失败')
}
}
})
}
</script> </script>
<style scoped> <style scoped>
.border{ .border {
margin: 0px 10px 0px 40px;
margin: 0px 10px 0px 40px; }
} .submit-row .el-form-item__content {
.submit-row .el-form-item__content { display: flex;
display: flex; justify-content: center;
justify-content: center; }
} .icon-reverse {
.icon-reverse { display: inline-block;
display: inline-block; transform: rotate(270deg); /* 旋转180度 */
transform: rotate(270deg); /* 旋转180度 */ }
} .customer-form-page {
.customer-form-page { min-height: 100vh;
min-height: 100vh; background: #fff;
background: #fff; display: flex;
display: flex; flex-direction: column;
flex-direction: column; align-items: center;
align-items: center; padding-top: 18px;
padding-top: 18px; }
} .form-header {
.form-header {
width: 98%; width: 98%;
margin-bottom: 24px; margin-bottom: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 18px; font-size: 18px;
justify-content: flex-start; /* 新增,左对齐 */ justify-content: flex-start; /* 新增,左对齐 */
} }
.form-title{ .form-title {
font-weight: bold; font-weight: bold;
color: #222; color: #222;
} }
.customer-form { .customer-form {
width: 600px; width: 600px;
background: #fff; background: #fff;
padding: 10px 24px 16px 24px; padding: 10px 24px 16px 24px;
} }
.el-form-item { .el-form-item {
margin-bottom: 18px; margin-bottom: 18px;
} }
.el-form-item__label { .el-form-item__label {
color: #222; color: #222;
font-weight: 500; font-weight: 500;
} }
</style> </style>
+388 -359
View File
@@ -1,41 +1,53 @@
<template> <template>
<div class="follow-add-page"> <div class="follow-add-page">
<div class="boxshadow pad20"> <div class="boxshadow pad20">
<el-link :underline="false" @click="goBack"> <el-link underline="never" @click="goBack">
<i class="iconfont-sys icon-reverse">&#xe772;</i> 返回 <i class="iconfont-sys icon-reverse">&#xe772;</i> 返回
</el-link> </el-link>
<span class="border"></span><span class="edit-title" style="margin-left: 12px;">添加跟进</span> <span class="border"></span><span class="edit-title" style="margin-left: 12px">添加跟进</span>
</div> </div>
<div class="edit-header boxshadow" style="position:relative;"> <div class="edit-header boxshadow" style="position: relative">
<!-- 右上角三角形角标 --> <!-- 右上角三角形角标 -->
<div class="corner-tag"><span class="corner-text">{{customerInfo.follow_txt}}</span></div> <div class="corner-tag"
<el-row :gutter="20" style="flex: 1;"> ><span class="corner-text">{{ customerInfo.follow_txt }}</span></div
<el-col :sm="24" :md="6" :lg="4"> >
<span>客户名称:{{ customerInfo.contacts }}</span> <el-row :gutter="20" style="flex: 1">
</el-col> <el-col :sm="24" :md="6" :lg="4">
<el-col :sm="24" :md="6" :lg="4"> <span>客户名称:{{ customerInfo.contacts }}</span>
<span>渠道:{{customerInfo.channel_name}}</span> </el-col>
</el-col> <el-col :sm="24" :md="6" :lg="4">
<el-col :sm="24" :md="6" :lg="5"> <span>渠道:{{ customerInfo.channel_name }}</span>
<span>联系方式:{{customerInfo.phone}}</span> </el-col>
</el-col> <el-col :sm="24" :md="6" :lg="5">
<el-col :sm="24" :md="6" :lg="4"> <span>联系方式:{{ customerInfo.phone }}</span>
<span>客户偏好:{{customerInfo.car_type_name}}</span> </el-col>
</el-col> <el-col :sm="24" :md="6" :lg="4">
<el-col :sm="24" :md="6" :lg="4"> <span>客户偏好:{{ customerInfo.car_type_name }}</span>
<span>客户商机:{{customerInfo.magnitude_txt}}</span> </el-col>
</el-col> <el-col :sm="24" :md="6" :lg="4">
</el-row> <span>客户商机:{{ customerInfo.magnitude_txt }}</span>
</el-col>
</el-row>
</div> </div>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-row"> <div class="info-row">
<span class="edit-title">客户分级</span> <span class="edit-title">客户分级</span>
<el-select v-model="customerInfo.grade" placeholder="请选择客户分级" style="width: 120px; margin-left: 16px;" @change="handleGradeChange"> <el-select
<el-option v-for="item in levelOptions" :key="item.id" :label="item.name" :value="item.id" /> v-model="customerInfo.grade"
placeholder="请选择客户分级"
style="width: 120px; margin-left: 16px"
@change="handleGradeChange"
>
<el-option
v-for="item in levelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</div> </div>
</el-card> </el-card>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-row"> <div class="info-row">
<span class="edit-title">跟进方式</span> <span class="edit-title">跟进方式</span>
<el-radio-group v-model="form.type"> <el-radio-group v-model="form.type">
@@ -44,18 +56,23 @@
</el-radio-group> </el-radio-group>
</div> </div>
</el-card> </el-card>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-row"> <div class="info-row">
<span class="edit-title">跟进时间</span> <span class="edit-title">跟进时间</span>
<el-date-picker v-model="form.firstFollowtime" type="datetime" :value-format="'YYYY-MM-DD HH:mm:ss'" style="width: 200px;" /> <el-date-picker
v-model="form.firstFollowtime"
type="datetime"
:value-format="'YYYY-MM-DD HH:mm:ss'"
style="width: 200px"
/>
</div> </div>
</el-card> </el-card>
<el-card v-if="form.type=='1'" class="info-card" shadow="never" style="margin-top: 16px;"> <el-card v-if="form.type == '1'" class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-top"> <div class="info-top">
<span class="edit-title">上传跟进截图/聊天截图</span> <span class="edit-title">上传跟进截图/聊天截图</span>
<el-upload <el-upload
class="upload-demo" class="upload-demo"
:action="actionImageUplode" :action="actionImageUplode"
:headers="uploadHeaders" :headers="uploadHeaders"
:on-success="handleUploadSuccess" :on-success="handleUploadSuccess"
:show-file-list="false" :show-file-list="false"
@@ -64,25 +81,31 @@
</el-upload> </el-upload>
</div> </div>
</el-card> </el-card>
<el-card v-if="form.type=='1'" class="info-card" shadow="never" style="margin-top: 16px;"> <el-card v-if="form.type == '1'" class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-top"> <div class="info-top">
<span class="edit-title">跟进描述</span> <span class="edit-title">跟进描述</span>
<el-input v-model="form.audio_text" type="textarea" placeholder="请输入跟进描述" /> <el-input v-model="form.audio_text" type="textarea" placeholder="请输入跟进描述" />
</div> </div>
</el-card> </el-card>
<el-card v-if="form.type=='0'" class="info-card" shadow="never" style="margin-top: 16px;"> <el-card v-if="form.type == '0'" class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-row"> <div class="info-row">
<span class="edit-title">当前位置</span> <span class="edit-title">当前位置</span>
<el-input v-model="followDesc" type="input" placeholder="请输入当前位置" style="width: 200px;" disabled> <el-input
v-model="followDesc"
type="input"
placeholder="请输入当前位置"
style="width: 200px"
disabled
>
</el-input> </el-input>
</div> </div>
</el-card> </el-card>
<el-card v-if="form.type=='0'" class="info-card" shadow="never" style="margin-top: 16px;"> <el-card v-if="form.type == '0'" class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-top"> <div class="info-top">
<span class="edit-title">当前位置拍照上传</span> <span class="edit-title">当前位置拍照上传</span>
<el-upload <el-upload
class="upload-demo" class="upload-demo"
:action="actionImageUplode" :action="actionImageUplode"
:headers="uploadHeaders" :headers="uploadHeaders"
:on-success="handleUploadSuccess1" :on-success="handleUploadSuccess1"
:show-file-list="false" :show-file-list="false"
@@ -91,24 +114,25 @@
</el-upload> </el-upload>
</div> </div>
</el-card> </el-card>
<el-card v-if="form.type=='0'" class="info-card" shadow="never" style="margin-top: 16px;"> <el-card v-if="form.type == '0'" class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-top"> <div class="info-top">
<span class="edit-title">拜访内容</span> <span class="edit-title">拜访内容</span>
<el-input v-model="form.audio_text" type="textarea" placeholder="请输入拜访内容" /> <el-input v-model="form.audio_text" type="textarea" placeholder="请输入拜访内容" />
</div> </div>
</el-card> </el-card>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-top"> <div class="info-top">
<span class="edit-title">备注</span> <span class="edit-title">备注</span>
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" /> <el-input v-model="form.remark" type="textarea" placeholder="请输入备注" />
</div> </div>
</el-card> </el-card>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div> <div>
<span class="border" style="margin: 0px 10px 0px 0px;"></span><span class="edit-title">跟进记录</span> <span class="border" style="margin: 0px 10px 0px 0px"></span
><span class="edit-title">跟进记录</span>
</div> </div>
<div class="follow-list"> <div class="follow-list">
<ElScrollbar > <ElScrollbar>
<template v-if="followList.length > 0"> <template v-if="followList.length > 0">
<ElTimeline> <ElTimeline>
<ElTimelineItem <ElTimelineItem
@@ -121,10 +145,21 @@
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-content-row"> <div class="timeline-content-row">
<div class="timeline-content"> <div class="timeline-content">
<p class="timeline-text">{{item.type_txt}} <span class="timeline-mom">{{ item.created_at }}</span></p> <p class="timeline-text"
<p class="timeline-mom"><span v-if="item.type==1">跟进描述</span><span v-else>拜访内容</span> {{ item.audio_text }}</p> >{{ item.type_txt }}
<span class="timeline-mom">{{ item.created_at }}</span></p
>
<p class="timeline-mom"
><span v-if="item.type == 1">跟进描述</span><span v-else>拜访内容</span>
{{ item.audio_text }}</p
>
</div> </div>
<img v-if="item.picture_full" :src="item.picture_full" class="timeline-img" alt="图片" /> <img
v-if="item.picture_full"
:src="item.picture_full"
class="timeline-img"
alt="图片"
/>
</div> </div>
</div> </div>
</ElTimelineItem> </ElTimelineItem>
@@ -132,7 +167,7 @@
</template> </template>
<template v-else> <template v-else>
<!-- 使用 Element Plus 的空状态组件 --> <!-- 使用 Element Plus 的空状态组件 -->
<el-empty :image="emptyimg" description="暂无跟进记录" /> <el-empty :image="emptyimg" description="暂无跟进记录" />
<!-- <el-empty /> --> <!-- <el-empty /> -->
<!-- 或者自定义图片 --> <!-- 或者自定义图片 -->
<!-- <img src="你的空图片地址.png" alt="暂无数据" class="empty-img" /> --> <!-- <img src="你的空图片地址.png" alt="暂无数据" class="empty-img" /> -->
@@ -158,7 +193,7 @@
<div class="remind-title">下次跟进提醒设置</div> <div class="remind-title">下次跟进提醒设置</div>
<div class="remind-options"> <div class="remind-options">
<div <div
v-for="(item, idx) in remindOptions.filter(opt => opt.value !== 'custom')" v-for="item in remindOptions.filter((opt) => opt.value !== 'custom')"
:key="item.value" :key="item.value"
:class="['remind-btn', { active: remindValue === item.value }]" :class="['remind-btn', { active: remindValue === item.value }]"
@click="remindValue = item.value" @click="remindValue = item.value"
@@ -167,12 +202,12 @@
</div> </div>
</div> </div>
<el-date-picker <el-date-picker
v-model="customDate" v-model="customDate"
type="datetime" type="datetime"
placeholder="自定义" placeholder="自定义"
style="width:100%;" style="width: 100%"
CircleClose CircleClose
:disabled-date="disabledPastDate" :disabled-date="disabledPastDate"
/> />
<el-button type="primary" class="confirm-btn" @click="handleSuccessConfirm">确定</el-button> <el-button type="primary" class="confirm-btn" @click="handleSuccessConfirm">确定</el-button>
</div> </div>
@@ -181,335 +216,329 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import emptyimg from '@/assets/img/crm/empty.png' import emptyimg from '@/assets/img/crm/empty.png'
import imageUrl1 from '@/assets/img/crm/upimg.png' import imageUrl1 from '@/assets/img/crm/upimg.png'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ElTimeline, ElTimelineItem, ElScrollbar } from 'element-plus' import { ElTimeline, ElTimelineItem, ElScrollbar } from 'element-plus'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
import { useUploadHeaders } from '@/composables/useUploadHeaders' import { useUploadHeaders } from '@/composables/useUploadHeaders'
const { uploadHeaders } = useUploadHeaders() const { uploadHeaders } = useUploadHeaders()
let actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload` let actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload`
const TIMELINE_PLACEMENT = 'top' const TIMELINE_PLACEMENT = 'top'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const customerId = route.query.id const customerId = route.query.id
const levelOptions = ref<any>() const levelOptions = ref<any>()
const followDesc = ref('') const followDesc = ref('')
const activecolor = "#3471FF" const activecolor = '#3471FF'
const successDialogVisible = ref(false) // 控制弹窗显示 const successDialogVisible = ref(false) // 控制弹窗显示
const remindValue = ref('1') const remindValue = ref('1')
const customDate = ref('') const customDate = ref('')
const imageUrl = ref(imageUrl1) const imageUrl = ref(imageUrl1)
const imageUrls = ref(imageUrl1) const imageUrls = ref(imageUrl1)
function getNowStr() { function getNowStr() {
const d = new Date() const d = new Date()
const pad = (n: any) => n < 10 ? '0' + n : n const pad = (n: any) => (n < 10 ? '0' + n : n)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
} }
const form=ref({ const form = ref({
customer_id:route.query.id, customer_id: route.query.id,
type: '1', type: '1',
address: '', address: '',
lat: '', lat: '',
lng: '', lng: '',
firstFollowtime: getNowStr(), firstFollowtime: getNowStr(),
audio_text:'', audio_text: '',
remark:'', remark: '',
picture:'' picture: ''
}) })
// 定义客户详情类型 // 定义客户详情类型
interface CustomerInfo { interface CustomerInfo {
contacts?: string contacts?: string
// 可根据实际接口补充更多字段 // 可根据实际接口补充更多字段
[key: string]: any [key: string]: any
} }
const customerInfo = ref<CustomerInfo>({}) const customerInfo = ref<CustomerInfo>({})
const followList = ref<CustomerInfo>({}) const followList = ref<CustomerInfo>({})
//获取详情 //获取详情
const getdetilfn = async() => { const getdetilfn = async () => {
try { const res = await CrmService.getcustomerdetail({ id: customerId })
// 这里调用获取品牌列表的接口
const res = await CrmService.getcustomerdetail({id:customerId})
customerInfo.value = res customerInfo.value = res
console.log(res) }
} catch (error) { } //客户等级
} const getGrade = async () => {
//客户等级
const getGrade = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getGrade() const res = await CrmService.getGrade()
levelOptions.value = res||[] levelOptions.value = res || []
} catch (error) { levelOptions.value = [] } }
} onMounted(async () => {
onMounted(async () => { await getGrade()
await getGrade() await getdetilfn()
await getdetilfn() getCurrentLocation()
getCurrentLocation() getfollowlist()
getfollowlist()
})
//修改等级
function handleGradeChange(newGrade: number) {
if (!customerId) return
CrmService.customergrade({ id: customerId, grade: newGrade }).then(res => {
ElMessage.success(res?.message || '修改成功')
}).catch(() => {
ElMessage.error('修改失败')
}) })
} //修改等级
//跟进记录getfollowlist function handleGradeChange(newGrade: number) {
const getfollowlist = async () => { if (!customerId) return
try { CrmService.customergrade({ id: customerId, grade: newGrade })
const res = await CrmService.getfollowlist({customer_id:route.query.id}) .then((res) => {
followList.value = res||[] ElMessage.success(res?.message || '修改成功')
} catch (error) { followList.value = [] } })
} .catch(() => {
ElMessage.error('修改失败')
})
}
//跟进记录getfollowlist
const getfollowlist = async () => {
const res = await CrmService.getfollowlist({ customer_id: route.query.id })
followList.value = res || []
}
const remindOptions = [
{ label: '1天后', value: '1' },
{ label: '3天后', value: '3' },
{ label: '7天后', value: '7' }
]
function disabledPastDate(time: Date) {
const remindOptions = [ return time.getTime() < Date.now() - 86400000
{ label: '1天后', value: '1' }, }
{ label: '3天后', value: '3' }, const handleSuccessConfirm = async () => {
{ label: '7天后', value: '7' } let remindTimestamp = null
] if (remindValue.value === 'custom') {
if (!customDate.value) {
function disabledPastDate(time: Date) { return ElMessage.warning('请选择自定义日期时间')
return time.getTime() < Date.now() - 86400000 }
} // 自定义日期转时间戳
const handleSuccessConfirm = async () =>{ remindTimestamp = Math.floor(new Date(customDate.value).getTime() / 1000)
let remindTimestamp = null } else {
if (remindValue.value === 'custom') { // 固定天数,当前时间加天数
if (!customDate.value) { const days = Number(remindValue.value)
return ElMessage.warning('请选择自定义日期时间') remindTimestamp = Math.floor((Date.now() + days * 24 * 60 * 60 * 1000) / 1000)
} }
// 自定义日期转时间戳 const res = await CrmService.followNext({ id: customerId, follow_next: remindTimestamp })
remindTimestamp = Math.floor(new Date(customDate.value).getTime() / 1000)
} else {
// 固定天数,当前时间加天数
const days = Number(remindValue.value)
remindTimestamp = Math.floor((Date.now() + days * 24 * 60 * 60 * 1000) / 1000)
}
const res = await CrmService.followNext({id:customerId,follow_next:remindTimestamp})
ElMessage.success(res?.message || '提交成功!')
successDialogVisible.value = false
setTimeout(() => {
router.push('/crmpc/customer')
}, 1000);
}
function goBack() {
router.back()
}
const handleSubmit = async () => {
try {
form.value.address=followDesc.value
const res = await CrmService.followAdd(form.value)
ElMessage.success(res?.message || '提交成功!') ElMessage.success(res?.message || '提交成功!')
successDialogVisible.value=true successDialogVisible.value = false
// router.push('/crmpc/customer') setTimeout(() => {
} catch (e: any) { router.push('/crmpc/customer')
ElMessage.error(e?.message || '提交失败') }, 1000)
} }
} function goBack() {
// 在 <script setup> 里添加获取地理位置方法 router.back()
function getCurrentLocation() { }
if (navigator.geolocation) { const handleSubmit = async () => {
navigator.geolocation.getCurrentPosition( try {
async (position) => { form.value.address = followDesc.value
const { latitude, longitude } = position.coords const res = await CrmService.followAdd(form.value)
// 可选:调用第三方逆地理编码API获取详细地址 ElMessage.success(res?.message || '提交成功!')
const googleKey = 'AIzaSyCiQdUlA2QrEBEaZthoj7B18HTz6sxU_3I' successDialogVisible.value = true
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${googleKey}` // router.push('/crmpc/customer')
try { } catch (e: any) {
const res = await fetch(url) ElMessage.error(e?.message || '提交失败')
const data = await res.json() }
if (data.status === 'OK' && data.plus_code) { }
followDesc.value = data.plus_code.compound_code // 在 <script setup> 里添加获取地理位置方法
} else { function getCurrentLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords
// 可选:调用第三方逆地理编码API获取详细地址
const googleKey = 'AIzaSyCiQdUlA2QrEBEaZthoj7B18HTz6sxU_3I'
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${googleKey}`
try {
const res = await fetch(url)
const data = await res.json()
if (data.status === 'OK' && data.plus_code) {
followDesc.value = data.plus_code.compound_code
} else {
followDesc.value = `纬度: ${latitude}, 经度: ${longitude}`
}
} catch (e) {
console.error('获取地址失败:', e)
followDesc.value = `纬度: ${latitude}, 经度: ${longitude}` followDesc.value = `纬度: ${latitude}, 经度: ${longitude}`
} }
} catch (e) { },
followDesc.value = `纬度: ${latitude}, 经度: ${longitude}` (error) => {
console.log('定位失败:', error)
followDesc.value = '无法获取当前位置'
} }
}, )
(error) => { } else {
followDesc.value = '无法获取当前位置' followDesc.value = '浏览器不支持定位功能'
} }
) }
} else { function handleUploadSuccess(response: any) {
followDesc.value = '浏览器不支持定位功能' // 假设返回图片地址字段为 response.url 或 response.data.url
imageUrl.value = response.url || (response.data && response.data.file_url) || ''
// 如需同步到 form.picture
form.value.picture = response.url || (response.data && response.data.file_name) || ''
}
function handleUploadSuccess1(response: any) {
// 假设返回图片地址字段为 response.url 或 response.data.url
imageUrls.value = response.url || (response.data && response.data.file_url) || ''
// 如需同步到 form.picture
form.value.picture = response.url || (response.data && response.data.file_name) || ''
} }
}
function handleUploadSuccess(response: any) {
// 假设返回图片地址字段为 response.url 或 response.data.url
imageUrl.value = response.url || (response.data && response.data.file_url) || ''
// 如需同步到 form.picture
form.value.picture = response.url || (response.data && response.data.file_name) || ''
}
function handleUploadSuccess1(response: any) {
// 假设返回图片地址字段为 response.url 或 response.data.url
imageUrls.value = response.url || (response.data && response.data.file_url) || ''
// 如需同步到 form.picture
form.value.picture = response.url || (response.data && response.data.file_name) || ''
}
</script> </script>
<style scoped> <style scoped>
.timeline-content-row{ .timeline-content-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.avatar,.timeline-img{ .avatar,
width: 80px; .timeline-img {
} width: 80px;
.border{ }
margin-left: 30px; .border {
} margin-left: 30px;
.info-row { }
display: flex; .info-row {
align-items: center; display: flex;
justify-content: space-between; /* 两端对齐 */ align-items: center;
font-size: 15px; justify-content: space-between; /* 两端对齐 */
padding: 0; font-size: 15px;
} padding: 0;
.edit-header{ }
padding: 40px 20px; .edit-header {
} padding: 40px 20px;
.follow-add-page { }
background: #f7f9fa; .follow-add-page {
min-height: 100vh; background: #f7f9fa;
padding: 24px; min-height: 100vh;
} padding: 24px;
.boxshadow { }
background: #fff; .boxshadow {
border-radius: 8px; background: #fff;
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04); border-radius: 8px;
} box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
.pad20 { }
padding: 20px; .pad20 {
} padding: 20px;
.edit-title { }
font-size: 18px; .edit-title {
font-weight: bold; font-size: 18px;
} font-weight: bold;
.info-card { }
margin-bottom: 0; .info-card {
border-radius: 8px; margin-bottom: 0;
} border-radius: 8px;
}
.submit-btn { .submit-btn {
display: block; display: block;
width: 240px; width: 240px;
margin: 32px auto 0 auto; margin: 32px auto 0 auto;
font-size: 18px; font-size: 18px;
height: 48px; height: 48px;
border-radius: 6px; border-radius: 6px;
} }
.corner-tag { .corner-tag {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
width: 90px; width: 90px;
height: 90px; height: 90px;
background: transparent; background: transparent;
z-index: 2; z-index: 2;
pointer-events: none; pointer-events: none;
} }
.corner-tag::before { .corner-tag::before {
content: ''; content: '';
position: absolute; position: absolute;
top: -9px; top: -9px;
right: -9px; right: -9px;
width: 90px; width: 90px;
height: 75px; height: 75px;
background: #3471FF; background: #3471ff;
clip-path: polygon(100% 0, 0 0, 100% 100%); clip-path: polygon(100% 0, 0 0, 100% 100%);
border-top-right-radius: 12px; border-top-right-radius: 12px;
} }
.corner-tag { .corner-tag {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: flex-end; justify-content: flex-end;
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
width: 55px; width: 55px;
height: 40px; height: 40px;
z-index: 3; z-index: 3;
padding-top: 6px; padding-top: 6px;
padding-right: 0px; padding-right: 0px;
pointer-events: none; pointer-events: none;
} }
.corner-text { .corner-text {
display: inline-block; display: inline-block;
transform: rotate(45deg); transform: rotate(45deg);
} }
.info-top span{ .info-top span {
margin-bottom: 15px; margin-bottom: 15px;
display: inline-block; display: inline-block;
} }
/* 成功弹出层 */ /* 成功弹出层 */
.success-dialog-content { .success-dialog-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 12px 0 0 0; padding: 12px 0 0 0;
} }
.success-img { .success-img {
width:55%; width: 55%;
margin-bottom: 12px; margin-bottom: 12px;
} }
.conts{ .conts {
margin-top: -55px; margin-top: -55px;
} }
.success-title { .success-title {
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
margin-bottom: 40px; margin-bottom: 40px;
color: #333; color: #333;
text-align: center; text-align: center;
} }
.remind-title { .remind-title {
font-size: 16px; font-size: 16px;
color: #333; color: #333;
margin-bottom: 20px; margin-bottom: 20px;
} }
.remind-options { .remind-options {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.remind-btn { .remind-btn {
background: #f4f7fe; background: #f4f7fe;
color: #3471FF; color: #3471ff;
border-radius: 8px; border-radius: 8px;
padding: 6px 18px; padding: 6px 18px;
cursor: pointer; cursor: pointer;
font-size: 15px; font-size: 15px;
border: 1px solid transparent; border: 1px solid transparent;
transition: all 0.2s; transition: all 0.2s;
min-width: 64px; min-width: 64px;
text-align: center; text-align: center;
flex: 1; flex: 1;
} }
.remind-btn.active { .remind-btn.active {
background: #3471FF; background: #3471ff;
color: #fff; color: #fff;
border: 1px solid #3471FF; border: 1px solid #3471ff;
} }
.confirm-btn { .confirm-btn {
width: 100%; width: 100%;
height: 44px; height: 44px;
font-size: 18px; font-size: 18px;
border-radius: 6px; border-radius: 6px;
margin-top: 16px; margin-top: 16px;
} }
</style> </style>
+411 -386
View File
@@ -1,62 +1,82 @@
<template> <template>
<div class="customer-edit-page"> <div class="customer-edit-page">
<div class="boxshadow pad20"> <div class="boxshadow pad20">
<el-link :underline="false" @click="goBack"> <el-link underline="never" @click="goBack">
<i class="iconfont-sys icon-reverse">&#xe772;</i> 返回 <i class="iconfont-sys icon-reverse">&#xe772;</i> 返回
</el-link> </el-link>
</div> </div>
<div class="edit-header boxshadow pad20" style="position:relative;"> <div class="edit-header boxshadow pad20" style="position: relative">
<span class="border"></span><span class="edit-title">客户资料</span> <span class="border"></span><span class="edit-title">客户资料</span>
<!-- 右上角三角形角标 --> <!-- 右上角三角形角标 -->
<div class="corner-tag"><span class="corner-text">{{customerInfo.follow_txt}}</span></div> <div class="corner-tag"
><span class="corner-text">{{ customerInfo.follow_txt }}</span></div
>
<div class="info-row-bar"> <div class="info-row-bar">
<el-row :gutter="20" style="flex: 1;"> <el-row :gutter="20" style="flex: 1">
<el-col :sm="24" :md="6" :lg="4"> <el-col :sm="24" :md="6" :lg="4">
<span>客户名称:{{ customerInfo.contacts }}</span> <span>客户名称:{{ customerInfo.contacts }}</span>
</el-col> </el-col>
<el-col :sm="24" :md="6" :lg="4"> <el-col :sm="24" :md="6" :lg="4">
<span>渠道:{{customerInfo.channel_name}}</span> <span>渠道:{{ customerInfo.channel_name }}</span>
</el-col> </el-col>
<el-col :sm="24" :md="6" :lg="5"> <el-col :sm="24" :md="6" :lg="5">
<span>联系方式:{{customerInfo.phone}}</span> <span>联系方式:{{ customerInfo.phone }}</span>
</el-col> </el-col>
<el-col :sm="24" :md="6" :lg="4"> <el-col :sm="24" :md="6" :lg="4">
<span>客户偏好:{{customerInfo.car_type_name}}</span> <span>客户偏好:{{ customerInfo.car_type_name }}</span>
</el-col> </el-col>
<el-col :sm="24" :md="6" :lg="4"> <el-col :sm="24" :md="6" :lg="4">
<span>客户商机:{{customerInfo.magnitude_txt}}</span> <span>客户商机:{{ customerInfo.magnitude_txt }}</span>
</el-col> </el-col>
</el-row> </el-row>
<el-button @click="goEdit" type="primary" class="edit-btn" size="small"><i class="iconfont-sys"> &#xe6bf;</i> 编辑资料</el-button> <el-button @click="goEdit" type="primary" class="edit-btn" size="small"
><i class="iconfont-sys"> &#xe6bf;</i> 编辑资料</el-button
>
</div> </div>
</div> </div>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class=""> <div class="">
<span class="border"></span><span class="edit-title">客户画像</span> <span class="border"></span><span class="edit-title">客户画像</span>
<el-button @click="addtag" type="primary" size="small" style="float: right;">添加标签</el-button> <el-button @click="addtag" type="primary" size="small" style="float: right"
>添加标签</el-button
>
</div> </div>
<div class="tag-list"> <div class="tag-list">
<el-tag type="primary" v-for="(item, index) in customerInfo.tags" :key="index">{{item.name}}</el-tag> <el-tag type="primary" v-for="(item, index) in customerInfo.tags" :key="index">{{
item.name
}}</el-tag>
</div> </div>
</el-card> </el-card>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-row"> <div class="info-row">
<span class="edit-title">客户分级</span> <span class="edit-title">客户分级</span>
<el-select v-model="customerInfo.grade" placeholder="请选择客户分级" style="width: 120px; margin-left: 16px;" @change="handleGradeChange"> <el-select
<el-option v-for="item in levelOptions" :key="item.id" :label="item.name" :value="item.id" /> v-model="customerInfo.grade"
placeholder="请选择客户分级"
style="width: 120px; margin-left: 16px"
@change="handleGradeChange"
>
<el-option
v-for="item in levelOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</div> </div>
</el-card> </el-card>
<el-card class="info-card" shadow="never" style="margin-top: 16px;"> <el-card class="info-card" shadow="never" style="margin-top: 16px">
<div class="info-row"> <div class="info-row">
<span class="edit-title">跟进记录</span> <span class="edit-title">跟进记录</span>
<el-button type="primary" size="small" style="float: right;" @click="goAddFollow">+ 添加跟进</el-button> <el-button type="primary" size="small" style="float: right" @click="goAddFollow"
>+ 添加跟进</el-button
>
</div> </div>
<div class="follow-list"> <div class="follow-list">
<ElScrollbar > <ElScrollbar>
<template v-if="followList.length > 0"> <template v-if="followList.length > 0">
<ElTimeline> <ElTimeline>
<ElTimelineItem <ElTimelineItem
@@ -69,10 +89,21 @@
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-content-row"> <div class="timeline-content-row">
<div class="timeline-content"> <div class="timeline-content">
<p class="timeline-text">{{item.type_txt}} <span class="timeline-mom">{{ item.created_at }}</span></p> <p class="timeline-text"
<p class="timeline-mom"><span v-if="item.type==1">跟进描述</span><span v-else>拜访内容</span> {{ item.audio_text }}</p> >{{ item.type_txt }}
<span class="timeline-mom">{{ item.created_at }}</span></p
>
<p class="timeline-mom"
><span v-if="item.type == 1">跟进描述</span><span v-else>拜访内容</span>
{{ item.audio_text }}</p
>
</div> </div>
<img v-if="item.picture_full" :src="item.picture_full" class="timeline-img" alt="图片" /> <img
v-if="item.picture_full"
:src="item.picture_full"
class="timeline-img"
alt="图片"
/>
</div> </div>
</div> </div>
</ElTimelineItem> </ElTimelineItem>
@@ -80,7 +111,7 @@
</template> </template>
<template v-else> <template v-else>
<!-- 使用 Element Plus 的空状态组件 --> <!-- 使用 Element Plus 的空状态组件 -->
<el-empty :image="emptyimg" description="暂无跟进记录" /> <el-empty :image="emptyimg" description="暂无跟进记录" />
<!-- <el-empty /> --> <!-- <el-empty /> -->
<!-- 或者自定义图片 --> <!-- 或者自定义图片 -->
<!-- <img src="你的空图片地址.png" alt="暂无数据" class="empty-img" /> --> <!-- <img src="你的空图片地址.png" alt="暂无数据" class="empty-img" /> -->
@@ -97,12 +128,12 @@
:show-close="false" :show-close="false"
> >
<div class="tag-dialog-scroll"> <div class="tag-dialog-scroll">
<div v-for="group in tagGroups" :key="group.name" style="margin-bottom: 12px;"> <div v-for="group in tagGroups" :key="group.name" style="margin-bottom: 12px">
<div style="font-weight: bold; margin-bottom: 6px;">{{ group.name }}</div> <div style="font-weight: bold; margin-bottom: 6px">{{ group.name }}</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;"> <div style="display: flex; flex-wrap: wrap; gap: 8px">
<div <div
class="col4 tagitem" class="col4 tagitem"
style="cursor: pointer;" style="cursor: pointer"
v-for="(item1, index1) in group.tags" v-for="(item1, index1) in group.tags"
:class="{ 'tagactive': activeMap[group.id] === item1.id }" :class="{ 'tagactive': activeMap[group.id] === item1.id }"
@click="tagSelect(item1)" @click="tagSelect(item1)"
@@ -123,73 +154,61 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref,reactive } from 'vue' import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import emptyimg from '@/assets/img/crm/empty.png' import emptyimg from '@/assets/img/crm/empty.png'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
// 常量配置 // 常量配置
const TIMELINE_PLACEMENT = 'top' const TIMELINE_PLACEMENT = 'top'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const customerLevel = ref('A') const activecolor = '#3471ff'
const activecolor = "#3471FF" const tagDialogVisible = ref(false)
const tagDialogVisible = ref(false) const customerId = route.query.id
const selectedTags = ref<string[]>([]) const levelOptions = ref<any>()
const customerId = route.query.id const tagGroups = ref<any[]>([])
const levelOptions = ref<any>() const activeMap = reactive<{ [groupId: string]: number }>({})
const tagGroups = ref<any[]>([]) // 定义客户详情类型
const activeMap = reactive<{ [groupId: string]: number }>({}) interface CustomerInfo {
// 定义客户详情类型 contacts?: string
interface CustomerInfo { // 可根据实际接口补充更多字段
contacts?: string [key: string]: any
// 可根据实际接口补充更多字段 }
[key: string]: any const customerInfo = ref<CustomerInfo>({})
} //获取详情
const customerInfo = ref<CustomerInfo>({}) const getdetilfn = async () => {
//获取详情 const res = await CrmService.getcustomerdetail({ id: customerId })
const getdetilfn = async() => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getcustomerdetail({id:customerId})
customerInfo.value = res customerInfo.value = res
console.log(res) }
} catch (error) { } //客户等级
} const getGrade = async () => {
//客户等级
const getGrade = async () => {
try {
// 这里调用获取品牌列表的接口
const res = await CrmService.getGrade() const res = await CrmService.getGrade()
levelOptions.value = res||[] levelOptions.value = res || []
} catch (error) { levelOptions.value = [] } }
} //跟进记录getfollowlist
//跟进记录getfollowlist const followList = ref<CustomerInfo>({})
const followList = ref<CustomerInfo>({}) const getfollowlist = async () => {
const getfollowlist = async () => { const res = await CrmService.getfollowlist({ customer_id: route.query.id })
try { followList.value = res || []
const res = await CrmService.getfollowlist({customer_id:route.query.id}) }
followList.value = res||[] onMounted(async () => {
} catch (error) { followList.value = [] } await getGrade()
} await getdetilfn()
onMounted(async () => { // 在 getdetilfn 之后调用 tagGrouplistfn,确保 customerInfo 有数据
await getGrade() await tagGrouplistfn()
await getdetilfn() getfollowlist()
// 在 getdetilfn 之后调用 tagGrouplistfn,确保 customerInfo 有数据 })
await tagGrouplistfn() function addtag() {
getfollowlist() tagDialogVisible.value = true
}) tagGrouplistfn()
function addtag(){ }
tagDialogVisible.value = true const tagGrouplistfn = async () => {
tagGrouplistfn() const res = await CrmService.tagGrouplist({})
}
const tagGrouplistfn = async () => {
try {
const res = await CrmService.tagGrouplist()
tagGroups.value = res || [] tagGroups.value = res || []
// 回显选中 // 回显选中
if (customerInfo.value && Array.isArray(customerInfo.value.tags)) { if (customerInfo.value && Array.isArray(customerInfo.value.tags)) {
tagGroups.value.forEach(group => { tagGroups.value.forEach((group) => {
if (group.tags && Array.isArray(group.tags)) { if (group.tags && Array.isArray(group.tags)) {
const matched = group.tags.find((item: any) => const matched = group.tags.find((item: any) =>
customerInfo.value.tags.some((t: any) => t.id === item.id) customerInfo.value.tags.some((t: any) => t.id === item.id)
@@ -200,301 +219,307 @@ const tagGrouplistfn = async () => {
} }
}) })
} }
} catch (error) { }
}
// 4. 点击标签
async function tagSelect(item: { group_id: string | number; id: number }) {
const key = String(item.group_id)
let tag_id = ''
if (activeMap[key] === item.id) {
// 取消选中
delete activeMap[key]
tag_id = ''
} else {
// 选中
activeMap[key] = item.id
tag_id = String(item.id)
} }
try {
const res = await CrmService.tagGroupupdatePivot({ customer_id: customerId, group_id: key, tag_id }) // 4. 点击标签
ElMessage.success(res?.message || '操作成功') async function tagSelect(item: { group_id: string | number; id: number }) {
await getdetilfn() const key = String(item.group_id)
await tagGrouplistfn() let tag_id = ''
} catch (e) { if (activeMap[key] === item.id) {
ElMessage.error('操作失败') // 取消选中
delete activeMap[key]
tag_id = ''
} else {
// 选中
activeMap[key] = item.id
tag_id = String(item.id)
}
try {
const res = await CrmService.tagGroupupdatePivot({
customer_id: customerId,
group_id: key,
tag_id
})
ElMessage.success(res?.message || '操作成功')
await getdetilfn()
await tagGrouplistfn()
} catch (e) {
console.error(e)
ElMessage.error('操作失败')
}
} }
}
// 5. 获取所有已选中的标签 // 5. 获取所有已选中的标签
// function getSelectedTags() { // function getSelectedTags() {
// const selected: any[] = [] // const selected: any[] = []
// for (const group of tagGroups) { // for (const group of tagGroups) {
// const key = String(group.id) // const key = String(group.id)
// const tagId = activeMap[key] // const tagId = activeMap[key]
// if (tagId) { // if (tagId) {
// const tag = group.tags.find(t => t.id === tagId) // const tag = group.tags.find(t => t.id === tagId)
// if (tag) selected.push(tag) // if (tag) selected.push(tag)
// } // }
// } // }
// return selected // return selected
// } // }
// 6. 示例:确定按钮 // 6. 示例:确定按钮
function handleConfirm() { function handleConfirm() {
// const selectedTags = getSelectedTags() // const selectedTags = getSelectedTags()
// console.log('选中的标签:', selectedTags) // console.log('选中的标签:', selectedTags)
// 处理选中的标签 // 处理选中的标签
tagDialogVisible.value = false tagDialogVisible.value = false
} }
function handleCancel() { function handleCancel() {
tagDialogVisible.value = false tagDialogVisible.value = false
} }
//编辑 //编辑
function goEdit() { function goEdit() {
router.push({ router.push({
path: '/crmpc/customer/edit', path: '/crmpc/customer/edit',
query: { id: customerId } query: { id: customerId }
}) })
} }
function goBack() { function goBack() {
router.back() router.back()
} }
//修改等级 //修改等级
function handleGradeChange(newGrade: number) { function handleGradeChange(newGrade: number) {
if (!customerId) return if (!customerId) return
CrmService.customergrade({ id: customerId, grade: newGrade }).then(res => { CrmService.customergrade({ id: customerId, grade: newGrade })
ElMessage.success(res?.message || '修改成功') .then((res) => {
}).catch(() => { ElMessage.success(res?.message || '修改成功')
ElMessage.error('修改失败') })
}) .catch(() => {
} ElMessage.error('修改失败')
//添加跟进 })
function goAddFollow() { }
router.push({ //添加跟进
path: '/crmpc/customer/followadd', function goAddFollow() {
query: { id: customerId } router.push({
}) path: '/crmpc/customer/followadd',
} query: { id: customerId }
})
}
</script> </script>
<style scoped> <style scoped>
.timeline-content-row{ .timeline-content-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.timeline-img{ .timeline-img {
width: 80px; width: 80px;
} }
.tag-list { .tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; /* 标签间距 */ gap: 12px; /* 标签间距 */
margin-top: 20px; margin-top: 20px;
} }
.border{ .border {
margin-right: 10px; margin-right: 10px;
} }
.pad20{ .pad20 {
padding: 20px; padding: 20px;
} }
.icon-reverse { .icon-reverse {
display: inline-block; display: inline-block;
transform: rotate(270deg); /* 旋转180度 */ transform: rotate(270deg); /* 旋转180度 */
} }
.customer-edit-page { .customer-edit-page {
background: #f7f9fa; background: #f7f9fa;
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
} }
.edit-header { .edit-header {
/* display: flex; */ /* display: flex; */
/* align-items: center; */ /* align-items: center; */
/* gap: 12px; */ /* gap: 12px; */
margin-bottom: 16px; margin-bottom: 16px;
} }
.edit-title { .edit-title {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
} }
.edit-status { .edit-status {
margin-left: 16px; margin-left: 16px;
} }
.edit-btn { .edit-btn {
margin-left: auto; margin-left: auto;
} }
.info-card { .info-card {
margin-bottom: 0; margin-bottom: 0;
border-radius: 8px; border-radius: 8px;
} }
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; /* 两端对齐 */ justify-content: space-between; /* 两端对齐 */
font-size: 15px; font-size: 15px;
padding: 0; padding: 0;
} }
.follow-list { .follow-list {
margin-top: 16px; margin-top: 16px;
} }
.follow-item { .follow-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
margin-bottom: 16px; margin-bottom: 16px;
background: #f7f9fa; background: #f7f9fa;
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
} }
.follow-main { .follow-main {
flex: 1; flex: 1;
} }
.follow-title { .follow-title {
font-size: 14px; font-size: 14px;
color: #222; color: #222;
margin-bottom: 4px; margin-bottom: 4px;
display: flex; display: flex;
gap: 16px; gap: 16px;
} }
.follow-type { .follow-type {
color: #3471FF; color: #3471FF;
font-weight: bold; font-weight: bold;
} }
.follow-time { .follow-time {
color: #888; color: #888;
} }
.follow-content { .follow-content {
color: #333; color: #333;
font-size: 14px; font-size: 14px;
} }
.follow-img { .follow-img {
width: 60px; width: 60px;
height: 60px; height: 60px;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 6px;
margin-left: 16px; margin-left: 16px;
} }
.info-row-bar { .info-row-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin: 16px 0px; margin: 16px 0px;
} }
.corner-tag { .corner-tag {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
width: 90px; width: 90px;
height: 90px; height: 90px;
background: transparent; background: transparent;
z-index: 2; z-index: 2;
pointer-events: none; pointer-events: none;
} }
.corner-tag::before { .corner-tag::before {
content: ''; content: '';
position: absolute; position: absolute;
top: -10px; top: -10px;
right: -10px; right: -10px;
width: 90px; width: 90px;
height: 80px; height: 80px;
background: #3471FF; background: #3471FF;
clip-path: polygon(100% 0, 0 0, 100% 100%); clip-path: polygon(100% 0, 0 0, 100% 100%);
border-top-right-radius: 12px; border-top-right-radius: 12px;
} }
.corner-tag { .corner-tag {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: flex-end; justify-content: flex-end;
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
width: 55px; width: 55px;
height: 40px; height: 40px;
z-index: 3; z-index: 3;
padding-top: 6px; padding-top: 6px;
padding-right: 0px; padding-right: 0px;
pointer-events: none; pointer-events: none;
} }
.corner-text { .corner-text {
display: inline-block; display: inline-block;
transform: rotate(45deg); transform: rotate(45deg);
} }
.timeline-content-row { .timeline-content-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.timeline-text{ .timeline-text {
font-size: 16px; font-size: 16px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.timeline-mom{ .timeline-mom {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
} }
.timeline-img { .timeline-img {
width: 48px; width: 48px;
height: 48px; height: 48px;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 6px;
} }
.tagitem{ .tagitem {
background: #DCE7FF; background: #DCE7FF;
border-radius: 8px; border-radius: 8px;
color: #3471FF; color: #3471FF;
text-align: center; text-align: center;
padding: 5px 13px; padding: 5px 13px;
} }
.col4{ .col4 {
display: inline-block; display: inline-block;
margin: 4px 3px; margin: 4px 3px;
} }
.tagactive{ .tagactive {
background: #3471FF !important; background: #3471FF !important;
color: #fff !important; color: #fff !important;
} }
.tag-dialog-scroll { .tag-dialog-scroll {
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding-right: 8px; padding-right: 8px;
} }
.tag-dialog-scroll { .tag-dialog-scroll {
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding-right: 8px; padding-right: 8px;
/* 让内容不被滚动条遮挡 */ /* 让内容不被滚动条遮挡 */
} }
/* 优化滚动条样式(Webkit内核浏览器) */ /* 优化滚动条样式(Webkit内核浏览器) */
.tag-dialog-scroll::-webkit-scrollbar { .tag-dialog-scroll::-webkit-scrollbar {
width: 6px; width: 6px;
background: transparent; background: transparent;
} }
.tag-dialog-scroll::-webkit-scrollbar-thumb { .tag-dialog-scroll::-webkit-scrollbar-thumb {
background: #d4e0f7; background: #d4e0f7;
border-radius: 4px; border-radius: 4px;
transition: background 0.2s; transition: background 0.2s;
} }
.tag-dialog-scroll::-webkit-scrollbar-thumb:hover { .tag-dialog-scroll::-webkit-scrollbar-thumb:hover {
background: #b3c8e6; background: #b3c8e6;
} }
.tag-dialog-scroll::-webkit-scrollbar-track { .tag-dialog-scroll::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* Firefox 滚动条优化 */ /* Firefox 滚动条优化 */
.tag-dialog-scroll { .tag-dialog-scroll {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #d4e0f7 transparent; scrollbar-color: #d4e0f7 transparent;
} }
.dialog-footer-center { .dialog-footer-center {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
} }
</style> </style>
+451 -271
View File
@@ -2,213 +2,297 @@
<div class="customer-page"> <div class="customer-page">
<!-- 顶部操作栏 --> <!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow"> <div class="customer-toolbar boxshadow">
<el-button type="primary" @click="goAdd"><el-icon><Plus /></el-icon>添加</el-button> <el-button type="primary" @click="goAdd" v-if="hasAuth('crm_customer@add')">
<el-icon><Plus /> </el-icon>
添加
</el-button>
<div class="customer-search-group"> <div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input v-model="searchValue" placeholder="请输入客户名称或电话" /> <el-input v-model="searchValue" placeholder="请输入客户名称或电话" />
<el-select v-model="selectValue"> </el-form-item>
<el-option <el-form-item label="国家" label-width="70px">
v-for="v in selectOptions" <el-select v-model="country_id" filterable placeholder="请选择国家">
:key="v.value" <el-option
:label="v.label" v-for="item in countryOptions"
:value="v.value" :key="item.id"
> :label="item.cn_addr"
</el-option> :value="item.id"
/>
</el-select> </el-select>
</el-form-item>
<el-form-item label="时间" label-width="70px">
<el-select v-model="selectValue" placeholder="请选择时间" style="width: 120px">
<el-option v-for="v in selectOptions" :key="v.value" :label="v.label" :value="v.value">
</el-option>
</el-select>
</el-form-item>
</div> </div>
</div> </div>
<!-- 筛选标签 --> <!-- 筛选标签 -->
<div class="customer-tags boxshadow"> <div class="customer-tags boxshadow">
<div class="filter-row"> <!-- 前3组 -->
<span class="filter-label">价值标签</span> <div class="filter-row" v-for="item in visibleTagGroups" :key="item.id">
<span class="filter-label">{{ item.name }}</span>
<div class="filter-tags"> <div class="filter-tags">
<span v-for="item in valueTags" :key="item.label" :class="['tag-item', { 'tag-active': tagActiveMap['value'] === item.label }]" @click="handleTagClick('value', item.label)"> <span
{{ item.label }}({{ item.count }}) :class="['tag-itemall', { 'tag-active': tagActiveMap[item.id] === '' }]"
</span> @click="handleTagClick(item.id, '')"
</div> >全部</span
</div> >
<div class="filter-row"> <span
<span class="filter-label">生命周期标签</span> class="tag-items"
<div class="filter-tags"> v-for="item1 in item.tags"
<span v-for="item in lifeTags" :key="item.label" :class="['tag-item', { 'tag-active': tagActiveMap['life'] === item.label }]" @click="handleTagClick('life', item.label)"> :key="item1.id"
{{ item.label }}({{ item.count }}) @click="handleTagClick(item.id, item1.id)"
</span> >
</div> <span :class="['tag-item', { 'tag-active': tagActiveMap[item.id] === item1.id }]">
</div> {{ item1.name }}
<div class="filter-row">
<span class="filter-label">行为标签</span>
<div class="filter-tags">
<span v-for="item in actionTags" :key="item.label" :class="['tag-item', { 'tag-active': tagActiveMap['action'] === item.label }]" @click="handleTagClick('action', item.label)">
{{ item.label }}({{ item.count }})
</span>
</div>
</div>
<transition name="fade">
<div v-if="showMore" class="filter-row">
<span class="filter-label">更多标签</span>
<div class="filter-tags">
<span v-for="item in moreTags" :key="item.label" :class="['tag-item', { 'tag-active': tagActiveMap['more'] === item.label }]" @click="handleTagClick('more', item.label)">
{{ item.label }}({{ item.count }})
</span> </span>
<el-badge :value="item1.count" :max="99" class="itemtag" type="primary"> </el-badge>
</span>
</div>
</div>
<!-- 更多 -->
<transition name="fade">
<div v-if="showMore">
<div class="filter-row" v-for="item in moreTagGroups" :key="item.id">
<span class="filter-label">{{ item.name }}</span>
<div class="filter-tags">
<span
:class="['tag-itemall', { 'tag-active': tagActiveMap[item.id] === '' }]"
@click="handleTagClick(item.id, '')"
>全部</span
>
<span
class="tag-items"
v-for="item1 in item.tags"
:key="item1.id"
@click="handleTagClick(item.id, item1.id)"
>
<span :class="['tag-item', { 'tag-active': tagActiveMap[item.id] === item1.id }]">
{{ item1.name }}
</span>
<el-badge :value="item1.count" :max="99" class="itemtag" type="primary"> </el-badge>
</span>
</div>
</div> </div>
</div> </div>
</transition> </transition>
<div class="filter-more-btn"> <div class="filter-more-btn" v-if="moreTagGroups.length">
<el-link :underline="false" @click="showMore = !showMore"> <el-link underline="never" @click="showMore = !showMore">
{{ showMore ? '收起' : '更多' }} {{ showMore ? '收起' : '更多' }}
<i v-if="showMore" class="iconfont-sys"> &#xe864;</i> <i v-if="showMore" class="iconfont-sys"> &#xe864;</i>
<i v-else class="iconfont-sys">&#xe867;</i> <i v-else class="iconfont-sys">&#xe867;</i>
</el-link> </el-link>
</div> </div>
</div> </div>
<div class="boxshadow"> <div class="boxshadow">
<!-- Tab切换 --> <!-- Tab切换 -->
<el-tabs v-model="activeTab" class="customer-tabs"> <el-tabs v-model="activeTab" class="customer-tabs">
<el-tab-pane label="全部" name=""></el-tab-pane> <el-tab-pane label="全部" name=""></el-tab-pane>
<el-tab-pane label="跟进中" name="0"></el-tab-pane> <el-tab-pane label="跟进中" name="0"></el-tab-pane>
<el-tab-pane label="已成交" name="1"></el-tab-pane> <el-tab-pane label="已成交" name="1"></el-tab-pane>
<el-tab-pane label="已丢失" name="2"></el-tab-pane> <el-tab-pane label="已丢失" name="2"></el-tab-pane>
</el-tabs> </el-tabs>
<!-- 表格 --> <!-- 表格 -->
<ArtTable ref="tableRef" row-key="id" :loading="loading" :data="tableData" :currentPage="pagination.currentPage" <ArtTable
:pageSize="pagination.pageSize" :total="pagination.total" @size-change="handleSizeChange" ref="tableRef"
@current-change="handleCurrentChange" lass="customer-table"> row-key="id"
<template #default> :loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column prop="code" label="编号" align="center" />
<el-table-column prop="contacts" label="客户名称" align="center" /> <el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" /> <el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" /> <el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<el-table-column prop="phone" label="电话" align="center" /> <el-table-column prop="phone" label="电话" align="center" />
<!-- <el-table-column prop="WhatsApp" label="WhatsApp" align="center" /> -->
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="grade_txt" label="等级" align="center" /> <el-table-column prop="grade_txt" label="等级" align="center" />
<el-table-column prop="follow" label="状态" align="center"> <el-table-column prop="describe" label="备注" align="center" />
<template #default="scope"> <!-- <el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)"> <span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }} {{ scope.row.follow_txt }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column> -->
<el-table-column prop="follow_next" label="下次跟进时间"></el-table-column>
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center"> <el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope"> <template #default="scope">
<el-link type="primary" :underline="false" style="margin-right: 8px;" @click="goEdit(scope.row)"> <i class="iconfont-sys"> &#xe70f;</i>查看</el-link> <el-link
<el-link type="primary" :underline="false" @click="goAddFollow(scope.row)"> <i class="iconfont-sys"> &#xe6bf;</i> 添加跟进</el-link> type="success"
</template> underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('crm_customer@show')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="goEdit(scope.row)"
v-if="hasAuth('crm_customer@show')"
>
<i class="iconfont-sys"> &#xe70f;</i>查看
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="goAddFollow(scope.row)"
v-if="hasAuth('crm_customer@follow_add')"
>
<i class="iconfont-sys">&#xe6bf;</i> 跟进
</el-link>
<el-link type="danger" underline="never" @click="reportfn(scope.row)">
<i class="iconfont-sys">&#xe701;</i> 标记无效
</el-link>
<el-link
type="danger"
underline="never"
@click="delectfn(scope.row)"
v-if="hasAuth('crm_customer@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i> 删除
</el-link>
</template>
</el-table-column> </el-table-column>
</template> </template>
</ArtTable> </ArtTable>
</div> </div>
</div> </div>
<el-dialog v-model="detailDialogVisible" title="详情">
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
const searchValue = ref('') import { useAuth } from '@/composables/useAuth'
const selectValue = ref('') const { hasAuth } = useAuth()
const activeTab = ref('') const searchValue = ref('')
const showMore = ref(false) const selectValue = ref('')
const loading = ref(false) const activeTab = ref('')
const pagination = reactive({ const country_id = ref('')
currentPage: 1, const showMore = ref(false)
pageSize: 10, const loading = ref(false)
total: 0 const detailDialogVisible = ref(false)
}) const detailRow = ref<any>(null)
const selectOptions = [ const pagination = reactive({
{ label: '全部', value: '' }, currentPage: 1,
{ label: '当天', value: '0' }, pageSize: 10,
{ label: '近三天', value: '1' }, total: 0
{ label: '近七天', value: '2' } })
] const selectOptions = [
{ label: '全部', value: '' },
const valueTags = [ { label: '当天', value: '0' },
{ label: '高价值', count: 10 }, { label: '近三天', value: '1' },
{ label: '中价值', count: 10 }, { label: '近七天', value: '2' }
{ label: '低价值', count: 10 }, ]
{ label: '潜在高价值', count: 10 } const countryOptions = ref<any>()
] //国家
const lifeTags = [ const getarea = async () => {
{ label: '新客户', count: 10 }, try {
{ label: '活跃', count: 10 }, // 这里调用获取品牌列表的接口
{ label: '深度', count: 10 }, const res = await CrmService.getCountry()
{ label: '流失', count: 10 } countryOptions.value = res||[]
] } catch (error) { countryOptions.value = [] }
const actionTags = [
{ label: '高频购买', count: 10 },
{ label: '大单', count: 10 },
{ label: '促销敏感', count: 10 }
]
const moreTags = [
{ label: '标签A', count: 5 },
{ label: '标签B', count: 3 }
]
//获取标签
const tagGroups = ref<any[]>([])
const tagGrouplistfn = async () => {
try {
const res = await CrmService.tagGrouplist()
tagGroups.value = res || []
} catch (error) { }
}
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
gettableList()
})
// tabs 切换联动
watch([activeTab, searchValue, selectValue], () => {
pagination.currentPage = 1
gettableList()
})
// 获取列表数据
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (searchValue.value) params.keyword = searchValue.value
if (selectValue.value) params.follow_last = selectValue.value
if (activeTab.value && activeTab.value !== '') params.follow = activeTab.value
// 标签参数
if (tagActiveMap.value) params.value_tag = tagActiveMap.value
if (tagActiveMap.life) params.life_tag = tagActiveMap.life
if (tagActiveMap.action) params.action_tag = tagActiveMap.action
if (tagActiveMap.more) params.more_tag = tagActiveMap.more
const { data, current, size, total } = await CrmService.getcustomerList(params)
tableData.value = data.map((item: any, index: number) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
} }
} // 获取列表数据
// 处理表格分页变化 const tagActiveMap = reactive<{ [groupId: string]: string }>({})
const handleSizeChange = (newPageSize: number) => { //获取标签
pagination.pageSize = newPageSize type TagGroup = { id: string; name: string; tags: { id: string; name: string; count: number }[] }
// getWorkList() const tagGroups = ref<TagGroup[]>([])
} const tagGrouplistfn = async () => {
const res = await CrmService.tagGrouplist({ scene: 'customers' })
const handleCurrentChange = (newCurrentPage: number) => { tagGroups.value = res || []
pagination.currentPage = newCurrentPage tagGroups.value.forEach((group) => {
// getWorkList() if (group.tags && Array.isArray(group.tags)) {
} tagActiveMap[group.id] = ''
function statusClass(status: any) { }
if (status === 0) return 'status-following' })
if (status === 1) return 'status-dealed' }
if (status === 2) return 'status-lost' // 计算前3组和更多组
return '' // eslint-disable-next-line vue/valid-define-props
} defineProps()
const visibleTagGroups = computed(() => tagGroups.value.slice(0, 3))
const router = useRouter() const moreTagGroups = computed(() => tagGroups.value.slice(3))
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getarea()
gettableList()
tagGrouplistfn()
})
// tabs 切换联动
watch([activeTab, searchValue, selectValue, country_id], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (searchValue.value) params.keyword = searchValue.value
if (selectValue.value) params.follow_last = selectValue.value
if (country_id.value) params.country_id = country_id.value
if (activeTab.value && activeTab.value !== '') params.follow = activeTab.value
// 标签参数,所有选中的标签id组成数组
const selectedTagIds = Object.values(tagActiveMap).filter(Boolean)
if (selectedTagIds.length) {
params.tag_id = selectedTagIds
}
console.log(params)
const { data, current, size, total } = await CrmService.getcustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
function statusClass(status: any) {
if (status === 0) return 'status-following'
if (status === 1) return 'status-dealed'
if (status === 2) return 'status-lost'
return ''
}
const router = useRouter()
function goAdd() { function goAdd() {
router.push('/crmpc/customer/add') router.push('/crmpc/customer/add')
} }
function goEdit(row: any) { function goEdit(row: any) {
router.push({ router.push({
@@ -222,122 +306,218 @@ const router = useRouter()
query: { id: row.id } // 可带客户名等参数 query: { id: row.id } // 可带客户名等参数
}) })
} }
// 标签选中状态,key为组id,值为选中的标签id
// 标签选中状态,key为组名,值为选中的标签label function handleTagClick(groupId: string, tagId: string) {
const tagActiveMap = reactive<{ [group: string]: string }>({}) // if (tagActiveMap[groupId] === tagId) {
// tagActiveMap[groupId] = '' // 取消选中
function handleTagClick(group: string, label: string) { // } else {
if (tagActiveMap[group] === label) { tagActiveMap[groupId] = tagId // 选中
tagActiveMap[group] = '' // 取消选中 // }
} else { pagination.currentPage = 1
tagActiveMap[group] = label // 选中 gettableList()
}
//删除
const delectfn = (row: any) => {
ElMessageBox.confirm('确定要删除此用户吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await CrmService.getcustomerdelete({ id: row.id })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
// 标记无效
const reportfn = (row: any) => {
ElMessageBox.confirm('确定要标记无效吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await CrmService.customerReport({ id: row.id })
ElMessage.success(res?.message || '提交成功!')
gettableList()
})
.catch(() => {
// 用户点击了取消
})
}
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
} }
pagination.currentPage = 1
gettableList()
}
</script> </script>
<style scoped> <style scoped lang="scss">
:deep(.el-table__header th) { :deep(.el-badge) {
background: #f7f9fa !important; --el-badge-size: 16px;
color: #333; }
font-weight: bold; .itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
} }
:deep(.el-table__body tr.custom-table-row td) { :deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #F7F9FA !important; border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #F7F9FA !important; border-right: 1px solid #f7f9fa !important;
} }
:deep(.el-table__body tr td:last-child) { :deep(.el-table__body tr td:last-child) {
border-right: none !important; border-right: none !important;
} }
:deep(.el-table__body tr:last-child td) { :deep(.el-table__body tr:last-child td) {
border-bottom: none !important; border-bottom: none !important;
} }
:deep(.el-table__header-wrapper th) { :deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #F7F9FA !important; border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #F7F9FA !important; border-right: 1px solid #f7f9fa !important;
} }
:deep(.el-table__header-wrapper th:last-child) { :deep(.el-table__header-wrapper th:last-child) {
border-right: none !important; border-right: none !important;
} }
:deep(.el-table) { :deep(.el-table) {
border: 1px solid #F7F9FA !important; border: 1px solid #f7f9fa !important;
border-radius: 6px; border-radius: 6px;
} }
.customer-page {
background: var(--art-main-bg-color); .customer-page {
min-height: 100vh; background: var(--art-main-bg-color);
} min-height: 100vh;
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 10px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
span{
margin-right: 20px;
} }
}
.customer-tabs { .customer-toolbar {
margin-bottom: 8px; display: flex;
} justify-content: space-between;
.status-following { align-items: center;
color: #00A43F; margin-bottom: 16px;
font-weight: bold; padding: 20px 20px;
} background: var(--art-main-bg-color);
.status-dealed { }
color: #3471FF;
font-weight: bold; .customer-search-group {
} display: flex;
.status-lost { gap: 12px;
color: #FF0000; }
font-weight: bold;
} .customer-tags {
.customer-table { background: var(--art-main-bg-color);
background: var(--art-main-bg-color); border-radius: 8px;
border-radius: 8px; padding: 16px 20px;
margin-top: 0; margin-bottom: 16px;
} }
.filter-more-btn {
text-align: center; .filter-row {
margin-top: 8px; display: flex;
} align-items: center;
.fade-enter-active, .fade-leave-active { margin-bottom: 14px;
transition: opacity 0.2s; }
}
.fade-enter-from, .fade-leave-to { .filter-label {
opacity: 0; width: 120px;
} color: #666;
.tag-item { cursor: pointer; border-radius: 4px; padding: 2px 10px; margin-right: 8px; transition: background 0.2s; } margin-right: 8px;
.tag-active { background: #3471FF; color: #fff; } flex-shrink: 0;
</style> font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
</style>
+226 -212
View File
@@ -29,266 +29,280 @@
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
<template #default> <template #default>
<el-table-column prop="contacts" label="客户名称" min-width="120"></el-table-column> <el-table-column prop="contacts" label="客户名称"></el-table-column>
<el-table-column prop="phone" label="电话" min-width="120"></el-table-column> <el-table-column prop="phone" label="电话"></el-table-column>
<el-table-column prop="magnitude" label="客户商机" min-width="140"></el-table-column> <el-table-column prop="WhatsApp" label="WhatsApp"></el-table-column>
<el-table-column label="状态" min-width="140"> <el-table-column prop="email" label="邮箱"></el-table-column>
<template #default="scope"> <el-table-column prop="magnitude_txt" label="客户商机"></el-table-column>
<span :class="getCountdownClass(scope.row.deleted_at)"> <el-table-column prop="channel_name" label="渠道"></el-table-column>
<el-table-column prop="country_name" label="国家"></el-table-column>
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px;"><i class="iconfont-sys">&#xe7b1;</i></span> <el-table-column prop="enter_user_name" label="录单人"></el-table-column>
{{ getCountdown(scope.row.deleted_at) }} <el-table-column prop="created_at" label="创建时间"></el-table-column>
</span>
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="160px"> <el-table-column fixed="right" label="操作" width="160px">
<template #default="scope"> <template #default="scope">
<div class="follow-detli" @click="onDetail(scope.row)"> <div class="follow-detli" @click="onDetail(scope.row)">
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px;"><i class="iconfont-sys">&#xe70f;</i></span> <span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px">
详情 <i class="iconfont-sys">&#xe70f;</i>
</span>
详情
</div> </div>
<div class="follow-action" @click="onFollow(scope.row)"> <div class="follow-action" @click="onFollow(scope.row)">
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px;"><i class="iconfont-sys">&#xe841;</i></span> <span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px">
领单 <i class="iconfont-sys">&#xe841;</i>
</span>
领单
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
</template> </template>
</ArtTable> </ArtTable>
</div> </div>
</div> </div>
<el-dialog v-model="detailDialogVisible" title="详情" width="400px"> <el-dialog v-model="detailDialogVisible" title="详情">
<div v-if="detailRow" v-html="detailRow.desc_collect" > <div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</div>
</el-dialog> </el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
const loading = ref(false) const loading = ref(false)
const pagination = reactive({ const pagination = reactive({
currentPage: 1, currentPage: 1,
pageSize: 20, pageSize: 10,
total: 0 total: 0
}) })
const remindRange = ref('') const remindRange = ref('')
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
watch([remindRange], () => { watch([remindRange], () => {
pagination.currentPage = 1 pagination.currentPage = 1
gettableList() gettableList()
}) })
// 获取列表数据 // 获取列表数据
const gettableList = async () => { const gettableList = async () => {
loading.value = true loading.value = true
try { try {
const { currentPage, pageSize } = pagination const { currentPage, pageSize } = pagination
const params: any = { const params: any = {
current: currentPage, current: currentPage,
size: pageSize, size: pageSize,
seas:'seas' seas: 'seas'
}
if (remindRange.value) params.follow_last = remindRange.value
const { data, current, size, total } = await CrmService.getcustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
} }
if (remindRange.value) params.follow_last = remindRange.value
const { data, current, size, total } = await CrmService.getcustomerList(params)
tableData.value = data.map((item: any, index: number) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
} }
} // 选中的行数据
// 选中的行数据 const selectedRows = ref<any[]>([])
const selectedRows = ref<any[]>([])
// 处理表格行选择变化 // 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => { const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 处理表格分页变化 // 处理表格分页变化
const handleSizeChange = (newPageSize: number) => { const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize pagination.pageSize = newPageSize
gettableList() gettableList()
} }
const handleCurrentChange = (newCurrentPage: number) => { const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage pagination.currentPage = newCurrentPage
gettableList() gettableList()
} }
const timer = ref(0) const timer = ref(0)
const refreshKey = ref(0) const refreshKey = ref(0)
const detailDialogVisible = ref(false) const detailDialogVisible = ref(false)
const detailRow = ref<any>(null) const detailRow = ref<any>(null)
onMounted(() => { onMounted(() => {
gettableList() gettableList()
timer.value = window.setInterval(() => { timer.value = window.setInterval(() => {
refreshKey.value++ refreshKey.value++
}, 1000) }, 1000)
}) })
onUnmounted(() => { onUnmounted(() => {
if (timer.value) { if (timer.value) {
clearInterval(timer.value) clearInterval(timer.value)
}
})
function getCountdown(targetDate: string) {
// 让 refreshKey 成为依赖,确保每秒刷新
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
refreshKey.value
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid()) return '时间格式错误'
const diff = target.diff(now)
if (diff <= 0) return '已超时'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((diff / (1000 * 60)) % 60)
const seconds = Math.floor((diff / 1000) % 60)
return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
} }
})
function getCountdown(targetDate: string) { function getCountdownClass(targetDate: string) {
// 让 refreshKey 成为依赖,确保每秒刷新 const now = dayjs()
refreshKey.value const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
const now = dayjs() if (!target.isValid() || target.diff(now) <= 0) {
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss') return 'countdown-danger'
if (!target.isValid()) return '时间格式错误' }
const diff = target.diff(now) return 'countdown-success'
if (diff <= 0) return '已超时'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((diff / (1000 * 60)) % 60)
const seconds = Math.floor((diff / 1000) % 60)
return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
}
function getCountdownClass(targetDate: string) {
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid() || target.diff(now) <= 0) {
return 'countdown-danger'
} }
return 'countdown-success' const onFollow = async (row: any) => {
} ElMessageBox.confirm('确定要领取此客户吗?', '提示', {
const onFollow = async (row: any) => {
ElMessageBox.confirm(
'确定要领取此客户吗?',
'提示',
{
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning'
} })
).then(async () => { .then(async () => {
const res = await CrmService.customerclaim({customer_id:row.id}) const res = await CrmService.customerclaim({ customer_id: row.id })
ElMessage.success(res?.message || '提交成功!') ElMessage.success(res?.message || '提交成功!')
gettableList() gettableList()
// 你可以在这里调用接口或做后续处理 // 你可以在这里调用接口或做后续处理
}).catch(() => { })
// 用户点击了取消 .catch(() => {
}) // 用户点击了取消
} })
}
function onDetail(row: any) { function processImages(html: string) {
detailRow.value = row const parser = new DOMParser()
detailDialogVisible.value = true const doc = parser.parseFromString(html, 'text/html')
} doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.boxshadow { .detils-content {
border-radius: 8px; padding-left: 15px;
box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1); img {
background:var(--el-card-bg-color); max-width: 100% !important;
padding: 10px; }
margin-bottom: 10px; }
} .boxshadow {
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1);
background: var(--el-card-bg-color);
padding: 20px;
margin-bottom: 10px;
}
.heard { .heard {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.heard-title {
font-size: 20px;
font-weight: bold;
color: #333;
border-left: 4px solid #3471FF;
padding-left: 12px;
height: 32px;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 10px;
.heard-title {
font-size: 20px;
font-weight: bold;
color: #333;
border-left: 4px solid #3471ff;
padding-left: 12px;
height: 32px;
display: flex;
align-items: center;
}
.heard-date-picker {
width: 120px;
}
.heard-select {
width: 120px;
}
} }
.heard-date-picker { .dark {
width: 120px; .card-list {
} .art-custom-card {
.heard-select { .iconfont-sys {
width: 120px; background-color: #232323 !important;
} }
}
.dark {
.card-list {
.art-custom-card {
.iconfont-sys {
background-color: #232323 !important;
} }
} }
} }
} .table {
:deep(.el-table__empty-block) {
.table { height: 300px !important;
:deep(.el-table__header th) { }
:deep(.el-table__header th) {
background: #f7f9fa !important; background: #f7f9fa !important;
color: #333; color: #333;
font-weight: bold; font-weight: bold;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
:deep(.el-table th),
:deep(.el-table td) {
text-align: center !important;
}
} }
:deep(.el-table__body tr.custom-table-row td) { .countdown-danger {
border-bottom: 1px solid #F7F9FA !important; color: #ff0000 !important;
border-right: 1px solid #F7F9FA !important;
} }
:deep(.el-table__body tr td:last-child) { .countdown-success {
border-right: none !important; color: #00a43f !important;
} }
:deep(.el-table__body tr:last-child td) { .follow-detli {
border-bottom: none !important; display: inline-flex;
align-items: center;
cursor: pointer;
color: #00a43f;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
} }
:deep(.el-table__header-wrapper th) { .follow-action {
border-bottom: 1px solid #F7F9FA !important; display: inline-flex;
border-right: 1px solid #F7F9FA !important; align-items: center;
cursor: pointer;
color: #409eff;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
} }
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #F7F9FA !important;
border-radius: 6px;
}
:deep(.el-table th),
:deep(.el-table td) {
text-align: center !important;
}
}
.countdown-danger {
color: #FF0000 !important;
}
.countdown-success {
color: #00A43F !important;
}
.follow-detli{
display: inline-flex;
align-items: center;
cursor: pointer;
color: #00A43F;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
.follow-action {
display: inline-flex;
align-items: center;
cursor: pointer;
color: #409EFF;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
</style> </style>
+379 -289
View File
@@ -1,6 +1,10 @@
<template> <template>
<div class="homeheard"> <div class="homeheard">
您已陪伴领客 CRM <span class="daynum"><span class="day">{{homestatistics.day}}</span>天</span> 您已陪伴领客 CRM
<span class="daynum"
><span class="day">{{ homestatistics.day }}</span
>天</span
>
</div> </div>
<div class="boxshadow"> <div class="boxshadow">
<div class="heard"> <div class="heard">
@@ -15,8 +19,15 @@
/> />
</div> </div>
<el-row :gutter="20" class="card-list"> <el-row :gutter="20" class="card-list">
<el-col :sm="12" :md="6" :lg="4" > <el-col :sm="12" :md="6" :lg="4">
<div class="card art-custom-card" :style="{ backgroundImage: `url(${adduser})`, backgroundSize: 'cover', backgroundPosition: 'center' }"> <div
class="card art-custom-card"
:style="{
backgroundImage: `url(${adduser})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<CountTo <CountTo
class="number box-title" class="number box-title"
:endVal="homestatistics.customer" :endVal="homestatistics.customer"
@@ -28,8 +39,15 @@
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :md="6" :lg="4" > <el-col :sm="12" :md="6" :lg="4">
<div class="card art-custom-card" :style="{ backgroundImage: `url(${addbf})`, backgroundSize: 'cover', backgroundPosition: 'center' }"> <div
class="card art-custom-card"
:style="{
backgroundImage: `url(${addbf})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<CountTo <CountTo
class="number box-title" class="number box-title"
:endVal="homestatistics.visit" :endVal="homestatistics.visit"
@@ -41,8 +59,15 @@
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :md="6" :lg="4" > <el-col :sm="12" :md="6" :lg="4">
<div class="card art-custom-card" :style="{ backgroundImage: `url(${addgj})`, backgroundSize: 'cover', backgroundPosition: 'center' }"> <div
class="card art-custom-card"
:style="{
backgroundImage: `url(${addgj})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<CountTo <CountTo
class="number box-title" class="number box-title"
:endVal="homestatistics.follow" :endVal="homestatistics.follow"
@@ -54,40 +79,64 @@
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :md="6" :lg="4" > <el-col :sm="12" :md="6" :lg="4">
<div class="card art-custom-card" :style="{ backgroundImage: `url(${deal})`, backgroundSize: 'cover', backgroundPosition: 'center' }"> <div
class="card art-custom-card"
:style="{
backgroundImage: `url(${deal})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<CountTo <CountTo
class="number box-title" class="number box-title"
:endVal="homestatistics.customer_init" :endVal="homestatistics.customer_init"
:duration="1000" :duration="1000"
separator="" separator=""
></CountTo> >
</CountTo>
<div class="change-box"> <div class="change-box">
<span class="change-text">跟进中</span> <span class="change-text">跟进中</span>
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :md="6" :lg="4" > <el-col :sm="12" :md="6" :lg="4">
<div class="card art-custom-card" :style="{ backgroundImage: `url(${gjing})`, backgroundSize: 'cover', backgroundPosition: 'center' }"> <div
class="card art-custom-card"
:style="{
backgroundImage: `url(${gjing})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<CountTo <CountTo
class="number box-title" class="number box-title"
:endVal="homestatistics.customero_fllow" :endVal="homestatistics.customero_fllow"
:duration="1000" :duration="1000"
separator="" separator=""
></CountTo> >
</CountTo>
<div class="change-box"> <div class="change-box">
<span class="change-text">已成交</span> <span class="change-text">已成交</span>
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :md="6" :lg="4" > <el-col :sm="12" :md="6" :lg="4">
<div class="card art-custom-card" :style="{ backgroundImage: `url(${loss})`, backgroundSize: 'cover', backgroundPosition: 'center' }"> <div
class="card art-custom-card"
:style="{
backgroundImage: `url(${loss})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<CountTo <CountTo
class="number box-title" class="number box-title"
:endVal="homestatistics.customer_deal" :endVal="homestatistics.customer_deal"
:duration="1000" :duration="1000"
separator="" separator=""
></CountTo> >
</CountTo>
<div class="change-box"> <div class="change-box">
<span class="change-text">已丢失</span> <span class="change-text">已丢失</span>
</div> </div>
@@ -111,91 +160,107 @@
</el-select> </el-select>
</div> </div>
<div class="table"> <div class="table">
<ArtTable ref="tableRef" row-key="id" :loading="loading" :data="tableData" :currentPage="pagination.currentPage" <ArtTable
:pageSize="pagination.pageSize" :total="pagination.total" @size-change="handleSizeChange" ref="tableRef"
@current-change="handleCurrentChange" lass="customer-table"> row-key="id"
<template #default> :loading="loading"
<el-table-column prop="contacts" label="客户名称" min-width="120"></el-table-column> :data="tableData"
<el-table-column prop="phone" label="电话" min-width="120"></el-table-column> :currentPage="pagination.currentPage"
<el-table-column prop="follow_next" label="下次跟进时间" min-width="140"></el-table-column> :pageSize="pagination.pageSize"
<el-table-column label="状态" min-width="140"> :total="pagination.total"
<template #default="scope"> @size-change="handleSizeChange"
<span :class="getCountdownClass(scope.row.follow_next)"> @current-change="handleCurrentChange"
lass="customer-table"
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px;"><i class="iconfont-sys">&#xe7b1;</i></span> >
{{ getCountdown(scope.row.follow_next) }} <template #default>
<el-table-column prop="contacts" label="客户名称" min-width="120"></el-table-column>
<el-table-column prop="phone" label="电话" min-width="120"></el-table-column>
<el-table-column
prop="follow_next"
label="下次跟进时间"
min-width="140"
></el-table-column>
<el-table-column label="状态" min-width="140">
<template #default="scope">
<span :class="getCountdownClass(scope.row.follow_next)">
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px">
<i class="iconfont-sys">&#xe7b1;</i>
</span> </span>
</template> {{ getCountdown(scope.row.follow_next) }}
</el-table-column> </span>
<el-table-column label="操作" min-width="100">
<template #default="scope">
<div class="follow-action" @click="goAddFollow(scope.row)">
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px;"><i class="iconfont-sys">&#xe6bf;</i></span>
跟进
</div>
</template>
</el-table-column>
</template> </template>
</ArtTable> </el-table-column>
<el-table-column label="操作" min-width="100">
<template #default="scope">
<div class="follow-action" @click="goAddFollow(scope.row)">
<span class="custom-clock-icon" style="vertical-align: middle; margin-right: 4px">
<i class="iconfont-sys">&#xe6bf;</i>
</span>
跟进
</div>
</template>
</el-table-column>
</template>
</ArtTable>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted } from 'vue' import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { CountTo } from 'vue3-count-to' import { CountTo } from 'vue3-count-to'
import adduser from '@/assets/img/crm/adduser.png' import adduser from '@/assets/img/crm/adduser.png'
import addbf from '@/assets/img/crm/addbf.png' import addbf from '@/assets/img/crm/addbf.png'
import addgj from '@/assets/img/crm/addgj.png' import addgj from '@/assets/img/crm/addgj.png'
import deal from '@/assets/img/crm/deal.png' import deal from '@/assets/img/crm/deal.png'
import gjing from '@/assets/img/crm/gjing.png' import gjing from '@/assets/img/crm/gjing.png'
import loss from '@/assets/img/crm/loss.png' import loss from '@/assets/img/crm/loss.png'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
const router = useRouter() const router = useRouter()
function getCurrentMonth() { function getCurrentMonth() {
const d = new Date() const d = new Date()
const month = d.getMonth() + 1 const month = d.getMonth() + 1
return `${d.getFullYear()}-${month < 10 ? '0' + month : month}` return `${d.getFullYear()}-${month < 10 ? '0' + month : month}`
} }
const selectedDate = ref(getCurrentMonth()) const selectedDate = ref(getCurrentMonth())
const remindRange = ref('') const remindRange = ref('')
const loading = ref(false) const loading = ref(false)
const pagination = reactive({ const pagination = reactive({
currentPage: 1, currentPage: 1,
pageSize: 10, pageSize: 10,
total: 0 total: 0
}) })
const tableData = ref([]) const tableData = ref([])
watch([remindRange], () => { watch([remindRange], () => {
pagination.currentPage = 1 pagination.currentPage = 1
gettableList() gettableList()
}) })
watch(selectedDate, (val) => { watch(selectedDate, (val) => {
console.log('用户选择的时间:', val) console.log('用户选择的时间:', val)
gethomestatistics(val) gethomestatistics(val)
}) })
//获取统计数据 homestatistics //获取统计数据 homestatistics
interface CustomerInfo { interface CustomerInfo {
[key: string]: any [key: string]: any
} }
const homestatistics = ref<CustomerInfo>({}) const homestatistics = ref<CustomerInfo>({})
const gethomestatistics = async (val:any) => { const gethomestatistics = async (val: any) => {
loading.value = true loading.value = true
try { try {
const data = await CrmService.homestatistics({created:val?val:getCurrentMonth()}) const data = await CrmService.homestatistics({ created: val ? val : getCurrentMonth() })
homestatistics.value = data homestatistics.value = data
} catch (error) { } catch (error) {
console.error('获取用户列表失败:', error) console.error('获取用户列表失败:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// 获取列表数据 // 获取列表数据
const gettableList = async () => { const gettableList = async () => {
loading.value = true loading.value = true
try { try {
const { currentPage, pageSize } = pagination const { currentPage, pageSize } = pagination
@@ -203,10 +268,10 @@ const gettableList = async () => {
current: currentPage, current: currentPage,
size: pageSize size: pageSize
} }
if (remindRange.value) params.filter = remindRange.value if (remindRange.value) params.filter = remindRange.value
const { data, current, size, total } = await CrmService.waitFollow(params) const { data, current, size, total } = await CrmService.waitFollow(params)
tableData.value = data.map((item: any, index: number) => ({ tableData.value = data.map((item: any) => ({
...item ...item
})) }))
Object.assign(pagination, { currentPage: current, pageSize: size, total }) Object.assign(pagination, { currentPage: current, pageSize: size, total })
@@ -215,242 +280,267 @@ const gettableList = async () => {
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// 处理表格分页变化 // 处理表格分页变化
const handleSizeChange = (newPageSize: number) => { const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize pagination.pageSize = newPageSize
// getWorkList() // getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
// getWorkList()
}
const timer = ref(0)
const refreshKey = ref(0)
onMounted(() => {
gethomestatistics()
gettableList()
timer.value = window.setInterval(() => {
refreshKey.value++
}, 1000)
})
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
} }
})
function getCountdown(targetDate: string) { const handleCurrentChange = (newCurrentPage: number) => {
// 让 refreshKey 成为依赖,确保每秒刷新 pagination.currentPage = newCurrentPage
refreshKey.value // getWorkList()
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid()) return '时间格式错误'
const diff = target.diff(now)
if (diff <= 0) return '已超时'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((diff / (1000 * 60)) % 60)
const seconds = Math.floor((diff / 1000) % 60)
return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
}
function getCountdownClass(targetDate: string) {
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid() || target.diff(now) <= 0) {
return 'countdown-danger'
} }
return 'countdown-success' const timer = ref(0)
} const refreshKey = ref(0)
function goAddFollow(row: any) { onMounted(() => {
gethomestatistics('')
gettableList()
timer.value = window.setInterval(() => {
refreshKey.value++
}, 1000)
})
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
function getCountdown(targetDate: string) {
// 让 refreshKey 成为依赖,确保每秒刷新
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
refreshKey.value
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid()) return '时间格式错误'
const diff = target.diff(now)
if (diff <= 0) return '已超时'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((diff / (1000 * 60)) % 60)
const seconds = Math.floor((diff / 1000) % 60)
return `${days > 0 ? days + '天' : ''}${hours}小时${minutes}分${seconds}秒`
}
function getCountdownClass(targetDate: string) {
const now = dayjs()
const target = dayjs(targetDate, 'YYYY-MM-DD HH:mm:ss')
if (!target.isValid() || target.diff(now) <= 0) {
return 'countdown-danger'
}
return 'countdown-success'
}
function goAddFollow(row: any) {
router.push({ router.push({
path: '/crmpc/customer/followadd', path: '/crmpc/customer/followadd',
query: { id: row.id } // 可带客户名等参数 query: { id: row.id } // 可带客户名等参数
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.homeheard{ .table {
background: ::v-deep(.el-scrollbar__view) {
url('@/assets/img/crm/hometop.png') no-repeat center/cover, width: 100% !important;
linear-gradient(90deg, #EDF2FF 0%, #CBE5FC 72.55%);
padding: 10px;
margin-bottom: 10px;
border-radius: 6px;
display: flex;
align-items: center; /* 垂直居中 */
color: #333333;
}
.daynum{
margin-left: 20px;
color: #3471FF;
}
.day{
font-size: 70px;
font-weight: bold;
}
.card-list {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
background-color: var(--el-card-bg-color) !important;
.art-custom-card {
border: none !important;
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 120px;
align-items: end;
padding: 0 18px;
list-style: none;
transition: all 0.3s ease;
$icon-size: 52px;
.iconfont-sys {
position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: $icon-size;
height: $icon-size;
margin: auto;
overflow: hidden;
font-size: 22px;
line-height: $icon-size;
color: var(--el-color-primary) !important;
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 12px;
} }
:deep(.el-table__empty-block) {
.des { height: 400px !important;
display: block; width: 100% !important;
height: 14px;
font-size: 14px;
line-height: 14px;
}
.number {
display: block;
margin-top: 10px;
font-size: 28px;
font-weight: 400;
}
.change-box {
display: flex;
align-items: center;
margin-top: 10px;
.change-text {
display: block;
font-size: 13px;
// color: var(--art-text-gray-600);
}
.change {
display: block;
margin-left: 5px;
font-size: 13px;
font-weight: bold;
&.text-success {
color: var(--el-color-success);
}
&.text-danger {
color: var(--el-color-danger);
}
}
} }
} }
} .homeheard {
background:
.heard { url('@/assets/img/crm/hometop.png') no-repeat center/cover,
display: flex; linear-gradient(90deg, #edf2ff 0%, #cbe5fc 72.55%);
justify-content: space-between; padding: 10px 20px;
align-items: center; margin-bottom: 10px;
margin-bottom: 10px; border-radius: 6px;
.heard-title {
font-size: 20px;
font-weight: bold;
color: #333;
border-left: 4px solid #3471FF;
padding-left: 12px;
height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
/* 垂直居中 */
color: #333333;
}
.daynum {
margin-left: 20px;
color: #3471ff;
}
.day {
font-size: 70px;
font-weight: bold;
} }
.heard-select {
width: 120px;
}
}
.dark {
.card-list { .card-list {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
background-color: var(--el-card-bg-color) !important;
.art-custom-card { .art-custom-card {
border: none !important;
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 120px;
align-items: end;
padding: 0 18px;
list-style: none;
transition: all 0.3s ease;
$icon-size: 52px;
.iconfont-sys { .iconfont-sys {
background-color: #232323 !important; position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: $icon-size;
height: $icon-size;
margin: auto;
overflow: hidden;
font-size: 22px;
line-height: $icon-size;
color: var(--el-color-primary) !important;
text-align: center;
background-color: var(--el-color-primary-light-9);
border-radius: 12px;
}
.des {
display: block;
height: 14px;
font-size: 14px;
line-height: 14px;
}
.number {
display: block;
margin-top: 10px;
font-size: 28px;
font-weight: 400;
}
.change-box {
display: flex;
align-items: center;
margin-top: 10px;
.change-text {
display: block;
font-size: 13px;
// color: var(--art-text-gray-600);
}
.change {
display: block;
margin-left: 5px;
font-size: 13px;
font-weight: bold;
&.text-success {
color: var(--el-color-success);
}
&.text-danger {
color: var(--el-color-danger);
}
}
} }
} }
} }
}
.table { .heard {
::v-deep .custom-table-header { display: flex;
background: #F7F9FA !important; justify-content: space-between;
color: #333; align-items: center;
font-weight: bold; margin-bottom: 10px;
.heard-title {
font-size: 20px;
font-weight: bold;
color: #333;
border-left: 4px solid #3471ff;
padding-left: 12px;
height: 32px;
display: flex;
align-items: center;
}
.heard-select {
width: 120px;
}
} }
::v-deep .el-table__body tr.custom-table-row td {
border-bottom: 1px solid #F7F9FA !important; .dark {
border-right: 1px solid #F7F9FA !important; .card-list {
.art-custom-card {
.iconfont-sys {
background-color: #232323 !important;
}
}
}
} }
::v-deep .el-table__body tr td:last-child {
border-right: none !important; .table {
::v-deep .custom-table-header {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
::v-deep .el-table__body tr.custom-table-row td {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
::v-deep .el-table__body tr td:last-child {
border-right: none !important;
}
::v-deep .el-table__body tr:last-child td {
border-bottom: none !important;
}
::v-deep .el-table__header-wrapper th {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
::v-deep .el-table__header-wrapper th:last-child {
border-right: none !important;
}
::v-deep .el-table {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
} }
::v-deep .el-table__body tr:last-child td {
border-bottom: none !important; .countdown-danger {
color: #ff0000 !important;
} }
::v-deep .el-table__header-wrapper th {
border-bottom: 1px solid #F7F9FA !important; .countdown-success {
border-right: 1px solid #F7F9FA !important; color: #00a43f !important;
} }
::v-deep .el-table__header-wrapper th:last-child {
border-right: none !important; .follow-action {
display: inline-flex;
align-items: center;
cursor: pointer;
color: #409eff;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
} }
::v-deep .el-table {
border: 1px solid #F7F9FA !important;
border-radius: 6px;
}
}
.countdown-danger {
color: #FF0000 !important;
}
.countdown-success {
color: #00A43F !important;
}
.follow-action {
display: inline-flex;
align-items: center;
cursor: pointer;
color: #409EFF;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
</style> </style>
+93 -89
View File
@@ -2,7 +2,8 @@
<div class="recycle-page"> <div class="recycle-page">
<el-card class="recycle-card"> <el-card class="recycle-card">
<div class="recycle-title"> <div class="recycle-title">
<span class="title-dot"></span>回收站 <span class="title-dot"></span>
回收站
</div> </div>
<ArtTable <ArtTable
ref="tableRef" ref="tableRef"
@@ -21,9 +22,11 @@
<el-table-column prop="contacts" label="姓名" min-width="120"></el-table-column> <el-table-column prop="contacts" label="姓名" min-width="120"></el-table-column>
<el-table-column prop="phone" label="联系方式" min-width="120"></el-table-column> <el-table-column prop="phone" label="联系方式" min-width="120"></el-table-column>
<el-table-column fixed="right" label="操作" width="160px"> <el-table-column fixed="right" label="操作" width="160px">
<template #default="scope"> <template #default="scope">
<el-button type="primary" @click="onDetail(scope.row)" size="small">恢复客户</el-button> <el-button type="primary" @click="onDetail(scope.row)" size="small"
</template> >恢复客户
</el-button>
</template>
</el-table-column> </el-table-column>
</template> </template>
</ArtTable> </ArtTable>
@@ -32,103 +35,104 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
const loading = ref(false) const loading = ref(false)
const pagination = reactive({ const pagination = reactive({
currentPage: 1, currentPage: 1,
pageSize: 20, pageSize: 10,
total: 0 total: 0
}) })
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
// 获取列表数据 // 获取列表数据
const gettableList = async () => { const gettableList = async () => {
loading.value = true loading.value = true
try { try {
const { currentPage, pageSize } = pagination const { currentPage, pageSize } = pagination
const params: any = { const params: any = {
current: currentPage, current: currentPage,
size: pageSize, size: pageSize,
recycle:'recycle' recycle: 'recycle'
}
const { data, current, size, total } = await CrmService.getcustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
} }
const { data, current, size, total } = await CrmService.getcustomerList(params)
tableData.value = data.map((item: any, index: number) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
} }
} // 选中的行数据
// 选中的行数据 const selectedRows = ref<any[]>([])
const selectedRows = ref<any[]>([]) // 处理表格行选择变化
// 处理表格行选择变化 const handleSelectionChange = (selection: any[]) => {
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 处理表格分页变化 // 处理表格分页变化
const handleSizeChange = (newPageSize: number) => { const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize pagination.pageSize = newPageSize
gettableList() gettableList()
} }
const handleCurrentChange = (newCurrentPage: number) => { const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage pagination.currentPage = newCurrentPage
gettableList() gettableList()
} }
onMounted(() => { onMounted(() => {
gettableList() gettableList()
}) })
const onDetail = async (row: any) => { const onDetail = async (row: any) => {
ElMessageBox.confirm( ElMessageBox.confirm('确定要恢复此客户吗?', '提示', {
'确定要恢复此客户吗?',
'提示',
{
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning'
} })
).then(async () => { .then(async () => {
const res = await CrmService.customerrestore({id:row.id}) const res = await CrmService.customerrestore({ id: row.id })
ElMessage.success(res?.message || '提交成功!') ElMessage.success(res?.message || '提交成功!')
gettableList() gettableList()
// 你可以在这里调用接口或做后续处理 // 你可以在这里调用接口或做后续处理
}).catch(() => { })
// 用户点击了取消 .catch(() => {
}) // 用户点击了取消
} })
}
</script> </script>
<style scoped> <style scoped>
.recycle-page { .recycle-page {
background: #fafbfc; background: #fafbfc;
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
} }
.recycle-card { .recycle-card {
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
} }
.recycle-title { .recycle-title {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
margin-bottom: 8px; margin-bottom: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.title-dot { .title-dot {
display: inline-block; display: inline-block;
width: 6px; width: 6px;
height: 16px; height: 16px;
background: #3471FF; background: #3471FF;
border-radius: 2px; border-radius: 2px;
margin-right: 8px; margin-right: 8px;
} }
.el-table .hide-header th { .el-table .hide-header th {
display: none; display: none;
} }
</style> :deep(.el-table__empty-block) {
height: 400px !important;
}
</style>
+307
View File
@@ -0,0 +1,307 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
<el-form-item label="组员:" label-width="70px">
<el-select
v-model="selectValue"
style="min-width: 120px"
filterable
placeholder="请选择组员"
>
<el-option
v-for="v in userOptions"
:key="v.id"
:label="v.name"
:value="v.id"
></el-option>
</el-select>
</el-form-item>
</div>
</div>
<div class="table boxshadow">
<el-button type="primary" @click="gotransfer" :disabled="selectedRows.length === 0">
<i class="iconfont-sys">&#xe794;</i> 批量转移
</el-button>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column prop="code" label="编号" align="center" />
<el-table-column prop="contacts" label="客户名称"></el-table-column>
<el-table-column prop="phone" label="电话"></el-table-column>
<el-table-column prop="WhatsApp" label="WhatsApp"></el-table-column>
<el-table-column prop="email" label="邮箱"></el-table-column>
<el-table-column prop="magnitude_txt" label="客户商机"></el-table-column>
<el-table-column prop="channel_name" label="渠道"></el-table-column>
<el-table-column prop="country_name" label="国家"></el-table-column>
<el-table-column prop="user_name" label="跟单人"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
</template>
</ArtTable>
</div>
</div>
<el-dialog v-model="zyDialogVisible" title="转移" width="360px">
<el-form :model="zyForm" label-width="0">
<el-form-item>
<el-select
v-model="zyForm.user_id"
filterable
placeholder="请选择转移人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="zyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlezySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { CrmService } from '@/api/crmApi'
import { useAuth } from '@/composables/useAuth'
import { manageService } from '@/api/adminApi'
const selectValue = ref<string[]>([])
const keyword = ref<string>('')
const { hasAuth } = useAuth()
const loading = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const zyForm = reactive({
customer_id: '',
user_id: ''
})
watch([selectValue, keyword], () => {
pagination.currentPage = 1
gettableList()
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
const remindRange = ref('')
const tableData = ref<any[]>([])
watch([remindRange], () => {
pagination.currentPage = 1
gettableList()
})
// 获取列表数据
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (selectValue.value) {
params.user_id = selectValue.value
}
if (keyword.value) {
params.keyword = keyword.value
}
const { records, current, size, total } = await CrmService.getCustomercsList(params)
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
const timer = ref(0)
const refreshKey = ref(0)
const zyDialogVisible = ref(false)
onMounted(() => {
getuserList()
gettableList()
timer.value = window.setInterval(() => {
refreshKey.value++
}, 1000)
})
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
const gotransfer = () => {
zyDialogVisible.value = true
}
// 转移
const handlezySubmit = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
zyForm.customer_id = selectedRows.value.map((item) => item.id).join(',')
const res = await CrmService.cedeCustomer(zyForm)
ElMessage.success(res?.message || '批量转移成功!')
zyDialogVisible.value = false
gettableList()
}
</script>
<style lang="scss" scoped>
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.detils-content {
padding-left: 15px;
img {
max-width: 100% !important;
}
}
.boxshadow {
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 10, 118, 0.1);
background: var(--el-card-bg-color);
padding: 20px;
margin-bottom: 10px;
}
.heard {
margin-bottom: 10px;
}
.dark {
.card-list {
.art-custom-card {
.iconfont-sys {
background-color: #232323 !important;
}
}
}
}
.table {
margin-top: 10px;
:deep(.el-table__empty-block) {
height: 300px !important;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
:deep(.el-table th),
:deep(.el-table td) {
text-align: center !important;
}
}
.countdown-danger {
color: #ff0000 !important;
}
.countdown-success {
color: #00a43f !important;
}
.follow-detli {
display: inline-flex;
align-items: center;
cursor: pointer;
color: #00a43f;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
.follow-action {
display: inline-flex;
align-items: center;
cursor: pointer;
color: #409eff;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<template>
<div class="customer-page">
<!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
<el-form-item label="状态:" label-width="50px">
<el-select v-model="selectValue" style="min-width: 120px">
<el-option label="全部" value="" />
<el-option label="正常" value="1" />
<el-option label="离职" value="2" />
</el-select>
</el-form-item>
</div>
</div>
<div class="boxshadow">
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<!-- <el-table-column prop="id" label="用户ID" align="center" /> -->
<el-table-column prop="sn" label="SN" align="center" />
<el-table-column prop="follow" label="头像" align="center">
<template #default="scope">
<img
:src="scope.row.avatar"
alt="头像"
class="avatar"
@click="handleAvatarClick(scope.row.avatar)"
style="cursor: pointer"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="名字" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<!-- <el-table-column prop="corp_name" label="所属公司" align="center" /> -->
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.status)">
{{ scope.row.status == 1 ? '在职' : '离职' }}
</span>
</template>
</el-table-column>
<el-table-column prop="customer_count" label="客户数量" align="center" />
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="warning"
underline="never"
style="margin-right: 8px"
@click="statusfn(scope.row)"
v-if="hasAuth('crm_customer@status') && scope.row.status == 1"
>
<i class="iconfont-sys">&#xe641;</i>标记离职
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
<!-- 图片预览组件 -->
<ElImageViewer
v-if="showImageViewer"
:url-list="[currentImageUrl]"
:hide-on-click-modal="true"
@close="showImageViewer = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElImageViewer } from 'element-plus'
import { CrmService } from '@/api/crmApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const keyword = ref<string>('')
const loading = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
gettableList()
})
// tabs 切换联动
watch([selectValue, keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (selectValue.value) {
params.status = selectValue.value
}
if (keyword.value) {
params.keyword = keyword.value
}
const { records, current, size, total } = await CrmService.getCustomerUserList(params)
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
function statusClass(status: any) {
if (status === 1) return 'status-following'
if (status === 0) return 'status-lost'
return ''
}
// 图片预览相关
const showImageViewer = ref(false)
const currentImageUrl = ref('')
const handleAvatarClick = (avatarUrl: string) => {
if (avatarUrl) {
currentImageUrl.value = avatarUrl
showImageViewer.value = true
}
}
const statusfn = (row?: any) => {
ElMessageBox.confirm('确定标记离职吗?', '标记确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await CrmService.markLeave({
user_id: row.id
})
ElMessage.success(message)
gettableList()
})
}
</script>
<style scoped lang="scss">
:deep(.el-badge) {
--el-badge-size: 16px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.form-submit-row {
text-align: center;
}
</style>
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
<template>
<div class="system-notification-demo">
<h2>浏览器原生Notification API测试Demo</h2>
<div class="permission-status">
<p
>当前通知权限状态: <strong>{{ permissionStatus }}</strong></p
>
<el-button v-if="permissionStatus !== 'granted'" @click="requestPermission" type="primary">
请求通知权限
</el-button>
</div>
<div class="button-group">
<el-button @click="showBasicNotification">基本通知</el-button>
<el-button @click="showIconNotification">带图标通知</el-button>
<el-button @click="showPersistentNotification">持久化通知</el-button>
<el-button @click="showDataNotification">带数据的通知</el-button>
<el-button @click="showEventNotification">带事件监听的通知</el-button>
<el-button @click="checkSupport">检查浏览器支持</el-button>
</div>
<div class="log-area">
<h3>操作日志</h3>
<ul>
<li v-for="(log, index) in logs" :key="index">{{ log }}</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// import { ElButton } from 'element-plus'
// 状态变量
const permissionStatus = ref('default')
const logs = ref([])
const isSupported = ref(false)
// 添加日志
const addLog = (message) => {
const timestamp = new Date().toLocaleTimeString()
logs.value.unshift(`[${timestamp}] ${message}`)
// 限制日志数量
if (logs.value.length > 10) {
logs.value.pop()
}
}
// 检查浏览器支持情况
const checkSupport = () => {
if ('Notification' in window) {
isSupported.value = true
addLog('浏览器支持原生Notification API')
addLog(`当前权限状态: ${Notification.permission}`)
} else {
isSupported.value = false
addLog('⚠️ 浏览器不支持原生Notification API')
}
}
// 请求通知权限
const requestPermission = async () => {
if (!('Notification' in window)) {
addLog('浏览器不支持原生Notification API')
return
}
try {
const permission = await Notification.requestPermission()
permissionStatus.value = permission
if (permission === 'granted') {
addLog('用户已授予通知权限')
} else if (permission === 'denied') {
addLog('用户拒绝了通知权限')
} else {
addLog('用户暂时未授予通知权限')
}
} catch (error) {
addLog(`请求权限时出错: ${error.message}`)
}
}
// 显示基本通知
const showBasicNotification = () => {
if (!('Notification' in window)) {
addLog('浏览器不支持原生Notification API')
return
}
if (Notification.permission !== 'granted') {
addLog('请先授予通知权限')
return
}
try {
const notification = new Notification('基本通知', {
body: '这是一个使用浏览器原生API的系统通知'
})
notification.onclick = () => {
addLog('用户点击了基本通知')
notification.close()
}
addLog('已发送基本通知')
} catch (error) {
addLog(`发送通知失败: ${error.message}`)
}
}
// 显示带图标通知
const showIconNotification = () => {
if (Notification.permission !== 'granted') {
addLog('请先授予通知权限')
return
}
try {
const notification = new Notification('带图标通知', {
body: '这个通知包含自定义图标',
icon: 'https://element-plus.org/images/element-plus-logo.svg', // 替换为实际图标URL
badge: 'https://element-plus.org/images/element-plus-logo.svg' // 小图标(移动端)
})
addLog('已发送带图标通知')
} catch (error) {
addLog(`发送通知失败: ${error.message}`)
}
}
// 显示持久化通知
const showPersistentNotification = () => {
if (Notification.permission !== 'granted') {
addLog('请先授予通知权限')
return
}
try {
const notification = new Notification('持久化通知', {
body: '这个通知需要用户手动关闭',
requireInteraction: true, // 持久化显示,直到用户交互
icon: 'https://element-plus.org/images/element-plus-logo.svg'
})
notification.onclose = () => {
addLog('用户关闭了持久化通知')
}
addLog('已发送持久化通知')
} catch (error) {
addLog(`发送通知失败: ${error.message}`)
}
}
// 显示带数据的通知
const showDataNotification = () => {
if (Notification.permission !== 'granted') {
addLog('请先授予通知权限')
return
}
try {
const notification = new Notification('带数据的通知', {
body: '这个通知包含自定义数据',
data: {
url: 'https://example.com',
id: 12345,
message: '这是附加数据'
},
icon: 'https://element-plus.org/images/element-plus-logo.svg'
})
notification.onclick = () => {
addLog(`通知数据: ${JSON.stringify(notification.data)}`)
window.open(notification.data.url)
notification.close()
}
addLog('已发送带数据的通知')
} catch (error) {
addLog(`发送通知失败: ${error.message}`)
}
}
// 显示带事件监听的通知
const showEventNotification = () => {
if (Notification.permission !== 'granted') {
addLog('请先授予通知权限')
return
}
try {
const notification = new Notification('带事件监听的通知', {
body: '这个通知演示各种事件监听',
icon: 'https://element-plus.org/images/element-plus-logo.svg'
})
notification.onshow = () => {
addLog('通知显示事件触发')
}
notification.onclick = () => {
addLog('通知点击事件触发')
notification.close()
}
notification.onclose = () => {
addLog('通知关闭事件触发')
}
notification.onerror = (error) => {
addLog(`通知错误事件: ${error.message}`)
}
addLog('已发送带事件监听的通知')
} catch (error) {
addLog(`发送通知失败: ${error.message}`)
}
}
// 页面加载时检查权限状态
onMounted(() => {
addLog('页面加载完成,初始化Demo')
// 检查浏览器支持
checkSupport()
// 检查当前权限状态
if ('Notification' in window) {
permissionStatus.value = Notification.permission
addLog(`初始化权限状态: ${Notification.permission}`)
// 监听权限变化
if (Notification.permissionChange) {
Notification.permissionChange.then((permission) => {
permissionStatus.value = permission
addLog(`通知权限已变更为: ${permission}`)
})
}
}
})
</script>
<style scoped>
.system-notification-demo {
max-width: 800px;
margin: 20px auto;
padding: 20px;
font-family: Arial, sans-serif;
}
h2, h3 {
color: #333;
text-align: center;
}
.permission-status {
margin: 20px 0;
padding: 15px;
background-color: #f5f7fa;
border-radius: 8px;
text-align: center;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin: 30px 0;
}
.log-area {
margin-top: 30px;
padding: 15px;
background-color: #f5f7fa;
border-radius: 8px;
}
.log-area ul {
list-style-type: none;
padding: 0;
max-height: 200px;
overflow-y: auto;
}
.log-area li {
padding: 5px 0;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.log-area li:first-child {
color: #409eff;
}
</style>
+79 -35
View File
@@ -4,32 +4,37 @@
<div class="left-wrap"> <div class="left-wrap">
<div class="user-wrap box-style"> <div class="user-wrap box-style">
<!-- <img class="bg" src="@imgs/user/bg.webp" /> --> <!-- <img class="bg" src="@imgs/user/bg.webp" /> -->
<img class="avatar" :src="userInfo.avatar_full" /> <img
<h2 class="name">{{userInfo.name}}</h2> class="avatar"
<p class="des">ID:{{userInfo.id}}</p> :src="
userInfo.avatar_full != '' ? userInfo.avatar_full : '/src/assets/img/user/avatar.webp'
"
/>
<h2 class="name">{{ userInfo.name }}</h2>
<p class="des">SN:{{ userInfo.sn }}</p>
<div class="outer-info"> <div class="outer-info">
<div class="info-row"> <div class="info-row">
<span class="info-label">电 话:</span> <span class="info-label">电 话:</span>
<span class="info-value">{{userInfo.phone}}</span> <span class="info-value">{{ userInfo.phone }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">职 位:</span> <span class="info-label">职 位:</span>
<span class="info-value">{{userInfo.position}}</span> <span class="info-value">{{ userInfo.position }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">公 司:</span> <span class="info-label">公 司:</span>
<span class="info-value">{{userInfo.corp_name}}</span> <span class="info-value">{{ userInfo.corp_name }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">国 家:</span> <span class="info-label">国 家:</span>
<span class="info-value">{{userInfo.country_txt}}</span> <span class="info-value">{{ userInfo.country_txt }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="right-wrap"> <div class="right-wrap">
<div class="info box-style"> <div class="info box-style">
<h1 class="title"><span class="border" style="margin-right: 5px;"></span>基本信息</h1> <h1 class="title"><span class="border" style="margin-right: 5px"></span>基本信息</h1>
<ElForm <ElForm
:model="form" :model="form"
@@ -40,14 +45,20 @@
> >
<ElFormItem label="头像" prop="avatar"> <ElFormItem label="头像" prop="avatar">
<el-upload <el-upload
class="qrcode-upload" class="qrcode-upload"
:action="actionImageUplode" :action="actionImageUplode"
:headers="uploadHeaders" :headers="uploadHeaders"
:show-file-list="false" :show-file-list="false"
:on-success="handleUploadavatar" :on-success="handleUploadavatar"
> :disabled="!isEdit"
>
<div class="qrcode-upload-box"> <div class="qrcode-upload-box">
<img class="avatar" :src="imageUrlavatar"/> <img
class="avatar"
:src="
imageUrlavatar != '' ? imageUrlavatar : '/src/assets/img/user/avatar.webp'
"
/>
</div> </div>
</el-upload> </el-upload>
</ElFormItem> </ElFormItem>
@@ -56,7 +67,7 @@
<el-input v-model="form.name" :disabled="!isEdit" /> <el-input v-model="form.name" :disabled="!isEdit" />
</ElFormItem> </ElFormItem>
<ElFormItem label="性别" prop="sex" class="right-input"> <ElFormItem label="性别" prop="sex" class="right-input">
<ElSelect v-model="form.sex" placeholder="Select" :disabled="!isEdit"> <ElSelect v-model="form.sex" placeholder="未知" :disabled="!isEdit">
<ElOption <ElOption
v-for="item in options" v-for="item in options"
:key="item.value" :key="item.value"
@@ -69,7 +80,7 @@
<ElRow> <ElRow>
<ElFormItem label="联系方式" prop="phone"> <ElFormItem label="联系方式" prop="phone">
<ElInput v-model="form.phone" :disabled="!isEdit" /> <ElInput v-model="form.phone" disabled />
</ElFormItem> </ElFormItem>
<ElFormItem label="职务" prop="position" class="right-input"> <ElFormItem label="职务" prop="position" class="right-input">
<ElInput v-model="form.position" :disabled="!isEdit" /> <ElInput v-model="form.position" :disabled="!isEdit" />
@@ -78,22 +89,45 @@
<ElRow> <ElRow>
<ElFormItem label="公司" prop="company"> <ElFormItem label="公司" prop="company">
<ElInput v-model="form.corp_name" disabled/> <ElInput v-model="form.corp_name" disabled />
</ElFormItem> </ElFormItem>
<ElFormItem label="国家" prop="address" class="right-input"> <ElFormItem label="国家" prop="address" class="right-input">
<el-select v-model="form.country_id" filterable placeholder="请选择国家" :disabled="!isEdit" > <el-select
<el-option v-for="item in countryOptions" :key="item.id" :label="item.cn_addr" :value="item.id" /> v-model="form.country_id"
filterable
placeholder="请选择国家"
:disabled="!isEdit"
>
<el-option
v-for="item in countryOptions"
:key="item.id"
:label="item.cn_addr"
:value="item.id"
/>
</el-select> </el-select>
</ElFormItem> </ElFormItem>
</ElRow> </ElRow>
<ElRow> <ElRow>
<ElFormItem label="二维码" prop="socialize" :class="isEdit?'qrcode-radio-group':''"> <ElFormItem label="邮箱" prop="email">
<el-radio-group v-model="form.socialize" v-if="isEdit"> <ElInput v-model="form.email" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem
label="二维码"
prop="socialize"
:class="isEdit ? 'qrcode-radio-group' : ''"
>
<el-radio-group v-model="form.socialize" v-if="isEdit">
<el-radio label="微信">微信</el-radio> <el-radio label="微信">微信</el-radio>
<el-radio label="WhatsApp">WhatsApp</el-radio> <el-radio label="WhatsApp">WhatsApp</el-radio>
</el-radio-group> </el-radio-group>
<template v-else> <template v-else>
<img v-if="form.socialize_pic_full" :src="form.socialize_pic_full" class="qrcode-img" /> <img
v-if="form.socialize_pic_full"
:src="form.socialize_pic_full"
class="qrcode-img"
/>
<span v-else class="qrcode-upload-text">暂无二维码</span> <span v-else class="qrcode-upload-text">暂无二维码</span>
</template> </template>
</ElFormItem> </ElFormItem>
@@ -106,15 +140,19 @@
:on-success="handleUploadavatarqrcode" :on-success="handleUploadavatarqrcode"
> >
<div class="qrcode-upload-box"> <div class="qrcode-upload-box">
<img v-if="form.socialize_pic_full" :src="socialize_pic_full" class="qrcode-img" /> <img
v-if="form.socialize_pic_full"
:src="socialize_pic_full"
class="qrcode-img"
/>
<span v-else class="qrcode-upload-text"> <span v-else class="qrcode-upload-text">
<img :src="imageUrl" class="qrcode-img" /> <img :src="imageUrls" class="qrcode-img" />
</span> </span>
</div> </div>
</el-upload> </el-upload>
</ElFormItem> </ElFormItem>
</ElRow> </ElRow>
<div class="el-form-item-right"> <div class="el-form-item-right">
<ElButton type="primary" style="width: 90px" v-ripple @click="edit"> <ElButton type="primary" style="width: 90px" v-ripple @click="edit">
<i v-if="!isEdit" class="iconfont-sys"> &#xe6bf;</i> <i v-if="!isEdit" class="iconfont-sys"> &#xe6bf;</i>
@@ -169,8 +207,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from '@/store/modules/user' import { ElForm, FormInstance } from 'element-plus'
import { ElForm, FormInstance, FormRules } from 'element-plus'
import imageUrl from '@/assets/img/crm/upimg.png' import imageUrl from '@/assets/img/crm/upimg.png'
import { CrmService } from '@/api/crmApi' import { CrmService } from '@/api/crmApi'
import { useUploadHeaders } from '@/composables/useUploadHeaders' import { useUploadHeaders } from '@/composables/useUploadHeaders'
@@ -181,10 +218,11 @@
interface CustomerInfo { interface CustomerInfo {
[key: string]: any [key: string]: any
} }
const imageUrlavatar=ref('') const imageUrls = ref(imageUrl)
const socialize_pic_full=ref('') const imageUrlavatar = ref('')
const socialize_pic_full = ref('')
const countryOptions = ref<any>() const countryOptions = ref<any>()
const form= ref<CustomerInfo>({}) const form = ref<CustomerInfo>({})
//获取公司 getCompanyList //获取公司 getCompanyList
const companyList = ref<any>() const companyList = ref<any>()
//国家 //国家
@@ -231,6 +269,10 @@
// }) // })
const options = [ const options = [
{
value: '',
label: '未知'
},
{ {
value: '1', value: '1',
label: '男' label: '男'
@@ -277,6 +319,8 @@
function handleUploadavatarqrcode(response: any) { function handleUploadavatarqrcode(response: any) {
// 假设返回图片地址字段为 response.url 或 response.data.url // 假设返回图片地址字段为 response.url 或 response.data.url
socialize_pic_full.value = response.url || (response.data && response.data.file_url) || '' socialize_pic_full.value = response.url || (response.data && response.data.file_url) || ''
imageUrls.value = response.url || (response.data && response.data.file_url) || ''
// 如需同步到 form.picture // 如需同步到 form.picture
form.value.socialize_pic = response.url || (response.data && response.data.file_name) || '' form.value.socialize_pic = response.url || (response.data && response.data.file_name) || ''
} }
File diff suppressed because it is too large Load Diff
+292 -18
View File
@@ -50,7 +50,7 @@
</ArtTable> </ArtTable>
<ElDialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增' : '编辑'" width="60%" align-center> <ElDialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增' : '编辑'" width="60%" align-center>
<ElForm ref="formRef" :model="form" label-width="160px"> <ElForm ref="formRef" :model="form" label-width="120px" style="padding-right: 30px">
<ElFormItem label="问题分类" prop="categorie_id"> <ElFormItem label="问题分类" prop="categorie_id">
<ElSelect v-model="form.categorie_id"> <ElSelect v-model="form.categorie_id">
<ElOption v-for="type in categorieSelectList" :key="type.id" :value="type.id" :label="type.name" /> <ElOption v-for="type in categorieSelectList" :key="type.id" :value="type.id" :label="type.name" />
@@ -62,16 +62,20 @@
<ArtWangEditor class="el-top" v-model="editorHtml" /> <ArtWangEditor class="el-top" v-model="editorHtml" />
</ElFormItem> </ElFormItem>
<ElFormItem label="上传附件" prop="attachment"> <ElFormItem label="上传附件" prop="attachment">
<ElUpload <ElUpload
v-model:file-list="fileList" v-model:file-list="fileList"
class="upload-demo" class="upload-demo"
:action="actionImageUplode" :action="actionImageUplode"
:on-success="handleUploadSuccess" :on-success="handleUploadSuccess"
:headers="uploadHeaders" :headers="uploadHeaders"
multiple multiple
:limit="3"> :limit="3"
:on-exceed="handleExceed"
>
<el-button type="primary">上传</el-button> <el-button type="primary">上传</el-button>
</ElUpload> </ElUpload>
</ElFormItem>
<ElFormItem label="" prop="">
<!-- 附件预览区 --> <!-- 附件预览区 -->
<div class="attachment-preview" v-if="fileList.length"> <div class="attachment-preview" v-if="fileList.length">
<div class="attachment-list"> <div class="attachment-list">
@@ -113,21 +117,115 @@
<!-- 聊天消息区域 --> <!-- 聊天消息区域 -->
<div class="chat-messages" ref="messageContainer"> <div class="chat-messages" ref="messageContainer">
<template v-for="(message, index) in messages" :key="index"> <template v-for="(message, index) in messages" :key="index">
<div :class="['message-item', String(message.user_id) === String(userid) ? 'message-right' : 'message-left']"> <div
<el-avatar :size="32" :src="String(message.user_id) === String(userid) ? meAvatar : aiAvatar" class="message-avatar" /> :class="[
'message-item',
String(message.user_id) === String(userid) ? 'message-right' : 'message-left'
]"
>
<!-- :src="String(message.user_id) === String(userid) ? meAvatar : aiAvatar" -->
<el-avatar
:size="32"
:src="message.user_avatar"
class="message-avatar"
/>
<div class="message-content"> <div class="message-content">
<div class="message-info"> <div class="message-info">
<span class="sender-name">{{ message.user_id }}</span> <span class="sender-name">{{ message.user_name }}</span>
<span class="message-time">{{ message.updated_at }}</span> <span class="message-time">{{ message.updated_at }}</span>
</div> </div>
<div class="message-text">{{ message.content }}</div> <div class="message-text">{{ message.content }}</div>
<!-- 附件显示区域 -->
<div class="message-attachments" v-if="message.attachment && message.attachment.length > 0">
<div class="attachment-item" v-for="(attachment, attIndex) in message.attachment" :key="attIndex">
<template v-if="/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(attachment.original_name)">
<img
:src="attachment.storage_path_full"
:alt="attachment.original_name"
class="attachment-thumb"
@click="handleChatImageClick(attachment.storage_path_full)"
/>
</template>
<template v-else-if="/\.(mp4|webm|ogg)$/i.test(attachment.original_name)">
<video
:src="attachment.storage_path_full"
controls
class="attachment-thumb"
@click.prevent="handleChatVideoClick(attachment.storage_path_full)"
></video>
</template>
<template v-else>
<div class="file-attachment">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ attachment.original_name }}</span>
<el-button
type="primary"
size="small"
link
@click="downloadAttachment(attachment)"
>
下载
</el-button>
</div>
</template>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<div class="chat-input" v-if="hasAuth('work_order@conversation_add')"> <div class="chat-input" v-if="hasAuth('work_order@conversation_add')">
<el-input v-model="messageText" type="textarea" :rows="3" placeholder="输入消息" resize="none" <!-- 附件上传区域 -->
@keyup.enter.prevent="sendMessage" /> <div class="chat-attachment-upload">
<el-upload
v-model:file-list="chatFileList"
class="chat-upload"
:action="actionImageUplode"
:on-success="handleChatUploadSuccess"
:headers="uploadHeaders"
multiple
:limit="5"
:on-exceed="handleChatExceed"
:show-file-list="false"
>
<el-button type="primary" link>
<el-icon><Paperclip /></el-icon>
添加附件
</el-button>
</el-upload>
<!-- 已上传附件预览 -->
<div class="uploaded-attachments" v-if="chatFileList.length > 0">
<div class="attachment-preview-item" v-for="(file, idx) in chatFileList" :key="idx">
<template v-if="/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name)">
<img
:src="file.file_url || file.response?.data?.file_url"
:alt="file.name"
class="preview-thumb"
/>
</template>
<template v-else>
<el-icon class="file-icon"><Document /></el-icon>
</template>
<span class="file-name">{{ file.name }}</span>
<el-button
type="danger"
size="small"
link
@click="removeChatFile(idx)"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
<el-input
v-model="messageText"
type="textarea"
:rows="3"
placeholder="输入消息"
resize="none"
@keyup.enter.prevent="sendMessage"
/>
<ElInput v-model="ticket_id" type="hidden" /> <ElInput v-model="ticket_id" type="hidden" />
<div class="chat-input-actions"> <div class="chat-input-actions">
<el-button type="primary" @click="sendMessage" v-ripple>发送</el-button> <el-button type="primary" @click="sendMessage" v-ripple>发送</el-button>
@@ -156,8 +254,8 @@
</template> </template>
</ElDialog> </ElDialog>
<!-- 图片预览弹窗 --> <!-- 图片预览弹窗 -->
<ElImageViewer v-if="imageViewerVisible" :url-list="imageList" :initial-index="imageViewerIndex" @close="imageViewerVisible = false" /> <ElImageViewer v-if="imageViewerVisible" :url-list="chatImageList.length > 0 ? chatImageList : imageList" :initial-index="imageViewerIndex" @close="closeImageViewer" />
<!-- 视频预览弹窗 --> <!-- 视频预览弹窗 -->
<ElDialog v-model="videoDialogVisible" width="60%" top="10vh" :show-close="true" append-to-body> <ElDialog v-model="videoDialogVisible" width="60%" top="10vh" :show-close="true" append-to-body>
<video v-if="currentVideoUrl" :src="currentVideoUrl" controls autoplay style="width:100%;max-height:70vh;display:block;"></video> <video v-if="currentVideoUrl" :src="currentVideoUrl" controls autoplay style="width:100%;max-height:70vh;display:block;"></video>
@@ -178,6 +276,7 @@ import { ElImageViewer } from 'element-plus'
import { useAuth } from "@/composables/useAuth"; import { useAuth } from "@/composables/useAuth";
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useUploadHeaders } from '@/composables/useUploadHeaders' import { useUploadHeaders } from '@/composables/useUploadHeaders'
import { Paperclip, Document, Close } from '@element-plus/icons-vue'
const { uploadHeaders } = useUploadHeaders() const { uploadHeaders } = useUploadHeaders()
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
defineOptions({ name: 'Order' }) defineOptions({ name: 'Order' })
@@ -213,6 +312,10 @@ const scrollToBottom = () => {
} }
const openChat = (row: any) => { const openChat = (row: any) => {
isDrawerVisible.value = true isDrawerVisible.value = true
// 清空数据
messageText.value = ''
messages.value = []
chatFileList.value = []
getConversationList(row.id) getConversationList(row.id)
} }
@@ -224,7 +327,7 @@ const statusList = ref<any>()
const getstatusList = async () => { const getstatusList = async () => {
try { try {
// 这里调用获取品牌列表的接口 // 这里调用获取品牌列表的接口
const response = await WorkService.getStatus() const response = await WorkService.getStatus({})
statusList.value = response || [] statusList.value = response || []
} catch (error) { } } catch (error) { }
} }
@@ -314,14 +417,28 @@ const getConversationList = async (id:any) => {
const sendMessage = async () => { const sendMessage = async () => {
const text = messageText.value.trim() const text = messageText.value.trim()
const ids = ticket_id.value const ids = ticket_id.value
if (!text) return if (!text && chatFileList.value.length === 0) return
// 准备附件数据
const attachments = chatFileList.value.map(file => {
const data = file.response?.data
if (data) {
return {
original_name: data.original_name,
storage_path: data.storage_path || data.file_name
}
}
return null
}).filter(Boolean)
const { message } = await WorkService.conversationAdd({ const { message } = await WorkService.conversationAdd({
content: text, content: text,
ticket_id: ids, ticket_id: ids,
attachment: attachments
}) })
ElMessage.success(message) ElMessage.success(message)
messageText.value = '' messageText.value = ''
chatFileList.value = [] // 清空附件列表
getConversationList(ids) getConversationList(ids)
scrollToBottom() scrollToBottom()
} }
@@ -381,7 +498,9 @@ const delCodeTrim = (content: string): string => {
return content.replace(/(\s*)<\/code>/g, '</code>') return content.replace(/(\s*)<\/code>/g, '</code>')
} }
//添加工单 //添加工单
const handleExceed = () => {
ElMessage.warning('最多上传3个文件')
}
const uploadedFiles = ref<any[]>([]) const uploadedFiles = ref<any[]>([])
const handleUploadSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => { const handleUploadSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => {
// 只处理上传成功的文件 // 只处理上传成功的文件
@@ -452,6 +571,8 @@ const statusSubmit = async () => {
const imageViewerVisible = ref(false) const imageViewerVisible = ref(false)
const imageViewerIndex = ref(0) const imageViewerIndex = ref(0)
const imageList = computed(() => fileList.value.filter(file => /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name)).map(file => file.file_url?file.file_url:file.response.data.file_url)) const imageList = computed(() => fileList.value.filter(file => /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name)).map(file => file.file_url?file.file_url:file.response.data.file_url))
// 聊天图片预览列表
const chatImageList = ref<string[]>([])
function handleImageClick(idx: number) { function handleImageClick(idx: number) {
imageViewerIndex.value = idx imageViewerIndex.value = idx
imageViewerVisible.value = true imageViewerVisible.value = true
@@ -463,6 +584,60 @@ function handleVideoClick(url: string) {
currentVideoUrl.value = url currentVideoUrl.value = url
videoDialogVisible.value = true videoDialogVisible.value = true
} }
// 聊天相关
const chatFileList = ref<any[]>([])
const handleChatUploadSuccess = (response: any, uploadFile: any, uploadFiles: any[]) => {
chatFileList.value = uploadFiles.filter(item => item.status === 'success').map(item => {
const data = item.response?.data
if (data) {
return {
name: data.original_name,
file_url: data.storage_path_full,
status: 'success',
response: { data: data }
}
}
return null
}).filter(Boolean)
}
const handleChatExceed = () => {
ElMessage.warning('最多上传5个文件')
}
const removeChatFile = (index: number) => {
chatFileList.value.splice(index, 1)
}
const handleChatImageClick = (url: string) => {
// 设置聊天图片预览列表
chatImageList.value = [url]
imageViewerIndex.value = 0
imageViewerVisible.value = true
}
const closeImageViewer = () => {
imageViewerVisible.value = false
chatImageList.value = [] // 清空聊天图片列表
}
const handleChatVideoClick = (url: string) => {
currentVideoUrl.value = url
videoDialogVisible.value = true
}
const downloadAttachment = (attachment: any) => {
try {
const fileName = attachment.original_name || 'download'
const link = document.createElement('a')
link.href = attachment.storage_path_full
link.download = fileName
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载')
} catch (error) {
ElMessage.error('下载失败')
console.error('下载失败:', error)
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -547,6 +722,7 @@ function handleVideoClick(url: string) {
.message-info { .message-info {
flex-direction: row; flex-direction: row;
align-items: center;
} }
.message-text { .message-text {
@@ -563,6 +739,7 @@ function handleVideoClick(url: string) {
.message-info { .message-info {
flex-direction: row-reverse; flex-direction: row-reverse;
align-items: center;
} }
.message-text { .message-text {
@@ -686,4 +863,101 @@ function handleVideoClick(url: string) {
margin-top: 2px; margin-top: 2px;
} }
} }
// 聊天附件样式
.message-right .message-attachments{
text-align: right;
}
.message-left .message-attachments{
text-align: left;
}
.message-attachments {
margin-top: 8px;
.attachment-item {
margin-bottom: 8px;
.attachment-thumb {
max-width: 200px;
max-height: 150px;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--el-border-color-lighter);
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
.file-attachment {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
.file-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
.file-name {
flex: 1;
font-size: 14px;
color: var(--el-text-color-regular);
word-break: break-all;
}
}
}
}
.chat-attachment-upload {
margin-bottom: 12px;
.chat-upload {
margin-bottom: 8px;
}
.uploaded-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
.attachment-preview-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
max-width: 200px;
.preview-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.file-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
.file-name {
flex: 1;
font-size: 12px;
color: var(--el-text-color-regular);
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
</style> </style>