Compare commits
18 Commits
fe1ebce332
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e263aaca3 | |||
| d628dfdd72 | |||
| 24e517dfec | |||
| 9e45acf60b | |||
| 94882bc9a5 | |||
| 049f9dd68f | |||
| 8d14f023fa | |||
| 0b352c2718 | |||
| 038e4d72d3 | |||
| 4271e7d2d9 | |||
| c4bdfe2bb3 | |||
| 4f547b5bff | |||
| 6bf571c681 | |||
| 3055da01c7 | |||
| 919aaa195d | |||
| 4c91dd6fd5 | |||
| c80f67d2ab | |||
| 091a159eec |
2
.env
2
.env
@@ -1,5 +1,5 @@
|
||||
# port 端口号
|
||||
VITE_PORT = 8888
|
||||
VITE_PORT = 8080
|
||||
|
||||
# open 运行 npm run dev 时自动打开浏览器
|
||||
VITE_OPEN = true
|
||||
|
||||
@@ -4,5 +4,4 @@ ENV = 'development'
|
||||
# 统一后端服务地址前缀(网关服务名:admin-go)
|
||||
# 开发环境走本地代理,避免 CORS
|
||||
VITE_API_URL = 'http://116.204.74.41:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.30:8000'
|
||||
|
||||
# VITE_API_URL = 'http://192.168.3.30:8000'
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
src/**/*.vue.js
|
||||
|
||||
|
||||
# local env files
|
||||
|
||||
@@ -11,6 +11,6 @@ RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8888
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
132
package-lock.json
generated
132
package-lock.json
generated
@@ -12,6 +12,8 @@
|
||||
"@codemirror/lang-javascript": "^6.1.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.0",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@logicflow/core": "^2.2.1",
|
||||
"@logicflow/extension": "^2.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"axios": "1.8.2",
|
||||
"codemirror": "^6.0.1",
|
||||
@@ -75,6 +77,12 @@
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antv/hierarchy": {
|
||||
"version": "0.6.14",
|
||||
"resolved": "https://registry.npmjs.org/@antv/hierarchy/-/hierarchy-0.6.14.tgz",
|
||||
"integrity": "sha512-V3uknf7bhynOqQDw2sg+9r9DwZ9pc6k/EcqyTFdfXB1+ydr7urisP0MipIuimucvQKN+Qkd+d6w601r1UIroqQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
@@ -1138,6 +1146,41 @@
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@logicflow/core": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@logicflow/core/-/core-2.2.1.tgz",
|
||||
"integrity": "sha512-VzLPrCrT4eXnOLjoGQ5v4GUSay3+6rd3YNZD0qOJw4vME5e4WjQ5fd+hKK2zlIzgdRI4D54dXiEFJrS6xdV6yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mobx": "^5.15.7",
|
||||
"mobx-preact": "^3.0.0",
|
||||
"mobx-utils": "^5.6.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"preact": "^10.17.1",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@logicflow/extension": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@logicflow/extension/-/extension-2.2.1.tgz",
|
||||
"integrity": "sha512-Qc2n514UzQlbpFtUnU7ggaKwCEMFDNY20fQyAww/MFLevP5y1+IfVKOUXyfbfKg6UITZsazEBEhgivwqV1sasw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@antv/hierarchy": "^0.6.11",
|
||||
"classnames": "^2.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"medium-editor": "^5.23.3",
|
||||
"mobx": "^5.15.7",
|
||||
"preact": "^10.17.1",
|
||||
"rangy": "^1.3.1",
|
||||
"vanilla-picker": "^2.12.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@logicflow/core": "2.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
@@ -1825,6 +1868,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sphinxxxx/color-conversion": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@sphinxxxx/color-conversion/-/color-conversion-2.2.2.tgz",
|
||||
"integrity": "sha512-XExJS3cLqgrmNBIP3bBw6+1oQ1ksGjFh0+oClDKFYpCCqx/hlqwWO5KO/S63fzUo67SxI9dMrF0y5T/Ey7h8Zw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@transloadit/prettier-bytes": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
|
||||
@@ -2748,6 +2797,12 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/claygl": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
|
||||
@@ -3912,6 +3967,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
||||
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||
@@ -4310,6 +4371,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/medium-editor": {
|
||||
"version": "5.23.3",
|
||||
"resolved": "https://registry.npmjs.org/medium-editor/-/medium-editor-5.23.3.tgz",
|
||||
"integrity": "sha512-he9/TdjX8f8MGdXGfCs8AllrYnqXJJvjNkDKmPg3aPW/uoIrlRqtkFthrwvmd+u4QyzEiadhCCM0EwTiRdUCJw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
@@ -4392,6 +4459,44 @@
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mobx": {
|
||||
"version": "5.15.7",
|
||||
"resolved": "https://registry.npmjs.org/mobx/-/mobx-5.15.7.tgz",
|
||||
"integrity": "sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mobx"
|
||||
}
|
||||
},
|
||||
"node_modules/mobx-preact": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mobx-preact/-/mobx-preact-3.0.0.tgz",
|
||||
"integrity": "sha512-ijan/cBs3WmRye87E5+3JmoFBB00KDAwNA3pm7bMwYLPHBAXlN86aC3gdrXw8aKzM5RI8V3a993PphzPv6P4FA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mobx": "5.x",
|
||||
"preact": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/mobx-utils": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/mobx-utils/-/mobx-utils-5.6.2.tgz",
|
||||
"integrity": "sha512-a/WlXyGkp6F12b01sTarENpxbmlRgPHFyR1Xv2bsSjQBm5dcOtd16ONb40/vOqck8L99NHpI+C9MXQ+SZ8f+yw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"mobx": "^4.13.1 || ^5.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mousetrap": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz",
|
||||
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
|
||||
"license": "Apache-2.0 WITH LLVM-exception"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4819,6 +4924,11 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rangy": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/rangy/-/rangy-1.3.2.tgz",
|
||||
"integrity": "sha512-fS1C4MOyk8T+ZJZdLcgrukPWxkyDXa+Hd2Kj+Zg4wIK71yrWgmjzHubzPMY1G+WD9EgGxMp3fIL0zQ1ickmSWA=="
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -5488,6 +5598,28 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vanilla-picker": {
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/vanilla-picker/-/vanilla-picker-2.12.3.tgz",
|
||||
"integrity": "sha512-qVkT1E7yMbUsB2mmJNFmaXMWE2hF8ffqzMMwe9zdAikd8u2VfnsVY2HQcOUi2F38bgbxzlJBEdS1UUhOXdF9GQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sphinxxxx/color-conversion": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@codemirror/lang-javascript": "^6.1.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.0",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@logicflow/core": "^2.2.1",
|
||||
"@logicflow/extension": "^2.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"axios": "1.8.2",
|
||||
"codemirror": "^6.0.1",
|
||||
|
||||
@@ -1,36 +1,70 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
//获取账号列表
|
||||
export function getaccountList(data: object) {
|
||||
export interface AccountParams {
|
||||
datasetIds: number[];
|
||||
documentIds: number[];
|
||||
accountCode: string;
|
||||
accountName?: string;
|
||||
status?: number;
|
||||
greeting?: string;
|
||||
keywordOption?: string[];
|
||||
selfIdentity?: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface AccountListParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
accountCode?: string;
|
||||
accountName?: string;
|
||||
status?: number;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface AccountDetailParams {
|
||||
id: number;
|
||||
}
|
||||
|
||||
//获取客服账号列表
|
||||
export function getAccountList(data: AccountListParams) {
|
||||
return request({
|
||||
url: '/customer-server/customer/service/account/list',
|
||||
url: '/customer-server/account/list',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
//获取客服账号详情
|
||||
export function getAccountOne(params: AccountDetailParams) {
|
||||
return request({
|
||||
url: '/customer-server/account/getOne',
|
||||
method: 'get',
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
//添加客服账号
|
||||
export function addAccount(data: object) {
|
||||
export function addAccount(data: AccountParams) {
|
||||
return request({
|
||||
url: '/customer-server/customer/service/account/add',
|
||||
url: '/customer-server/account/add',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
//禁用账号
|
||||
export function updatestate(data: object) {
|
||||
//更新客服账号
|
||||
export function updateAccount(data: AccountParams & { id: number }) {
|
||||
return request({
|
||||
url: '/customer-server/customer/service/account/toggleStatus',
|
||||
url: '/customer-server/account/update',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
//更新账号
|
||||
export function updateAccount(data: object) {
|
||||
//删除客服账号
|
||||
export function deleteAccount(data: { id: number }) {
|
||||
return request({
|
||||
url: '/customer-server/customer/service/account/update',
|
||||
url: '/customer-server/account/delete',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import request from '/@/utils/request';
|
||||
//获取话术列表
|
||||
export function getscriptList(data: object) {
|
||||
return request({
|
||||
url: '/customer-server/speechcraft/list',
|
||||
url: '/customer-server/scripted/speech/list',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
@@ -12,7 +12,7 @@ export function getscriptList(data: object) {
|
||||
//增加话术
|
||||
export function addScript(data: object) {
|
||||
return request({
|
||||
url: '/customer-server/speechcraft/add',
|
||||
url: '/customer-server/scripted/speech/add',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export function addScript(data: object) {
|
||||
//删除话术列表
|
||||
export function deleteScript(data: object) {
|
||||
return request({
|
||||
url: '/customer-server/speechcraft/delete',
|
||||
url: '/customer-server/scripted/speech/delete',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
@@ -30,8 +30,17 @@ export function deleteScript(data: object) {
|
||||
//更新话术列表
|
||||
export function updateScript(data: object) {
|
||||
return request({
|
||||
url: '/customer-server/speechcraft/update',
|
||||
url: '/customer-server/scripted/speech/update',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
//获取话术详情
|
||||
export function getScriptDetail(data: object) {
|
||||
return request({
|
||||
url: '/customer-server/scripted/speech/getOne',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
92
src/api/digitalHuman/creation/index.ts
Normal file
92
src/api/digitalHuman/creation/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface CreationListParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface CreationImageItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CreationTitleItem {
|
||||
title: string;
|
||||
htmlFileUrl: string;
|
||||
imageUrls: CreationImageItem[] | null;
|
||||
}
|
||||
|
||||
export interface CreationThemeItem {
|
||||
theme: string;
|
||||
titles: CreationTitleItem[];
|
||||
}
|
||||
|
||||
export interface CreationContentTypeItem {
|
||||
contentType: string;
|
||||
themes: CreationThemeItem[];
|
||||
}
|
||||
|
||||
export interface CreationTreeItem {
|
||||
createdDate: string;
|
||||
contentTypes: CreationContentTypeItem[];
|
||||
}
|
||||
|
||||
export interface CreationListData {
|
||||
list: unknown[] | null;
|
||||
total: number;
|
||||
Tree: CreationTreeItem[];
|
||||
imgAddressPrefix: string;
|
||||
}
|
||||
|
||||
export interface CreationListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: CreationListData;
|
||||
}
|
||||
|
||||
export interface CreationSubmitParams {
|
||||
mode: string;
|
||||
content_type: string;
|
||||
theme: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
style: string;
|
||||
count: number;
|
||||
image_per_post: number;
|
||||
image_ratio: string;
|
||||
}
|
||||
|
||||
export interface DownloadToFileParams {
|
||||
fileURL: string;
|
||||
}
|
||||
|
||||
// requestOptions 用来声明“这个接口的错误提示由谁负责”。
|
||||
export function getCreationList(params: CreationListParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<CreationListResponse>;
|
||||
}
|
||||
|
||||
export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/creation',
|
||||
method: 'post',
|
||||
data,
|
||||
timeout: 0,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadToFile(data: DownloadToFileParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/oss/file/downloadToBrowser',
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export interface knowledgeInfo {
|
||||
// 获取知识库列表
|
||||
export function listknowledges(params: knowledgeQueryParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/dataset/listDataset',
|
||||
url: '/rag/dataset/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export function listknowledges(params: knowledgeQueryParams) {
|
||||
// 创建知识库
|
||||
export function createknowledge(data: CreateknowledgeParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/dataset/createDataset',
|
||||
url: '/rag/dataset/create',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function createknowledge(data: CreateknowledgeParams) {
|
||||
// 更新知识库
|
||||
export function updateknowledge(data: UpdateknowledgeParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/dataset/updateDataset',
|
||||
url: '/rag/dataset/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export function updateknowledge(data: UpdateknowledgeParams) {
|
||||
// 删除知识库
|
||||
export function deleteknowledge(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/dataset/deleteDataset',
|
||||
url: '/rag/dataset/delete',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface UpdateDocumentParams {
|
||||
fileSize?: number;
|
||||
format?: string;
|
||||
title?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// 文档分段查询参数
|
||||
@@ -53,10 +54,34 @@ export interface DocumentInfo {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// 文件块向量查询参数
|
||||
export interface DocumentVectorQueryParams {
|
||||
documentId?: string;
|
||||
datasetId?: string;
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// 文件块向量信息
|
||||
export interface DocumentVectorInfo {
|
||||
id: number;
|
||||
status: number;
|
||||
vectorStatus: number;
|
||||
datasetId: number;
|
||||
documentId: number;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
chunkIndex: number;
|
||||
vector: number[];
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 获取文档列表
|
||||
export function listDocuments(params: DocumentQueryParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/listDocument',
|
||||
url: '/rag/document/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
@@ -65,7 +90,7 @@ export function listDocuments(params: DocumentQueryParams) {
|
||||
// 获取文档详情
|
||||
export function getDocument(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/getDocument',
|
||||
url: '/rag/document/get',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
@@ -74,7 +99,7 @@ export function getDocument(id: string) {
|
||||
// 创建文档
|
||||
export function createDocument(data: CreateDocumentParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/createDocument',
|
||||
url: '/rag/document/create',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
@@ -83,7 +108,7 @@ export function createDocument(data: CreateDocumentParams) {
|
||||
// 更新文档
|
||||
export function updateDocument(data: UpdateDocumentParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/updateDocument',
|
||||
url: '/rag/document/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -104,7 +129,7 @@ export function uploadFile(file: File) {
|
||||
// 上传文档
|
||||
export function uploadDocument(data: FormData) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/createDocument',
|
||||
url: '/rag/document/create',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: {
|
||||
@@ -116,7 +141,7 @@ export function uploadDocument(data: FormData) {
|
||||
// 预览文档
|
||||
export function previewDocument(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/previewDocument',
|
||||
url: '/rag/document/preview',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
@@ -125,7 +150,7 @@ export function previewDocument(id: string) {
|
||||
// 删除文档
|
||||
export function deleteDocument(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/deleteDocument',
|
||||
url: '/rag/document/delete',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
@@ -134,7 +159,7 @@ export function deleteDocument(id: string) {
|
||||
// 批量删除文档
|
||||
export function batchDeleteDocuments(ids: string[]) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/batchDeleteDocument',
|
||||
url: '/rag/document/batchDeleteDocument',
|
||||
method: 'delete',
|
||||
data: { ids },
|
||||
});
|
||||
@@ -143,7 +168,7 @@ export function batchDeleteDocuments(ids: string[]) {
|
||||
// 重新处理文档
|
||||
export function reprocessDocument(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/reprocessDocument',
|
||||
url: '/rag/document/reprocessDocument',
|
||||
method: 'post',
|
||||
data: { id },
|
||||
});
|
||||
@@ -152,7 +177,7 @@ export function reprocessDocument(id: string) {
|
||||
// 获取文档分段列表
|
||||
export function listDocumentChunks(params: DocumentChunkQueryParams) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/listDocumentChunk',
|
||||
url: '/rag/document/listDocumentChunk',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
@@ -161,7 +186,7 @@ export function listDocumentChunks(params: DocumentChunkQueryParams) {
|
||||
// 更新文档分段
|
||||
export function updateDocumentChunk(data: { id: string; content: string }) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/updateDocumentChunk',
|
||||
url: '/rag/document/updateDocumentChunk',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -170,7 +195,7 @@ export function updateDocumentChunk(data: { id: string; content: string }) {
|
||||
// 删除文档分段
|
||||
export function deleteDocumentChunk(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/deleteDocumentChunk',
|
||||
url: '/rag/document/deleteDocumentChunk',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
@@ -179,8 +204,52 @@ export function deleteDocumentChunk(id: string) {
|
||||
// 获取文件向量化处理进度
|
||||
export function getDocumentProcess(id: string) {
|
||||
return request({
|
||||
url: '/rag-knowledge/document/getProcess',
|
||||
url: '/rag/document/getProcess',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// 生成向量
|
||||
export function generateVector(id: string, datasetId: string) {
|
||||
return request({
|
||||
url: '/rag/document/vectorization',
|
||||
method: 'post',
|
||||
data: { id, datasetId },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取文件块向量列表
|
||||
export function listDocumentVectors(params: DocumentVectorQueryParams) {
|
||||
return request({
|
||||
url: '/rag/document/vector/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新文件块
|
||||
export function updateDocumentVector(data: any) {
|
||||
return request({
|
||||
url: '/rag/document/vector/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取任务列表
|
||||
export function listTasks() {
|
||||
return request({
|
||||
url: '/rag/task/get',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 重新执行任务
|
||||
export function reexecuteTask(id: string) {
|
||||
return request({
|
||||
url: '/rag/task/reexecute',
|
||||
method: 'post',
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
83
src/api/knowledge/model/index.ts
Normal file
83
src/api/knowledge/model/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
// 模型配置查询参数
|
||||
export interface ModelConfigQueryParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
modelType?: string;
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
// 模型配置信息
|
||||
export interface ModelConfigInfo {
|
||||
id: number;
|
||||
modelType: string;
|
||||
modelName: string;
|
||||
modelDesc: string;
|
||||
configType: string;
|
||||
configContent: number[];
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 获取模型配置列表
|
||||
export function listModelConfigs(params?: ModelConfigQueryParams) {
|
||||
return request({
|
||||
url: '/rag/model/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建模型配置
|
||||
export function createModelConfig(data: any) {
|
||||
return request({
|
||||
url: '/rag/model/create',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新模型配置
|
||||
export function updateModelConfig(data: any) {
|
||||
return request({
|
||||
url: '/rag/model/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除模型配置
|
||||
export function deleteModelConfig(id: string | number) {
|
||||
return request({
|
||||
url: '/rag/model/delete',
|
||||
method: 'post',
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模型配置详情
|
||||
export function getModelConfig(id: string | number, modelType: string) {
|
||||
return request({
|
||||
url: '/rag/model/get',
|
||||
method: 'get',
|
||||
params: { id, modelType },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模型类型和配置类型枚举
|
||||
export function getAllModelEnums() {
|
||||
return request({
|
||||
url: '/rag/model/getAllEnums',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模型表单字段
|
||||
export function getModelFormField(modelType: string, configType: string) {
|
||||
return request({
|
||||
url: '/rag/model/getModelFormField',
|
||||
method: 'get',
|
||||
params: { modelType, configType },
|
||||
});
|
||||
}
|
||||
69
src/api/trade/operation/setting/anchor/index.ts
Normal file
69
src/api/trade/operation/setting/anchor/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
// 主播管理API接口
|
||||
|
||||
export interface AnchorListParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface AnchorParams {
|
||||
id?: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
code: string;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export function getAnchorList(data: AnchorListParams) {
|
||||
return request({
|
||||
url: '/erp/anchor/controller/listAnchors',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAnchorOne(data: { id: string }) {
|
||||
return request({
|
||||
url: '/erp/anchor/controller/getAnchor',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function addAnchor(data: AnchorParams) {
|
||||
return request({
|
||||
url: '/erp/anchor/controller/createAnchor',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAnchor(data: AnchorParams) {
|
||||
return request({
|
||||
url: '/erp/anchor/controller/updateAnchor',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAnchor(data: { id: string }) {
|
||||
return request({
|
||||
url: '/erp/anchor/controller/deleteAnchor',
|
||||
method: 'delete',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAnchorStatus(data: { id: string; status: number }) {
|
||||
return request({
|
||||
url: '/erp/anchor/controller/updateAnchorStatus',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
93
src/api/trade/operation/setting/live-account/index.ts
Normal file
93
src/api/trade/operation/setting/live-account/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface LiveAccountParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
platform?: string;
|
||||
accountName?: string;
|
||||
accountId?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface LiveAccountSaveParams {
|
||||
id?: string;
|
||||
platform: string;
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface LiveAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
status: number;
|
||||
statusName: string;
|
||||
remark: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface LiveAccountListResult {
|
||||
list: LiveAccount[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LiveAccountListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: LiveAccountListResult;
|
||||
}
|
||||
|
||||
export interface LiveAccountDetailResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: LiveAccount;
|
||||
}
|
||||
|
||||
export function getLiveAccountList(params: LiveAccountParams, requestOptions?: RequestOptions): Promise<LiveAccountListResponse> {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/listLiveAccounts',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<LiveAccountListResponse>;
|
||||
}
|
||||
|
||||
export function getLiveAccountDetail(params: { id: string }, requestOptions?: RequestOptions): Promise<LiveAccountDetailResponse> {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/getLiveAccount',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<LiveAccountDetailResponse>;
|
||||
}
|
||||
|
||||
export function createLiveAccount(data: LiveAccountSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/createLiveAccount',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLiveAccount(data: LiveAccountSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/updateLiveAccount',
|
||||
method: 'put',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLiveAccount(params: { id: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/live/account/controller/deleteLiveAccount',
|
||||
method: 'delete',
|
||||
params,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
119
src/api/trade/operation/setting/scheduling/index.ts
Normal file
119
src/api/trade/operation/setting/scheduling/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface ScheduleListParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
anchorId?: string;
|
||||
accountId?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ScheduleSaveParams {
|
||||
id?: string;
|
||||
anchorId: string;
|
||||
accountId: string;
|
||||
productId?: number;
|
||||
orderId?: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string;
|
||||
anchorId: string;
|
||||
anchorName: string;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
platform: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
status: number;
|
||||
statusName: string;
|
||||
productId: number;
|
||||
orderId: number;
|
||||
remark: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ScheduleDetail {
|
||||
id: string;
|
||||
tenantId: number;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
updater: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
anchorId: string;
|
||||
accountId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: number;
|
||||
productId: number;
|
||||
orderId: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ScheduleListResult {
|
||||
list: ScheduleItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ScheduleListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: ScheduleListResult;
|
||||
}
|
||||
|
||||
export interface ScheduleDetailResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: ScheduleDetail;
|
||||
}
|
||||
|
||||
export function getScheduleList(params: ScheduleListParams, requestOptions?: RequestOptions): Promise<ScheduleListResponse> {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/listSchedules',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ScheduleListResponse>;
|
||||
}
|
||||
|
||||
export function getScheduleDetail(params: { id: string }, requestOptions?: RequestOptions): Promise<ScheduleDetailResponse> {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/getSchedule',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ScheduleDetailResponse>;
|
||||
}
|
||||
|
||||
export function createSchedule(data: ScheduleSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/createSchedule',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSchedule(data: ScheduleSaveParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/updateSchedule',
|
||||
method: 'put',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedule(params: { id: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/erp/schedule/controller/deleteSchedule',
|
||||
method: 'delete',
|
||||
params,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -1,100 +1,113 @@
|
||||
<template>
|
||||
<div :class="{'hidden':hidden}" class="pagination-container">
|
||||
<el-pagination
|
||||
:background="background"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:layout="layout"
|
||||
:page-sizes="pageSizes"
|
||||
:pager-count="pagerCount"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
<div :class="{ hidden: hidden }" class="pagination-container">
|
||||
<el-pagination
|
||||
:background="background"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:layout="layout"
|
||||
:page-sizes="pageSizes"
|
||||
:pager-count="pagerCount"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { toRefs, defineComponent,computed } from 'vue';
|
||||
import { toRefs, defineComponent, computed, ref, onMounted } from 'vue';
|
||||
const props = {
|
||||
total: {
|
||||
required: true,
|
||||
type: Number
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
pageSizes: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [10, 20, 30, 50]
|
||||
}
|
||||
},
|
||||
// 移动端页码按钮的数量端默认值5
|
||||
pagerCount: {
|
||||
type: Number,
|
||||
default: document.body.clientWidth < 992 ? 5 : 7
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'total, sizes, prev, pager, next, jumper'
|
||||
},
|
||||
background: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
total: {
|
||||
required: true,
|
||||
type: Number,
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
pageSizes: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [10, 20, 30, 50];
|
||||
},
|
||||
},
|
||||
// 移动端页码按钮的数量端默认值5
|
||||
pagerCount: {
|
||||
type: Number,
|
||||
default: document.body.clientWidth < 992 ? 5 : 7,
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'total, sizes, prev, pager, next, jumper',
|
||||
},
|
||||
background: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
export default defineComponent({
|
||||
name: 'pagination',
|
||||
props: props,
|
||||
setup(props,{emit}){
|
||||
const { page,limit,pageSizes } = toRefs(props);
|
||||
const currentPage = computed({
|
||||
get() {
|
||||
return page.value;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:page', val)
|
||||
}
|
||||
});
|
||||
const pageSize = computed({
|
||||
get() {
|
||||
return limit.value
|
||||
},
|
||||
set(val) {
|
||||
emit('update:limit', val)
|
||||
}
|
||||
});
|
||||
const handleSizeChange = (val:number) => {
|
||||
emit('pagination', { page: currentPage.value, limit: val })
|
||||
};
|
||||
const handleCurrentChange=(val:number) => {
|
||||
emit('pagination', { page: val, limit: pageSizes.value })
|
||||
}
|
||||
return {
|
||||
currentPage,
|
||||
pageSize,
|
||||
handleSizeChange,
|
||||
handleCurrentChange
|
||||
}
|
||||
}
|
||||
name: 'pagination',
|
||||
props: props,
|
||||
setup(props, { emit }) {
|
||||
const { page, limit } = toRefs(props);
|
||||
const isInitialized = ref(false);
|
||||
|
||||
const currentPage = computed({
|
||||
get() {
|
||||
return page.value;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:page', val);
|
||||
},
|
||||
});
|
||||
const pageSize = computed({
|
||||
get() {
|
||||
return limit.value;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:limit', val);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
if (isInitialized.value) {
|
||||
emit('pagination', { page: currentPage.value, limit: val });
|
||||
}
|
||||
};
|
||||
const handleCurrentChange = (val: number) => {
|
||||
if (isInitialized.value) {
|
||||
emit('pagination', { page: val, limit: pageSize.value });
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载后标记为已初始化
|
||||
onMounted(() => {
|
||||
isInitialized.value = true;
|
||||
});
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageSize,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pagination-container {
|
||||
padding: 32px 16px;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.pagination-container.hidden {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
49
src/directives/debounce.ts
Normal file
49
src/directives/debounce.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Directive, DirectiveBinding } from 'vue';
|
||||
|
||||
interface DebounceBinding extends DirectiveBinding {
|
||||
value: () => void;
|
||||
arg?: string;
|
||||
modifiers?: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const debounceTimers = new Map<string, number>();
|
||||
|
||||
const debounce = (fn: () => void, key: string, delay = 300) => {
|
||||
if (debounceTimers.has(key)) {
|
||||
clearTimeout(debounceTimers.get(key) as number);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fn();
|
||||
debounceTimers.delete(key);
|
||||
}, delay) as unknown as number;
|
||||
|
||||
debounceTimers.set(key, timer);
|
||||
};
|
||||
|
||||
export const debounceDirective: Directive = {
|
||||
mounted(el: HTMLElement, binding: DebounceBinding) {
|
||||
const fn = binding.value;
|
||||
const key = binding.arg || `debounce_${Date.now()}_${Math.random()}`;
|
||||
const delay = binding.modifiers?.fast ? 200 : binding.modifiers?.slow ? 500 : 300;
|
||||
|
||||
el.addEventListener('click', (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
debounce(fn, key, delay);
|
||||
});
|
||||
|
||||
el.dataset.debounceKey = key;
|
||||
},
|
||||
unmounted(el: HTMLElement) {
|
||||
const key = el.dataset.debounceKey;
|
||||
if (key && debounceTimers.has(key)) {
|
||||
clearTimeout(debounceTimers.get(key) as number);
|
||||
debounceTimers.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default debounceDirective;
|
||||
@@ -178,8 +178,8 @@ export default defineComponent({
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
// 清除缓存/token等
|
||||
Session.clear();
|
||||
// 手动退出登录也只清理登录态缓存,保留主题、语言等本地配置。
|
||||
Session.clearAuth();
|
||||
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
||||
await router.replace('/login');
|
||||
})
|
||||
|
||||
@@ -10,8 +10,17 @@ import { useRoutesList } from '/@/stores/routesList';
|
||||
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
|
||||
import { getUserMenus } from '/@/api/system/menu/index';
|
||||
|
||||
// 扩展Window接口,添加nextLoading属性
|
||||
declare global {
|
||||
interface Window {
|
||||
nextLoading?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const layouModules: any = import.meta.glob('../layout/routerView/*.{vue,tsx}');
|
||||
const viewsModules: any = import.meta.glob('../views/**/*.{vue,tsx}');
|
||||
const parentView = layouModules['../layout/routerView/parent.vue'];
|
||||
const notFoundView = viewsModules['../views/error/404.vue'];
|
||||
|
||||
// 后端控制路由
|
||||
|
||||
@@ -22,6 +31,57 @@ const viewsModules: any = import.meta.glob('../views/**/*.{vue,tsx}');
|
||||
*/
|
||||
const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...layouModules }, { ...viewsModules });
|
||||
|
||||
const normalizeRouteComponent = (component?: string) => {
|
||||
if (!component) return '';
|
||||
return component
|
||||
.trim()
|
||||
.replace(/^\/@\//, '')
|
||||
.replace(/^\//, '')
|
||||
.replace(/^views\//, '')
|
||||
.replace(/\.(vue|tsx)$/i, '')
|
||||
.replace(/\/index$/i, '');
|
||||
};
|
||||
|
||||
const resolveRouteComponent = (component?: string, path?: string, isParent = false) => {
|
||||
const normalizedComponent = normalizeRouteComponent(component);
|
||||
const normalizedPath = normalizeRouteComponent(path);
|
||||
const candidates = [normalizedComponent, normalizedPath].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const exactViewIndexVueKey = `../views/${candidate}/index.vue`;
|
||||
if (dynamicViewsModules[exactViewIndexVueKey]) {
|
||||
return dynamicViewsModules[exactViewIndexVueKey];
|
||||
}
|
||||
|
||||
const exactViewVueKey = `../views/${candidate}.vue`;
|
||||
if (dynamicViewsModules[exactViewVueKey]) {
|
||||
return dynamicViewsModules[exactViewVueKey];
|
||||
}
|
||||
|
||||
const exactViewTsxKey = `../views/${candidate}.tsx`;
|
||||
if (dynamicViewsModules[exactViewTsxKey]) {
|
||||
return dynamicViewsModules[exactViewTsxKey];
|
||||
}
|
||||
|
||||
const exactLayoutVueKey = `../${candidate}.vue`;
|
||||
if (dynamicViewsModules[exactLayoutVueKey]) {
|
||||
return dynamicViewsModules[exactLayoutVueKey];
|
||||
}
|
||||
|
||||
const exactLayoutTsxKey = `../${candidate}.tsx`;
|
||||
if (dynamicViewsModules[exactLayoutTsxKey]) {
|
||||
return dynamicViewsModules[exactLayoutTsxKey];
|
||||
}
|
||||
|
||||
if (candidate === 'layout/routerView/parent') {
|
||||
return parentView;
|
||||
}
|
||||
}
|
||||
|
||||
if (isParent) return parentView;
|
||||
return notFoundView || parentView;
|
||||
};
|
||||
|
||||
/**
|
||||
* 后端控制路由:初始化方法,防止刷新时路由丢失
|
||||
* @method NextLoading 界面 loading 动画开始执行
|
||||
@@ -31,25 +91,18 @@ const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...lay
|
||||
* @method setFilterMenuAndCacheTagsViewRoutes 设置路由到 vuex routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
|
||||
*/
|
||||
export async function initBackEndControlRoutes() {
|
||||
// 界面 loading 动画开始执行
|
||||
if (window.nextLoading === undefined) NextLoading.start();
|
||||
// 无 token 停止执行下一步
|
||||
if (!Session.get('token')) return false;
|
||||
// 触发初始化用户信息 pinia
|
||||
// https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP
|
||||
|
||||
await useUserInfo().setUserInfos();
|
||||
await useUserInfo().setPermissions();
|
||||
// 获取路由菜单数据
|
||||
await getBackEndControlRoutes();
|
||||
let menuRoute = Session.get('userMenu');
|
||||
// 存储接口原始路由(未处理component),根据需求选择使用
|
||||
const menuRoute = Session.get('userMenu');
|
||||
|
||||
useRequestOldRoutes().setRequestOldRoutes(JSON.parse(JSON.stringify(menuRoute)));
|
||||
// 处理路由(component),替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由
|
||||
dynamicRoutes[0].children = [...defaultDynamicRouteChildren];
|
||||
dynamicRoutes[0].children?.push(...(await backEndComponent(menuRoute)));
|
||||
// 添加动态路由
|
||||
await setAddRoute();
|
||||
// 设置路由到 vuex routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
|
||||
await setFilterMenuAndCacheTagsViewRoutes();
|
||||
}
|
||||
|
||||
@@ -79,7 +132,7 @@ export function setCacheTagsViewRoutes() {
|
||||
* @returns 返回替换后的路由数组
|
||||
*/
|
||||
export function setFilterRouteEnd() {
|
||||
let filterRouteEnd: any = formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes));
|
||||
const filterRouteEnd: any = formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes));
|
||||
filterRouteEnd[0].children = [...filterRouteEnd[0].children, ...notFoundAndNoPower];
|
||||
return filterRouteEnd;
|
||||
}
|
||||
@@ -103,8 +156,8 @@ export async function setAddRoute() {
|
||||
* @returns 返回后端路由菜单数据
|
||||
*/
|
||||
export async function getBackEndControlRoutes() {
|
||||
let menuRoute = Session.get('userMenu');
|
||||
let permissions = Session.get('permissions');
|
||||
const menuRoute = Session.get('userMenu');
|
||||
const permissions = Session.get('permissions');
|
||||
if (!menuRoute || !permissions) {
|
||||
await refreshBackEndControlRoutes();
|
||||
}
|
||||
@@ -116,7 +169,6 @@ export async function getBackEndControlRoutes() {
|
||||
* @returns 返回后端路由菜单数据
|
||||
*/
|
||||
export async function refreshBackEndControlRoutes() {
|
||||
// 获取路由
|
||||
await getUserMenus().then((res: any) => {
|
||||
Session.set('userMenu', res.data.menuList);
|
||||
Session.set('permissions', res.data.permissions);
|
||||
@@ -141,17 +193,21 @@ export function setBackEndControlRefreshRoutes() {
|
||||
export function backEndComponent(routes: any) {
|
||||
if (!routes) return [];
|
||||
return routes.map((item: any) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
item.children.some((ci: any) => {
|
||||
if (!ci.meta.isHide) {
|
||||
item.redirect = ci;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
item.children = backEndComponent(item.children);
|
||||
const firstVisibleChild = item.children.find((ci: any) => !ci.meta?.isHide);
|
||||
if (firstVisibleChild) {
|
||||
item.redirect = firstVisibleChild.path;
|
||||
}
|
||||
|
||||
const routeComponent = resolveRouteComponent(item.component as string, item.path as string, false);
|
||||
item.component = routeComponent === notFoundView ? parentView : routeComponent;
|
||||
} else {
|
||||
item.component = resolveRouteComponent(item.component as string, item.path as string, false);
|
||||
}
|
||||
if (item.component) item.component = dynamicImport(dynamicViewsModules, item.component as string);
|
||||
item.children && backEndComponent(item.children);
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
@@ -163,16 +219,27 @@ export function backEndComponent(routes: any) {
|
||||
* @returns 返回处理成函数后的 component
|
||||
*/
|
||||
export function dynamicImport(dynamicViewsModules: Record<string, Function>, component: string) {
|
||||
const normalizedComponent = normalizeRouteComponent(component);
|
||||
if (!normalizedComponent) return false;
|
||||
|
||||
const directViewIndexVueKey = `../views/${normalizedComponent}/index.vue`;
|
||||
if (dynamicViewsModules[directViewIndexVueKey]) return dynamicViewsModules[directViewIndexVueKey];
|
||||
|
||||
const directViewVueKey = `../views/${normalizedComponent}.vue`;
|
||||
if (dynamicViewsModules[directViewVueKey]) return dynamicViewsModules[directViewVueKey];
|
||||
|
||||
const directViewTsxKey = `../views/${normalizedComponent}.tsx`;
|
||||
if (dynamicViewsModules[directViewTsxKey]) return dynamicViewsModules[directViewTsxKey];
|
||||
|
||||
const directLayoutVueKey = `../${normalizedComponent}.vue`;
|
||||
if (dynamicViewsModules[directLayoutVueKey]) return dynamicViewsModules[directLayoutVueKey];
|
||||
|
||||
const directLayoutTsxKey = `../${normalizedComponent}.tsx`;
|
||||
if (dynamicViewsModules[directLayoutTsxKey]) return dynamicViewsModules[directLayoutTsxKey];
|
||||
|
||||
const keys = Object.keys(dynamicViewsModules);
|
||||
const matchKeys = keys.filter((key) => {
|
||||
const k = key.replace(/..\/views|../, '');
|
||||
return k.startsWith(`${component}`) || k.startsWith(`/${component}`);
|
||||
});
|
||||
if (matchKeys?.length === 1) {
|
||||
const matchKey = matchKeys[0];
|
||||
return dynamicViewsModules[matchKey];
|
||||
}
|
||||
if (matchKeys?.length > 1) {
|
||||
return false;
|
||||
}
|
||||
const fuzzyKey = keys.find((key) => key.includes(`/${normalizedComponent}/`) || key.includes(`/${normalizedComponent}.`));
|
||||
if (fuzzyKey) return dynamicViewsModules[fuzzyKey];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
} else {
|
||||
if (!token) {
|
||||
next(`/login?redirect=${to.path}¶ms=${JSON.stringify(to.query ? to.query : to.params)}`);
|
||||
Session.clear();
|
||||
// 进入受保护页面但本地已没有 token 时,只清理登录态缓存即可。
|
||||
Session.clearAuth();
|
||||
NProgress.done();
|
||||
} else if (token && to.path === '/login') {
|
||||
next('/home');
|
||||
|
||||
53
src/utils/debounce.ts
Normal file
53
src/utils/debounce.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { onUnmounted } from 'vue';
|
||||
|
||||
interface DebounceOptions {
|
||||
delay?: number;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export function useDebounce() {
|
||||
const timers = new Map<string, number>();
|
||||
|
||||
const debounce = (key: string, fn: () => void, options: DebounceOptions = {}) => {
|
||||
const { delay = 300 } = options;
|
||||
|
||||
if (timers.has(key)) {
|
||||
clearTimeout(timers.get(key) as number);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fn();
|
||||
timers.delete(key);
|
||||
}, delay) as unknown as number;
|
||||
|
||||
timers.set(key, timer);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
return { debounce, clear };
|
||||
}
|
||||
|
||||
export function createDebouncedFn(key: string, fn: () => void, delay = 300) {
|
||||
const timers = new Map<string, number>();
|
||||
|
||||
return () => {
|
||||
if (timers.has(key)) {
|
||||
clearTimeout(timers.get(key) as number);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fn();
|
||||
timers.delete(key);
|
||||
}, delay) as unknown as number;
|
||||
|
||||
timers.set(key, timer);
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { App } from 'vue';
|
||||
import { authDirective } from '/@/utils/authDirective';
|
||||
import { wavesDirective, dragDirective } from '/@/utils/customDirective';
|
||||
import { debounceDirective } from '/@/directives/debounce';
|
||||
|
||||
/**
|
||||
* 导出指令方法:v-xxx
|
||||
* @methods authDirective 用户权限指令,用法:v-auth
|
||||
* @methods wavesDirective 按钮波浪指令,用法:v-waves
|
||||
* @methods dragDirective 自定义拖动指令,用法:v-drag
|
||||
* @methods debounceDirective 防抖指令,用法:v-debounce
|
||||
*/
|
||||
export function directive(app: App) {
|
||||
// 用户权限指令
|
||||
@@ -15,4 +17,6 @@ export function directive(app: App) {
|
||||
wavesDirective(app);
|
||||
// 自定义拖动指令
|
||||
dragDirective(app);
|
||||
// 防抖指令
|
||||
app.directive('debounce', debounceDirective);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,92 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { MessageHandler } from 'element-plus';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import qs from 'qs';
|
||||
import { getChangedFields } from '/@/utils/diffUtils';
|
||||
import { handleModuleNotEnabled } from '/@/utils/assetSubscribe';
|
||||
|
||||
// 标记是否正在处理 token 过期,避免重复弹窗
|
||||
let isHandlingTokenExpired = false;
|
||||
/**
|
||||
* 控制一次请求的错误提示归属:
|
||||
* - global: 交给 request.ts 统一弹错,适合绝大多数接口
|
||||
* - page: 页面自己在 catch 中决定提示文案,避免与全局重复
|
||||
* - silent: 完全静默,适合轮询、后台刷新等不希望打扰用户的请求
|
||||
*/
|
||||
export interface RequestOptions {
|
||||
errorMode?: 'global' | 'page' | 'silent';
|
||||
}
|
||||
|
||||
// 错误消息防抖:防止短时间内显示多个错误消息
|
||||
let lastErrorTime = 0;
|
||||
const ERROR_MESSAGE_INTERVAL = 2000; // 2秒内只显示一个错误
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
const now = Date.now();
|
||||
|
||||
// 2秒内只显示一个错误消息(不管内容是否相同)
|
||||
if (now - lastErrorTime < ERROR_MESSAGE_INTERVAL) {
|
||||
return; // 跳过
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
requestOptions?: RequestOptions;
|
||||
}
|
||||
|
||||
lastErrorTime = now;
|
||||
ElMessage.error(message);
|
||||
interface InternalAxiosRequestConfig {
|
||||
requestOptions?: RequestOptions;
|
||||
}
|
||||
}
|
||||
|
||||
// 标记是否正在处理 token 过期,避免出现多个登录过期弹窗。
|
||||
let isHandlingTokenExpired = false;
|
||||
|
||||
// 始终只保留一个错误消息实例,新的错误会先关闭旧的提示。
|
||||
let activeErrorMessage: MessageHandler | null = null;
|
||||
|
||||
// 同类错误提示做一个短时间节流,避免接口连发时刷屏。
|
||||
let lastErrorTime = 0;
|
||||
const ERROR_MESSAGE_INTERVAL = 2000;
|
||||
|
||||
const getErrorMode = (config?: InternalAxiosRequestConfig) => config?.requestOptions?.errorMode ?? 'global';
|
||||
const shouldShowGlobalError = (config?: InternalAxiosRequestConfig) => getErrorMode(config) === 'global';
|
||||
|
||||
const closeActiveErrorMessage = () => {
|
||||
activeErrorMessage?.close();
|
||||
activeErrorMessage = null;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Axios 实例配置
|
||||
// 地址配置见 .env.development 文件
|
||||
// ============================================================
|
||||
/**
|
||||
* token 过期只接受明确的后端信号:
|
||||
* - HTTP 401
|
||||
* - 业务 code = 401
|
||||
* - 已知的固定错误文案
|
||||
* 不再使用模糊的 includes('token'),避免把普通业务错误误判成登录过期。
|
||||
*/
|
||||
const isTokenExpiredError = (httpStatus?: number, code?: number, message?: string) => {
|
||||
const normalizedMessage = String(message || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const tokenExpiredMessages = ['token is invalid', 'token 解析失败', 'decrypt error', 'jwt expired', 'invalid token'];
|
||||
|
||||
return httpStatus === 401 || code === 401 || tokenExpiredMessages.includes(normalizedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局错误提示统一从这里走:
|
||||
* 1. 先判断当前请求是否允许全局弹错
|
||||
* 2. 再做节流,防止短时间重复提示
|
||||
* 3. 最后保证页面上同一时刻只有一个错误弹窗
|
||||
*/
|
||||
const showErrorMessage = (message: string, config?: InternalAxiosRequestConfig) => {
|
||||
if (!shouldShowGlobalError(config)) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastErrorTime < ERROR_MESSAGE_INTERVAL) return;
|
||||
|
||||
lastErrorTime = now;
|
||||
closeActiveErrorMessage();
|
||||
|
||||
let currentMessage: MessageHandler | null = null;
|
||||
currentMessage = ElMessage.error({
|
||||
message,
|
||||
onClose: () => {
|
||||
if (activeErrorMessage === currentMessage) {
|
||||
activeErrorMessage = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
activeErrorMessage = currentMessage;
|
||||
};
|
||||
|
||||
// 统一服务实例(端口8000)- 全部模块共用
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 50000,
|
||||
@@ -41,23 +98,21 @@ const service: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// token 过期处理函数
|
||||
/**
|
||||
* 登录过期时优先关闭普通错误消息,再弹出唯一的登录过期确认框。
|
||||
* 这样用户不会先看到一个普通报错,再叠一个登录过期弹窗。
|
||||
*/
|
||||
const handleTokenExpired = () => {
|
||||
if (isHandlingTokenExpired) return;
|
||||
|
||||
isHandlingTokenExpired = true;
|
||||
closeActiveErrorMessage();
|
||||
|
||||
ElMessageBox.alert('登录状态已过期,请重新登录', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
showClose: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false,
|
||||
beforeClose: (action, _instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
done();
|
||||
performLogout();
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
performLogout();
|
||||
@@ -67,62 +122,47 @@ const handleTokenExpired = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 执行退出登录操作
|
||||
/**
|
||||
* 统一退出动作:
|
||||
* - 只清理登录态相关缓存
|
||||
* - 重置 token 过期处理标记
|
||||
* - 最后回到登录页
|
||||
*/
|
||||
const performLogout = () => {
|
||||
Session.clear();
|
||||
localStorage.clear();
|
||||
Session.clearAuth();
|
||||
isHandlingTokenExpired = false;
|
||||
// Hash 路由统一回登录页,避免跳到错误地址
|
||||
setTimeout(() => {
|
||||
window.location.href = '/#/login';
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 请求拦截器
|
||||
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||||
// 检查 token 是否有效
|
||||
const token = Session.get('token');
|
||||
if (token) {
|
||||
// 可以在这里添加 token 有效性检查(如果需要)
|
||||
config.headers!['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// PUT 请求最小化传参处理
|
||||
// 如果请求数据中包含 _originalData,则自动计算差异,只传递修改过的字段
|
||||
// PUT 请求只传变更字段,避免把未修改的数据整包提交给后端。
|
||||
if (config.method?.toLowerCase() === 'put' && config.data && typeof config.data === 'object') {
|
||||
const { _originalData, ...currentData } = config.data;
|
||||
|
||||
if (_originalData && typeof _originalData === 'object') {
|
||||
// 获取 id 字段(必须保留)
|
||||
const idField = currentData.id || currentData.Id || currentData.ID;
|
||||
|
||||
// 计算差异
|
||||
const changedFields = getChangedFields(_originalData, currentData, {
|
||||
exclude: ['_originalData', 'id', 'Id', 'ID'],
|
||||
});
|
||||
|
||||
// 如果有变化,只传递 id + 变化的字段
|
||||
if (Object.keys(changedFields).length > 0) {
|
||||
config.data = { id: idField, ...changedFields };
|
||||
} else {
|
||||
// 没有变化,只传递 id
|
||||
config.data = { id: idField };
|
||||
}
|
||||
|
||||
console.log('[最小化传参] 原始字段数:', Object.keys(currentData).length, '-> 传递字段数:', Object.keys(config.data).length);
|
||||
config.data = Object.keys(changedFields).length > 0 ? { id: idField, ...changedFields } : { id: idField };
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const requestErrorHandler = (error: any) => {
|
||||
return Promise.reject(error);
|
||||
};
|
||||
const requestErrorHandler = (error: any) => Promise.reject(error);
|
||||
|
||||
// 响应拦截器
|
||||
const responseInterceptor = (response: AxiosResponse) => {
|
||||
// 文件流响应直接返回
|
||||
// 文件流直接返回原始响应,调用方需要自行处理 Blob。
|
||||
if (
|
||||
response.config.responseType === 'blob' ||
|
||||
response.headers['content-type']?.includes('application/zip') ||
|
||||
@@ -135,121 +175,97 @@ const responseInterceptor = (response: AxiosResponse) => {
|
||||
const httpStatus = response.status;
|
||||
const code = res?.code;
|
||||
const message = res?.message;
|
||||
const config = response.config;
|
||||
|
||||
// 检查 token 相关错误
|
||||
if (
|
||||
httpStatus === 401 ||
|
||||
code === 401 ||
|
||||
message?.includes('token') ||
|
||||
message === 'token is invalid' ||
|
||||
message === 'token 解析失败' ||
|
||||
message?.includes('decrypt error')
|
||||
) {
|
||||
if (isTokenExpiredError(httpStatus, code, message)) {
|
||||
handleTokenExpired();
|
||||
return Promise.reject(new Error('登录状态已过期'));
|
||||
}
|
||||
|
||||
// 处理模块未开通错误 (403)
|
||||
// 跳过资产SKU查询接口,避免弹窗内部请求触发循环
|
||||
const requestUrl = response.config.url || '';
|
||||
if (code === 402 && !requestUrl.includes('/assets/asset/sku/')) {
|
||||
// 获取当前路由路径
|
||||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||||
console.log('[request.ts] 检测到403错误,当前路径:', currentPath);
|
||||
handleModuleNotEnabled(currentPath);
|
||||
// 直接返回,不再显示错误消息
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
|
||||
// 业务逻辑错误处理(排除403,因为上面已处理)
|
||||
// 业务失败默认走全局提示;如果页面声明自己处理,这里只抛错不弹窗。
|
||||
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
||||
const errorMsg = message || `请求失败(${code})`;
|
||||
showErrorMessage(errorMsg);
|
||||
showErrorMessage(errorMsg, config);
|
||||
return Promise.reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// 响应错误拦截器
|
||||
const responseErrorHandler = (error: any) => {
|
||||
console.error('API请求错误:', error);
|
||||
const config = error.config as InternalAxiosRequestConfig | undefined;
|
||||
const httpStatus = error.response?.status;
|
||||
const responseMessage = error.response?.data?.message;
|
||||
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
showErrorMessage('请求超时,请检查网络连接');
|
||||
showErrorMessage('请求超时,请检查网络连接', config);
|
||||
return Promise.reject(new Error('请求超时'));
|
||||
}
|
||||
|
||||
if (!error.response) {
|
||||
if (error.message === 'Network Error') {
|
||||
// ElMessage.error('网络连接错误,请检查网络设置');
|
||||
} else {
|
||||
// ElMessage.error('网络异常,请检查连接');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const httpStatus = error.response.status;
|
||||
// 优先使用返回数据中的 message 字段
|
||||
const responseMessage = error.response.data?.message;
|
||||
if (isTokenExpiredError(httpStatus, error.response?.data?.code, responseMessage)) {
|
||||
handleTokenExpired();
|
||||
return Promise.reject(new Error('登录状态已过期'));
|
||||
}
|
||||
|
||||
// 处理 HTTP 错误状态
|
||||
const requestUrl = error.response.config?.url || '';
|
||||
|
||||
switch (httpStatus) {
|
||||
case 401:
|
||||
handleTokenExpired();
|
||||
break;
|
||||
case 402:
|
||||
// 模块未开通处理,跳过SKU相关接口避免循环
|
||||
if (!requestUrl.includes('/assets/asset/sku/') && !requestUrl.includes('getAssetAndSku')) {
|
||||
// 检查是否刚从开通页面返回(5秒内不再跳转)
|
||||
const lastSubscribeTime = sessionStorage.getItem('lastSubscribeTime');
|
||||
const now = Date.now();
|
||||
if (lastSubscribeTime && now - parseInt(lastSubscribeTime) < 5000) {
|
||||
console.log('[responseErrorHandler] 刚完成开通,跳过402处理');
|
||||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面');
|
||||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面', config);
|
||||
return Promise.reject(new Error('模块开通中'));
|
||||
}
|
||||
|
||||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||||
console.log('[responseErrorHandler] 检测到HTTP 402错误,当前路径:', currentPath);
|
||||
handleModuleNotEnabled(currentPath);
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
showErrorMessage(responseMessage || '服务未开通');
|
||||
showErrorMessage(responseMessage || '服务未开通', config);
|
||||
break;
|
||||
case 403:
|
||||
showErrorMessage(responseMessage || '没有权限访问该资源');
|
||||
showErrorMessage(responseMessage || '没有权限访问该资源', config);
|
||||
break;
|
||||
case 404:
|
||||
showErrorMessage(responseMessage || '请求的资源不存在');
|
||||
showErrorMessage(responseMessage || '请求的资源不存在', config);
|
||||
break;
|
||||
case 429:
|
||||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试');
|
||||
handleTokenExpired();
|
||||
// 429 是限流,不等于登录过期,这里只保留频率提示。
|
||||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试', config);
|
||||
break;
|
||||
case 500:
|
||||
showErrorMessage(responseMessage || '服务器内部错误');
|
||||
showErrorMessage(responseMessage || '服务器内部错误', config);
|
||||
break;
|
||||
case 502:
|
||||
showErrorMessage(responseMessage || '网关错误');
|
||||
showErrorMessage(responseMessage || '网关错误', config);
|
||||
break;
|
||||
case 503:
|
||||
showErrorMessage(responseMessage || '服务不可用');
|
||||
showErrorMessage(responseMessage || '服务不可用', config);
|
||||
break;
|
||||
default:
|
||||
if (httpStatus >= 400) {
|
||||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`);
|
||||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`, config);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
// 为实例添加拦截器
|
||||
service.interceptors.request.use(requestInterceptor, requestErrorHandler);
|
||||
service.interceptors.response.use(responseInterceptor, responseErrorHandler);
|
||||
|
||||
// 导出
|
||||
export default service;
|
||||
export { showErrorMessage };
|
||||
export { closeActiveErrorMessage, showErrorMessage };
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
/**
|
||||
* 这些 key 属于登录态或用户会话上下文。
|
||||
* 退出登录时只清理这部分数据,避免误删主题、语言、布局等本地个性化配置。
|
||||
*/
|
||||
const SESSION_AUTH_KEYS = ['token', 'userInfo', 'userMenu', 'permissions'];
|
||||
|
||||
/**
|
||||
* window.localStorage 浏览器永久缓存
|
||||
* @method set 设置永久缓存
|
||||
@@ -33,6 +39,7 @@ export const Local = {
|
||||
* @method get 获取临时缓存
|
||||
* @method remove 移除临时缓存
|
||||
* @method clear 移除全部临时缓存
|
||||
* @method clearAuth 移除登录态相关缓存
|
||||
*/
|
||||
export const Session = {
|
||||
// 设置临时缓存
|
||||
@@ -56,4 +63,10 @@ export const Session = {
|
||||
Cookies.remove('token');
|
||||
window.sessionStorage.clear();
|
||||
},
|
||||
// 只清理登录态相关缓存,保留非登录相关的页面状态与本地配置
|
||||
clearAuth() {
|
||||
SESSION_AUTH_KEYS.forEach((key) => this.remove(key));
|
||||
},
|
||||
};
|
||||
|
||||
export { SESSION_AUTH_KEYS };
|
||||
|
||||
432
src/views/ads/summary/customer/index.vue
Normal file
432
src/views/ads/summary/customer/index.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<div class="ads-summary-customer">
|
||||
<el-card shadow="hover">
|
||||
<template #header><div class="card-header"><span>客户监控</span></div></template>
|
||||
<div class="search-container">
|
||||
<el-form :model="searchParams" :inline="true" class="search-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="searchParams.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="searchParams.customerType" clearable placeholder="请选择客户类型">
|
||||
<el-option label="新客户" value="new" />
|
||||
<el-option label="老客户" value="returning" />
|
||||
<el-option label="高价值客户" value="high-value" />
|
||||
<el-option label="潜在客户" value="potential" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">客户类型分布</div></template>
|
||||
<div ref="typeChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">客户行为趋势</div></template>
|
||||
<div ref="behaviorChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">数据概览</div></template>
|
||||
<div class="stats-grid">
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">总客户数</div>
|
||||
<div class="stats-value">{{ totalStats.totalCustomers }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">新客户数</div>
|
||||
<div class="stats-value">{{ totalStats.newCustomers }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">老客户数</div>
|
||||
<div class="stats-value">{{ totalStats.returningCustomers }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">客户留存率</div>
|
||||
<div class="stats-value">{{ totalStats.retentionRate }}%</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">客户明细</div></template>
|
||||
<el-table :data="pagedData" style="width: 100%">
|
||||
<el-table-column prop="customerId" label="客户ID" />
|
||||
<el-table-column prop="customerType" label="客户类型"><template #default="scope">{{ customerTypeMap[scope.row.customerType] }}</template></el-table-column>
|
||||
<el-table-column prop="firstSeen" label="首次接触时间" />
|
||||
<el-table-column prop="lastSeen" label="最近接触时间" />
|
||||
<el-table-column prop="totalSpend" label="总花费"><template #default="scope">¥{{ scope.row.totalSpend }}</template></el-table-column>
|
||||
<el-table-column prop="conversionCount" label="转化次数" />
|
||||
<el-table-column prop="avgOrderValue" label="平均订单价值"><template #default="scope">¥{{ scope.row.avgOrderValue }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const customerTypeMap = {
|
||||
new: '新客户',
|
||||
returning: '老客户',
|
||||
'high-value': '高价值客户',
|
||||
potential: '潜在客户',
|
||||
};
|
||||
|
||||
const searchParams = reactive({
|
||||
dateRange: [],
|
||||
customerType: '',
|
||||
});
|
||||
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近90天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const typeChartRef = ref();
|
||||
const behaviorChartRef = ref();
|
||||
let typeChart: echarts.ECharts | null = null;
|
||||
let behaviorChart: echarts.ECharts | null = null;
|
||||
|
||||
const totalStats = reactive({
|
||||
totalCustomers: 0,
|
||||
newCustomers: 0,
|
||||
returningCustomers: 0,
|
||||
retentionRate: 0,
|
||||
});
|
||||
|
||||
const data = ref<any[]>([]);
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pagedData = computed(() => {
|
||||
const start = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
const end = start + pagination.pageSize;
|
||||
return data.value.slice(start, end);
|
||||
});
|
||||
|
||||
const getMockData = () => {
|
||||
const mockData = [];
|
||||
const customerTypes = ['new', 'returning', 'high-value', 'potential'];
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const customerType = customerTypes[Math.floor(Math.random() * customerTypes.length)];
|
||||
const firstSeen = new Date(Date.now() - Math.random() * 90 * 24 * 3600 * 1000).toISOString().split('T')[0];
|
||||
const lastSeen = new Date(Date.now() - Math.random() * 7 * 24 * 3600 * 1000).toISOString().split('T')[0];
|
||||
const totalSpend = (Math.random() * 10000 + 100).toFixed(2);
|
||||
const conversionCount = Math.floor(Math.random() * 10) + 1;
|
||||
const avgOrderValue = (parseFloat(totalSpend) / conversionCount).toFixed(2);
|
||||
|
||||
mockData.push({
|
||||
customerId: `C${i.toString().padStart(6, '0')}`,
|
||||
customerType,
|
||||
firstSeen,
|
||||
lastSeen,
|
||||
totalSpend,
|
||||
conversionCount,
|
||||
avgOrderValue,
|
||||
});
|
||||
}
|
||||
|
||||
return mockData;
|
||||
};
|
||||
|
||||
const calculateTotalStats = (data: any[]) => {
|
||||
totalStats.totalCustomers = data.length;
|
||||
totalStats.newCustomers = data.filter(item => item.customerType === 'new').length;
|
||||
totalStats.returningCustomers = data.filter(item => item.customerType === 'returning').length;
|
||||
totalStats.retentionRate = ((totalStats.returningCustomers / totalStats.totalCustomers) * 100).toFixed(2);
|
||||
};
|
||||
|
||||
const initTypeChart = (data: any[]) => {
|
||||
if (!typeChartRef.value) return;
|
||||
|
||||
if (typeChart) typeChart.dispose();
|
||||
typeChart = echarts.init(typeChartRef.value);
|
||||
|
||||
const typeData = {
|
||||
new: 0,
|
||||
returning: 0,
|
||||
'high-value': 0,
|
||||
potential: 0,
|
||||
};
|
||||
|
||||
data.forEach(item => {
|
||||
typeData[item.customerType]++;
|
||||
});
|
||||
|
||||
typeChart.setOption({
|
||||
title: {
|
||||
text: '客户类型分布',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
data: Object.values(customerTypeMap),
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '客户类型',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: [
|
||||
{ value: typeData.new, name: customerTypeMap.new },
|
||||
{ value: typeData.returning, name: customerTypeMap.returning },
|
||||
{ value: typeData['high-value'], name: customerTypeMap['high-value'] },
|
||||
{ value: typeData.potential, name: customerTypeMap.potential },
|
||||
],
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const initBehaviorChart = (data: any[]) => {
|
||||
if (!behaviorChartRef.value) return;
|
||||
|
||||
if (behaviorChart) behaviorChart.dispose();
|
||||
behaviorChart = echarts.init(behaviorChartRef.value);
|
||||
|
||||
const dates = [];
|
||||
const newCustomers = [];
|
||||
const returningCustomers = [];
|
||||
|
||||
const end = new Date();
|
||||
for (let i = 30; i >= 0; i--) {
|
||||
const date = new Date(end);
|
||||
date.setDate(date.getDate() - i);
|
||||
dates.push(date.toISOString().split('T')[0]);
|
||||
newCustomers.push(Math.floor(Math.random() * 20) + 5);
|
||||
returningCustomers.push(Math.floor(Math.random() * 15) + 3);
|
||||
}
|
||||
|
||||
behaviorChart.setOption({
|
||||
title: {
|
||||
text: '客户行为趋势',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
legend: {
|
||||
data: ['新客户', '老客户'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '客户数',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '新客户',
|
||||
type: 'line',
|
||||
data: newCustomers,
|
||||
},
|
||||
{
|
||||
name: '老客户',
|
||||
type: 'line',
|
||||
data: returningCustomers,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
data.value = getMockData();
|
||||
pagination.total = data.value.length;
|
||||
calculateTotalStats(data.value);
|
||||
initTypeChart(data.value);
|
||||
initBehaviorChart(data.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchParams.dateRange = [];
|
||||
searchParams.customerType = '';
|
||||
pagination.currentPage = 1;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.currentPage = 1;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.currentPage = page;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleSearch();
|
||||
window.addEventListener('resize', () => {
|
||||
typeChart?.resize();
|
||||
behaviorChart?.resize();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ads-summary-customer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.data-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
297
src/views/ads/summary/daily/index.vue
Normal file
297
src/views/ads/summary/daily/index.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="ads-summary-daily">
|
||||
<el-card shadow="hover">
|
||||
<template #header><div class="card-header"><span>分日监控</span></div></template>
|
||||
<div class="search-container">
|
||||
<el-form :model="searchParams" :inline="true" class="search-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="searchParams.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">每日数据趋势</div></template>
|
||||
<div ref="chartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">每日详细数据</div></template>
|
||||
<el-table :data="pagedData" style="width: 100%">
|
||||
<el-table-column prop="date" label="日期" />
|
||||
<el-table-column prop="impressions" label="曝光" />
|
||||
<el-table-column prop="clicks" label="点击" />
|
||||
<el-table-column prop="ctr" label="点击率"><template #default="scope">{{ scope.row.ctr }}%</template></el-table-column>
|
||||
<el-table-column prop="conversions" label="转化" />
|
||||
<el-table-column prop="conversionRate" label="转化率"><template #default="scope">{{ scope.row.conversionRate }}%</template></el-table-column>
|
||||
<el-table-column prop="cost" label="花费"><template #default="scope">¥{{ scope.row.cost }}</template></el-table-column>
|
||||
<el-table-column prop="cpc" label="CPC"><template #default="scope">¥{{ scope.row.cpc }}</template></el-table-column>
|
||||
<el-table-column prop="cpa" label="CPA"><template #default="scope">¥{{ scope.row.cpa }}</template></el-table-column>
|
||||
<el-table-column prop="roi" label="ROI"><template #default="scope">{{ scope.row.roi }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const searchParams = reactive({
|
||||
dateRange: [],
|
||||
});
|
||||
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近90天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const chartRef = ref();
|
||||
let chart: echarts.ECharts | null = null;
|
||||
|
||||
const data = ref<any[]>([]);
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pagedData = computed(() => {
|
||||
const start = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
const end = start + pagination.pageSize;
|
||||
return data.value.slice(start, end);
|
||||
});
|
||||
|
||||
const getMockData = () => {
|
||||
const mockData = [];
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
|
||||
let current = new Date(start);
|
||||
while (current <= end) {
|
||||
const date = current.toISOString().split('T')[0];
|
||||
const impressions = Math.floor(Math.random() * 10000) + 5000;
|
||||
const clicks = Math.floor(impressions * (Math.random() * 0.05 + 0.01));
|
||||
const ctr = ((clicks / impressions) * 100).toFixed(2);
|
||||
const conversions = Math.floor(clicks * (Math.random() * 0.1 + 0.02));
|
||||
const conversionRate = ((conversions / clicks) * 100).toFixed(2);
|
||||
const cost = (Math.random() * 1000 + 100).toFixed(2);
|
||||
const cpc = (parseFloat(cost) / clicks).toFixed(2);
|
||||
const cpa = (parseFloat(cost) / conversions).toFixed(2);
|
||||
const roi = (Math.random() * 5 + 1).toFixed(2);
|
||||
|
||||
mockData.push({
|
||||
date,
|
||||
impressions,
|
||||
clicks,
|
||||
ctr,
|
||||
conversions,
|
||||
conversionRate,
|
||||
cost,
|
||||
cpc,
|
||||
cpa,
|
||||
roi,
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return mockData;
|
||||
};
|
||||
|
||||
const initChart = (data: any[]) => {
|
||||
if (!chartRef.value) return;
|
||||
|
||||
if (chart) chart.dispose();
|
||||
chart = echarts.init(chartRef.value);
|
||||
|
||||
const dates = data.map(item => item.date);
|
||||
const impressions = data.map(item => item.impressions);
|
||||
const clicks = data.map(item => item.clicks);
|
||||
const conversions = data.map(item => item.conversions);
|
||||
const cost = data.map(item => parseFloat(item.cost));
|
||||
|
||||
chart.setOption({
|
||||
title: {
|
||||
text: '每日数据趋势',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
legend: {
|
||||
data: ['曝光', '点击', '转化', '花费'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '数量',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '花费',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}',
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '曝光',
|
||||
type: 'bar',
|
||||
data: impressions,
|
||||
},
|
||||
{
|
||||
name: '点击',
|
||||
type: 'line',
|
||||
data: clicks,
|
||||
},
|
||||
{
|
||||
name: '转化',
|
||||
type: 'line',
|
||||
data: conversions,
|
||||
},
|
||||
{
|
||||
name: '花费',
|
||||
type: 'line',
|
||||
data: cost,
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
data.value = getMockData();
|
||||
pagination.total = data.value.length;
|
||||
initChart(data.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchParams.dateRange = [];
|
||||
pagination.currentPage = 1;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.currentPage = 1;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.currentPage = page;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleSearch();
|
||||
window.addEventListener('resize', () => chart?.resize());
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ads-summary-daily {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
461
src/views/ads/summary/industry/index.vue
Normal file
461
src/views/ads/summary/industry/index.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="ads-summary-industry">
|
||||
<el-card shadow="hover">
|
||||
<template #header><div class="card-header"><span>行业监控</span></div></template>
|
||||
<div class="search-container">
|
||||
<el-form :model="searchParams" :inline="true" class="search-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="searchParams.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="行业">
|
||||
<el-select v-model="searchParams.industry" clearable placeholder="请选择行业">
|
||||
<el-option label="电商" value="ecommerce" />
|
||||
<el-option label="教育" value="education" />
|
||||
<el-option label="金融" value="finance" />
|
||||
<el-option label="医疗" value="medical" />
|
||||
<el-option label="游戏" value="game" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">行业趋势</div></template>
|
||||
<div ref="trendChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">行业对比</div></template>
|
||||
<div ref="comparisonChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">数据概览</div></template>
|
||||
<div class="stats-grid">
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">行业平均曝光</div>
|
||||
<div class="stats-value">{{ totalStats.avgImpressions }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">行业平均点击</div>
|
||||
<div class="stats-value">{{ totalStats.avgClicks }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">行业平均CTR</div>
|
||||
<div class="stats-value">{{ totalStats.avgCtr }}%</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">行业平均转化</div>
|
||||
<div class="stats-value">{{ totalStats.avgConversions }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">行业明细</div></template>
|
||||
<el-table :data="pagedData" style="width: 100%">
|
||||
<el-table-column prop="date" label="日期" />
|
||||
<el-table-column prop="industry" label="行业"><template #default="scope">{{ industryMap[scope.row.industry] }}</template></el-table-column>
|
||||
<el-table-column prop="impressions" label="曝光" />
|
||||
<el-table-column prop="clicks" label="点击" />
|
||||
<el-table-column prop="ctr" label="点击率"><template #default="scope">{{ scope.row.ctr }}%</template></el-table-column>
|
||||
<el-table-column prop="conversions" label="转化" />
|
||||
<el-table-column prop="conversionRate" label="转化率"><template #default="scope">{{ scope.row.conversionRate }}%</template></el-table-column>
|
||||
<el-table-column prop="cost" label="花费"><template #default="scope">¥{{ scope.row.cost }}</template></el-table-column>
|
||||
<el-table-column prop="roi" label="ROI"><template #default="scope">{{ scope.row.roi }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const industryMap = {
|
||||
ecommerce: '电商',
|
||||
education: '教育',
|
||||
finance: '金融',
|
||||
medical: '医疗',
|
||||
game: '游戏',
|
||||
};
|
||||
|
||||
const searchParams = reactive({
|
||||
dateRange: [],
|
||||
industry: '',
|
||||
});
|
||||
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近90天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const trendChartRef = ref();
|
||||
const comparisonChartRef = ref();
|
||||
let trendChart: echarts.ECharts | null = null;
|
||||
let comparisonChart: echarts.ECharts | null = null;
|
||||
|
||||
const totalStats = reactive({
|
||||
avgImpressions: 0,
|
||||
avgClicks: 0,
|
||||
avgCtr: 0,
|
||||
avgConversions: 0,
|
||||
});
|
||||
|
||||
const data = ref<any[]>([]);
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pagedData = computed(() => {
|
||||
const start = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
const end = start + pagination.pageSize;
|
||||
return data.value.slice(start, end);
|
||||
});
|
||||
|
||||
const getMockData = () => {
|
||||
const mockData = [];
|
||||
const industries = ['ecommerce', 'education', 'finance', 'medical', 'game'];
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
|
||||
let current = new Date(start);
|
||||
while (current <= end) {
|
||||
const date = current.toISOString().split('T')[0];
|
||||
industries.forEach(industry => {
|
||||
const impressions = Math.floor(Math.random() * 10000) + 5000;
|
||||
const clicks = Math.floor(impressions * (Math.random() * 0.05 + 0.01));
|
||||
const ctr = ((clicks / impressions) * 100).toFixed(2);
|
||||
const conversions = Math.floor(clicks * (Math.random() * 0.1 + 0.02));
|
||||
const conversionRate = ((conversions / clicks) * 100).toFixed(2);
|
||||
const cost = (Math.random() * 1000 + 100).toFixed(2);
|
||||
const roi = (Math.random() * 5 + 1).toFixed(2);
|
||||
|
||||
mockData.push({
|
||||
date,
|
||||
industry,
|
||||
impressions,
|
||||
clicks,
|
||||
ctr,
|
||||
conversions,
|
||||
conversionRate,
|
||||
cost,
|
||||
roi,
|
||||
});
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return mockData;
|
||||
};
|
||||
|
||||
const calculateTotalStats = (data: any[]) => {
|
||||
if (data.length === 0) return;
|
||||
|
||||
totalStats.avgImpressions = Math.round(data.reduce((sum, item) => sum + item.impressions, 0) / data.length);
|
||||
totalStats.avgClicks = Math.round(data.reduce((sum, item) => sum + item.clicks, 0) / data.length);
|
||||
totalStats.avgCtr = (data.reduce((sum, item) => sum + parseFloat(item.ctr), 0) / data.length).toFixed(2);
|
||||
totalStats.avgConversions = Math.round(data.reduce((sum, item) => sum + item.conversions, 0) / data.length);
|
||||
};
|
||||
|
||||
const initTrendChart = (data: any[]) => {
|
||||
if (!trendChartRef.value) return;
|
||||
|
||||
if (trendChart) trendChart.dispose();
|
||||
trendChart = echarts.init(trendChartRef.value);
|
||||
|
||||
// 按日期分组数据
|
||||
const dateMap: { [key: string]: { impressions: number; clicks: number } } = {};
|
||||
data.forEach(item => {
|
||||
if (!dateMap[item.date]) {
|
||||
dateMap[item.date] = { impressions: 0, clicks: 0 };
|
||||
}
|
||||
dateMap[item.date].impressions += item.impressions;
|
||||
dateMap[item.date].clicks += item.clicks;
|
||||
});
|
||||
|
||||
const dates = Object.keys(dateMap).sort();
|
||||
const impressions = dates.map(date => dateMap[date].impressions);
|
||||
const clicks = dates.map(date => dateMap[date].clicks);
|
||||
|
||||
trendChart.setOption({
|
||||
title: {
|
||||
text: '行业趋势',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
legend: {
|
||||
data: ['曝光', '点击'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '数量',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '曝光',
|
||||
type: 'line',
|
||||
data: impressions,
|
||||
},
|
||||
{
|
||||
name: '点击',
|
||||
type: 'line',
|
||||
data: clicks,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const initComparisonChart = (data: any[]) => {
|
||||
if (!comparisonChartRef.value) return;
|
||||
|
||||
if (comparisonChart) comparisonChart.dispose();
|
||||
comparisonChart = echarts.init(comparisonChartRef.value);
|
||||
|
||||
// 按行业分组数据
|
||||
const industryMap: { [key: string]: { ctr: number; conversionRate: number } } = {};
|
||||
data.forEach(item => {
|
||||
if (!industryMap[item.industry]) {
|
||||
industryMap[item.industry] = { ctr: 0, conversionRate: 0 };
|
||||
}
|
||||
industryMap[item.industry].ctr += parseFloat(item.ctr);
|
||||
industryMap[item.industry].conversionRate += parseFloat(item.conversionRate);
|
||||
});
|
||||
|
||||
// 计算平均值
|
||||
const industryCount = Object.keys(industryMap).length;
|
||||
Object.keys(industryMap).forEach(industry => {
|
||||
industryMap[industry].ctr /= industryCount;
|
||||
industryMap[industry].conversionRate /= industryCount;
|
||||
});
|
||||
|
||||
comparisonChart.setOption({
|
||||
title: {
|
||||
text: '行业对比',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['点击率', '转化率'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: Object.keys(industryMap).map(industry => industryMap[industry as keyof typeof industryMap]),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '百分比',
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '点击率',
|
||||
type: 'bar',
|
||||
data: Object.values(industryMap).map(item => item.ctr.toFixed(2)),
|
||||
},
|
||||
{
|
||||
name: '转化率',
|
||||
type: 'bar',
|
||||
data: Object.values(industryMap).map(item => item.conversionRate.toFixed(2)),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
data.value = getMockData();
|
||||
pagination.total = data.value.length;
|
||||
calculateTotalStats(data.value);
|
||||
initTrendChart(data.value);
|
||||
initComparisonChart(data.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchParams.dateRange = [];
|
||||
searchParams.industry = '';
|
||||
pagination.currentPage = 1;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.currentPage = 1;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.currentPage = page;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleSearch();
|
||||
window.addEventListener('resize', () => {
|
||||
trendChart?.resize();
|
||||
comparisonChart?.resize();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ads-summary-industry {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.data-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
1345
src/views/ads/summary/monitor/index.vue
Normal file
1345
src/views/ads/summary/monitor/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
453
src/views/ads/summary/new-customer/index.vue
Normal file
453
src/views/ads/summary/new-customer/index.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="ads-summary-new-customer">
|
||||
<el-card shadow="hover">
|
||||
<template #header
|
||||
><div class="card-header"><span>真新客监控</span></div></template
|
||||
>
|
||||
<div class="search-container">
|
||||
<el-form :model="searchParams" :inline="true" class="search-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="searchParams.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select v-model="searchParams.channel" clearable placeholder="请选择渠道">
|
||||
<el-option label="搜索广告" value="search" />
|
||||
<el-option label="社交媒体" value="social" />
|
||||
<el-option label="信息流" value="feed" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">新客户趋势</div></template>
|
||||
<div ref="trendChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">渠道分布</div></template>
|
||||
<div ref="channelChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">数据概览</div></template>
|
||||
<div class="stats-grid">
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">新客户数</div>
|
||||
<div class="stats-value">{{ totalStats.newCustomers }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">获客成本</div>
|
||||
<div class="stats-value">¥{{ totalStats.acquisitionCost }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">新客转化率</div>
|
||||
<div class="stats-value">{{ totalStats.newCustomerConversionRate }}%</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">新客留存率</div>
|
||||
<div class="stats-value">{{ totalStats.newCustomerRetentionRate }}%</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">新客户明细</div></template>
|
||||
<el-table :data="pagedData" style="width: 100%">
|
||||
<el-table-column prop="date" label="日期" />
|
||||
<el-table-column prop="channel" label="渠道"
|
||||
><template #default="scope">{{ channelMap[scope.row.channel as keyof typeof channelMap] }}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="newCustomers" label="新客户数" />
|
||||
<el-table-column prop="acquisitionCost" label="获客成本"
|
||||
><template #default="scope">¥{{ scope.row.acquisitionCost }}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="conversionRate" label="转化率"
|
||||
><template #default="scope">{{ scope.row.conversionRate }}%</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="retentionRate" label="留存率"
|
||||
><template #default="scope">{{ scope.row.retentionRate }}%</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="roi" label="ROI"
|
||||
><template #default="scope">{{ scope.row.roi }}</template></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
interface NewCustomerData {
|
||||
date: string;
|
||||
channel: string;
|
||||
newCustomers: number;
|
||||
acquisitionCost: string;
|
||||
conversionRate: string;
|
||||
retentionRate: string;
|
||||
roi: string;
|
||||
}
|
||||
|
||||
const channelMap = {
|
||||
search: '搜索广告',
|
||||
social: '社交媒体',
|
||||
feed: '信息流',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const searchParams = reactive({
|
||||
dateRange: [],
|
||||
channel: '',
|
||||
});
|
||||
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近90天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const trendChartRef = ref();
|
||||
const channelChartRef = ref();
|
||||
let trendChart: echarts.ECharts | null = null;
|
||||
let channelChart: echarts.ECharts | null = null;
|
||||
|
||||
const totalStats = reactive({
|
||||
newCustomers: 0,
|
||||
acquisitionCost: '0',
|
||||
newCustomerConversionRate: 0,
|
||||
newCustomerRetentionRate: 0,
|
||||
});
|
||||
|
||||
const data = ref<NewCustomerData[]>([]);
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pagedData = computed(() => {
|
||||
const start = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
const end = start + pagination.pageSize;
|
||||
return data.value.slice(start, end);
|
||||
});
|
||||
|
||||
const getMockData = (): NewCustomerData[] => {
|
||||
const mockData: NewCustomerData[] = [];
|
||||
const channels: (keyof typeof channelMap)[] = ['search', 'social', 'feed', 'other'];
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
|
||||
let current = new Date(start);
|
||||
while (current <= end) {
|
||||
const date = current.toISOString().split('T')[0];
|
||||
channels.forEach((channel) => {
|
||||
const newCustomers = Math.floor(Math.random() * 100) + 10;
|
||||
const acquisitionCost = (Math.random() * 500 + 50).toFixed(2);
|
||||
const conversionRate = (Math.random() * 5 + 1).toFixed(2);
|
||||
const retentionRate = (Math.random() * 30 + 20).toFixed(2);
|
||||
const roi = (Math.random() * 3 + 0.5).toFixed(2);
|
||||
|
||||
mockData.push({
|
||||
date,
|
||||
channel,
|
||||
newCustomers,
|
||||
acquisitionCost,
|
||||
conversionRate,
|
||||
retentionRate,
|
||||
roi,
|
||||
});
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return mockData;
|
||||
};
|
||||
|
||||
const calculateTotalStats = (data: NewCustomerData[]) => {
|
||||
totalStats.newCustomers = data.reduce((sum, item) => sum + item.newCustomers, 0);
|
||||
totalStats.acquisitionCost = data.reduce((sum, item) => sum + parseFloat(item.acquisitionCost), 0).toFixed(2);
|
||||
totalStats.newCustomerConversionRate = data.reduce((sum, item) => sum + parseFloat(item.conversionRate), 0) / data.length;
|
||||
totalStats.newCustomerRetentionRate = data.reduce((sum, item) => sum + parseFloat(item.retentionRate), 0) / data.length;
|
||||
};
|
||||
|
||||
const initTrendChart = (data: NewCustomerData[]) => {
|
||||
if (!trendChartRef.value) return;
|
||||
|
||||
if (trendChart) trendChart.dispose();
|
||||
trendChart = echarts.init(trendChartRef.value);
|
||||
|
||||
// 按日期分组数据
|
||||
const dateMap: { [key: string]: number } = {};
|
||||
data.forEach((item) => {
|
||||
if (!dateMap[item.date]) {
|
||||
dateMap[item.date] = 0;
|
||||
}
|
||||
dateMap[item.date] += item.newCustomers;
|
||||
});
|
||||
|
||||
const dates = Object.keys(dateMap).sort();
|
||||
const newCustomers = dates.map((date) => dateMap[date]);
|
||||
|
||||
trendChart.setOption({
|
||||
title: {
|
||||
text: '新客户趋势',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
legend: {
|
||||
data: ['新客户数'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '新客户数',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '新客户数',
|
||||
type: 'line',
|
||||
data: newCustomers,
|
||||
areaStyle: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const initChannelChart = (data: NewCustomerData[]) => {
|
||||
if (!channelChartRef.value) return;
|
||||
|
||||
if (channelChart) channelChart.dispose();
|
||||
channelChart = echarts.init(channelChartRef.value);
|
||||
|
||||
// 按渠道分组数据
|
||||
const channelDataMap: { [key: string]: number } = {};
|
||||
data.forEach((item) => {
|
||||
if (!channelDataMap[item.channel]) {
|
||||
channelDataMap[item.channel] = 0;
|
||||
}
|
||||
channelDataMap[item.channel] += item.newCustomers;
|
||||
});
|
||||
|
||||
channelChart.setOption({
|
||||
title: {
|
||||
text: '渠道分布',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
data: Object.values(channelMap),
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '渠道',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: Object.entries(channelDataMap).map(([channel, value]) => ({
|
||||
value,
|
||||
name: channelMap[channel as keyof typeof channelMap],
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
data.value = getMockData();
|
||||
pagination.total = data.value.length;
|
||||
calculateTotalStats(data.value);
|
||||
initTrendChart(data.value);
|
||||
initChannelChart(data.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchParams.dateRange = [];
|
||||
searchParams.channel = '';
|
||||
pagination.currentPage = 1;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.currentPage = 1;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.currentPage = page;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleSearch();
|
||||
window.addEventListener('resize', () => {
|
||||
trendChart?.resize();
|
||||
channelChart?.resize();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ads-summary-new-customer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.data-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -3,14 +3,18 @@
|
||||
<el-dialog :title="(formData.id ? '修改' : '添加') + '客服账号'" v-model="isShowDialog" width="769px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" size="default" label-width="90px">
|
||||
<el-row :gutter="35">
|
||||
<!-- 客服账号输入框 -->
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="客服账号" prop="accountName">
|
||||
<el-input v-model="formData.accountName" placeholder="请输入客服账号,如:cs_xhs_qixue" clearable />
|
||||
<el-form-item label="账号编码" prop="accountCode">
|
||||
<el-input v-model="formData.accountCode" placeholder="请输入账号编码" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="客服账号" prop="accountName">
|
||||
<el-input v-model="formData.accountName" placeholder="请输入客服账号名称" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<!-- 客服平台选择 -->
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="客服平台" prop="platform">
|
||||
<el-select v-model="formData.platform" placeholder="请选择客服平台" clearable style="width: 100%">
|
||||
@@ -22,37 +26,35 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 高级配置(可选) -->
|
||||
<el-collapse v-model="activeNames" class="mt20">
|
||||
<el-collapse-item title="高级配置(可选)" name="advanced">
|
||||
<el-form-item label="自定义提示词" prop="prompt">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="formData.prompt"
|
||||
:rows="8"
|
||||
placeholder="留空则使用系统默认提示词模板,如:你是专业的客服顾问,请用温暖关心的语气回答用户问题..."
|
||||
/>
|
||||
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
|
||||
提示:系统会自动引用知识库内容,您只需专注于业务话术即可。留空将使用默认模板,创建后也可在此修改
|
||||
</div>
|
||||
<el-form-item label="AI身份" prop="selfIdentity">
|
||||
<el-input type="textarea" v-model="formData.selfIdentity" :rows="4" placeholder="AI客服身份描述,如:你是专业的小红书客服顾问..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="开场白" prop="greeting">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="formData.greeting"
|
||||
:rows="6"
|
||||
:rows="4"
|
||||
placeholder="WebSocket连接时发送的开场白,如:你好,有什么可以帮你的吗?"
|
||||
/>
|
||||
<div class="form-tip">留空则不发送开场白</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词" prop="keywordOption">
|
||||
<el-select v-model="formData.keywordOption" multiple placeholder="选择关键词选项" style="width: 100%">
|
||||
<el-option label="推荐回复" value="recommend" />
|
||||
<el-option label="智能问答" value="qa" />
|
||||
<el-option label="人工转接" value="transfer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="onCancel" size="default">取 消</el-button>
|
||||
<el-button type="primary" @click="onSubmit" size="default" :loading="loading">
|
||||
<el-button v-debounce @click="onCancel" size="default">取 消</el-button>
|
||||
<el-button type="primary" v-debounce @click="onSubmit" size="default" :loading="loading">
|
||||
{{ formData.id ? '修 改' : '新 增' }}
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -64,17 +66,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, nextTick } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { addAccount, updateAccount } from '/@/api/customerService/account';
|
||||
import { addAccount, updateAccount, getAccountOne } from '/@/api/customerService/account';
|
||||
|
||||
interface DialogFormData {
|
||||
id?: string;
|
||||
id?: number;
|
||||
datasetIds: number[];
|
||||
documentIds: number[];
|
||||
accountCode: string;
|
||||
accountName: string;
|
||||
platform: string;
|
||||
prompt?: string;
|
||||
status?: number;
|
||||
greeting?: string;
|
||||
status: number;
|
||||
creator: '';
|
||||
modifier: '';
|
||||
keywordOption?: string[];
|
||||
selfIdentity?: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -86,22 +90,21 @@ const state = reactive({
|
||||
isShowDialog: false,
|
||||
activeNames: [] as string[],
|
||||
formData: {
|
||||
id: '',
|
||||
id: 0,
|
||||
datasetIds: [] as number[],
|
||||
documentIds: [] as number[],
|
||||
accountCode: '',
|
||||
accountName: '',
|
||||
platform: '',
|
||||
prompt: '',
|
||||
greeting: '',
|
||||
status: 1,
|
||||
creator: '',
|
||||
modifier: '',
|
||||
greeting: '',
|
||||
keywordOption: [] as string[],
|
||||
selfIdentity: '',
|
||||
} as DialogFormData,
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
accountName: [
|
||||
{ required: true, message: '客服账号不能为空', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '客服账号长度在 2 到 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
accountCode: [{ required: true, message: '账号编码不能为空', trigger: 'blur' }],
|
||||
platform: [{ required: true, message: '请选择客服平台', trigger: 'change' }],
|
||||
};
|
||||
|
||||
@@ -111,21 +114,38 @@ const { loading, isShowDialog, formData, activeNames } = toRefs(state);
|
||||
/**
|
||||
* 打开对话框
|
||||
*/
|
||||
const openDialog = (row?: DialogFormData) => {
|
||||
const openDialog = async (row?: DialogFormData) => {
|
||||
resetForm();
|
||||
|
||||
if (row && row.id) {
|
||||
// 编辑模式:填充数据
|
||||
state.formData = { ...row };
|
||||
} else {
|
||||
// 新增模式:重置ID和状态
|
||||
state.formData.id = '';
|
||||
state.formData.status = 1;
|
||||
try {
|
||||
state.loading = true;
|
||||
const res = await getAccountOne({ id: row.id });
|
||||
if (res.data) {
|
||||
state.formData = {
|
||||
...res.data,
|
||||
id: res.data.id,
|
||||
datasetIds: res.data.datasetIds || [],
|
||||
documentIds: res.data.documentIds || [],
|
||||
accountCode: res.data.accountCode || '',
|
||||
accountName: res.data.accountName || '',
|
||||
platform: res.data.platform || '',
|
||||
status: res.data.status ?? 1,
|
||||
greeting: res.data.greeting || '',
|
||||
keywordOption: res.data.keywordOption || [],
|
||||
selfIdentity: res.data.selfIdentity || '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取账号详情失败:', error);
|
||||
ElMessage.error('获取账号详情失败');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
state.isShowDialog = true;
|
||||
|
||||
// 下次DOM更新后清除验证
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
@@ -158,12 +178,10 @@ const onSubmit = async () => {
|
||||
state.loading = true;
|
||||
|
||||
if (state.formData.id) {
|
||||
// 修改操作
|
||||
await updateAccount(state.formData);
|
||||
await updateAccount(state.formData as any);
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
// 新增操作
|
||||
await addAccount(state.formData);
|
||||
await addAccount(state.formData as any);
|
||||
ElMessage.success('添加成功');
|
||||
}
|
||||
|
||||
@@ -171,9 +189,6 @@ const onSubmit = async () => {
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,14 +197,16 @@ const onSubmit = async () => {
|
||||
*/
|
||||
const resetForm = () => {
|
||||
state.formData = {
|
||||
id: '',
|
||||
id: 0,
|
||||
datasetIds: [],
|
||||
documentIds: [],
|
||||
accountCode: '',
|
||||
accountName: '',
|
||||
platform: '',
|
||||
prompt: '',
|
||||
greeting: '',
|
||||
status: 1,
|
||||
creator: '',
|
||||
modifier: '',
|
||||
greeting: '',
|
||||
keywordOption: [],
|
||||
selfIdentity: '',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -21,19 +21,19 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="default" type="primary" class="ml10" @click="handleSearch" :loading="tableData.loading">
|
||||
<el-button size="default" type="primary" class="ml10" v-debounce @click="handleSearch" :loading="tableData.loading">
|
||||
<el-icon>
|
||||
<ele-Search />
|
||||
</el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" type="success" class="ml10 " @click="onOpenAddRole">
|
||||
<el-button size="default" type="success" class="ml10" v-debounce @click="onOpenAddRole">
|
||||
<el-icon>
|
||||
<ele-FolderAdd />
|
||||
</el-icon>
|
||||
新增客服
|
||||
</el-button>
|
||||
<el-button size="default" class="ml10" @click="handleReset" :disabled="tableData.loading">
|
||||
<el-button size="default" class="ml10" v-debounce @click="handleReset" :disabled="tableData.loading">
|
||||
<el-icon>
|
||||
<ele-Refresh />
|
||||
</el-icon>
|
||||
@@ -50,6 +50,7 @@
|
||||
<el-button
|
||||
size="small"
|
||||
:type="!scope.row.isDisabled ? 'success' : 'info'"
|
||||
v-debounce
|
||||
@click="handleStatusChange(scope.row)"
|
||||
:loading="scope.row.statusLoading"
|
||||
>
|
||||
@@ -72,7 +73,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button style="color: deepskyblue" size="small" text type="primary" @click="onOpenEditRole(scope.row)">
|
||||
<el-button style="color: deepskyblue" size="small" text type="primary" v-debounce @click="onOpenEditRole(scope.row)">
|
||||
<el-icon><ele-EditPen /></el-icon>修改
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -94,19 +95,26 @@
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessageBox, ElMessage } from 'element-plus';
|
||||
import EditAccount from './component/editAccount.vue';
|
||||
import { addAccount, getaccountList, updatestate } from '/@/api/customerService/account';
|
||||
import { getAccountList, updateAccount } from '/@/api/customerService/account';
|
||||
|
||||
// 定义类型接口
|
||||
interface TableDataItem {
|
||||
id: string;
|
||||
id: number;
|
||||
accountName: string;
|
||||
isDisabled: boolean; // 布尔值:false=启用,true=禁用
|
||||
isDisabled: boolean;
|
||||
platform: string;
|
||||
creator: string;
|
||||
modifier: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
statusLoading?: boolean;
|
||||
datasetIds?: number[];
|
||||
documentIds?: number[];
|
||||
accountCode?: string;
|
||||
status?: number;
|
||||
greeting?: string;
|
||||
keywordOption?: string[];
|
||||
selfIdentity?: string;
|
||||
}
|
||||
|
||||
interface TableParam {
|
||||
@@ -158,7 +166,7 @@ const getList = async () => {
|
||||
platform: tableData.param.platform || undefined,
|
||||
};
|
||||
|
||||
const res = await getaccountList(queryParams);
|
||||
const res = await getAccountList(queryParams);
|
||||
|
||||
if (res && res.data) {
|
||||
tableData.data = (res.data.list || []).map((item: TableDataItem) => ({
|
||||
@@ -172,7 +180,6 @@ const getList = async () => {
|
||||
ElMessage.warning('暂无数据');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取客服账号列表失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
tableData.data = [];
|
||||
tableData.total = 0;
|
||||
@@ -236,7 +243,6 @@ const formatTime = (time: string | number | Date): string => {
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (error) {
|
||||
console.error('时间格式化错误:', error);
|
||||
return String(time);
|
||||
}
|
||||
};
|
||||
@@ -253,21 +259,23 @@ const handleStatusChange = async (row: TableDataItem) => {
|
||||
});
|
||||
|
||||
row.statusLoading = true;
|
||||
const newStatus = !row.isDisabled; // 切换布尔值
|
||||
const newStatus = row.isDisabled ? 1 : 0;
|
||||
|
||||
await updatestate({
|
||||
await updateAccount({
|
||||
id: row.id,
|
||||
isDisabled: newStatus,
|
||||
datasetIds: [],
|
||||
documentIds: [],
|
||||
accountCode: '',
|
||||
platform: '',
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
ElMessage.success(`客服账号已${newStatus ? '禁用' : '启用'}`);
|
||||
await getList(); // 重新获取数据
|
||||
ElMessage.success(`客服账号已${!row.isDisabled ? '禁用' : '启用'}`);
|
||||
await getList();
|
||||
} catch (error) {
|
||||
if (error == 'cancel') {
|
||||
return;
|
||||
}
|
||||
console.error('状态切换失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
row.statusLoading = false;
|
||||
@@ -289,7 +297,6 @@ const onOpenEditRole = (row: any) => {
|
||||
editRoleRef.value?.openDialog(row);
|
||||
};
|
||||
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getList();
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" size="default" label-width="90px">
|
||||
<el-row :gutter="35">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="标签" prop="tag">
|
||||
<el-input v-model="formData.tag" placeholder="请输入标签" clearable />
|
||||
<el-form-item label="数据集">
|
||||
<el-input v-model="formData.datasetName" placeholder="数据集" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="场景类型">
|
||||
<el-input v-model="formData.sceneType" placeholder="场景类型" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<!-- 富文本编辑器 -->
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
||||
<el-form-item label="话术" prop="content">
|
||||
<Editor v-model="formData.content" height="400px" :key="editorKey" placeholder="请输入产品详情" :disableExceptEmotion="true" />
|
||||
<el-form-item label="话术内容" prop="questionContent">
|
||||
<Editor v-model="formData.questionContent" height="400px" :key="editorKey" placeholder="请输入话术内容" :disableExceptEmotion="true" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -29,18 +33,19 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, nextTick } from 'vue';
|
||||
import { ref, reactive, toRefs, nextTick, onMounted } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import Editor from '/@/components/editor/index.vue';
|
||||
import { addScript, updateScript } from '/@/api/customerService/script';
|
||||
import { addScript, updateScript, getScriptDetail } from '/@/api/customerService/script';
|
||||
import { listknowledges } from '/@/api/knowledge/dataset';
|
||||
|
||||
// 定义类型接口
|
||||
interface DialogRow {
|
||||
id?: number | string;
|
||||
tag: string;
|
||||
creator: string;
|
||||
content: string;
|
||||
modifier: string;
|
||||
id?: number;
|
||||
datasetId: string | number;
|
||||
datasetName?: string;
|
||||
sceneType: number;
|
||||
questionContent: string;
|
||||
}
|
||||
|
||||
// 定义事件
|
||||
@@ -48,30 +53,30 @@ const emit = defineEmits<{
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
// 定义数据集选项类型
|
||||
interface DatasetOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
isShowDialog: false,
|
||||
datasets: [] as DatasetOption[], // 数据集列表
|
||||
editorKey: 0, // 用于强制重新渲染编辑器
|
||||
formData: {
|
||||
id: 0,
|
||||
tag: '',
|
||||
content: '',
|
||||
creator: '',
|
||||
modifier: '',
|
||||
datasetId: 0,
|
||||
datasetName: '',
|
||||
sceneType: 0,
|
||||
questionContent: '',
|
||||
} as DialogRow,
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
tag: [
|
||||
{ required: true, message: '标签名称不能为空', trigger: 'blur' },
|
||||
{ max: 64, message: '标签长度最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '产品详情不能为空', trigger: 'blur' },
|
||||
{ max: 8126, message: '产品名称长度最多 8126 个字符', trigger: 'blur' },
|
||||
],
|
||||
questionContent: [{ required: true, message: '话术内容不能为空', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
// 模板引用
|
||||
@@ -80,28 +85,74 @@ const formRef = ref<FormInstance>();
|
||||
// 解构状态数据
|
||||
const { loading, isShowDialog, formData, editorKey } = toRefs(state);
|
||||
|
||||
/**
|
||||
* 加载数据集列表
|
||||
*/
|
||||
const loadDatasets = async () => {
|
||||
try {
|
||||
const response = await listknowledges({ pageNum: 1, pageSize: 100 });
|
||||
if (response.data && response.data.list) {
|
||||
state.datasets = response.data.list.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载数据集列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadDatasets();
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开对话框
|
||||
* @param row - 可选的编辑数据
|
||||
*/
|
||||
const openDialog = (row?: DialogRow) => {
|
||||
const openDialog = async (row?: DialogRow) => {
|
||||
resetForm();
|
||||
|
||||
if (row) {
|
||||
// 深拷贝数据,避免引用问题
|
||||
state.formData = { ...row };
|
||||
// 重新加载数据集列表,确保数据是最新的
|
||||
await loadDatasets();
|
||||
|
||||
if (row && row.id) {
|
||||
try {
|
||||
// 加载话术详情
|
||||
const response = await getScriptDetail({ id: row.id });
|
||||
if (response.data) {
|
||||
// 确保datasetId是字符串类型,与datasets选项的value类型一致
|
||||
const detailData = {
|
||||
...response.data,
|
||||
datasetId: String(response.data.datasetId),
|
||||
datasetName: '',
|
||||
};
|
||||
|
||||
// 查找对应的数据集名称
|
||||
const dataset = state.datasets.find((d) => d.value === detailData.datasetId);
|
||||
if (dataset) {
|
||||
detailData.datasetName = dataset.label;
|
||||
}
|
||||
|
||||
state.formData = detailData;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取话术详情失败');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 新增模式,确保清空数据
|
||||
state.formData = {
|
||||
id: 0,
|
||||
tag: '',
|
||||
content: '',
|
||||
creator: '',
|
||||
modifier: '',
|
||||
datasetId: 0,
|
||||
datasetName: '',
|
||||
sceneType: 0,
|
||||
questionContent: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 更新编辑器 key 强制重新渲染
|
||||
// 强制重新渲染编辑器
|
||||
state.editorKey++;
|
||||
|
||||
state.isShowDialog = true;
|
||||
@@ -139,21 +190,22 @@ const onSubmit = async () => {
|
||||
const valid = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 额外验证话术内容
|
||||
if (!state.formData.content || state.formData.content === '<p><br></p>' || state.formData.content.trim() === '<p><br></p>') {
|
||||
ElMessage.warning('话术内容不能为空');
|
||||
return;
|
||||
}
|
||||
// 确保数据类型正确
|
||||
const submitData = {
|
||||
...state.formData,
|
||||
datasetId: String(state.formData.datasetId),
|
||||
sceneType: Number(state.formData.sceneType),
|
||||
};
|
||||
|
||||
state.loading = true;
|
||||
|
||||
if (state.formData.id === 0) {
|
||||
// 新增模式
|
||||
await addScript(state.formData);
|
||||
await addScript(submitData);
|
||||
ElMessage.success('添加成功');
|
||||
} else {
|
||||
// 编辑模式
|
||||
await updateScript(state.formData);
|
||||
await updateScript(submitData);
|
||||
ElMessage.success('修改成功');
|
||||
}
|
||||
|
||||
@@ -161,7 +213,6 @@ const onSubmit = async () => {
|
||||
state.isShowDialog = false;
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
@@ -174,10 +225,10 @@ const onSubmit = async () => {
|
||||
const resetForm = () => {
|
||||
state.formData = {
|
||||
id: 0,
|
||||
tag: '',
|
||||
content: '',
|
||||
creator: '',
|
||||
modifier: '',
|
||||
datasetId: 0,
|
||||
datasetName: '',
|
||||
sceneType: 0,
|
||||
questionContent: '',
|
||||
};
|
||||
|
||||
// 重置表单验证状态
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
<el-input size="default" v-model="tableData.param.tag" placeholder="请输入标签" class="w-50 m-2" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="default" type="primary" class="ml10" @click="handleSearch" :loading="tableData.loading">
|
||||
<el-button size="default" type="primary" class="ml10" v-debounce @click="handleSearch" :loading="tableData.loading">
|
||||
<el-icon>
|
||||
<ele-Search />
|
||||
</el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" class="ml10" @click="handleReset" :disabled="tableData.loading">
|
||||
<el-button size="default" class="ml10" v-debounce @click="handleReset" :disabled="tableData.loading">
|
||||
<el-icon>
|
||||
<ele-Refresh />
|
||||
</el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button size="default" type="success" @click="handleAdd">
|
||||
<el-button size="default" type="success" v-debounce @click="handleAdd">
|
||||
<el-icon><FolderAdd /></el-icon>
|
||||
新增话术
|
||||
</el-button>
|
||||
@@ -31,9 +31,10 @@
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData.data" v-loading="tableData.loading" style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="tag" label="标签" show-overflow-tooltip min-width="120" />
|
||||
<el-table-column prop="creator" label="创建人" show-overflow-tooltip min-width="100" />
|
||||
<el-table-column prop="updater" label="修改人" show-overflow-tooltip min-width="100" />
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="datasetId" label="数据集ID" width="120" align="center" />
|
||||
<el-table-column prop="sceneType" label="场景类型" width="120" align="center" />
|
||||
<el-table-column prop="questionContent" label="问题内容" show-overflow-tooltip min-width="200" />
|
||||
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.createdAt) }}
|
||||
@@ -46,10 +47,10 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" class="op-btn-edit" @click="handleEdit(row)">
|
||||
<el-button size="small" text type="primary" class="op-btn-edit" v-debounce @click="handleEdit(row)">
|
||||
<el-icon><EditPen /></el-icon>修改
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" class="op-btn-del" @click="handleDelete(row)">
|
||||
<el-button size="small" text type="danger" class="op-btn-del" v-debounce @click="handleDelete(row)">
|
||||
<el-icon><DeleteFilled /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -81,13 +82,12 @@ import { getscriptList, deleteScript } from '/@/api/customerService/script';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface ScriptItem {
|
||||
id: string;
|
||||
tag: string;
|
||||
creator: string;
|
||||
modifier: string;
|
||||
createdAt: string; // 保持原字段不变
|
||||
updatedAt: string; // 保持原字段不变
|
||||
content?: string;
|
||||
id: number;
|
||||
datasetId: number;
|
||||
sceneType: number;
|
||||
questionContent: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TableParams {
|
||||
@@ -176,7 +176,6 @@ const formatTime = (time: string | number | Date): string => {
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (error) {
|
||||
console.error('时间格式化错误:', error);
|
||||
return String(time);
|
||||
}
|
||||
};
|
||||
@@ -196,8 +195,6 @@ const loadTableData = async () => {
|
||||
tableData.data = list;
|
||||
tableData.total = total;
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
tableData.data = [];
|
||||
tableData.total = 0;
|
||||
} finally {
|
||||
@@ -215,8 +212,8 @@ const handleAdd = () => {
|
||||
/**
|
||||
* 编辑话术
|
||||
*/
|
||||
const handleEdit = (row: ScriptItem) => {
|
||||
editRoleRef.value?.openDialog(row as any);
|
||||
const handleEdit = async (row: ScriptItem) => {
|
||||
await editRoleRef.value?.openDialog(row as any);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -224,7 +221,7 @@ const handleEdit = (row: ScriptItem) => {
|
||||
*/
|
||||
const handleDelete = async (row: ScriptItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除话术「${row.tag}」吗?此操作不可恢复。`, '提示', {
|
||||
await ElMessageBox.confirm(`确定要删除话术「${row.questionContent}」吗?此操作不可恢复。`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
@@ -237,19 +234,11 @@ const handleDelete = async (row: ScriptItem) => {
|
||||
await loadTableData();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error);
|
||||
// 错误已由请求拦截器统一处理
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 操作成功回调
|
||||
*/
|
||||
const handleSuccess = () => {
|
||||
loadTableData();
|
||||
};
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(() => {
|
||||
loadTableData();
|
||||
|
||||
581
src/views/digitalHuman/creation/index.vue
Normal file
581
src/views/digitalHuman/creation/index.vue
Normal file
@@ -0,0 +1,581 @@
|
||||
<template>
|
||||
<div class="creation-page" :class="{ 'is-submitting': submitLoading }">
|
||||
<div v-if="submitLoading" class="creation-loading-mask">
|
||||
<div class="creation-loading-card">
|
||||
<div class="loading-orbit">
|
||||
<span class="loading-ring ring-outer"></span>
|
||||
<span class="loading-ring ring-inner"></span>
|
||||
<span class="loading-core"></span>
|
||||
</div>
|
||||
<div class="loading-title">正在创作中</div>
|
||||
<div class="loading-desc">内容生成较慢,请稍候,创作完成后会自动刷新结果</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel left" v-loading="treeLoading">
|
||||
<div class="title">工作空间</div>
|
||||
<div class="tree-wrap">
|
||||
<el-empty v-if="treeNodes.length === 0 && !treeLoading" description="暂无作品数据" />
|
||||
<el-tree
|
||||
v-else
|
||||
:data="treeNodes"
|
||||
node-key="id"
|
||||
:props="treeProps"
|
||||
default-expand-all
|
||||
:highlight-current="true"
|
||||
:expand-on-click-node="false"
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<div class="tree-node-main">
|
||||
<el-icon v-if="data.nodeType === 'date'"><ele-Calendar /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'contentType'"><ele-Collection /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'theme'"><ele-CollectionTag /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'title'"><ele-FolderOpened /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'html'"><ele-Document /></el-icon>
|
||||
<el-icon v-else><ele-Picture /></el-icon>
|
||||
<span class="ellipsis">{{ data.label }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
|
||||
type="primary"
|
||||
link
|
||||
class="tree-download"
|
||||
@click.stop="downloadNode(data)"
|
||||
><el-icon><ele-Download /></el-icon
|
||||
></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel center">
|
||||
<div class="title">内容创建参数配置</div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top" class="compact-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="1. 创作模式" prop="mode" class="span-1"
|
||||
><el-select v-model="formData.mode"><el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="2. 内容类型" prop="content_type" class="span-1"
|
||||
><el-select v-model="formData.content_type"
|
||||
><el-option v-for="item in contentTypeOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="3. 主题(系列名)" prop="theme" class="span-1"
|
||||
><el-input v-model="formData.theme" placeholder="例如:春季通勤穿搭、小个子显高技巧"
|
||||
/></el-form-item>
|
||||
<el-form-item label="4. 标题(具体标题)" prop="title" class="span-1"
|
||||
><el-input v-model="formData.title" placeholder="例如:通勤穿搭技巧、5个显高穿搭法则"
|
||||
/></el-form-item>
|
||||
<el-form-item label="5. 内容风格" prop="style" class="span-1"
|
||||
><el-select v-model="formData.style"><el-option v-for="item in styleOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="6. 生成条数" prop="count" class="span-1"
|
||||
><el-input-number v-model="formData.count" :min="1" :max="3" controls-position="right" class="w100"
|
||||
/></el-form-item>
|
||||
<el-form-item v-if="showImageConfig" label="7. 每条配图数量" prop="image_per_post" class="span-1"
|
||||
><el-input-number v-model="formData.image_per_post" :min="1" :max="3" controls-position="right" class="w100"
|
||||
/></el-form-item>
|
||||
<el-form-item v-if="showImageConfig" label="8. 图片比例" prop="image_ratio" class="span-1"
|
||||
><el-select v-model="formData.image_ratio"
|
||||
><el-option v-for="item in imageRatioOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item :label="showImageConfig ? '9. 描述' : '7. 描述'" prop="description" class="span-2 description-item"
|
||||
><el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入内容补充描述、重点要求或限制条件"
|
||||
/></el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" class="submit-btn" :loading="submitLoading" @click="handleSubmit">告诉我你的选择,我马上开始创作!</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="panel right" v-loading="previewLoading">
|
||||
<div class="title preview-title">预览区域</div>
|
||||
<div class="preview-main">
|
||||
<el-empty v-if="!selectedPreview" description="请选择预览节点" />
|
||||
<iframe v-else-if="selectedPreview.nodeType === 'html'" :src="selectedPreview.url" class="iframe" frameborder="0"></iframe>
|
||||
<div v-else class="img-wrap">
|
||||
<el-image :src="selectedPreview.url" :preview-src-list="[selectedPreview.url]" fit="contain" preview-teleported class="img" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
createCreation,
|
||||
downloadToFile,
|
||||
getCreationList,
|
||||
type CreationListParams,
|
||||
type CreationSubmitParams,
|
||||
type CreationTreeItem,
|
||||
} from '/@/api/digitalHuman/creation';
|
||||
|
||||
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: NodeType;
|
||||
children?: TreeNode[];
|
||||
createdDate?: string;
|
||||
contentType?: string;
|
||||
theme?: string;
|
||||
creationTitle?: string;
|
||||
fileUrl?: string;
|
||||
}
|
||||
interface PreviewState {
|
||||
url: string;
|
||||
nodeType: 'html' | 'image';
|
||||
}
|
||||
const formRef = ref<FormInstance>();
|
||||
const treeLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const imgAddressPrefix = ref('');
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const selectedPreview = ref<PreviewState | null>(null);
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||||
const treeProps = { children: 'children', label: 'label' };
|
||||
const queryParams = reactive<CreationListParams>({ keyword: '', pageNum: 1, pageSize: 10 });
|
||||
const formData = reactive<CreationSubmitParams>({
|
||||
mode: '混合模式(文案 + 图片)',
|
||||
content_type: '穿搭分享',
|
||||
theme: '',
|
||||
title: '',
|
||||
description: '',
|
||||
style: '生活分享 — 亲切自然,像朋友聊天',
|
||||
count: 1,
|
||||
image_per_post: 1,
|
||||
image_ratio: '3:4 — 小红书',
|
||||
});
|
||||
const showImageConfig = computed(() => formData.mode === '混合模式(文案 + 图片)' || formData.mode === '纯图片模式');
|
||||
const modeOptions = ['混合模式(文案 + 图片)', '纯文案模式', '纯图片模式'];
|
||||
const contentTypeOptions = ['穿搭分享', '好物推荐', '美妆护肤', '探店分享', '旅行日常', '美食分享'];
|
||||
const styleOptions = [
|
||||
'生活分享 — 亲切自然,像朋友聊天',
|
||||
'专业测评 — 深度分析,数据支撑',
|
||||
'种草推荐 — 强调亮点,感染力强',
|
||||
'干货教学 — 条理清晰,步骤明确',
|
||||
];
|
||||
const imageRatioOptions = ['3:4 — 小红书', '1:1 — 方图', '16:9 — 横版'];
|
||||
watch(
|
||||
() => formData.mode,
|
||||
() => {
|
||||
if (!showImageConfig.value) {
|
||||
formData.image_per_post = 1;
|
||||
formData.image_ratio = '3:4 — 小红书';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const rules: FormRules = {
|
||||
mode: [{ required: true, message: '请选择创作模式', trigger: 'change' }],
|
||||
content_type: [{ required: true, message: '请选择内容类型', trigger: 'change' }],
|
||||
theme: [{ required: true, message: '请输入主题', trigger: 'blur' }],
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
style: [{ required: true, message: '请选择内容风格', trigger: 'change' }],
|
||||
count: [{ required: true, message: '请输入生成条数', trigger: 'change' }],
|
||||
image_per_post: [{
|
||||
required: true,
|
||||
message: '请输入配图数量',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
void rule;
|
||||
if (!showImageConfig.value) return callback();
|
||||
if (!value) return callback(new Error('请输入配图数量'));
|
||||
callback();
|
||||
},
|
||||
}],
|
||||
image_ratio: [{
|
||||
required: true,
|
||||
message: '请选择图片比例',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
void rule;
|
||||
if (!showImageConfig.value) return callback();
|
||||
if (!value) return callback(new Error('请选择图片比例'));
|
||||
callback();
|
||||
},
|
||||
}],
|
||||
};
|
||||
const joinUrl = (base: string, path: string) => `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const buildAssetUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
const prefix = imgAddressPrefix.value || '';
|
||||
if (/^https?:\/\//i.test(prefix)) return joinUrl(prefix, path);
|
||||
if (prefix) return joinUrl(joinUrl(apiBaseUrl, prefix), path);
|
||||
return joinUrl(apiBaseUrl, path);
|
||||
};
|
||||
const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
|
||||
tree.map((dateGroup, dIndex) => ({
|
||||
id: `date-${dIndex}`,
|
||||
label: dateGroup.createdDate,
|
||||
nodeType: 'date' as const,
|
||||
children: (dateGroup.contentTypes || []).map((contentTypeGroup, cIndex) => ({
|
||||
id: `content-type-${dIndex}-${cIndex}`,
|
||||
label: contentTypeGroup.contentType,
|
||||
nodeType: 'contentType' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
children: (contentTypeGroup.themes || []).map((themeGroup, tIndex) => ({
|
||||
id: `theme-${dIndex}-${cIndex}-${tIndex}`,
|
||||
label: themeGroup.theme,
|
||||
nodeType: 'theme' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
children: (themeGroup.titles || []).map((titleItem, i) => ({
|
||||
id: `title-${dIndex}-${cIndex}-${tIndex}-${i}`,
|
||||
label: titleItem.title || `作品${i + 1}`,
|
||||
nodeType: 'title' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
||||
children: [
|
||||
...(titleItem.htmlFileUrl
|
||||
? [
|
||||
{
|
||||
id: `html-${dIndex}-${cIndex}-${tIndex}-${i}`,
|
||||
label: 'HTML',
|
||||
nodeType: 'html' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
||||
fileUrl: titleItem.htmlFileUrl,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(titleItem.imageUrls || []).map((img, imgIndex) => ({
|
||||
id: `img-${dIndex}-${cIndex}-${tIndex}-${i}-${imgIndex}`,
|
||||
label: img.name || `图片 ${imgIndex + 1}`,
|
||||
nodeType: 'image' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
||||
fileUrl: img.url,
|
||||
})),
|
||||
],
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
const handleNodeClick = (data: TreeNode) => {
|
||||
if (data.contentType) formData.content_type = data.contentType;
|
||||
if (data.theme) formData.theme = data.theme;
|
||||
if (data.nodeType === 'title') {
|
||||
formData.title = data.creationTitle || data.label || formData.title;
|
||||
return;
|
||||
}
|
||||
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
|
||||
const url = buildAssetUrl(data.fileUrl);
|
||||
if (!url) return ElMessage.warning('当前节点没有可预览地址');
|
||||
selectedPreview.value = { url, nodeType: data.nodeType };
|
||||
formData.title = data.creationTitle || formData.title;
|
||||
};
|
||||
const downloadNode = async (data: TreeNode) => {
|
||||
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
|
||||
if (!data.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
|
||||
try {
|
||||
const response = await downloadToFile({ fileURL: data.fileUrl });
|
||||
const blob = response instanceof Blob ? response : response?.data;
|
||||
if (!(blob instanceof Blob)) throw new Error('无效的下载数据');
|
||||
const fileName = decodeURIComponent(data.fileUrl.split('/').pop() || `${data.label}.${data.nodeType === 'html' ? 'html' : 'png'}`);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
ElMessage.success('下载成功');
|
||||
} catch {
|
||||
ElMessage.error('下载失败');
|
||||
}
|
||||
};
|
||||
const findFirstPreviewNode = (nodes: TreeNode[]): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === 'html' || node.nodeType === 'image') return node;
|
||||
if (node.children?.length) {
|
||||
const matched = findFirstPreviewNode(node.children);
|
||||
if (matched) return matched;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const getList = async () => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined });
|
||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
|
||||
selectedPreview.value = null;
|
||||
await nextTick();
|
||||
const firstLeaf = findFirstPreviewNode(treeNodes.value);
|
||||
if (firstLeaf) handleNodeClick(firstLeaf);
|
||||
} catch {
|
||||
treeNodes.value = [];
|
||||
imgAddressPrefix.value = '';
|
||||
selectedPreview.value = null;
|
||||
ElMessage.error('获取作品创作列表失败');
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value || submitLoading.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
submitLoading.value = true;
|
||||
selectedPreview.value = null;
|
||||
await createCreation({
|
||||
...formData,
|
||||
count: Number(formData.count),
|
||||
image_per_post: Number(formData.image_per_post),
|
||||
description: formData.description?.trim() || undefined,
|
||||
});
|
||||
ElMessage.success('创作任务已提交');
|
||||
await getList();
|
||||
} catch {
|
||||
ElMessage.error('提交创作任务失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
onMounted(getList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.creation-page {
|
||||
height: calc(100vh - 100px);
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(470px, 1fr) minmax(500px, 1.02fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: #f6f8fb;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
.creation-page.is-submitting {
|
||||
overflow: hidden;
|
||||
}
|
||||
.creation-loading-mask {
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(246, 248, 251, 0.78);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.creation-loading-card {
|
||||
width: min(420px, calc(100% - 40px));
|
||||
padding: 36px 28px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 18px 48px rgba(64, 102, 255, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-orbit {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.loading-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
animation: orbit-rotate 1.8s linear infinite;
|
||||
}
|
||||
.ring-outer {
|
||||
border-width: 4px;
|
||||
border-color: #5b8cff transparent #8fb3ff transparent;
|
||||
}
|
||||
.ring-inner {
|
||||
inset: 15px;
|
||||
border-width: 4px;
|
||||
border-color: transparent #7c9dff transparent #d2deff;
|
||||
animation-direction: reverse;
|
||||
animation-duration: 1.2s;
|
||||
}
|
||||
.loading-core {
|
||||
position: absolute;
|
||||
inset: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #5b8cff 0%, #7a5cff 100%);
|
||||
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
|
||||
animation: core-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.loading-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.loading-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #5f6b7a;
|
||||
}
|
||||
@keyframes orbit-rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 18px rgba(91, 140, 255, 0.2);
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.preview-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tree-wrap,
|
||||
.center,
|
||||
.preview-main {
|
||||
overflow: auto;
|
||||
}
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.tree-node-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.tree-download {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.compact-form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
.span-1 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.description-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.w100 {
|
||||
width: 100%;
|
||||
}
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.preview-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.img-wrap {
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
:deep(.el-form-item__label) {
|
||||
padding-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-select__wrapper),
|
||||
:deep(.el-textarea__inner),
|
||||
:deep(.el-input-number) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
:deep(.el-select),
|
||||
:deep(.el-input),
|
||||
:deep(.el-input-number),
|
||||
:deep(.el-textarea) {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 1800px) {
|
||||
.creation-page {
|
||||
grid-template-columns: 280px minmax(430px, 1fr) minmax(460px, 0.98fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,8 +36,8 @@ export default defineComponent({
|
||||
const { isTagsViewCurrenFull } = storeToRefs(storesTagsViewRoutes);
|
||||
const onSetAuth = () => {
|
||||
// https://gitee.com/lyt-top/vue-next-admin/issues/I5C3JS
|
||||
// 清除缓存/token等
|
||||
Session.clear();
|
||||
// 401 页面回登录时只清理登录态相关缓存,保留本地个性化配置。
|
||||
Session.clearAuth();
|
||||
// 使用 reload 时,不需要调用 resetRoute() 重置路由
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -11,73 +11,100 @@
|
||||
<div class="drawer-content">
|
||||
<!-- 左侧:文档原文 -->
|
||||
<div class="document-content">
|
||||
<div class="content-header">
|
||||
<h2>{{ documentInfo.name }}</h2>
|
||||
<div class="content-meta">Size:{{ formatFileSize(documentInfo.fileSize) }} Uploaded Time:{{ documentInfo.createdAt }}</div>
|
||||
<div class="content-header" style="display: flex; justify-content: space-between; align-items: flex-start">
|
||||
<div>
|
||||
<h2>{{ documentInfo.name }}</h2>
|
||||
<div class="content-meta">Size:{{ formatFileSize(documentInfo.fileSize) }} Uploaded Time:{{ documentInfo.createdAt }}</div>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
id="downloadLink"
|
||||
style="
|
||||
background-color: #f0f9eb;
|
||||
color: #67c23a;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e1f5dc;
|
||||
margin-top: 4px;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="margin-right: 4px"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
下载
|
||||
</a>
|
||||
</div>
|
||||
<div class="content-body" v-loading="contentLoading">
|
||||
<pre class="document-text">{{ documentContent }}</pre>
|
||||
<div class="document-text" v-html="documentContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:切片结果 -->
|
||||
<!-- 右侧:向量列表 -->
|
||||
<div class="chunk-panel">
|
||||
<div class="panel-header">
|
||||
<h3>切片结果</h3>
|
||||
<div class="panel-subtitle">查看用于嵌入和召回的切片段落。</div>
|
||||
<h3>向量列表</h3>
|
||||
<div class="panel-subtitle">查看文档的向量信息。</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-tabs">
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button label="full">全文</el-radio-button>
|
||||
<el-radio-button label="chunk">省略</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-input v-model="chunkSearch" placeholder="搜索" clearable size="small" style="width: 120px">
|
||||
<el-input v-model="vectorSearch" placeholder="搜索" clearable size="small" style="width: 120px">
|
||||
<template #prefix>
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button size="small" @click="onAddChunk">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chunk-list" v-loading="chunkLoading">
|
||||
<div class="select-all">
|
||||
<el-checkbox v-model="selectAll" @change="onSelectAllChange">选择所有</el-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="chunk-item" v-for="chunk in filteredChunks" :key="chunk.id">
|
||||
<div class="chunk-checkbox">
|
||||
<el-checkbox v-model="chunk.selected" />
|
||||
<div class="vector-list" v-loading="vectorLoading">
|
||||
<div class="vector-item" v-for="vector in vectorList" :key="vector.id">
|
||||
<div class="vector-header">
|
||||
<span class="vector-index">块 {{ vector.chunkIndex }}</span>
|
||||
<span class="vector-status">状态: {{ vector.status ? '启用' : '禁用' }}</span>
|
||||
<span class="vector-vector-status">向量状态: {{ vector.vectorStatus === 1 ? '已生成' : '未生成' }}</span>
|
||||
<el-switch v-model="vector.status" size="small" @change="(value: boolean) => onVectorStatusChange(vector, !value)" />
|
||||
</div>
|
||||
<div class="chunk-content">
|
||||
<span class="chunk-text">{{ viewMode === 'full' ? chunk.content : truncateText(chunk.content, 100) }}</span>
|
||||
<div class="vector-content">
|
||||
<span class="vector-text">{{ truncateText(vector.content, 150) }}</span>
|
||||
</div>
|
||||
<div class="chunk-actions">
|
||||
<el-switch v-model="chunk.enabled" size="small" @change="onChunkStatusChange(chunk)" />
|
||||
<div class="vector-meta">
|
||||
<span class="vector-hash">哈希: {{ vector.contentHash }}</span>
|
||||
<span class="vector-time">创建时间: {{ vector.createdAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="filteredChunks.length === 0 && !chunkLoading" description="暂无切片" :image-size="60" />
|
||||
<el-empty v-if="vectorList.length === 0 && !vectorLoading" description="暂无向量" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="panel-footer">
|
||||
<span class="total-info">总共 {{ chunkTotal }} 条</span>
|
||||
<span class="total-info">总共 {{ vectorTotal }} 条</span>
|
||||
<el-pagination
|
||||
v-model:current-page="chunkPage"
|
||||
:page-size="chunkPageSize"
|
||||
:total="chunkTotal"
|
||||
v-model:current-page="vectorPage"
|
||||
:page-size="vectorPageSize"
|
||||
:total="vectorTotal"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="getChunkList"
|
||||
@current-change="getVectorList"
|
||||
/>
|
||||
<el-select v-model="chunkPageSize" size="small" style="width: 80px" @change="getChunkList">
|
||||
<el-select v-model="vectorPageSize" size="small" style="width: 80px" @change="getVectorList">
|
||||
<el-option :value="10" label="10条/页" />
|
||||
<el-option :value="20" label="20条/页" />
|
||||
<el-option :value="50" label="50条/页" />
|
||||
@@ -95,8 +122,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { listDocumentVectors, updateDocumentVector } from '/@/api/knowledge/document';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -125,21 +153,14 @@ const documentInfo = reactive({
|
||||
const documentContent = ref('');
|
||||
const contentLoading = ref(false);
|
||||
|
||||
// 切片相关
|
||||
const chunkLoading = ref(false);
|
||||
const chunkList = ref<any[]>([]);
|
||||
const chunkTotal = ref(0);
|
||||
const chunkPage = ref(1);
|
||||
const chunkPageSize = ref(50);
|
||||
const chunkSearch = ref('');
|
||||
const viewMode = ref('full');
|
||||
const selectAll = ref(false);
|
||||
|
||||
// 过滤后的切片列表
|
||||
const filteredChunks = computed(() => {
|
||||
if (!chunkSearch.value) return chunkList.value;
|
||||
return chunkList.value.filter((chunk) => (chunk.content || '').toLowerCase().includes(chunkSearch.value.toLowerCase()));
|
||||
});
|
||||
// 向量列表相关
|
||||
const vectorLoading = ref(false);
|
||||
const vectorList = ref<any[]>([]);
|
||||
const vectorTotal = ref(0);
|
||||
const vectorPage = ref(1);
|
||||
const vectorPageSize = ref(50);
|
||||
const vectorSearch = ref('');
|
||||
const isInitializing = ref(true); // 初始化标志,用于防止加载时触发更新
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (size: number) => {
|
||||
@@ -159,15 +180,41 @@ const truncateText = (text: string, maxLength: number) => {
|
||||
const getDocumentDetail = async () => {
|
||||
contentLoading.value = true;
|
||||
try {
|
||||
// 模拟数据
|
||||
// 使用实际的文档数据
|
||||
documentInfo.id = props.document?.id || '';
|
||||
documentInfo.name = props.document?.name || '456_product(1).txt';
|
||||
documentInfo.fileType = props.document?.fileType || 'txt';
|
||||
documentInfo.fileSize = props.document?.fileSize || 10;
|
||||
documentInfo.createdAt = props.document?.createdAt || '22/01/2026 00:53:32';
|
||||
documentInfo.name = props.document?.title || '未知文件';
|
||||
documentInfo.fileSize = props.document?.fileSize || 0;
|
||||
documentInfo.createdAt = props.document?.createdAt || '';
|
||||
|
||||
// 模拟文档内容
|
||||
documentContent.value = '<p>123</p>';
|
||||
// 构建文件地址
|
||||
const imgAddressPrefix = props.document?.imgAddressPrefix?.trim() || '';
|
||||
const filePath = props.document?.filePath || '';
|
||||
const fileUrl = imgAddressPrefix + filePath;
|
||||
|
||||
// 设置下载链接
|
||||
nextTick(() => {
|
||||
const downloadLink = document.getElementById('downloadLink');
|
||||
if (downloadLink) {
|
||||
downloadLink.setAttribute('href', fileUrl);
|
||||
downloadLink.setAttribute('download', documentInfo.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 尝试获取文件内容
|
||||
let fileContent = '';
|
||||
try {
|
||||
const response = await fetch(fileUrl);
|
||||
if (response.ok) {
|
||||
fileContent = await response.text();
|
||||
} else {
|
||||
fileContent = '无法加载文件内容';
|
||||
}
|
||||
} catch (error) {
|
||||
fileContent = '无法加载文件内容';
|
||||
}
|
||||
|
||||
// 生成文档内容,只包含文件内容
|
||||
documentContent.value = `<pre style="background: #fafafa; padding: 16px; border-radius: 4px; border: 1px solid #ebeef5; margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 14px; line-height: 1.5;">${fileContent}</pre>`;
|
||||
} catch (_error) {
|
||||
ElMessage.error('获取文档详情失败');
|
||||
documentContent.value = '';
|
||||
@@ -176,44 +223,62 @@ const getDocumentDetail = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取切片列表
|
||||
const getChunkList = async () => {
|
||||
chunkLoading.value = true;
|
||||
// 获取向量列表
|
||||
const getVectorList = async () => {
|
||||
vectorLoading.value = true;
|
||||
try {
|
||||
// 模拟数据
|
||||
chunkList.value = [
|
||||
{
|
||||
id: '1',
|
||||
content: '123',
|
||||
enabled: true,
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
chunkTotal.value = 1;
|
||||
const response = await listDocumentVectors({
|
||||
documentId: props.document?.id,
|
||||
datasetId: props.document?.datasetId,
|
||||
pageNum: vectorPage.value,
|
||||
pageSize: vectorPageSize.value,
|
||||
});
|
||||
// 深拷贝数据,避免v-model触发不必要的更新,并将status从数字转换为布尔类型
|
||||
vectorList.value = (response.data?.list || []).map((item: any) => {
|
||||
const clonedItem = JSON.parse(JSON.stringify(item));
|
||||
// 将数字类型的status转换为布尔类型,以便正确绑定到el-switch
|
||||
clonedItem.status = clonedItem.status === 1;
|
||||
return clonedItem;
|
||||
});
|
||||
vectorTotal.value = response.data?.total || 0;
|
||||
} catch (_error) {
|
||||
ElMessage.error('获取切片列表失败');
|
||||
chunkList.value = [];
|
||||
chunkTotal.value = 0;
|
||||
ElMessage.error('获取向量列表失败');
|
||||
vectorList.value = [];
|
||||
vectorTotal.value = 0;
|
||||
} finally {
|
||||
chunkLoading.value = false;
|
||||
vectorLoading.value = false;
|
||||
// 初始化完成,允许状态更新
|
||||
setTimeout(() => {
|
||||
isInitializing.value = false;
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 全选变化
|
||||
const onSelectAllChange = (val: boolean) => {
|
||||
chunkList.value.forEach((chunk) => {
|
||||
chunk.selected = val;
|
||||
});
|
||||
};
|
||||
// 更新向量状态
|
||||
const onVectorStatusChange = async (vector: any, oldValue: boolean) => {
|
||||
// 初始化过程中不处理状态变化
|
||||
if (isInitializing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 切片状态变化
|
||||
const onChunkStatusChange = (chunk: any) => {
|
||||
ElMessage.success(chunk.enabled ? '已启用' : '已禁用');
|
||||
};
|
||||
// 计算新状态(ElSwitch的v-model是boolean,需要转换为数字)
|
||||
const newStatus = vector.status ? 1 : 0;
|
||||
const oldStatus = oldValue ? 1 : 0;
|
||||
|
||||
// 添加切片
|
||||
const onAddChunk = () => {
|
||||
ElMessage.info('添加切片功能开发中');
|
||||
// 只在状态真正改变时才调用API
|
||||
if (newStatus !== oldStatus) {
|
||||
try {
|
||||
await updateDocumentVector({
|
||||
id: String(vector.id), // 确保id是字符串类型
|
||||
status: newStatus,
|
||||
});
|
||||
ElMessage.success('更新成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('更新失败');
|
||||
// 恢复原始状态
|
||||
vector.status = oldValue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听弹窗打开
|
||||
@@ -221,8 +286,10 @@ watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val && props.document) {
|
||||
// 重置初始化标志
|
||||
isInitializing.value = true;
|
||||
getDocumentDetail();
|
||||
getChunkList();
|
||||
getVectorList();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -320,34 +387,43 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-list {
|
||||
.vector-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px 20px;
|
||||
|
||||
.select-all {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chunk-item {
|
||||
.vector-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
|
||||
.chunk-checkbox {
|
||||
margin-right: 12px;
|
||||
padding-top: 2px;
|
||||
.vector-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
|
||||
.vector-index {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.el-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.vector-content {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.chunk-text {
|
||||
.vector-text {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
@@ -355,9 +431,18 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-actions {
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
.vector-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
.vector-hash {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
<el-icon class="header-icon"><ele-Folder /></el-icon>
|
||||
<span class="header-title">知识库</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="onAddknowledge">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
新建知识库
|
||||
</el-button>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" v-debounce @click="onAddknowledge">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
新建知识库
|
||||
</el-button>
|
||||
<el-button type="success" v-debounce @click="onOpenModelConfig">
|
||||
<el-icon><ele-Setting /></el-icon>
|
||||
模型配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="knowledge-cards" v-loading="knowledgeLoading">
|
||||
@@ -27,18 +33,18 @@
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">{{ item.name }}</div>
|
||||
<div class="card-meta">{{ item.fileCount || 0 }} 个文件</div>
|
||||
<div class="card-meta">{{ item.documentCount || 0 }} 个文件</div>
|
||||
<div class="card-time">{{ item.createdAt }}</div>
|
||||
</div>
|
||||
<!-- 悬停操作按钮 -->
|
||||
<div class="card-actions" @click.stop>
|
||||
<el-tooltip content="重命名" placement="top">
|
||||
<el-button text size="small" @click="onRenameknowledge(item)">
|
||||
<el-button text size="small" v-debounce @click="onRenameknowledge(item)">
|
||||
<el-icon><ele-Edit /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button text size="small" type="danger" @click="onDeleteknowledge(item)">
|
||||
<el-button text size="small" type="danger" v-debounce @click="onDeleteknowledge(item)">
|
||||
<el-icon><ele-Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
@@ -78,7 +84,7 @@
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">{{ currentknowledge.name }}</div>
|
||||
<div class="profile-meta">{{ currentknowledge.fileCount || 0 }} 个文件 · {{ formatFileSize(currentknowledge.totalSize || 0) }}</div>
|
||||
<div class="profile-meta">{{ currentknowledge.documentCount || 0 }} 个文件 · {{ formatFileSize(currentknowledge.totalSize || 0) }}</div>
|
||||
<div class="profile-time">创建于 {{ currentknowledge.createdAt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +125,7 @@
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onUploadFile">
|
||||
<el-button type="primary" v-debounce @click="onUploadFile">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
新增文件
|
||||
</el-button>
|
||||
@@ -127,33 +133,33 @@
|
||||
</div>
|
||||
<div class="file-table" v-loading="fileLoading">
|
||||
<el-table :data="fileList" style="width: 100%" row-key="id" border>
|
||||
<el-table-column prop="Title" label="名称" min-width="200">
|
||||
<template #default="scope">
|
||||
<span class="file-link" @click="onViewDocumentDetail(scope.row)" style="cursor: pointer; color: #409eff">{{
|
||||
scope.row.Title
|
||||
}}</span>
|
||||
<el-table-column prop="title" label="名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="file-link" @click="onViewDocumentDetail(row)" style="cursor: pointer; color: #409eff">{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="chunkCount" label="分块数" width="80" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.statusEnabled" inline-prompt active-text="启" inactive-text="停" @change="onFileStatusChange(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vectorStatus" label="向量化" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.vectorStatus === 'done' ? 'success' : 'warning'" size="small">
|
||||
{{ scope.row.vectorStatus || '未处理' }}
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getVectorStatusType(row.vectorStatus)" size="small">
|
||||
{{ getVectorStatusText(row.vectorStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="上传日期" width="180" />
|
||||
<el-table-column label="动作" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button text size="small" @click="onPreviewFile(scope.row)">预览</el-button>
|
||||
<el-button text size="small" type="danger" @click="onDeleteFile(scope.row)">删除</el-button>
|
||||
<el-table-column label="动作" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" v-debounce @click="onPreviewFile(row)">预览</el-button>
|
||||
<el-button v-if="row.vectorStatus === 1" text size="small" type="primary" v-debounce @click="onGenerateVector(row)"
|
||||
>生成向量</el-button
|
||||
>
|
||||
<el-button v-else text size="small" type="primary" v-debounce @click="onViewTaskList(row)">查看任务</el-button>
|
||||
<el-button text size="small" type="danger" v-debounce @click="onDeleteFile(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -166,7 +172,7 @@
|
||||
<div class="panel-card">
|
||||
<h3>检索测试</h3>
|
||||
<el-input v-model="searchQuery" type="textarea" :rows="3" placeholder="输入问题进行检索测试..." />
|
||||
<el-button type="primary" class="mt15" @click="onSearchTest">测试检索</el-button>
|
||||
<el-button type="primary" class="mt15" v-debounce @click="onSearchTest">测试检索</el-button>
|
||||
<div class="search-results mt15" v-if="searchResults.length > 0">
|
||||
<h4>检索结果</h4>
|
||||
<div class="result-item" v-for="(item, index) in searchResults" :key="index">
|
||||
@@ -212,7 +218,7 @@
|
||||
<el-input-number v-model="settingsForm.chunkOverlap" :min="0" :max="500" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSaveSettings">保存配置</el-button>
|
||||
<el-button type="primary" v-debounce @click="onSaveSettings">保存配置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -233,7 +239,7 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showknowledgeDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSaveknowledge" :loading="knowledgeSaving">确定</el-button>
|
||||
<el-button type="primary" v-debounce @click="onSaveknowledge" :loading="knowledgeSaving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -258,7 +264,7 @@
|
||||
</el-upload>
|
||||
<template #footer>
|
||||
<el-button @click="showUploadDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="onConfirmUpload" :loading="uploading" :disabled="uploadFileList.length === 0">
|
||||
<el-button type="primary" v-debounce @click="onConfirmUpload" :loading="uploading" :disabled="uploadFileList.length === 0">
|
||||
上传 ({{ uploadFileList.length }} 个文件)
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -271,6 +277,138 @@
|
||||
:knowledgeName="currentknowledge?.name || ''"
|
||||
:document="currentDocument"
|
||||
/>
|
||||
|
||||
<!-- 模型配置弹窗 -->
|
||||
<el-dialog title="模型配置" v-model="showModelConfigDialog" width="1000px" :close-on-click-modal="false">
|
||||
<div class="model-config-list" v-loading="modelConfigLoading">
|
||||
<el-button type="primary" style="margin-bottom: 16px" v-debounce @click="onCreateModelConfig">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
创建模型配置
|
||||
</el-button>
|
||||
<el-table :data="modelConfigList" style="width: 100%" border>
|
||||
<el-table-column prop="modelName" label="模型名称" min-width="120" />
|
||||
<el-table-column prop="modelType" label="模型类型" min-width="100" />
|
||||
<el-table-column prop="modelDesc" label="模型描述" min-width="150" />
|
||||
<el-table-column prop="configType" label="配置类型" min-width="100" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateTime" label="修改时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.updateTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" type="primary" v-debounce @click="onEditModelConfig(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="modelConfigList.length === 0 && !modelConfigLoading" description="暂无模型配置" :image-size="60" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showModelConfigDialog = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 任务列表弹窗 -->
|
||||
<el-dialog title="任务列表" v-model="showTaskListDialog" width="900px" :close-on-click-modal="false">
|
||||
<el-table :data="taskList" style="width: 100%" border v-loading="taskListLoading">
|
||||
<el-table-column prop="taskType" label="任务类型" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ getTaskTypeText(row.taskType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="任务状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTaskStatusType(row.status)" size="small">
|
||||
{{ getTaskStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="200" />
|
||||
<el-table-column prop="startTime" label="开始时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.startTime ? formatDateTime(row.startTime) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="endTime" label="结束时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.endTime ? formatDateTime(row.endTime) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 'FAILED' || row.status === 'COMPLETED'"
|
||||
text
|
||||
size="small"
|
||||
type="primary"
|
||||
v-debounce
|
||||
@click="onReexecuteTask(row)"
|
||||
>重新执行</el-button
|
||||
>
|
||||
<el-button v-else text size="small" type="info" disabled>执行中</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="taskList.length === 0 && !taskListLoading" description="暂无任务" :image-size="60" />
|
||||
<template #footer>
|
||||
<el-button @click="showTaskListDialog = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建/编辑模型配置弹窗 -->
|
||||
<el-dialog :title="isEditMode ? '编辑模型配置' : '创建模型配置'" v-model="showCreateModelDialog" width="600px" :close-on-click-modal="false">
|
||||
<div v-loading="modelEnumsLoading || modelFormLoading">
|
||||
<el-form :model="modelFormData" label-width="100px">
|
||||
<!-- 模型类型选择 -->
|
||||
<el-form-item label="模型类型" required>
|
||||
<el-select v-model="selectedModelType" style="width: 100%" @change="onModelTypeChange">
|
||||
<el-option v-for="item in modelEnums" :key="item.key" :label="item.value" :value="item.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 配置类型选择 -->
|
||||
<el-form-item label="配置类型" required>
|
||||
<el-select v-model="selectedConfigType" style="width: 100%" @change="onConfigTypeChange" :disabled="!selectedModelType">
|
||||
<el-option v-for="item in getConfigTypes()" :key="item.key" :label="item.value" :value="item.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="modelFormFields.length > 0">
|
||||
<el-form-item v-for="(field, index) in modelFormFields" :key="field.name || index" :label="field.label" :required="field.required">
|
||||
<template v-if="field.type === 'textarea'">
|
||||
<el-input
|
||||
v-model="modelFormData[field.name]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="field.disabled"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="field.type === 'switch'">
|
||||
<el-switch v-model="modelFormData[field.name]" :disabled="field.disabled" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input v-model="modelFormData[field.name]" :placeholder="field.placeholder" :disabled="field.disabled" style="width: 100%" />
|
||||
</template>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 无表单字段提示 -->
|
||||
<el-empty v-else description="暂无表单字段" :image-size="60" />
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateModelDialog = false">取消</el-button>
|
||||
<el-button type="primary" v-debounce @click="onSaveModelConfig" :disabled="!selectedModelType || !selectedConfigType"> 保存 </el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -286,7 +424,18 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus';
|
||||
import DocumentDetailDialog from './component/documentDetailDialog.vue';
|
||||
import { listknowledges, createknowledge, updateknowledge, deleteknowledge } from '/@/api/knowledge/dataset';
|
||||
import { listDocuments, uploadFile, createDocument, deleteDocument } from '/@/api/knowledge/document';
|
||||
import {
|
||||
listDocuments,
|
||||
uploadFile,
|
||||
createDocument,
|
||||
deleteDocument,
|
||||
updateDocument,
|
||||
generateVector,
|
||||
getDocument,
|
||||
listTasks,
|
||||
reexecuteTask,
|
||||
} from '/@/api/knowledge/document';
|
||||
import { listModelConfigs, createModelConfig, updateModelConfig, getModelConfig, getAllModelEnums, getModelFormField } from '/@/api/knowledge/model';
|
||||
|
||||
// 数据集相关
|
||||
const knowledgeLoading = ref(false);
|
||||
@@ -294,6 +443,28 @@ const knowledgeList = ref<any[]>([]);
|
||||
const currentknowledge = ref<any>(null);
|
||||
const showknowledgeDialog = ref(false);
|
||||
const knowledgeSaving = ref(false);
|
||||
|
||||
// 模型配置相关
|
||||
const showModelConfigDialog = ref(false);
|
||||
const modelConfigList = ref<any[]>([]);
|
||||
const modelConfigLoading = ref(false);
|
||||
|
||||
// 任务列表相关
|
||||
const showTaskListDialog = ref(false);
|
||||
const taskList = ref<any[]>([]);
|
||||
const taskListLoading = ref(false);
|
||||
|
||||
// 创建模型配置相关
|
||||
const showCreateModelDialog = ref(false);
|
||||
const modelEnums = ref<any[]>([]);
|
||||
const selectedModelType = ref('');
|
||||
const selectedConfigType = ref('');
|
||||
const modelFormFields = ref<any[]>([]);
|
||||
const modelFormData = ref<any>({});
|
||||
const modelFormLoading = ref(false);
|
||||
const modelEnumsLoading = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const currentModelId = ref<number | null>(null);
|
||||
const knowledgeFormRef = ref<FormInstance>();
|
||||
const knowledgeForm = reactive({
|
||||
id: '',
|
||||
@@ -497,7 +668,10 @@ const getFileList = async () => {
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
fileList.value = response.data?.list || [];
|
||||
fileList.value = (response.data?.list || []).map((item: any) => ({
|
||||
...item,
|
||||
statusEnabled: item.status === 1,
|
||||
}));
|
||||
} catch (_error) {
|
||||
ElMessage.error('获取文件列表失败');
|
||||
} finally {
|
||||
@@ -578,14 +752,47 @@ const onConfirmUpload = async () => {
|
||||
};
|
||||
|
||||
// 文件状态变化
|
||||
const _onFileStatusChange = (row: any) => {
|
||||
ElMessage.success(row.enabled ? '已启用' : '已禁用');
|
||||
const onFileStatusChange = async (row: any) => {
|
||||
const newStatus = row.statusEnabled ? 1 : 0;
|
||||
try {
|
||||
// 调用后端API来更新文件状态
|
||||
await updateDocument({
|
||||
id: row.id,
|
||||
status: newStatus,
|
||||
});
|
||||
ElMessage.success(row.statusEnabled ? '已启用' : '已禁用');
|
||||
} catch (error) {
|
||||
// 失败时恢复原状态
|
||||
row.statusEnabled = !row.statusEnabled;
|
||||
ElMessage.error('状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 生成向量
|
||||
const onGenerateVector = async (row: any) => {
|
||||
try {
|
||||
// 调用后端API来生成向量,传递id和datasetId
|
||||
await generateVector(row.id, currentknowledge.value.id);
|
||||
ElMessage.success('生成向量任务已提交');
|
||||
// 模拟更新状态
|
||||
setTimeout(() => {
|
||||
getFileList();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
ElMessage.error('生成向量失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 查看文档详情
|
||||
const onViewDocumentDetail = (row: any) => {
|
||||
currentDocument.value = row;
|
||||
showDocumentDetailDialog.value = true;
|
||||
const onViewDocumentDetail = async (row: any) => {
|
||||
try {
|
||||
// 调用getDocument接口获取最新的文件详情
|
||||
const response = await getDocument(row.id);
|
||||
currentDocument.value = response.data;
|
||||
showDocumentDetailDialog.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取文件详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 预览文件
|
||||
@@ -642,8 +849,310 @@ const getLogList = () => {
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const onSaveSettings = () => {
|
||||
ElMessage.success('配置保存成功');
|
||||
const onSaveSettings = async () => {
|
||||
ElMessage.success('保存成功');
|
||||
};
|
||||
|
||||
// 打开模型配置弹窗
|
||||
const onOpenModelConfig = async () => {
|
||||
await getModelConfigList();
|
||||
showModelConfigDialog.value = true;
|
||||
};
|
||||
|
||||
// 打开创建模型配置弹窗
|
||||
const onCreateModelConfig = async () => {
|
||||
// 重置状态
|
||||
selectedModelType.value = '';
|
||||
selectedConfigType.value = '';
|
||||
modelFormFields.value = [];
|
||||
modelFormData.value = {};
|
||||
isEditMode.value = false;
|
||||
currentModelId.value = null;
|
||||
|
||||
// 获取模型类型和配置类型枚举
|
||||
await getModelEnums();
|
||||
|
||||
// 打开创建弹窗
|
||||
showCreateModelDialog.value = true;
|
||||
};
|
||||
|
||||
// 编辑模型配置
|
||||
const onEditModelConfig = async (row: any) => {
|
||||
try {
|
||||
// 重置状态
|
||||
selectedModelType.value = '';
|
||||
selectedConfigType.value = '';
|
||||
modelFormFields.value = [];
|
||||
modelFormData.value = {};
|
||||
isEditMode.value = true;
|
||||
currentModelId.value = row.id;
|
||||
|
||||
// 获取模型类型和配置类型枚举
|
||||
await getModelEnums();
|
||||
|
||||
// 调用获取详情接口
|
||||
const response = await getModelConfig(row.id, row.modelType);
|
||||
const modelData = response.data;
|
||||
|
||||
// 设置模型类型和配置类型
|
||||
selectedModelType.value = modelData.modelType;
|
||||
selectedConfigType.value = modelData.configType;
|
||||
|
||||
// 填充表单数据
|
||||
modelFormData.value = {
|
||||
modelName: modelData.modelName,
|
||||
modelDesc: modelData.modelDesc,
|
||||
};
|
||||
|
||||
// 将configContent中的数据添加到表单数据中
|
||||
if (modelData.configContent) {
|
||||
Object.keys(modelData.configContent).forEach((key) => {
|
||||
modelFormData.value[key] = modelData.configContent[key];
|
||||
});
|
||||
}
|
||||
|
||||
// 获取动态表单字段
|
||||
await getModelFormFields();
|
||||
|
||||
// 打开弹窗
|
||||
showCreateModelDialog.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模型配置详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取模型类型和配置类型枚举
|
||||
const getModelEnums = async () => {
|
||||
modelEnumsLoading.value = true;
|
||||
try {
|
||||
const response = await getAllModelEnums();
|
||||
modelEnums.value = response.data?.options || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模型类型枚举失败');
|
||||
modelEnums.value = [];
|
||||
} finally {
|
||||
modelEnumsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 模型类型选择变化
|
||||
const onModelTypeChange = async () => {
|
||||
selectedConfigType.value = '';
|
||||
modelFormFields.value = [];
|
||||
// 在编辑模式下,只保留模型名称和描述,清空其他字段
|
||||
if (isEditMode.value) {
|
||||
const { modelName, modelDesc } = modelFormData.value;
|
||||
modelFormData.value = {
|
||||
modelName,
|
||||
modelDesc,
|
||||
};
|
||||
} else {
|
||||
// 创建模式下清空所有字段
|
||||
modelFormData.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
// 配置类型选择变化
|
||||
const onConfigTypeChange = async () => {
|
||||
if (selectedModelType.value && selectedConfigType.value) {
|
||||
await getModelFormFields();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取模型表单字段
|
||||
const getModelFormFields = async () => {
|
||||
modelFormLoading.value = true;
|
||||
try {
|
||||
const response = await getModelFormField(selectedModelType.value, selectedConfigType.value);
|
||||
// 过滤掉模型类型和配置类型字段,避免重复显示
|
||||
modelFormFields.value = (response.data?.fields || []).filter((field: any) => {
|
||||
return field.name !== 'modelType' && field.name !== 'configType';
|
||||
});
|
||||
// 设置字段的默认值,但保留已有的表单数据
|
||||
modelFormFields.value.forEach((field: any) => {
|
||||
if (field.value !== undefined && modelFormData.value[field.name] === undefined) {
|
||||
modelFormData.value[field.name] = field.value;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模型表单字段失败');
|
||||
modelFormFields.value = [];
|
||||
} finally {
|
||||
modelFormLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存模型配置
|
||||
const onSaveModelConfig = async () => {
|
||||
try {
|
||||
// 构建请求数据,只传递接口需要的字段
|
||||
const data = {
|
||||
modelType: selectedModelType.value,
|
||||
configType: selectedConfigType.value,
|
||||
modelName: modelFormData.value.modelName,
|
||||
modelDesc: modelFormData.value.modelDesc,
|
||||
configContent: {} as Record<string, any>,
|
||||
};
|
||||
|
||||
// 将动态表单字段(除了modelType、configType、modelName、modelDesc)添加到configContent中,以key-value形式
|
||||
Object.keys(modelFormData.value).forEach((key) => {
|
||||
if (!['modelType', 'configType', 'modelName', 'modelDesc'].includes(key)) {
|
||||
data.configContent[key] = modelFormData.value[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 根据模式调用不同的接口
|
||||
if (isEditMode.value && currentModelId.value) {
|
||||
// 编辑模式,调用更新接口
|
||||
await updateModelConfig({ ...data, id: currentModelId.value });
|
||||
ElMessage.success('更新模型配置成功');
|
||||
} else {
|
||||
// 创建模式,调用创建接口
|
||||
await createModelConfig(data);
|
||||
ElMessage.success('创建模型配置成功');
|
||||
}
|
||||
|
||||
// 关闭弹窗并刷新列表
|
||||
showCreateModelDialog.value = false;
|
||||
getModelConfigList();
|
||||
} catch (error) {
|
||||
ElMessage.error(isEditMode.value ? '更新模型配置失败' : '创建模型配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 根据选中的模型类型获取配置类型列表
|
||||
const getConfigTypes = () => {
|
||||
if (!selectedModelType.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedModel = modelEnums.value.find((item: any) => item.key === selectedModelType.value);
|
||||
return selectedModel?.configTypes || [];
|
||||
};
|
||||
|
||||
// 获取模型配置列表
|
||||
const getModelConfigList = async () => {
|
||||
modelConfigLoading.value = true;
|
||||
try {
|
||||
const response = await listModelConfigs({
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
modelConfigList.value = response.data?.list || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模型配置列表失败');
|
||||
modelConfigList.value = [];
|
||||
} finally {
|
||||
modelConfigLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatDateTime = (dateTime: string) => {
|
||||
if (!dateTime) return '-';
|
||||
const date = new Date(dateTime);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// 获取向量状态文本
|
||||
const getVectorStatusText = (status: number) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
1: '待处理',
|
||||
2: '处理中',
|
||||
3: '已完成',
|
||||
4: '失败',
|
||||
};
|
||||
return statusMap[status] || '未知';
|
||||
};
|
||||
|
||||
// 获取向量状态标签类型
|
||||
const getVectorStatusType = (status: number) => {
|
||||
const typeMap: Record<number, any> = {
|
||||
1: 'warning',
|
||||
2: 'primary',
|
||||
3: 'success',
|
||||
4: 'danger',
|
||||
};
|
||||
return typeMap[status] || 'info';
|
||||
};
|
||||
|
||||
// 获取任务类型文本
|
||||
const getTaskTypeText = (taskType: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
EXTRACT_KEYWORDS: '提取关键词',
|
||||
GENERATE_VECTOR: '生成向量',
|
||||
FULL_TEXT_SEARCH: '全文检索',
|
||||
DOC_PARSE: '文档解析',
|
||||
};
|
||||
return typeMap[taskType] || taskType;
|
||||
};
|
||||
|
||||
// 获取任务状态文本
|
||||
const getTaskStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
PENDING: '待执行',
|
||||
RUNNING: '执行中',
|
||||
COMPLETED: '已完成',
|
||||
FAILED: '执行失败',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
// 获取任务状态标签类型
|
||||
const getTaskStatusType = (status: string) => {
|
||||
const typeMap: Record<string, any> = {
|
||||
PENDING: 'warning',
|
||||
RUNNING: 'primary',
|
||||
COMPLETED: 'success',
|
||||
FAILED: 'danger',
|
||||
};
|
||||
return typeMap[status] || 'info';
|
||||
};
|
||||
|
||||
// 查看任务列表
|
||||
const onViewTaskList = async (row: any) => {
|
||||
showTaskListDialog.value = true;
|
||||
await getTaskList();
|
||||
};
|
||||
|
||||
// 获取任务列表
|
||||
const getTaskList = async () => {
|
||||
taskListLoading.value = true;
|
||||
try {
|
||||
const response = await listTasks();
|
||||
taskList.value = response.data?.list || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取任务列表失败');
|
||||
taskList.value = [];
|
||||
} finally {
|
||||
taskListLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重新执行任务
|
||||
const onReexecuteTask = async (task: any) => {
|
||||
ElMessageBox.confirm(`确定要重新执行任务【${getTaskTypeText(task.taskType)}】吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await reexecuteTask(task.id);
|
||||
ElMessage.success('重新执行任务成功');
|
||||
// 重新获取任务列表
|
||||
await getTaskList();
|
||||
} catch (error) {
|
||||
ElMessage.error('重新执行任务失败,请重试');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// 页面加载
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</el-aside>
|
||||
</el-card>
|
||||
</el-col> -->
|
||||
<el-col >
|
||||
<el-col>
|
||||
<el-card shadow="hover">
|
||||
<div class="system-user-search mb15">
|
||||
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="68px">
|
||||
@@ -62,25 +62,25 @@
|
||||
></el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="default" type="primary" class="ml10" @click="userList">
|
||||
<el-button size="default" type="primary" class="ml10" v-debounce @click="userList">
|
||||
<el-icon>
|
||||
<ele-Search />
|
||||
</el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" @click="resetQuery(queryRef)">
|
||||
<el-button size="default" v-debounce @click="resetQuery(queryRef)">
|
||||
<el-icon>
|
||||
<ele-Refresh />
|
||||
</el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button size="default" type="success" class="ml10" @click="onOpenAddUser">
|
||||
<el-button size="default" type="success" class="ml10" v-debounce @click="onOpenAddUser">
|
||||
<el-icon>
|
||||
<ele-FolderAdd />
|
||||
</el-icon>
|
||||
新增用户
|
||||
</el-button>
|
||||
<el-button size="default" type="danger" class="ml10" @click="onRowDel(null)">
|
||||
<el-button size="default" type="danger" class="ml10" v-debounce @click="onRowDel(null)">
|
||||
<el-icon>
|
||||
<ele-Delete />
|
||||
</el-icon>
|
||||
@@ -119,9 +119,27 @@
|
||||
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" class="op-btn-edit" :disabled="!scope.row.isOperation" @click="onOpenEditUser(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="primary" class="op-btn-del" :disabled="!scope.row.isOperation" @click="onRowDel(scope.row)">删除</el-button>
|
||||
<el-button size="small" text type="primary" @click="handleResetPwd(scope.row)">重置</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
class="op-btn-edit"
|
||||
:disabled="!scope.row.isOperation"
|
||||
v-debounce
|
||||
@click="onOpenEditUser(scope.row)"
|
||||
>修改</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
class="op-btn-del"
|
||||
:disabled="!scope.row.isOperation"
|
||||
v-debounce
|
||||
@click="onRowDel(scope.row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<el-button size="small" text type="primary" v-debounce @click="handleResetPwd(scope.row)">重置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<template>
|
||||
<div class="trade-operation-analysis-shop">
|
||||
<el-card shadow="hover">
|
||||
<template #header><div class="card-header"><span>店铺评分监控</span></div></template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>店铺评分监控</span>
|
||||
<el-button type="text" @click="openSettingDialog" class="setting-btn">
|
||||
<el-icon><eleSetting /></el-icon>
|
||||
<span>预警设置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">评分趋势 - {{ selectedShopName }}</div></template>
|
||||
<template #header
|
||||
><div class="card-header">评分趋势 - {{ selectedShopName }}</div></template
|
||||
>
|
||||
<div ref="scoreChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -14,14 +24,18 @@
|
||||
<div class="search-container">
|
||||
<el-form :model="searchParams" :inline="true" class="search-form">
|
||||
<el-form-item label="店铺搜索">
|
||||
<el-input
|
||||
v-model="searchParams.shopKeyword"
|
||||
placeholder="请输入店铺名称或店铺ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-input v-model="searchParams.shopKeyword" placeholder="请输入店铺名称或店铺ID" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围"><el-date-picker v-model="searchParams.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" :shortcuts="dateShortcuts" /></el-form-item>
|
||||
<el-form-item label="时间范围"
|
||||
><el-date-picker
|
||||
v-model="searchParams.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="dateShortcuts"
|
||||
/></el-form-item>
|
||||
<el-form-item label="时间粒度">
|
||||
<el-radio-group v-model="searchParams.granularity" class="granularity-group">
|
||||
<el-radio-button v-for="option in granularityOptions" :key="option.value" :label="option.value">
|
||||
@@ -29,41 +43,119 @@
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="handleSearch">查询</el-button><el-button @click="handleReset">重置</el-button></el-form-item>
|
||||
<el-form-item
|
||||
><el-button type="primary" @click="handleSearch">查询</el-button><el-button @click="handleReset">重置</el-button></el-form-item
|
||||
>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table :data="pagedShopList" style="width: 100%" highlight-current-row @row-click="handleShopClick">
|
||||
<el-table-column prop="id" label="店铺ID" width="100" />
|
||||
<el-table-column prop="name" label="店铺名称" />
|
||||
<el-table-column prop="type" label="店铺类型"><template #default="scope"><el-tag size="small">{{ scope.row.type === 'physical' ? '线下店铺' : '线上店铺' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="status" label="状态"><template #default="scope"><el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'danger'">{{ scope.row.status === 'active' ? '营业中' : '已关闭' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="120"><template #default="scope"><el-button type="primary" size="small" @click.stop="handleShopSelect(scope.row.id)">查看监控</el-button></template></el-table-column>
|
||||
<el-table-column prop="type" label="店铺类型"
|
||||
><template #default="scope"
|
||||
><el-tag size="small">{{ scope.row.type === 'physical' ? '线下店铺' : '线上店铺' }}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="status" label="状态"
|
||||
><template #default="scope"
|
||||
><el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'danger'">{{
|
||||
scope.row.status === 'active' ? '营业中' : '已关闭'
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" width="120"
|
||||
><template #default="scope"
|
||||
><el-button type="primary" size="small" @click.stop="handleShopSelect(scope.row.id)">查看监控</el-button></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<div class="pagination-container"><el-pagination v-model:current-page="shopPagination.currentPage" v-model:page-size="shopPagination.pageSize" :page-sizes="[5, 10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" :total="shopPagination.total" @size-change="handleShopPageSizeChange" @current-change="handleShopPageChange" /></div>
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="shopPagination.currentPage"
|
||||
v-model:page-size="shopPagination.pageSize"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="shopPagination.total"
|
||||
@size-change="handleShopPageSizeChange"
|
||||
@current-change="handleShopPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="score-setting"><el-card><template #header><div class="card-header">评分预警设置 - {{ selectedShopName }}</div></template><el-form :model="scoreSettings" label-width="120px"><el-form-item label="口碑分预警阈值"><el-input-number v-model="scoreSettings.reputationThreshold" :min="0" :max="5" :step="0.1" /></el-form-item><el-form-item label="体验分预警阈值"><el-input-number v-model="scoreSettings.experienceThreshold" :min="0" :max="5" :step="0.1" /></el-form-item><el-form-item><el-button type="primary" @click="handleSaveSettings">保存设置</el-button></el-form-item></el-form></el-card></div>
|
||||
<div class="score-detail">
|
||||
<el-card>
|
||||
<template #header><div class="card-header">评分详情 - {{ selectedShopName }}</div></template>
|
||||
<template #header
|
||||
><div class="card-header">评分详情 - {{ selectedShopName }}</div></template
|
||||
>
|
||||
<el-table :data="pagedScoreDetail" style="width: 100%">
|
||||
<el-table-column prop="date" label="日期" />
|
||||
<el-table-column prop="reputationScore" label="口碑分"><template #default="scope"><div class="score-item" :class="{ warning: scope.row.reputationScore < scoreSettings.reputationThreshold }">{{ scope.row.reputationScore }}</div></template></el-table-column>
|
||||
<el-table-column prop="experienceScore" label="体验分"><template #default="scope"><div class="score-item" :class="{ warning: scope.row.experienceScore < scoreSettings.experienceThreshold }">{{ scope.row.experienceScore }}</div></template></el-table-column>
|
||||
<el-table-column prop="reputationScore" label="口碑分"
|
||||
><template #default="scope"
|
||||
><div class="score-item" :class="{ warning: scope.row.reputationScore < scoreSettings.reputationThreshold }">
|
||||
{{ scope.row.reputationScore }}
|
||||
</div></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="experienceScore" label="体验分"
|
||||
><template #default="scope"
|
||||
><div class="score-item" :class="{ warning: scope.row.experienceScore < scoreSettings.experienceThreshold }">
|
||||
{{ scope.row.experienceScore }}
|
||||
</div></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="commentCount" label="评价数量" />
|
||||
</el-table>
|
||||
<div class="pagination-container"><el-pagination v-model:current-page="detailPagination.currentPage" v-model:page-size="detailPagination.pageSize" :page-sizes="[5, 10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" :total="detailPagination.total" @size-change="handleDetailPageSizeChange" @current-change="handleDetailPageChange" /></div>
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="detailPagination.currentPage"
|
||||
v-model:page-size="detailPagination.pageSize"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="detailPagination.total"
|
||||
@size-change="handleDetailPageSizeChange"
|
||||
@current-change="handleDetailPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 评分预警设置弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="'评分预警设置 - ' + selectedShopName" width="400px">
|
||||
<el-form :model="scoreSettings" label-width="120px">
|
||||
<el-form-item label="口碑分预警阈值">
|
||||
<el-input-number v-model="scoreSettings.reputationThreshold" :min="0" :max="5" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="体验分预警阈值">
|
||||
<el-input-number v-model="scoreSettings.experienceThreshold" :min="0" :max="5" :step="0.1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveSettings">保存设置</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
interface Shop { id: string; name: string; type: string; status: string }
|
||||
interface ScoreDetail { date: string; reputationScore: number; experienceScore: number; commentCount: number }
|
||||
import { Setting as eleSetting } from '@element-plus/icons-vue';
|
||||
interface Shop {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
}
|
||||
interface ScoreDetail {
|
||||
date: string;
|
||||
reputationScore: number;
|
||||
experienceScore: number;
|
||||
commentCount: number;
|
||||
}
|
||||
const granularityOptions = [
|
||||
{ label: '小时', value: 'hour' },
|
||||
{ label: '日', value: 'day' },
|
||||
@@ -73,8 +165,53 @@ const granularityOptions = [
|
||||
{ label: '年', value: 'year' },
|
||||
];
|
||||
const searchParams = reactive({ shopId: 'all', shopKeyword: '', dateRange: [], granularity: 'day' });
|
||||
const dateShortcuts = [{ text: '最近7天', value: () => { const end = new Date(); const start = new Date(); start.setTime(start.getTime() - 3600 * 1000 * 24 * 7); return [start, end]; } }, { text: '最近30天', value: () => { const end = new Date(); const start = new Date(); start.setTime(start.getTime() - 3600 * 1000 * 24 * 30); return [start, end]; } }, { text: '最近90天', value: () => { const end = new Date(); const start = new Date(); start.setTime(start.getTime() - 3600 * 1000 * 24 * 90); return [start, end]; } }, { text: '今年', value: () => { const end = new Date(); const start = new Date(new Date().getFullYear(), 0, 1); return [start, end]; } }, { text: '去年', value: () => { const end = new Date(new Date().getFullYear() - 1, 11, 31); const start = new Date(new Date().getFullYear() - 1, 0, 1); return [start, end]; } }];
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近90天',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '今年',
|
||||
value: () => {
|
||||
const end = new Date();
|
||||
const start = new Date(new Date().getFullYear(), 0, 1);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '去年',
|
||||
value: () => {
|
||||
const end = new Date(new Date().getFullYear() - 1, 11, 31);
|
||||
const start = new Date(new Date().getFullYear() - 1, 0, 1);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
];
|
||||
const scoreSettings = reactive({ reputationThreshold: 4.0, experienceThreshold: 4.2 });
|
||||
const dialogVisible = ref(false);
|
||||
const shopList = ref<Shop[]>([]);
|
||||
const scoreDetail = ref<ScoreDetail[]>([]);
|
||||
const filteredShopList = computed(() => {
|
||||
@@ -87,11 +224,23 @@ const detailPagination = reactive({ currentPage: 1, pageSize: 5, total: 0 });
|
||||
const pagedShopList = computed(() =>
|
||||
filteredShopList.value.slice((shopPagination.currentPage - 1) * shopPagination.pageSize, shopPagination.currentPage * shopPagination.pageSize)
|
||||
);
|
||||
const pagedScoreDetail = computed(() => scoreDetail.value.slice((detailPagination.currentPage - 1) * detailPagination.pageSize, detailPagination.currentPage * detailPagination.pageSize));
|
||||
const pagedScoreDetail = computed(() =>
|
||||
scoreDetail.value.slice((detailPagination.currentPage - 1) * detailPagination.pageSize, detailPagination.currentPage * detailPagination.pageSize)
|
||||
);
|
||||
const scoreChartRef = ref();
|
||||
let scoreChart: echarts.ECharts | null = null;
|
||||
const getMockShopList = () => [{ id: '1', name: '旗舰店', type: 'online', status: 'active' }, { id: '2', name: '华东直营网点', type: 'physical', status: 'active' }, { id: '3', name: '华南直营店', type: 'physical', status: 'active' }, { id: '4', name: '品牌商城', type: 'online', status: 'active' }, { id: '5', name: '北区体验店', type: 'physical', status: 'closed' }];
|
||||
const selectedShopName = computed(() => { if (searchParams.shopId === 'all') return '全部店铺'; const shop = shopList.value.find((item) => String(item.id) === searchParams.shopId); return shop?.name || '未知店铺'; });
|
||||
const getMockShopList = () => [
|
||||
{ id: '1', name: '旗舰店', type: 'online', status: 'active' },
|
||||
{ id: '2', name: '华东直营网点', type: 'physical', status: 'active' },
|
||||
{ id: '3', name: '华南直营店', type: 'physical', status: 'active' },
|
||||
{ id: '4', name: '品牌商城', type: 'online', status: 'active' },
|
||||
{ id: '5', name: '北区体验店', type: 'physical', status: 'closed' },
|
||||
];
|
||||
const selectedShopName = computed(() => {
|
||||
if (searchParams.shopId === 'all') return '全部店铺';
|
||||
const shop = shopList.value.find((item) => String(item.id) === searchParams.shopId);
|
||||
return shop?.name || '未知店铺';
|
||||
});
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const toDate = (value?: string | Date) => {
|
||||
if (!value) return new Date();
|
||||
@@ -216,12 +365,57 @@ const handleShopSelect = (shopId: string) => {
|
||||
searchParams.shopId = String(shopId);
|
||||
handleSearch();
|
||||
};
|
||||
const handleShopPageSizeChange = (size: number) => { shopPagination.pageSize = size; shopPagination.currentPage = 1; };
|
||||
const handleShopPageChange = (page: number) => { shopPagination.currentPage = page; };
|
||||
const handleDetailPageSizeChange = (size: number) => { detailPagination.pageSize = size; detailPagination.currentPage = 1; };
|
||||
const handleDetailPageChange = (page: number) => { detailPagination.currentPage = page; };
|
||||
const handleSaveSettings = () => {};
|
||||
const initScoreChart = (scoreTrend: Array<{ date: string; reputationScore: number; experienceScore: number }>) => { if (!scoreChartRef.value) return; if (scoreChart) scoreChart.dispose(); scoreChart = echarts.init(scoreChartRef.value); const isSingle = scoreTrend.length <= 1; scoreChart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } }, legend: { data: ['口碑分', '体验分'] }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', boundaryGap: isSingle, data: scoreTrend.map((item) => item.date) }, yAxis: { type: 'value', name: '评分', min: 0, max: 5 }, series: [{ name: '口碑分', type: 'line', data: scoreTrend.map((item) => item.reputationScore), smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 }, { name: '体验分', type: 'line', data: scoreTrend.map((item) => item.experienceScore), smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 }] }); };
|
||||
const handleShopPageSizeChange = (size: number) => {
|
||||
shopPagination.pageSize = size;
|
||||
shopPagination.currentPage = 1;
|
||||
};
|
||||
const handleShopPageChange = (page: number) => {
|
||||
shopPagination.currentPage = page;
|
||||
};
|
||||
const handleDetailPageSizeChange = (size: number) => {
|
||||
detailPagination.pageSize = size;
|
||||
detailPagination.currentPage = 1;
|
||||
};
|
||||
const handleDetailPageChange = (page: number) => {
|
||||
detailPagination.currentPage = page;
|
||||
};
|
||||
const openSettingDialog = () => {
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const handleSaveSettings = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
const initScoreChart = (scoreTrend: Array<{ date: string; reputationScore: number; experienceScore: number }>) => {
|
||||
if (!scoreChartRef.value) return;
|
||||
if (scoreChart) scoreChart.dispose();
|
||||
scoreChart = echarts.init(scoreChartRef.value);
|
||||
const isSingle = scoreTrend.length <= 1;
|
||||
scoreChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } },
|
||||
legend: { data: ['口碑分', '体验分'] },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', boundaryGap: isSingle, data: scoreTrend.map((item) => item.date) },
|
||||
yAxis: { type: 'value', name: '评分', min: 0, max: 5 },
|
||||
series: [
|
||||
{
|
||||
name: '口碑分',
|
||||
type: 'line',
|
||||
data: scoreTrend.map((item) => item.reputationScore),
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: isSingle ? 10 : 6,
|
||||
},
|
||||
{
|
||||
name: '体验分',
|
||||
type: 'line',
|
||||
data: scoreTrend.map((item) => item.experienceScore),
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: isSingle ? 10 : 6,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
watch(
|
||||
() => filteredShopList.value.length,
|
||||
(length) => {
|
||||
@@ -232,23 +426,74 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(() => searchParams.granularity, () => handleSearch());
|
||||
onMounted(() => { shopList.value = getMockShopList(); shopPagination.total = shopList.value.length; handleSearch(); window.addEventListener('resize', () => scoreChart?.resize()); });
|
||||
watch(
|
||||
() => searchParams.granularity,
|
||||
() => handleSearch()
|
||||
);
|
||||
onMounted(() => {
|
||||
shopList.value = getMockShopList();
|
||||
shopPagination.total = shopList.value.length;
|
||||
handleSearch();
|
||||
window.addEventListener('resize', () => scoreChart?.resize());
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trade-operation-analysis-shop { padding: 20px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; font-size: 16px; font-weight: 600; }
|
||||
.search-container { margin-bottom: 16px; }
|
||||
.search-form { display: flex; align-items: center; flex-wrap: wrap; }
|
||||
.search-form :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }
|
||||
.granularity-group { flex-wrap: wrap; }
|
||||
.shop-list { margin-bottom: 20px; }
|
||||
.score-setting { margin-bottom: 20px; }
|
||||
.chart-container { margin: 0 0 20px; }
|
||||
.chart { width: 100%; height: 400px; }
|
||||
.score-detail { margin-top: 20px; }
|
||||
.score-item { padding: 2px 8px; border-radius: 10px; display: inline-block; }
|
||||
.score-item.warning { background-color: #fde2e2; color: #f56c6c; }
|
||||
.pagination-container { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.trade-operation-analysis-shop {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.setting-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.search-container {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.granularity-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.shop-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.chart-container {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
.score-detail {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.score-item {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
.score-item.warning {
|
||||
background-color: #fde2e2;
|
||||
color: #f56c6c;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="anchor-edit-dialog">
|
||||
<el-dialog :title="(formData.id ? '修改' : '添加') + '主播'" v-model="isShowDialog" width="769px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" size="default" label-width="90px">
|
||||
<el-row :gutter="35">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="主播姓名" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入主播姓名" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="formData.phone" placeholder="请输入联系电话" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="工号" prop="code">
|
||||
<el-input v-model="formData.code" placeholder="请输入工号" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态" clearable style="width: 100%">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="4" placeholder="请输入备注" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="onCancel" size="default">取 消</el-button>
|
||||
<el-button type="primary" @click="onSubmit" size="default" :loading="loading">
|
||||
{{ formData.id ? '修 改' : '新 增' }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, nextTick } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { addAnchor, updateAnchor, getAnchorOne } from '/@/api/trade/operation/setting/anchor';
|
||||
|
||||
interface DialogFormData {
|
||||
id?: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
code: string;
|
||||
status: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
isShowDialog: false,
|
||||
formData: {
|
||||
id: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
status: 1,
|
||||
remark: '',
|
||||
} as DialogFormData,
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: '主播姓名不能为空', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '主播姓名长度在 2 到 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '联系电话不能为空', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' },
|
||||
],
|
||||
code: [{ required: true, message: '工号不能为空', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const { loading, isShowDialog, formData } = toRefs(state);
|
||||
|
||||
const openDialog = async (row?: DialogFormData) => {
|
||||
resetForm();
|
||||
|
||||
if (row && row.id) {
|
||||
try {
|
||||
state.loading = true;
|
||||
const res = await getAnchorOne({ id: String(row.id) });
|
||||
if (res.data) {
|
||||
state.formData = {
|
||||
id: String(res.data.id),
|
||||
name: res.data.name || '',
|
||||
phone: res.data.phone || '',
|
||||
code: res.data.code || '',
|
||||
status: res.data.status ?? 1,
|
||||
remark: res.data.remark || '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取主播详情失败');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
state.isShowDialog = true;
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
state.isShowDialog = false;
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
state.loading = true;
|
||||
|
||||
// 确保id是字符串类型
|
||||
const submitData = {
|
||||
...state.formData,
|
||||
id: state.formData.id ? String(state.formData.id) : undefined,
|
||||
};
|
||||
|
||||
if (submitData.id) {
|
||||
await updateAnchor(submitData);
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await addAnchor(submitData);
|
||||
ElMessage.success('添加成功');
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
state.formData = {
|
||||
id: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
status: 1,
|
||||
remark: '',
|
||||
};
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
closeDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
217
src/views/trade/operation/setting/anchor/index.vue
Normal file
217
src/views/trade/operation/setting/anchor/index.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="trade-operation-setting-anchor">
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" class="mb20">
|
||||
<el-form-item label="主播姓名">
|
||||
<el-input v-model="searchForm.name" placeholder="请输入主播姓名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="searchForm.phone" placeholder="请输入联系电话" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="工号">
|
||||
<el-input v-model="searchForm.code" placeholder="请输入工号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable size="default" style="width: 160px">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch" :loading="tableData.loading">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="handleReset" :disabled="tableData.loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="onOpenAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt20">
|
||||
<el-table v-loading="tableData.loading" :data="tableData.data" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="200" />
|
||||
<el-table-column prop="name" label="主播姓名" />
|
||||
<el-table-column prop="phone" label="联系电话" />
|
||||
<el-table-column prop="code" label="工号" />
|
||||
<el-table-column prop="statusName" label="状态" width="100" />
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" text @click="onOpenEdit(row)">
|
||||
<el-icon><EditPen /></el-icon>修改
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" text @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="tableData.param.pageNum"
|
||||
v-model:page-size="tableData.param.pageSize"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
class="mt20"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<EditAnchor ref="editAnchorRef" @refresh="getList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Search, Refresh, Plus, EditPen, Delete } from '@element-plus/icons-vue';
|
||||
import { getAnchorList, deleteAnchor } from '/@/api/trade/operation/setting/anchor';
|
||||
import EditAnchor from './component/editAnchor.vue';
|
||||
|
||||
interface TableDataItem {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
code: string;
|
||||
status: number;
|
||||
statusName: string;
|
||||
remark: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const searchForm = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const tableData = reactive({
|
||||
loading: false,
|
||||
data: [] as TableDataItem[],
|
||||
total: 0,
|
||||
param: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const editAnchorRef = ref();
|
||||
|
||||
const handleSearch = () => {
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.name = '';
|
||||
searchForm.phone = '';
|
||||
searchForm.code = '';
|
||||
searchForm.status = undefined;
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
tableData.param.pageSize = size;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (current: number) => {
|
||||
tableData.param.pageNum = current;
|
||||
getList();
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getList = async () => {
|
||||
try {
|
||||
tableData.loading = true;
|
||||
const res = await getAnchorList({
|
||||
...tableData.param,
|
||||
name: searchForm.name || undefined,
|
||||
phone: searchForm.phone || undefined,
|
||||
code: searchForm.code || undefined,
|
||||
status: searchForm.status,
|
||||
});
|
||||
if (res && res.data) {
|
||||
tableData.data = (res.data.list || []).map((item: any) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
}));
|
||||
tableData.total = res.data.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取主播列表失败');
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenAdd = () => {
|
||||
editAnchorRef.value?.openDialog();
|
||||
};
|
||||
|
||||
const onOpenEdit = (row: TableDataItem) => {
|
||||
editAnchorRef.value?.openDialog(row);
|
||||
};
|
||||
|
||||
const handleDelete = async (row: TableDataItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除主播 "${row.name}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteAnchor({ id: String(row.id) });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.trade-operation-setting-anchor {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mb20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '修改直播账号' : '新增直播账号'" width="500px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||
<el-form-item label="平台" prop="platform">
|
||||
<el-select v-model="formData.platform" placeholder="请选择平台" style="width: 100%">
|
||||
<el-option label="抖音" value="抖音" />
|
||||
<el-option label="快手" value="快手" />
|
||||
<el-option label="视频号" value="视频号" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号名称" prop="accountName">
|
||||
<el-input v-model="formData.accountName" placeholder="请输入账号名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号ID" prop="accountId">
|
||||
<el-input v-model="formData.accountId" placeholder="请输入账号ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">
|
||||
{{ isEdit ? '修改' : '新增' }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
createLiveAccount,
|
||||
getLiveAccountDetail,
|
||||
updateLiveAccount,
|
||||
type LiveAccountSaveParams,
|
||||
} from '/@/api/trade/operation/setting/live-account';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
interface LiveAccountFormData extends LiveAccountSaveParams {}
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const formData = reactive<LiveAccountFormData>({
|
||||
id: '',
|
||||
platform: '',
|
||||
accountName: '',
|
||||
accountId: '',
|
||||
status: 1,
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
|
||||
accountName: [{ required: true, message: '请输入账号名称', trigger: 'blur' }],
|
||||
accountId: [{ required: true, message: '请输入账号ID', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = '';
|
||||
formData.platform = '';
|
||||
formData.accountName = '';
|
||||
formData.accountId = '';
|
||||
formData.status = 1;
|
||||
formData.remark = '';
|
||||
};
|
||||
|
||||
const fillForm = (data: Partial<LiveAccountFormData>) => {
|
||||
formData.id = data.id ? String(data.id) : '';
|
||||
formData.platform = data.platform || '';
|
||||
formData.accountName = data.accountName || '';
|
||||
formData.accountId = data.accountId || '';
|
||||
formData.status = data.status ?? 1;
|
||||
formData.remark = data.remark || '';
|
||||
};
|
||||
|
||||
const openDialog = async (row?: { id?: string }) => {
|
||||
resetForm();
|
||||
isEdit.value = !!row?.id;
|
||||
|
||||
if (!row?.id) {
|
||||
dialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
// 详情加载失败时由当前弹窗给出更易懂的业务提示。
|
||||
const res = await getLiveAccountDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||
if (res?.data) {
|
||||
fillForm(res.data);
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取直播账号详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
const payload: LiveAccountSaveParams = {
|
||||
id: formData.id || undefined,
|
||||
platform: formData.platform,
|
||||
accountName: formData.accountName,
|
||||
accountId: formData.accountId,
|
||||
status: formData.status,
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
// 提交失败提示交给当前弹窗自己处理,避免和 request.ts 的统一报错重复。
|
||||
if (isEdit.value) {
|
||||
await updateLiveAccount(payload, { errorMode: 'page' });
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await createLiveAccount(payload, { errorMode: 'page' });
|
||||
ElMessage.success('新增成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '修改失败' : '新增失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
251
src/views/trade/operation/setting/live-account/index.vue
Normal file
251
src/views/trade/operation/setting/live-account/index.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="trade-operation-setting-live-account">
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" class="mb20">
|
||||
<el-form-item label="平台">
|
||||
<el-select v-model="searchForm.platform" placeholder="请选择平台" clearable style="width: 120px">
|
||||
<el-option label="抖音" :value="'抖音'" />
|
||||
<el-option label="快手" :value="'快手'" />
|
||||
<el-option label="视频号" :value="'视频号'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号名称">
|
||||
<el-input v-model="searchForm.accountName" placeholder="请输入账号名称" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号ID">
|
||||
<el-input v-model="searchForm.accountId" placeholder="请输入账号ID" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 120px">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch" :loading="tableData.loading" class="mr10">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="handleReset" :disabled="tableData.loading" class="mr10">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="onOpenAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt20">
|
||||
<el-table v-loading="tableData.loading" :data="tableData.data" style="width: 100%" stripe border empty-text="暂无直播账号数据">
|
||||
<el-table-column prop="id" label="ID" width="200" align="center" />
|
||||
<el-table-column prop="platform" label="平台" min-width="100" />
|
||||
<el-table-column prop="accountName" label="账号名称" min-width="150" />
|
||||
<el-table-column prop="accountId" label="账号ID" min-width="150" />
|
||||
<el-table-column prop="statusName" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.statusName }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" text @click="onOpenEdit(row)" class="mr5">
|
||||
<el-icon><EditPen /></el-icon>修改
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" text @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="tableData.param.pageNum"
|
||||
v-model:page-size="tableData.param.pageSize"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
class="mt20 flex justify-end"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<EditLiveAccount ref="editLiveAccountRef" @refresh="getList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Search, Refresh, Plus, EditPen, Delete } from '@element-plus/icons-vue';
|
||||
import { deleteLiveAccount, getLiveAccountList } from '/@/api/trade/operation/setting/live-account';
|
||||
import EditLiveAccount from './component/editLiveAccount.vue';
|
||||
|
||||
interface LiveAccountItem {
|
||||
id: string;
|
||||
platform: string;
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
status: number;
|
||||
statusName: string;
|
||||
remark: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const editLiveAccountRef = ref<InstanceType<typeof EditLiveAccount>>();
|
||||
|
||||
const searchForm = reactive({
|
||||
platform: '',
|
||||
accountName: '',
|
||||
accountId: '',
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const tableData = reactive({
|
||||
loading: false,
|
||||
data: [] as LiveAccountItem[],
|
||||
total: 0,
|
||||
param: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const getList = async () => {
|
||||
try {
|
||||
tableData.loading = true;
|
||||
// 列表失败文案由当前页面决定,避免和全局请求报错同时出现。
|
||||
const res = await getLiveAccountList(
|
||||
{
|
||||
...tableData.param,
|
||||
platform: searchForm.platform || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
accountId: searchForm.accountId || undefined,
|
||||
status: searchForm.status,
|
||||
},
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
if (res && res.data) {
|
||||
tableData.data = (res.data.list || []).map((item: any) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
}));
|
||||
tableData.total = res.data.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取直播账号列表失败');
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.platform = '';
|
||||
searchForm.accountName = '';
|
||||
searchForm.accountId = '';
|
||||
searchForm.status = undefined;
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
tableData.param.pageSize = size;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (current: number) => {
|
||||
tableData.param.pageNum = current;
|
||||
getList();
|
||||
};
|
||||
|
||||
const onOpenAdd = () => {
|
||||
editLiveAccountRef.value?.openDialog();
|
||||
};
|
||||
|
||||
const onOpenEdit = (row: LiveAccountItem) => {
|
||||
editLiveAccountRef.value?.openDialog(row);
|
||||
};
|
||||
|
||||
const handleDelete = async (row: LiveAccountItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除直播账号「${row.accountName}」吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteLiveAccount({ id: row.id }, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.trade-operation-setting-live-account {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mb20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.mr10 {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mr5 {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '修改排班' : '新增排班'" width="680px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="主播" prop="anchorId">
|
||||
<el-select v-model="formData.anchorId" placeholder="请选择主播" filterable style="width: 100%">
|
||||
<el-option v-for="item in anchorOptions" :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="accountId">
|
||||
<el-select v-model="formData.accountId" placeholder="请选择直播账号" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in liveAccountOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.platform} / ${item.accountName}`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker v-model="formData.startTime" type="datetime" placeholder="请选择开始时间" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker v-model="formData.endTime" type="datetime" placeholder="请选择结束时间" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
|
||||
<el-option label="待直播" :value="0" />
|
||||
<el-option label="直播中" :value="1" />
|
||||
<el-option label="已结束" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品ID" prop="productId">
|
||||
<el-input-number v-model="formData.productId" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="订单ID" prop="orderId">
|
||||
<el-input-number v-model="formData.orderId" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">提交</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { getAnchorList } from '/@/api/trade/operation/setting/anchor';
|
||||
import { getLiveAccountList } from '/@/api/trade/operation/setting/live-account';
|
||||
import { createSchedule, getScheduleDetail, updateSchedule, type ScheduleSaveParams } from '/@/api/trade/operation/setting/scheduling';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
interface AnchorOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface LiveAccountOption {
|
||||
id: string;
|
||||
platform: string;
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const anchorOptions = ref<AnchorOption[]>([]);
|
||||
const liveAccountOptions = ref<LiveAccountOption[]>([]);
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
anchorId: undefined as string | undefined,
|
||||
accountId: undefined as string | undefined,
|
||||
productId: 0,
|
||||
orderId: 0,
|
||||
startTime: undefined as Date | undefined,
|
||||
endTime: undefined as Date | undefined,
|
||||
status: 0,
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
anchorId: [{ required: true, message: '请选择主播', trigger: 'change' }],
|
||||
accountId: [{ required: true, message: '请选择直播账号', trigger: 'change' }],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = '';
|
||||
formData.anchorId = undefined;
|
||||
formData.accountId = undefined;
|
||||
formData.productId = 0;
|
||||
formData.orderId = 0;
|
||||
formData.startTime = undefined;
|
||||
formData.endTime = undefined;
|
||||
formData.status = 0;
|
||||
formData.remark = '';
|
||||
};
|
||||
|
||||
const loadOptions = async () => {
|
||||
const [anchorRes, liveAccountRes] = await Promise.all([
|
||||
getAnchorList({ pageNum: 1, pageSize: 9999 }),
|
||||
getLiveAccountList({ pageNum: 1, pageSize: 9999 }),
|
||||
]);
|
||||
|
||||
anchorOptions.value = (anchorRes?.data?.list || []).map((item: any) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || `主播${item.id}`,
|
||||
}));
|
||||
|
||||
liveAccountOptions.value = (liveAccountRes?.data?.list || []).map((item: any) => ({
|
||||
id: String(item.id),
|
||||
platform: item.platform || '',
|
||||
accountName: item.accountName || `账号${item.id}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const openDialog = async (row?: { id?: string }) => {
|
||||
resetForm();
|
||||
isEdit.value = !!row?.id;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
await loadOptions();
|
||||
|
||||
if (row?.id) {
|
||||
// 详情请求失败时,这个弹窗希望给出更明确的页面语义提示。
|
||||
const res = await getScheduleDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||
const detail = res?.data;
|
||||
if (detail) {
|
||||
formData.id = String(detail.id);
|
||||
formData.anchorId = detail.anchorId ? String(detail.anchorId) : undefined;
|
||||
formData.accountId = detail.accountId ? String(detail.accountId) : undefined;
|
||||
formData.productId = detail.productId || 0;
|
||||
formData.orderId = detail.orderId || 0;
|
||||
formData.startTime = detail.startTime ? new Date(detail.startTime) : undefined;
|
||||
formData.endTime = detail.endTime ? new Date(detail.endTime) : undefined;
|
||||
formData.status = detail.status ?? 0;
|
||||
formData.remark = detail.remark || '';
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '获取排班详情失败' : '加载排班基础数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
const payload: ScheduleSaveParams = {
|
||||
id: formData.id || undefined,
|
||||
anchorId: formData.anchorId as string,
|
||||
accountId: formData.accountId as string,
|
||||
productId: formData.productId || 0,
|
||||
orderId: formData.orderId || 0,
|
||||
startTime: (formData.startTime as Date).toISOString(),
|
||||
endTime: (formData.endTime as Date).toISOString(),
|
||||
status: formData.status,
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
// 提交失败文案由弹窗自己控制,避免接口层和弹窗层重复报错。
|
||||
if (isEdit.value) {
|
||||
await updateSchedule(payload, { errorMode: 'page' });
|
||||
ElMessage.success('修改排班成功');
|
||||
} else {
|
||||
await createSchedule(payload, { errorMode: 'page' });
|
||||
ElMessage.success('新增排班成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '修改排班失败' : '新增排班失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
241
src/views/trade/operation/setting/scheduling/index.vue
Normal file
241
src/views/trade/operation/setting/scheduling/index.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="trade-operation-setting-scheduling">
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" class="mb20">
|
||||
<el-form-item label="主播名">
|
||||
<el-input v-model="searchForm.anchorName" placeholder="请输入主播名" clearable style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="直播账号名">
|
||||
<el-input v-model="searchForm.accountName" placeholder="请输入直播账号名" clearable style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 140px">
|
||||
<el-option label="待直播" :value="0" />
|
||||
<el-option label="直播中" :value="1" />
|
||||
<el-option label="已结束" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch" :loading="tableData.loading">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="handleReset" :disabled="tableData.loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="onOpenAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增排班
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt20">
|
||||
<el-table v-loading="tableData.loading" :data="tableData.data" style="width: 100%" stripe border>
|
||||
<el-table-column label="排序" width="80" align="center">
|
||||
<template #default="scope">
|
||||
{{ (tableData.param.pageNum - 1) * tableData.param.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="anchorName" label="主播名" min-width="140" />
|
||||
<el-table-column prop="accountName" label="直播账号名" min-width="160" />
|
||||
<el-table-column prop="startTime" label="开始时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.startTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="endTime" label="结束时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.endTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="statusName" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)">{{ row.statusName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" text @click="onOpenEdit(row)">
|
||||
<el-icon><EditPen /></el-icon>修改
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" text @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="tableData.param.pageNum"
|
||||
v-model:page-size="tableData.param.pageSize"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
class="mt20"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<EditSchedule ref="editScheduleRef" @refresh="getList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Search, Refresh, Plus, EditPen, Delete } from '@element-plus/icons-vue';
|
||||
import { deleteSchedule, getScheduleList } from '/@/api/trade/operation/setting/scheduling';
|
||||
import EditSchedule from './component/editSchedule.vue';
|
||||
|
||||
interface ScheduleItem {
|
||||
id: string;
|
||||
anchorId: string;
|
||||
anchorName: string;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
status: number;
|
||||
statusName: string;
|
||||
remark: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const editScheduleRef = ref<InstanceType<typeof EditSchedule>>();
|
||||
|
||||
const searchForm = reactive({
|
||||
anchorName: '',
|
||||
accountName: '',
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const tableData = reactive({
|
||||
loading: false,
|
||||
data: [] as ScheduleItem[],
|
||||
total: 0,
|
||||
param: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
if (!timestamp) return '';
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getStatusTagType = (status: number): 'success' | 'info' | 'warning' => {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'info';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const getList = async () => {
|
||||
try {
|
||||
tableData.loading = true;
|
||||
// 列表失败文案由当前页面决定,避免和 request.ts 的全局错误提示重复。
|
||||
const res = await getScheduleList(
|
||||
{
|
||||
...tableData.param,
|
||||
anchorName: searchForm.anchorName || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
status: searchForm.status,
|
||||
} as any,
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
const scheduleData = res?.data;
|
||||
if (scheduleData) {
|
||||
tableData.data = (scheduleData.list || []).map((item: any) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
anchorId: String(item.anchorId),
|
||||
anchorName: item.anchorName || '',
|
||||
accountId: String(item.accountId),
|
||||
accountName: item.accountName || '',
|
||||
}));
|
||||
tableData.total = scheduleData.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取排班列表失败');
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.anchorName = '';
|
||||
searchForm.accountName = '';
|
||||
searchForm.status = undefined;
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
tableData.param.pageSize = size;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (current: number) => {
|
||||
tableData.param.pageNum = current;
|
||||
getList();
|
||||
};
|
||||
|
||||
const onOpenAdd = () => {
|
||||
editScheduleRef.value?.openDialog();
|
||||
};
|
||||
|
||||
const onOpenEdit = (row: ScheduleItem) => {
|
||||
editScheduleRef.value?.openDialog(row);
|
||||
};
|
||||
|
||||
const handleDelete = async (row: ScheduleItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除排班「${row.id}」吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteSchedule({ id: row.id }, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.trade-operation-setting-scheduling {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mb20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user