FAYO
commited on
Commit
·
77b0e0f
1
Parent(s):
460d4ca
model
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- docker/Dockerfile +25 -0
- frontend/.env +1 -0
- frontend/.eslintrc.cjs +15 -0
- frontend/.gitignore +30 -0
- frontend/.prettierrc.json +8 -0
- frontend/env.d.ts +1 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +50 -0
- frontend/public/favicon.ico +0 -0
- frontend/src/App.vue +11 -0
- frontend/src/api/base.ts +16 -0
- frontend/src/api/dashboard.ts +29 -0
- frontend/src/api/digitalHuman.ts +12 -0
- frontend/src/api/llm.ts +28 -0
- frontend/src/api/product.ts +120 -0
- frontend/src/api/streamerInfo.ts +87 -0
- frontend/src/api/streamingRoom.ts +253 -0
- frontend/src/api/system.ts +18 -0
- frontend/src/api/user.ts +56 -0
- frontend/src/assets/github.svg +1 -0
- frontend/src/assets/logo.png +0 -0
- frontend/src/components/AslideComponent.vue +132 -0
- frontend/src/components/BarChartComponent.vue +64 -0
- frontend/src/components/BreadCrumb.vue +35 -0
- frontend/src/components/FileUpload.vue +169 -0
- frontend/src/components/InfoDialogComponents.vue +328 -0
- frontend/src/components/LineChartComponent.vue +127 -0
- frontend/src/components/MessageComponent.vue +88 -0
- frontend/src/components/NavbarComponent.vue +78 -0
- frontend/src/components/StreamerInfoComponent.vue +231 -0
- frontend/src/components/VideoComponent.vue +103 -0
- frontend/src/layouts/BaseLayout.vue +41 -0
- frontend/src/main.ts +29 -0
- frontend/src/router/index.ts +183 -0
- frontend/src/stores/userToken.ts +26 -0
- frontend/src/style/index.scss +17 -0
- frontend/src/utils/navbar.ts +4 -0
- frontend/src/views/digital-human/DigitalHumanEditDialogView.vue +96 -0
- frontend/src/views/digital-human/DigitalHumanView.vue +155 -0
- frontend/src/views/error/NotFound.vue +7 -0
- frontend/src/views/home/HomeView.vue +160 -0
- frontend/src/views/login/LoginView.vue +170 -0
- frontend/src/views/order/OrderView.vue +90 -0
- frontend/src/views/product/ProductEditView.vue +284 -0
- frontend/src/views/product/ProductListView.vue +250 -0
- frontend/src/views/streaming/StreamingOnAirView.vue +433 -0
- frontend/src/views/streaming/StreamingRoomListView.vue +160 -0
- frontend/src/views/streaming/StreamingRoomeEditView.vue +544 -0
- frontend/src/views/system/SystemPluginsView.vue +89 -0
docker/Dockerfile
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM pytorch/pytorch:2.1.2-cuda12.1-cudnn8-devel
|
2 |
+
|
3 |
+
LABEL MAINTAINER="HinGwen.Wong"
|
4 |
+
|
5 |
+
# 设置时区
|
6 |
+
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
7 |
+
echo 'Asia/Shanghai' > /etc/timezone
|
8 |
+
|
9 |
+
# 切换阿里源并安装必须的系统库
|
10 |
+
RUN sed -i s/archive.ubuntu.com/mirrors.aliyun.com/g /etc/apt/sources.list \
|
11 |
+
&& sed -i s/security.ubuntu.com/mirrors.aliyun.com/g /etc/apt/sources.list \
|
12 |
+
&& apt-get update -y \
|
13 |
+
&& apt-get install -y --no-install-recommends wget git libgl1 libglib2.0-0 unzip libpq-dev \
|
14 |
+
&& apt-get clean \
|
15 |
+
&& rm -rf /var/lib/apt/lists/*
|
16 |
+
|
17 |
+
COPY . /workspace/Streamer-Sales
|
18 |
+
WORKDIR /workspace/Streamer-Sales
|
19 |
+
|
20 |
+
ENV HF_ENDPOINT="https://hf-mirror.com"
|
21 |
+
ENV LANG="en_US.UTF-8"
|
22 |
+
|
23 |
+
# 安装必备依赖环境
|
24 |
+
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
|
25 |
+
&& pip install --no-cache-dir -r requirements.txt
|
frontend/.env
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
VITE_BASE_SERVER_URL = 'http://127.0.0.1:8000/api/v1'
|
frontend/.eslintrc.cjs
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-env node */
|
2 |
+
require('@rushstack/eslint-patch/modern-module-resolution')
|
3 |
+
|
4 |
+
module.exports = {
|
5 |
+
root: true,
|
6 |
+
'extends': [
|
7 |
+
'plugin:vue/vue3-essential',
|
8 |
+
'eslint:recommended',
|
9 |
+
'@vue/eslint-config-typescript',
|
10 |
+
'@vue/eslint-config-prettier/skip-formatting'
|
11 |
+
],
|
12 |
+
parserOptions: {
|
13 |
+
ecmaVersion: 'latest'
|
14 |
+
}
|
15 |
+
}
|
frontend/.gitignore
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Logs
|
2 |
+
logs
|
3 |
+
*.log
|
4 |
+
npm-debug.log*
|
5 |
+
yarn-debug.log*
|
6 |
+
yarn-error.log*
|
7 |
+
pnpm-debug.log*
|
8 |
+
lerna-debug.log*
|
9 |
+
|
10 |
+
node_modules
|
11 |
+
.DS_Store
|
12 |
+
dist
|
13 |
+
dist-ssr
|
14 |
+
coverage
|
15 |
+
*.local
|
16 |
+
|
17 |
+
/cypress/videos/
|
18 |
+
/cypress/screenshots/
|
19 |
+
|
20 |
+
# Editor directories and files
|
21 |
+
.vscode/*
|
22 |
+
!.vscode/extensions.json
|
23 |
+
.idea
|
24 |
+
*.suo
|
25 |
+
*.ntvs*
|
26 |
+
*.njsproj
|
27 |
+
*.sln
|
28 |
+
*.sw?
|
29 |
+
|
30 |
+
*.tsbuildinfo
|
frontend/.prettierrc.json
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"$schema": "https://json.schemastore.org/prettierrc",
|
3 |
+
"semi": false,
|
4 |
+
"tabWidth": 2,
|
5 |
+
"singleQuote": true,
|
6 |
+
"printWidth": 100,
|
7 |
+
"trailingComma": "none"
|
8 |
+
}
|
frontend/env.d.ts
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
/// <reference types="vite/client" />
|
frontend/index.html
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" href="/favicon.ico" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>销冠——卖货主播大模型</title>
|
8 |
+
</head>
|
9 |
+
<body>
|
10 |
+
<div id="app"></div>
|
11 |
+
<script type="module" src="/src/main.ts"></script>
|
12 |
+
</body>
|
13 |
+
</html>
|
frontend/package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
frontend/package.json
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "frontend",
|
3 |
+
"version": "0.0.0",
|
4 |
+
"private": true,
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "run-p type-check \"build-only {@}\" --",
|
9 |
+
"preview": "vite preview",
|
10 |
+
"test:unit": "vitest",
|
11 |
+
"build-only": "vite build",
|
12 |
+
"type-check": "vue-tsc --build --force",
|
13 |
+
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
14 |
+
"format": "prettier --write src/"
|
15 |
+
},
|
16 |
+
"dependencies": {
|
17 |
+
"@vueuse/core": "^11.0.1",
|
18 |
+
"axios": "^1.7.7",
|
19 |
+
"echarts": "^5.5.1",
|
20 |
+
"element-plus": "^2.7.8",
|
21 |
+
"md-editor-v3": "^4.18.1",
|
22 |
+
"pinia": "^2.1.7",
|
23 |
+
"pinia-plugin-persistedstate": "^3.2.1",
|
24 |
+
"sass": "^1.77.8",
|
25 |
+
"vue": "^3.4.29",
|
26 |
+
"vue-router": "^4.3.3",
|
27 |
+
"xgplayer": "^3.0.19"
|
28 |
+
},
|
29 |
+
"devDependencies": {
|
30 |
+
"@rushstack/eslint-patch": "^1.8.0",
|
31 |
+
"@tsconfig/node20": "^20.1.4",
|
32 |
+
"@types/jsdom": "^21.1.7",
|
33 |
+
"@types/node": "^20.14.5",
|
34 |
+
"@vitejs/plugin-vue": "^5.0.5",
|
35 |
+
"@vue/eslint-config-prettier": "^9.0.0",
|
36 |
+
"@vue/eslint-config-typescript": "^13.0.0",
|
37 |
+
"@vue/test-utils": "^2.4.6",
|
38 |
+
"@vue/tsconfig": "^0.5.1",
|
39 |
+
"eslint": "^8.57.0",
|
40 |
+
"eslint-plugin-vue": "^9.23.0",
|
41 |
+
"jsdom": "^24.1.0",
|
42 |
+
"npm-run-all2": "^6.2.0",
|
43 |
+
"prettier": "^3.2.5",
|
44 |
+
"typescript": "~5.4.0",
|
45 |
+
"vite": "^5.3.1",
|
46 |
+
"vite-plugin-vue-devtools": "^7.3.1",
|
47 |
+
"vitest": "^1.6.0",
|
48 |
+
"vue-tsc": "^2.0.21"
|
49 |
+
}
|
50 |
+
}
|
frontend/public/favicon.ico
ADDED
frontend/src/App.vue
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { RouterView } from 'vue-router'
|
3 |
+
</script>
|
4 |
+
|
5 |
+
<template>
|
6 |
+
<div>
|
7 |
+
<router-view />
|
8 |
+
</div>
|
9 |
+
</template>
|
10 |
+
|
11 |
+
<style lang="scss" scoped></style>
|
frontend/src/api/base.ts
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import axios from 'axios'
|
2 |
+
|
3 |
+
const request_handler = axios.create({
|
4 |
+
// baseURL: import.meta.env.BASE_SERVER_URL
|
5 |
+
})
|
6 |
+
|
7 |
+
interface ResultPackage<T> {
|
8 |
+
success: boolean
|
9 |
+
code: number
|
10 |
+
message: string
|
11 |
+
data: T
|
12 |
+
timestamp: number
|
13 |
+
}
|
14 |
+
|
15 |
+
export { request_handler }
|
16 |
+
export { type ResultPackage }
|
frontend/src/api/dashboard.ts
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
2 |
+
|
3 |
+
interface DashboardItem {
|
4 |
+
registeredBrandNum: number //入驻品牌方
|
5 |
+
productNum: number //商品数
|
6 |
+
dailyActivity: number //日活
|
7 |
+
todayOrder: number //订单量
|
8 |
+
totalSales: number //销售额
|
9 |
+
conversionRate: number //转化率
|
10 |
+
|
11 |
+
orderNumList: number[] // 订单量
|
12 |
+
totalSalesList: number[] // 销售额
|
13 |
+
newUserList: number[] // 新增用户
|
14 |
+
activityUserList: number[] // 活跃用户
|
15 |
+
|
16 |
+
knowledgeBasesNum: number // 知识库数量
|
17 |
+
digitalHumanNum: number // 数字人数量
|
18 |
+
LiveRoomNum: number // 直播间数量
|
19 |
+
}
|
20 |
+
|
21 |
+
// 获取主控大屏信息
|
22 |
+
const getDashboardInfoRequest = () => {
|
23 |
+
return request_handler<ResultPackage<DashboardItem>>({
|
24 |
+
method: 'GET',
|
25 |
+
url: '/dashboard'
|
26 |
+
})
|
27 |
+
}
|
28 |
+
|
29 |
+
export { getDashboardInfoRequest, type DashboardItem }
|
frontend/src/api/digitalHuman.ts
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler } from '@/api/base'
|
2 |
+
|
3 |
+
// 生成数字人视频接口
|
4 |
+
const genDigitalHuamnVideoRequest = (streamerId_: number, salesDoc_: string) => {
|
5 |
+
return request_handler({
|
6 |
+
method: 'POST',
|
7 |
+
url: '/digital-human/gen',
|
8 |
+
data: { streamerId: streamerId_, salesDoc: salesDoc_ }
|
9 |
+
})
|
10 |
+
}
|
11 |
+
|
12 |
+
export { genDigitalHuamnVideoRequest }
|
frontend/src/api/llm.ts
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
2 |
+
import { header_authorization } from '@/api/user'
|
3 |
+
|
4 |
+
// 获取后端主播信息
|
5 |
+
const genSalesDocRequest = (productId: number, streamerId: number) => {
|
6 |
+
return request_handler<ResultPackage<string>>({
|
7 |
+
method: 'GET',
|
8 |
+
url: '/llm/gen_sales_doc',
|
9 |
+
params: { streamer_id: streamerId, product_id: productId },
|
10 |
+
headers: {
|
11 |
+
Authorization: header_authorization.value
|
12 |
+
}
|
13 |
+
})
|
14 |
+
}
|
15 |
+
|
16 |
+
// 使用说明书总结生成商品信息接口
|
17 |
+
const genProductInfoByLlmRequest = (productId: number) => {
|
18 |
+
return request_handler({
|
19 |
+
method: 'GET',
|
20 |
+
url: '/llm/gen_product_info',
|
21 |
+
params: { product_id: productId },
|
22 |
+
headers: {
|
23 |
+
Authorization: header_authorization.value
|
24 |
+
}
|
25 |
+
})
|
26 |
+
}
|
27 |
+
|
28 |
+
export { genSalesDocRequest, genProductInfoByLlmRequest }
|
frontend/src/api/product.ts
ADDED
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
2 |
+
import type { StreamerInfo } from '@/api/streamerInfo'
|
3 |
+
import { header_authorization } from '@/api/user'
|
4 |
+
|
5 |
+
// 调用商品信息接口数据结构定义
|
6 |
+
type ProductListType = {
|
7 |
+
currentPage?: number // 当前页号
|
8 |
+
pageSize?: number // 每页记录数
|
9 |
+
productName?: string // 商品名称
|
10 |
+
product_class?: string // 商品分类
|
11 |
+
}
|
12 |
+
|
13 |
+
interface ProductItem {
|
14 |
+
user_id: number // User 识别号,用于区分不用的用户
|
15 |
+
request_id: string // 请求 ID
|
16 |
+
|
17 |
+
product_id: number
|
18 |
+
product_name: string
|
19 |
+
product_class: string
|
20 |
+
heighlights: string
|
21 |
+
image_path: string
|
22 |
+
instruction: string
|
23 |
+
departure_place: string
|
24 |
+
delivery_company: string
|
25 |
+
selling_price: number
|
26 |
+
amount: number
|
27 |
+
upload_date: string
|
28 |
+
delete: boolean
|
29 |
+
}
|
30 |
+
|
31 |
+
interface ProductData {
|
32 |
+
product_list: ProductItem[]
|
33 |
+
currentPage: number
|
34 |
+
pageSize: number
|
35 |
+
totalSize: number
|
36 |
+
}
|
37 |
+
|
38 |
+
// 查询接口
|
39 |
+
const productListRequest = (params_: ProductListType) => {
|
40 |
+
return request_handler<ResultPackage<ProductData>>({
|
41 |
+
method: 'GET',
|
42 |
+
url: '/products/list',
|
43 |
+
params: params_,
|
44 |
+
headers: {
|
45 |
+
Authorization: header_authorization.value
|
46 |
+
}
|
47 |
+
})
|
48 |
+
}
|
49 |
+
|
50 |
+
// 查询指定商品的信息接口
|
51 |
+
const getProductByIdRequest = async (productId: string) => {
|
52 |
+
return request_handler<ResultPackage<ProductItem>>({
|
53 |
+
method: 'GET',
|
54 |
+
url: `/products/info/${productId}`,
|
55 |
+
headers: {
|
56 |
+
Authorization: header_authorization.value
|
57 |
+
}
|
58 |
+
})
|
59 |
+
}
|
60 |
+
|
61 |
+
// 添加或者更新商品接口
|
62 |
+
const productCreadeOrEditRequest = (params: ProductItem) => {
|
63 |
+
if (params.product_id === 0) {
|
64 |
+
// 新增商品
|
65 |
+
return request_handler<ResultPackage<ProductData>>({
|
66 |
+
method: 'POST', // 新增
|
67 |
+
url: '/products/create',
|
68 |
+
data: params,
|
69 |
+
headers: {
|
70 |
+
Authorization: header_authorization.value
|
71 |
+
}
|
72 |
+
})
|
73 |
+
} else {
|
74 |
+
// 修改商品
|
75 |
+
return request_handler<ResultPackage<ProductData>>({
|
76 |
+
method: 'PUT', // 新增
|
77 |
+
url: `/products/edit/${params.product_id}`,
|
78 |
+
data: params,
|
79 |
+
headers: {
|
80 |
+
Authorization: header_authorization.value
|
81 |
+
}
|
82 |
+
})
|
83 |
+
}
|
84 |
+
}
|
85 |
+
|
86 |
+
// 删除商品接口
|
87 |
+
const deleteProductByIdRequest = async (productId: number) => {
|
88 |
+
return request_handler<ResultPackage<string>>({
|
89 |
+
method: 'DELETE',
|
90 |
+
url: `/products/delete/${productId}`,
|
91 |
+
headers: {
|
92 |
+
Authorization: header_authorization.value
|
93 |
+
}
|
94 |
+
})
|
95 |
+
}
|
96 |
+
|
97 |
+
// 根据 ID 获取说明书内容
|
98 |
+
const genProductInstructionContentRequest = (instructionPath_: string) => {
|
99 |
+
// TODO 后续直接使用 axios 获取
|
100 |
+
return request_handler({
|
101 |
+
method: 'POST',
|
102 |
+
url: '/products/instruction',
|
103 |
+
data: { instructionPath: instructionPath_ },
|
104 |
+
headers: {
|
105 |
+
Authorization: header_authorization.value
|
106 |
+
}
|
107 |
+
})
|
108 |
+
}
|
109 |
+
|
110 |
+
export {
|
111 |
+
type ProductItem,
|
112 |
+
type StreamerInfo,
|
113 |
+
type ProductListType,
|
114 |
+
type ProductData,
|
115 |
+
productListRequest,
|
116 |
+
productCreadeOrEditRequest,
|
117 |
+
getProductByIdRequest,
|
118 |
+
deleteProductByIdRequest,
|
119 |
+
genProductInstructionContentRequest
|
120 |
+
}
|
frontend/src/api/streamerInfo.ts
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
2 |
+
import { header_authorization } from '@/api/user'
|
3 |
+
|
4 |
+
interface StreamerInfo {
|
5 |
+
user_id: number
|
6 |
+
|
7 |
+
streamer_id: number
|
8 |
+
name: string
|
9 |
+
value: string
|
10 |
+
character: string
|
11 |
+
avatar: string
|
12 |
+
|
13 |
+
tts_weight_tag: string
|
14 |
+
tts_reference_audio: string
|
15 |
+
tts_reference_sentence: string
|
16 |
+
|
17 |
+
poster_image: string
|
18 |
+
base_mp4_path: string
|
19 |
+
|
20 |
+
delete: boolean
|
21 |
+
}
|
22 |
+
|
23 |
+
// 获取后端主播信息
|
24 |
+
const streamerInfoListRequest = () => {
|
25 |
+
return request_handler<ResultPackage<StreamerInfo[]>>({
|
26 |
+
method: 'GET',
|
27 |
+
url: '/streamer/list',
|
28 |
+
headers: {
|
29 |
+
Authorization: header_authorization.value
|
30 |
+
}
|
31 |
+
})
|
32 |
+
}
|
33 |
+
|
34 |
+
// 获取特定主播信息
|
35 |
+
const streamerDetailInfoRequest = (streamerId: number) => {
|
36 |
+
return request_handler<ResultPackage<StreamerInfo>>({
|
37 |
+
method: 'GET',
|
38 |
+
url: `/streamer/info/${streamerId}`,
|
39 |
+
headers: {
|
40 |
+
Authorization: header_authorization.value
|
41 |
+
}
|
42 |
+
})
|
43 |
+
}
|
44 |
+
|
45 |
+
// 更新特定主播信息
|
46 |
+
const streamerEditDetailRequest = async (streamerItem: StreamerInfo) => {
|
47 |
+
if (typeof streamerItem.streamer_id != 'number' || streamerItem.streamer_id === 0) {
|
48 |
+
// 新建
|
49 |
+
console.info(streamerItem)
|
50 |
+
return request_handler<ResultPackage<number>>({
|
51 |
+
method: 'POST',
|
52 |
+
url: '/streamer/create',
|
53 |
+
data: streamerItem,
|
54 |
+
headers: {
|
55 |
+
Authorization: header_authorization.value
|
56 |
+
}
|
57 |
+
})
|
58 |
+
} else {
|
59 |
+
return request_handler<ResultPackage<number>>({
|
60 |
+
method: 'PUT',
|
61 |
+
url: `/streamer/edit/${streamerItem.streamer_id}`,
|
62 |
+
data: streamerItem,
|
63 |
+
headers: {
|
64 |
+
Authorization: header_authorization.value
|
65 |
+
}
|
66 |
+
})
|
67 |
+
}
|
68 |
+
}
|
69 |
+
|
70 |
+
// 删除特定主播信息
|
71 |
+
const deleteStreamerByIdRequest = (streamerId: number) => {
|
72 |
+
return request_handler<ResultPackage<string>>({
|
73 |
+
method: 'DELETE',
|
74 |
+
url: `/streamer/delete/${streamerId}`,
|
75 |
+
headers: {
|
76 |
+
Authorization: header_authorization.value
|
77 |
+
}
|
78 |
+
})
|
79 |
+
}
|
80 |
+
|
81 |
+
export {
|
82 |
+
type StreamerInfo,
|
83 |
+
streamerInfoListRequest,
|
84 |
+
streamerDetailInfoRequest,
|
85 |
+
streamerEditDetailRequest,
|
86 |
+
deleteStreamerByIdRequest
|
87 |
+
}
|
frontend/src/api/streamingRoom.ts
ADDED
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
2 |
+
import type { StreamerInfo, ProductItem } from '@/api/product'
|
3 |
+
import { header_authorization } from '@/api/user'
|
4 |
+
|
5 |
+
interface StreamingRoomProductList {
|
6 |
+
product_id: number
|
7 |
+
product_info: ProductItem
|
8 |
+
start_time: string
|
9 |
+
sales_doc: string
|
10 |
+
start_video: string
|
11 |
+
selected: boolean
|
12 |
+
}
|
13 |
+
|
14 |
+
interface messageItem {
|
15 |
+
role: string
|
16 |
+
userId: number
|
17 |
+
userName: string
|
18 |
+
avatar: string
|
19 |
+
message: string
|
20 |
+
send_time: string
|
21 |
+
}
|
22 |
+
|
23 |
+
interface StreamingRoomStatusItem {
|
24 |
+
streamerInfo: StreamerInfo
|
25 |
+
conversation: messageItem[]
|
26 |
+
currentProductInfo: ProductItem
|
27 |
+
currentStreamerVideo: string
|
28 |
+
currentProductIndex: number
|
29 |
+
start_time: string
|
30 |
+
currentPoductStartTime: string
|
31 |
+
finalProduct: boolean
|
32 |
+
live_status: number
|
33 |
+
}
|
34 |
+
|
35 |
+
interface StreamingRoomInfo {
|
36 |
+
room_id: number
|
37 |
+
room_poster: string
|
38 |
+
name: string
|
39 |
+
streamer_id: number
|
40 |
+
background_image: string
|
41 |
+
prohibited_words_id: number
|
42 |
+
product_list: StreamingRoomProductList[]
|
43 |
+
streamer_info: StreamerInfo
|
44 |
+
status: StreamingRoomStatusItem
|
45 |
+
}
|
46 |
+
|
47 |
+
interface RoomProductData {
|
48 |
+
currentPage: number
|
49 |
+
pageSize: number
|
50 |
+
totalSize: number
|
51 |
+
product_list: StreamingRoomProductList[]
|
52 |
+
}
|
53 |
+
|
54 |
+
interface RoomProductItem {
|
55 |
+
name: string
|
56 |
+
id: number
|
57 |
+
image_path: string
|
58 |
+
sales_doc: string
|
59 |
+
start_video: string
|
60 |
+
start_time: string
|
61 |
+
selected: boolean
|
62 |
+
heighlights: string
|
63 |
+
selling_price: number
|
64 |
+
}
|
65 |
+
|
66 |
+
interface RoomDetailItem {
|
67 |
+
currentPage: number
|
68 |
+
pageSize: number
|
69 |
+
totalSize: number
|
70 |
+
product_list: StreamingRoomProductList[]
|
71 |
+
streamer_info: StreamerInfo
|
72 |
+
room_id: number
|
73 |
+
name: string
|
74 |
+
room_poster: string
|
75 |
+
streamer_id: number
|
76 |
+
background_image: string
|
77 |
+
prohibited_words_id: number
|
78 |
+
status: StreamingRoomStatusItem
|
79 |
+
}
|
80 |
+
|
81 |
+
// 获取后端主播信息
|
82 |
+
const streamerRoomListRequest = () => {
|
83 |
+
return request_handler<ResultPackage<StreamingRoomInfo[]>>({
|
84 |
+
method: 'GET',
|
85 |
+
url: '/streaming-room/list',
|
86 |
+
headers: {
|
87 |
+
Authorization: header_authorization.value
|
88 |
+
}
|
89 |
+
})
|
90 |
+
}
|
91 |
+
|
92 |
+
// 获取特定直播间的详情
|
93 |
+
const roomDetailRequest = (roomId_: string, currentPage_: number, pageSize_: number) => {
|
94 |
+
return request_handler<ResultPackage<RoomDetailItem>>({
|
95 |
+
method: 'GET',
|
96 |
+
url: `/streaming-room/info/${roomId_}`,
|
97 |
+
params: { currentPage: currentPage_, pageSize: pageSize_ },
|
98 |
+
headers: {
|
99 |
+
Authorization: header_authorization.value
|
100 |
+
}
|
101 |
+
})
|
102 |
+
}
|
103 |
+
|
104 |
+
// 添加商品的时候,获取所有商品,内含选中商品
|
105 |
+
const roomPorductAddListRequest = (roomId_: number, currentPage_: number, pageSize_: number) => {
|
106 |
+
return request_handler<ResultPackage<RoomProductData>>({
|
107 |
+
method: 'GET',
|
108 |
+
url: `/streaming-room/product-edit-list/${roomId_}`,
|
109 |
+
headers: {
|
110 |
+
Authorization: header_authorization.value
|
111 |
+
}
|
112 |
+
})
|
113 |
+
}
|
114 |
+
|
115 |
+
// 添加或者更新直播间接口
|
116 |
+
const RoomCreadeOrEditRequest = async (params: RoomDetailItem) => {
|
117 |
+
if (params.room_id === 0) {
|
118 |
+
// 新建
|
119 |
+
return request_handler<ResultPackage<number>>({
|
120 |
+
method: 'POST',
|
121 |
+
url: '/streaming-room/create',
|
122 |
+
data: params,
|
123 |
+
headers: {
|
124 |
+
Authorization: header_authorization.value
|
125 |
+
}
|
126 |
+
})
|
127 |
+
} else {
|
128 |
+
// 编辑
|
129 |
+
return request_handler<ResultPackage<number>>({
|
130 |
+
method: 'PUT',
|
131 |
+
url: `/streaming-room/edit/${params.room_id}`,
|
132 |
+
data: params,
|
133 |
+
headers: {
|
134 |
+
Authorization: header_authorization.value
|
135 |
+
}
|
136 |
+
})
|
137 |
+
}
|
138 |
+
}
|
139 |
+
|
140 |
+
// 获取直播间实时信息:主播目前的视频地址,目前讲述的商品信息,聊天信息
|
141 |
+
const onAirRoomStartRequest = (roomId_: number) => {
|
142 |
+
return request_handler<ResultPackage<StreamingRoomStatusItem>>({
|
143 |
+
method: 'POST',
|
144 |
+
url: `/streaming-room/online/${roomId_}`,
|
145 |
+
headers: {
|
146 |
+
Authorization: header_authorization.value
|
147 |
+
}
|
148 |
+
})
|
149 |
+
}
|
150 |
+
|
151 |
+
// 获取直播间实时信息:主播目前的视频地址,目前讲述的商品信息,聊天信息
|
152 |
+
const onAirRoomInfoRequest = (roomId: number) => {
|
153 |
+
return request_handler<ResultPackage<StreamingRoomStatusItem>>({
|
154 |
+
method: 'GET',
|
155 |
+
url: `/streaming-room/live-info/${roomId}`,
|
156 |
+
headers: {
|
157 |
+
Authorization: header_authorization.value
|
158 |
+
}
|
159 |
+
})
|
160 |
+
}
|
161 |
+
|
162 |
+
// 用户发起对话
|
163 |
+
const onAirRoomChatRequest = async (roomId_: number, message_: string) => {
|
164 |
+
return request_handler<ResultPackage<messageItem>>({
|
165 |
+
method: 'PUT',
|
166 |
+
url: '/streaming-room/chat',
|
167 |
+
data: { roomId: roomId_, message: message_ },
|
168 |
+
headers: {
|
169 |
+
Authorization: header_authorization.value
|
170 |
+
}
|
171 |
+
})
|
172 |
+
}
|
173 |
+
|
174 |
+
// 下一个商品
|
175 |
+
const onAirRoomNextProductRequest = async (roomId_: number) => {
|
176 |
+
return request_handler<ResultPackage<StreamingRoomStatusItem>>({
|
177 |
+
method: 'POST',
|
178 |
+
url: `/streaming-room/next-product/${roomId_}`,
|
179 |
+
headers: {
|
180 |
+
Authorization: header_authorization.value
|
181 |
+
}
|
182 |
+
})
|
183 |
+
}
|
184 |
+
|
185 |
+
// 删除特定直播间信息
|
186 |
+
const deleteStreamingRoomByIdRequest = (roomId: number) => {
|
187 |
+
return request_handler<ResultPackage<string>>({
|
188 |
+
method: 'DELETE',
|
189 |
+
url: `/streaming-room/delete/${roomId}`,
|
190 |
+
headers: {
|
191 |
+
Authorization: header_authorization.value
|
192 |
+
}
|
193 |
+
})
|
194 |
+
}
|
195 |
+
|
196 |
+
// 发送浏览器录音音频文件到服务器,用于 ASR
|
197 |
+
const sendAudioToServer = async (blob: Blob) => {
|
198 |
+
const formData = new FormData()
|
199 |
+
formData.append('file', blob, 'recording.webm')
|
200 |
+
|
201 |
+
return request_handler<ResultPackage<string>>({
|
202 |
+
method: 'POST',
|
203 |
+
url: '/upload/file',
|
204 |
+
data: formData,
|
205 |
+
headers: {
|
206 |
+
Authorization: header_authorization.value
|
207 |
+
}
|
208 |
+
})
|
209 |
+
}
|
210 |
+
|
211 |
+
// 获取 ASR 结果
|
212 |
+
const genAsrResult = async (roomId_: number, asrFileUrl_: string) => {
|
213 |
+
return request_handler<ResultPackage<string>>({
|
214 |
+
method: 'POST',
|
215 |
+
url: '/streaming-room/asr',
|
216 |
+
data: { roomId: roomId_, asrFileUrl: asrFileUrl_ },
|
217 |
+
headers: {
|
218 |
+
Authorization: header_authorization.value
|
219 |
+
}
|
220 |
+
})
|
221 |
+
}
|
222 |
+
|
223 |
+
// 下播
|
224 |
+
const streamRoomOffline = (roomId_: number) => {
|
225 |
+
return request_handler<ResultPackage<string>>({
|
226 |
+
method: 'PUT',
|
227 |
+
url: `/streaming-room/offline/${roomId_}`,
|
228 |
+
headers: {
|
229 |
+
Authorization: header_authorization.value
|
230 |
+
}
|
231 |
+
})
|
232 |
+
}
|
233 |
+
|
234 |
+
export {
|
235 |
+
type StreamingRoomInfo,
|
236 |
+
type RoomProductItem,
|
237 |
+
type RoomProductData,
|
238 |
+
type RoomDetailItem,
|
239 |
+
type StreamingRoomStatusItem,
|
240 |
+
type StreamingRoomProductList,
|
241 |
+
streamerRoomListRequest,
|
242 |
+
roomDetailRequest,
|
243 |
+
roomPorductAddListRequest,
|
244 |
+
RoomCreadeOrEditRequest,
|
245 |
+
onAirRoomInfoRequest,
|
246 |
+
onAirRoomChatRequest,
|
247 |
+
onAirRoomNextProductRequest,
|
248 |
+
deleteStreamingRoomByIdRequest,
|
249 |
+
sendAudioToServer,
|
250 |
+
streamRoomOffline,
|
251 |
+
genAsrResult,
|
252 |
+
onAirRoomStartRequest
|
253 |
+
}
|
frontend/src/api/system.ts
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
2 |
+
|
3 |
+
interface SystemPluginsInfo {
|
4 |
+
plugin_name: string
|
5 |
+
describe: string
|
6 |
+
avatar_color: string
|
7 |
+
enabled: boolean
|
8 |
+
}
|
9 |
+
|
10 |
+
// 删除特定主播信息
|
11 |
+
const getSystemPluginsInfoRequest = () => {
|
12 |
+
return request_handler<ResultPackage<SystemPluginsInfo[]>>({
|
13 |
+
method: 'GET',
|
14 |
+
url: '/plugins_info'
|
15 |
+
})
|
16 |
+
}
|
17 |
+
|
18 |
+
export { type SystemPluginsInfo, getSystemPluginsInfoRequest }
|
frontend/src/api/user.ts
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { computed } from 'vue'
|
2 |
+
import { request_handler, type ResultPackage } from '@/api/base'
|
3 |
+
import { type TokenItem, useTokenStore } from '@/stores/userToken'
|
4 |
+
|
5 |
+
// 调用登录接口数据结构定义
|
6 |
+
type loginFormType = {
|
7 |
+
username: string
|
8 |
+
password: string
|
9 |
+
vertify_code?: string
|
10 |
+
}
|
11 |
+
|
12 |
+
interface UserInfo {
|
13 |
+
user_id: number
|
14 |
+
username: string
|
15 |
+
avatar: string
|
16 |
+
email: string
|
17 |
+
create_time: string
|
18 |
+
}
|
19 |
+
|
20 |
+
// pinia 保存的 token
|
21 |
+
const tokenStore = useTokenStore()
|
22 |
+
|
23 |
+
// jwt
|
24 |
+
const header_authorization = computed(() => {
|
25 |
+
console.log('Update token')
|
26 |
+
return `${tokenStore.token.token_type} ${tokenStore.token.access_token}`
|
27 |
+
})
|
28 |
+
|
29 |
+
// 登录接口
|
30 |
+
const loginRequest = (loginForm: loginFormType) => {
|
31 |
+
const formData = new FormData()
|
32 |
+
formData.append('username', loginForm.username)
|
33 |
+
formData.append('password', loginForm.password)
|
34 |
+
|
35 |
+
return request_handler<TokenItem>({
|
36 |
+
method: 'POST',
|
37 |
+
url: '/user/login',
|
38 |
+
data: formData,
|
39 |
+
headers: {
|
40 |
+
'Content-Type': 'application/x-www-form-urlencoded'
|
41 |
+
}
|
42 |
+
})
|
43 |
+
}
|
44 |
+
|
45 |
+
// 获取用户信息接口
|
46 |
+
const getUserInfoRequest = async () => {
|
47 |
+
return request_handler<ResultPackage<UserInfo>>({
|
48 |
+
method: 'GET',
|
49 |
+
url: '/user/me',
|
50 |
+
headers: {
|
51 |
+
Authorization: header_authorization.value
|
52 |
+
}
|
53 |
+
})
|
54 |
+
}
|
55 |
+
|
56 |
+
export { loginRequest, header_authorization, getUserInfoRequest, type UserInfo }
|
frontend/src/assets/github.svg
ADDED
frontend/src/assets/logo.png
ADDED
frontend/src/components/AslideComponent.vue
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts" setup>
|
2 |
+
import { Menu as Present, User, Mic, Setting, House, ShoppingCart } from '@element-plus/icons-vue'
|
3 |
+
|
4 |
+
import { useRoute } from 'vue-router'
|
5 |
+
import { isCollapse } from '@/utils/navbar'
|
6 |
+
|
7 |
+
const route = useRoute()
|
8 |
+
</script>
|
9 |
+
|
10 |
+
<template>
|
11 |
+
<div>
|
12 |
+
<el-aside>
|
13 |
+
<el-menu :router="true" :default-active="route.fullPath" :collapse="isCollapse">
|
14 |
+
<!-- logo -->
|
15 |
+
<div class="logo">
|
16 |
+
<a herf="/">
|
17 |
+
<img src="@/assets/logo.png" alt="" />
|
18 |
+
</a>
|
19 |
+
</div>
|
20 |
+
<div class="logo">
|
21 |
+
<h1>销冠 - AI 卖货主播大模型</h1>
|
22 |
+
</div>
|
23 |
+
<div class="logo" style="line-height: 0">
|
24 |
+
<a href="https://github.com/PeterH0323/Streamer-Sales">
|
25 |
+
<img src="@/assets/github.svg" alt="github repo" class="github-img" />
|
26 |
+
</a>
|
27 |
+
</div>
|
28 |
+
|
29 |
+
<el-menu-item index="/home">
|
30 |
+
<el-icon> <House /> </el-icon> <span>首页</span>
|
31 |
+
</el-menu-item>
|
32 |
+
<el-sub-menu index="/product">
|
33 |
+
<template #title>
|
34 |
+
<el-icon> <present /> </el-icon><span>商品管理</span>
|
35 |
+
</template>
|
36 |
+
<el-menu-item index="/product/list"><span>商品列表</span></el-menu-item>
|
37 |
+
</el-sub-menu>
|
38 |
+
<el-sub-menu index="/digital_human">
|
39 |
+
<template #title>
|
40 |
+
<el-icon> <User /> </el-icon><span>数字人管理</span>
|
41 |
+
</template>
|
42 |
+
<el-menu-item index="/digital_human/list"><span>角色管理</span></el-menu-item>
|
43 |
+
</el-sub-menu>
|
44 |
+
<el-sub-menu index="/streaming">
|
45 |
+
<template #title>
|
46 |
+
<el-icon> <Mic /> </el-icon><span>直播管理</span>
|
47 |
+
</template>
|
48 |
+
<el-menu-item index="/streaming/overview">直播间管理<span></span></el-menu-item>
|
49 |
+
</el-sub-menu>
|
50 |
+
<el-sub-menu index="/order">
|
51 |
+
<template #title>
|
52 |
+
<el-icon> <ShoppingCart /> </el-icon><span>订单管理</span>
|
53 |
+
</template>
|
54 |
+
<el-menu-item index="/order/overview"><span>订单总览</span></el-menu-item>
|
55 |
+
</el-sub-menu>
|
56 |
+
<el-sub-menu index="/system">
|
57 |
+
<template #title>
|
58 |
+
<el-icon> <setting /> </el-icon><span>系统配置</span>
|
59 |
+
</template>
|
60 |
+
<el-menu-item index="/system/plugins"><span>组件状态</span></el-menu-item>
|
61 |
+
</el-sub-menu>
|
62 |
+
</el-menu>
|
63 |
+
</el-aside>
|
64 |
+
</div>
|
65 |
+
</template>
|
66 |
+
|
67 |
+
<style lang="scss" scoped>
|
68 |
+
// logo 样式
|
69 |
+
.logo {
|
70 |
+
display: flex;
|
71 |
+
justify-content: center;
|
72 |
+
align-items: center;
|
73 |
+
// text-decoration: none;
|
74 |
+
color: #000000;
|
75 |
+
h1 {
|
76 |
+
font-size: 15px;
|
77 |
+
}
|
78 |
+
img {
|
79 |
+
width: 50px;
|
80 |
+
height: 50px;
|
81 |
+
}
|
82 |
+
|
83 |
+
.github-img {
|
84 |
+
width: 30px;
|
85 |
+
height: 30px;
|
86 |
+
}
|
87 |
+
}
|
88 |
+
|
89 |
+
/* 菜单样式 */
|
90 |
+
.el-menu {
|
91 |
+
width: 200px;
|
92 |
+
background-color: #ffffff;
|
93 |
+
border-right: none; // 右边边界线取消
|
94 |
+
|
95 |
+
// 折叠侧边栏样式
|
96 |
+
// &.el-menu--collapse -> el-menu.el-menu--collapse
|
97 |
+
&.el-menu--collapse {
|
98 |
+
width: 60px; // 将宽度变小
|
99 |
+
|
100 |
+
& h1 {
|
101 |
+
display: none; // 折叠的时候隐藏 logo 隔壁的 h1 文字
|
102 |
+
}
|
103 |
+
}
|
104 |
+
}
|
105 |
+
|
106 |
+
/* 设置选中菜单项的背景色 */
|
107 |
+
.el-menu-item.is-active {
|
108 |
+
background-color: #337ecc !important;
|
109 |
+
color: #ffffff;
|
110 |
+
/* 圆角的半径 */
|
111 |
+
border-radius: 10px;
|
112 |
+
}
|
113 |
+
|
114 |
+
/* 修改菜单项的形状 */
|
115 |
+
.el-menu-item {
|
116 |
+
border-radius: 10px; /* 例如,增加圆角 */
|
117 |
+
background-color: #ffffff;
|
118 |
+
}
|
119 |
+
|
120 |
+
/* 在 hover 状态下应用 */
|
121 |
+
.el-menu-item:hover {
|
122 |
+
border-radius: 10px; /* 保持一致的圆角 */
|
123 |
+
background-color: #dedfe0;
|
124 |
+
}
|
125 |
+
|
126 |
+
/* 侧边栏样式配置 */
|
127 |
+
.el-aside {
|
128 |
+
background-color: #ffffff;
|
129 |
+
height: 98.5vh;
|
130 |
+
width: auto;
|
131 |
+
}
|
132 |
+
</style>
|
frontend/src/components/BarChartComponent.vue
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, onMounted } from 'vue'
|
3 |
+
import * as echarts from 'echarts'
|
4 |
+
|
5 |
+
// 创建一个引用来存储图表的 DOM 容器
|
6 |
+
const chartRef = ref(null)
|
7 |
+
|
8 |
+
const props = defineProps({
|
9 |
+
knowledgeBasesNum: {
|
10 |
+
type: Number,
|
11 |
+
default: 0
|
12 |
+
},
|
13 |
+
|
14 |
+
digitalHumanNum: {
|
15 |
+
type: Number,
|
16 |
+
default: 0
|
17 |
+
},
|
18 |
+
|
19 |
+
LiveRoomNum: {
|
20 |
+
type: Number,
|
21 |
+
default: 0
|
22 |
+
}
|
23 |
+
})
|
24 |
+
|
25 |
+
// 初始化图表的函数
|
26 |
+
const initChart = () => {
|
27 |
+
const chart = echarts.init(chartRef.value)
|
28 |
+
|
29 |
+
// 配置图表的选项
|
30 |
+
const option = {
|
31 |
+
title: {
|
32 |
+
text: '系统配置'
|
33 |
+
},
|
34 |
+
tooltip: {},
|
35 |
+
xAxis: {
|
36 |
+
data: ['知识库数量', '数字人数量', '直播间数量']
|
37 |
+
},
|
38 |
+
yAxis: {},
|
39 |
+
series: [
|
40 |
+
{
|
41 |
+
name: '数量',
|
42 |
+
type: 'bar',
|
43 |
+
data: [props.knowledgeBasesNum, props.digitalHumanNum, props.LiveRoomNum]
|
44 |
+
}
|
45 |
+
]
|
46 |
+
}
|
47 |
+
|
48 |
+
// 使用设置的配置项渲染图表
|
49 |
+
chart.setOption(option)
|
50 |
+
}
|
51 |
+
|
52 |
+
// 在组件挂载后初始化图表
|
53 |
+
onMounted(() => {
|
54 |
+
initChart()
|
55 |
+
})
|
56 |
+
</script>
|
57 |
+
|
58 |
+
<template>
|
59 |
+
<div ref="chartRef" style="width: auto; height: 400px"></div>
|
60 |
+
</template>
|
61 |
+
|
62 |
+
<style scoped>
|
63 |
+
/* 可以根据需要调整图表容器的样式 */
|
64 |
+
</style>
|
frontend/src/components/BreadCrumb.vue
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { useRoute, useRouter, type RouteRecordNormalized, type RouteLocationRaw } from 'vue-router'
|
3 |
+
|
4 |
+
const route = useRoute()
|
5 |
+
const router = useRouter()
|
6 |
+
|
7 |
+
const handleLink = (item: RouteRecordNormalized) => {
|
8 |
+
const { redirect, name, path } = item
|
9 |
+
if (redirect) {
|
10 |
+
router.push(redirect as RouteLocationRaw)
|
11 |
+
} else {
|
12 |
+
if (name) {
|
13 |
+
router.push({ name })
|
14 |
+
} else {
|
15 |
+
router.push({ path })
|
16 |
+
}
|
17 |
+
}
|
18 |
+
}
|
19 |
+
</script>
|
20 |
+
|
21 |
+
<template>
|
22 |
+
<el-breadcrumb separator="/">
|
23 |
+
<el-breadcrumb-item
|
24 |
+
v-for="(item, index) in route.matched"
|
25 |
+
:key="index"
|
26 |
+
:to="{ path: item.path }"
|
27 |
+
>
|
28 |
+
<a @click.prevent="handleLink(item)">
|
29 |
+
{{ item.meta.title }}
|
30 |
+
</a>
|
31 |
+
</el-breadcrumb-item>
|
32 |
+
</el-breadcrumb>
|
33 |
+
</template>
|
34 |
+
|
35 |
+
<style lang="scss" scoped></style>
|
frontend/src/components/FileUpload.vue
ADDED
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, watchEffect } from 'vue'
|
3 |
+
import { ElMessage, type UploadProps, type UploadProgressEvent } from 'element-plus'
|
4 |
+
import { Plus, Document } from '@element-plus/icons-vue'
|
5 |
+
|
6 |
+
import { header_authorization } from '@/api/user'
|
7 |
+
|
8 |
+
// 定义组件入参
|
9 |
+
const props = defineProps({
|
10 |
+
fileType: {
|
11 |
+
type: String,
|
12 |
+
default: 'image'
|
13 |
+
}
|
14 |
+
})
|
15 |
+
|
16 |
+
// 定义 和父组件通信的双向绑定 model
|
17 |
+
const modelFilePath = defineModel({ default: '' })
|
18 |
+
|
19 |
+
// 上传文件,上传后为本机内存地址,方便加载
|
20 |
+
const fileUrl = ref('')
|
21 |
+
watchEffect(() => {
|
22 |
+
// 用于在编辑模式下显示图片
|
23 |
+
fileUrl.value = modelFilePath.value
|
24 |
+
})
|
25 |
+
|
26 |
+
// 是否显示进度条
|
27 |
+
const isShowProgress = ref(false)
|
28 |
+
|
29 |
+
// 文件上传成功后的 callback
|
30 |
+
const handleFileUploadSuccess: UploadProps['onSuccess'] = (response, uploadFile) => {
|
31 |
+
// fileUrl.value = URL.createObjectURL(uploadFile.raw!) // 生成内存地址,方便加载
|
32 |
+
fileUrl.value = response.data
|
33 |
+
console.info(fileUrl.value)
|
34 |
+
|
35 |
+
modelFilePath.value = response.data // 更新父组件双向绑定的值
|
36 |
+
isShowProgress.value = false
|
37 |
+
}
|
38 |
+
|
39 |
+
// 文件上传前的校验 callback
|
40 |
+
const beforeFileUploadUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
41 |
+
console.log(rawFile)
|
42 |
+
|
43 |
+
if (props.fileType === 'image' && rawFile.type !== 'image/png' && rawFile.type !== 'image/jpeg') {
|
44 |
+
ElMessage.error('商品图片必须是 PNG / JPG 格式!')
|
45 |
+
return false
|
46 |
+
} else if (props.fileType === 'doc' && !rawFile.name.endsWith('.md')) {
|
47 |
+
ElMessage.error('商品说明书必须是 markdown 格式!')
|
48 |
+
return false
|
49 |
+
} else if (props.fileType === 'video' && rawFile.type !== 'video/mp4') {
|
50 |
+
ElMessage.error('主播视频必须是 mp4 格式!')
|
51 |
+
return false
|
52 |
+
} else if (props.fileType === 'audio' && rawFile.type !== 'audio/wav') {
|
53 |
+
ElMessage.error('主播音频必须是 wav 格式!')
|
54 |
+
return false
|
55 |
+
}
|
56 |
+
|
57 |
+
if (props.fileType === 'video' && rawFile.size / 1024 / 1024 > 20) {
|
58 |
+
ElMessage.error('主播视频文件大小不能超过 20MB!')
|
59 |
+
return false
|
60 |
+
} else if (props.fileType !== 'video' && rawFile.size / 1024 / 1024 > 2) {
|
61 |
+
ElMessage.error('文件大小不能超过 2MB!')
|
62 |
+
return false
|
63 |
+
}
|
64 |
+
|
65 |
+
isShowProgress.value = true
|
66 |
+
return true
|
67 |
+
}
|
68 |
+
|
69 |
+
// 文件上传成功后的 callback
|
70 |
+
const handleFileUploadFail: UploadProps['onError'] = (error: Error) => {
|
71 |
+
ElMessage.error('上传文件失败')
|
72 |
+
console.error(error)
|
73 |
+
isShowProgress.value = false
|
74 |
+
}
|
75 |
+
|
76 |
+
// 文件上传进度条
|
77 |
+
const uploadPercentage = ref(0)
|
78 |
+
|
79 |
+
// 文件上传进度回调
|
80 |
+
const handleUploadProgress = (evt: UploadProgressEvent) => {
|
81 |
+
uploadPercentage.value = Math.floor(evt.percent)
|
82 |
+
}
|
83 |
+
</script>
|
84 |
+
|
85 |
+
<template>
|
86 |
+
<div>
|
87 |
+
<el-progress v-show="isShowProgress" type="circle" :percentage="uploadPercentage" />
|
88 |
+
<!-- TODO 长时间上传后端会断开? -->
|
89 |
+
<el-upload
|
90 |
+
v-show="!isShowProgress"
|
91 |
+
class="avatar-uploader"
|
92 |
+
action="/upload/file"
|
93 |
+
:headers="{
|
94 |
+
Authorization: header_authorization
|
95 |
+
}"
|
96 |
+
method="post"
|
97 |
+
:drag="props.fileType !== 'video' && props.fileType !== 'audio'"
|
98 |
+
:multiple="false"
|
99 |
+
:show-file-list="false"
|
100 |
+
:on-success="handleFileUploadSuccess"
|
101 |
+
:before-upload="beforeFileUploadUpload"
|
102 |
+
:on-progress="handleUploadProgress"
|
103 |
+
:on-error="handleFileUploadFail"
|
104 |
+
>
|
105 |
+
<!-- 图片 -->
|
106 |
+
<img
|
107 |
+
v-if="fileUrl && props.fileType === 'image'"
|
108 |
+
:src="fileUrl"
|
109 |
+
class="avatar"
|
110 |
+
@load="isShowProgress = false"
|
111 |
+
/>
|
112 |
+
|
113 |
+
<!-- markdown 文档 -->
|
114 |
+
<el-icon
|
115 |
+
v-else-if="fileUrl && props.fileType === 'doc'"
|
116 |
+
:size="50"
|
117 |
+
class="avatar-uploader-icon"
|
118 |
+
>
|
119 |
+
<Document />
|
120 |
+
</el-icon>
|
121 |
+
|
122 |
+
<!-- 视频上传 -->
|
123 |
+
<el-button v-else-if="props.fileType === 'video' || props.fileType === 'audio'" type="danger">
|
124 |
+
{{ fileUrl === '' ? '上传' : '更换' }}{{ props.fileType === 'video' ? '视频' : '音频' }}
|
125 |
+
</el-button>
|
126 |
+
|
127 |
+
<!-- 拖动上传框 -->
|
128 |
+
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
|
129 |
+
</el-upload>
|
130 |
+
</div>
|
131 |
+
</template>
|
132 |
+
|
133 |
+
<style lang="scss" scoped>
|
134 |
+
// 上传图片
|
135 |
+
.avatar-uploader .avatar {
|
136 |
+
width: 178px;
|
137 |
+
height: 178px;
|
138 |
+
display: block;
|
139 |
+
}
|
140 |
+
</style>
|
141 |
+
|
142 |
+
<style lang="scss">
|
143 |
+
// 上传图片全局 css
|
144 |
+
.avatar-uploader .el-upload {
|
145 |
+
border: 1px dashed var(--el-border-color);
|
146 |
+
border-radius: 6px;
|
147 |
+
cursor: pointer;
|
148 |
+
position: relative;
|
149 |
+
overflow: hidden;
|
150 |
+
transition: var(--el-transition-duration-fast);
|
151 |
+
}
|
152 |
+
|
153 |
+
.avatar-uploader .el-upload:hover {
|
154 |
+
border-color: var(--el-color-primary);
|
155 |
+
}
|
156 |
+
|
157 |
+
.el-icon.avatar-uploader-icon {
|
158 |
+
font-size: 28px;
|
159 |
+
color: #8c939d;
|
160 |
+
width: 178px;
|
161 |
+
height: 178px;
|
162 |
+
text-align: center;
|
163 |
+
}
|
164 |
+
|
165 |
+
// 进度条
|
166 |
+
.el-progress--circle {
|
167 |
+
margin-right: 15px;
|
168 |
+
}
|
169 |
+
</style>
|
frontend/src/components/InfoDialogComponents.vue
ADDED
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, onBeforeUnmount } from 'vue'
|
3 |
+
import { ElMessage } from 'element-plus'
|
4 |
+
import { MdPreview } from 'md-editor-v3'
|
5 |
+
|
6 |
+
import 'md-editor-v3/lib/preview.css'
|
7 |
+
|
8 |
+
import { genProductInstructionContentRequest } from '@/api/product'
|
9 |
+
import { genSalesDocRequest } from '@/api/llm'
|
10 |
+
import VideoComponent from '@/components/VideoComponent.vue'
|
11 |
+
import { genDigitalHuamnVideoRequest } from '@/api/digitalHuman'
|
12 |
+
import { type StreamingRoomProductList } from '@/api/streamingRoom'
|
13 |
+
import { AxiosError } from 'axios'
|
14 |
+
|
15 |
+
const dialogFormVisible = ref(false)
|
16 |
+
|
17 |
+
// AI 生成后的 文案 or 数字人视频 值双向绑定
|
18 |
+
const modelGenValue = defineModel<StreamingRoomProductList[]>({
|
19 |
+
default: [] as StreamingRoomProductList[]
|
20 |
+
})
|
21 |
+
|
22 |
+
// 定义标题
|
23 |
+
const titleMap = { SalesDoc: '主播文案', Instruction: '说明书', DigitalHuman: '数字人视频' }
|
24 |
+
const title = ref('')
|
25 |
+
const titleName = ref('')
|
26 |
+
const infoValue = ref('')
|
27 |
+
const itemType = ref('')
|
28 |
+
const productId = ref(0)
|
29 |
+
const streamerId = ref(0)
|
30 |
+
const salesDoc = ref('')
|
31 |
+
const showItemInfoDialog = async (
|
32 |
+
titleName_: string,
|
33 |
+
itemType_: keyof typeof titleMap,
|
34 |
+
itemValue: string,
|
35 |
+
productId_: number,
|
36 |
+
streamerId_: number = 0,
|
37 |
+
salesDoc_: string = ''
|
38 |
+
) => {
|
39 |
+
title.value = titleMap[itemType_]
|
40 |
+
titleName.value = titleName_ + ' - '
|
41 |
+
itemType.value = itemType_
|
42 |
+
productId.value = productId_
|
43 |
+
streamerId.value = streamerId_
|
44 |
+
salesDoc.value = salesDoc_
|
45 |
+
|
46 |
+
dialogFormVisible.value = true
|
47 |
+
|
48 |
+
if (itemType_ === 'Instruction') {
|
49 |
+
// 请求接口获取说明书数据
|
50 |
+
try {
|
51 |
+
const { data } = await genProductInstructionContentRequest(itemValue)
|
52 |
+
if (data.code === 0) {
|
53 |
+
infoValue.value = data.data
|
54 |
+
} else {
|
55 |
+
ElMessage.error('获取说明书失败:' + data.message)
|
56 |
+
}
|
57 |
+
} catch (error: unknown) {
|
58 |
+
if (error instanceof AxiosError) {
|
59 |
+
ElMessage.error('获取说明书失败:' + error.message)
|
60 |
+
} else {
|
61 |
+
ElMessage.error('未知错误:' + error)
|
62 |
+
}
|
63 |
+
}
|
64 |
+
} else {
|
65 |
+
infoValue.value = itemValue
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
const handleSaveClick = () => {
|
70 |
+
// 更新双向绑定的值
|
71 |
+
dialogFormVisible.value = false
|
72 |
+
updateGenValue(infoValue.value)
|
73 |
+
}
|
74 |
+
|
75 |
+
// 是否正在生成文案标识
|
76 |
+
const isGenerating = ref(false)
|
77 |
+
|
78 |
+
const updateGenValue = (newValue: string) => {
|
79 |
+
let index = -1
|
80 |
+
// 更新与父组件双向绑定的值
|
81 |
+
for (let i = 0; i < modelGenValue.value.length; i++) {
|
82 |
+
// 根据返回数据继续添加商品
|
83 |
+
if (modelGenValue.value[i].product_id === productId.value) {
|
84 |
+
index = i
|
85 |
+
break
|
86 |
+
}
|
87 |
+
}
|
88 |
+
|
89 |
+
if (itemType.value === 'SalesDoc') {
|
90 |
+
modelGenValue.value[index].sales_doc = newValue
|
91 |
+
} else if (itemType.value === 'DigitalHuman') {
|
92 |
+
modelGenValue.value[index].start_video = newValue
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
// 生成进度条
|
97 |
+
const genPercentage = ref(0)
|
98 |
+
|
99 |
+
// 定义计时器句柄
|
100 |
+
let timerId: number | null = null
|
101 |
+
let totalGenSec: number = 1 // 生成时间
|
102 |
+
|
103 |
+
// 开始/停止生成进度条
|
104 |
+
const startGenProgress = () => {
|
105 |
+
genPercentage.value = 0
|
106 |
+
if (timerId) {
|
107 |
+
// 如果计时器正在运行,则清除计时器
|
108 |
+
clearInterval(timerId)
|
109 |
+
}
|
110 |
+
|
111 |
+
if (itemType.value === 'DigitalHuman') {
|
112 |
+
// 数字人生成时间
|
113 |
+
totalGenSec = 5 * 60
|
114 |
+
} else {
|
115 |
+
// 文案生成时间
|
116 |
+
totalGenSec = 10
|
117 |
+
}
|
118 |
+
|
119 |
+
// 启动计时器
|
120 |
+
timerId = window.setInterval(() => {
|
121 |
+
if (genPercentage.value < 99) {
|
122 |
+
genPercentage.value += parseFloat((100 / totalGenSec).toFixed(2))
|
123 |
+
}
|
124 |
+
|
125 |
+
if (genPercentage.value > 99) {
|
126 |
+
genPercentage.value = 99
|
127 |
+
}
|
128 |
+
}, 1000)
|
129 |
+
}
|
130 |
+
|
131 |
+
const stopGenProgress = () => {
|
132 |
+
if (timerId) {
|
133 |
+
// 如果计时器正在运行,则清除计时器
|
134 |
+
clearInterval(timerId)
|
135 |
+
}
|
136 |
+
}
|
137 |
+
|
138 |
+
// 生成数据人视频
|
139 |
+
const getDigitalHumanVideo = async () => {
|
140 |
+
if (salesDoc.value === '') {
|
141 |
+
ElMessage.error('需先生成文案')
|
142 |
+
return
|
143 |
+
}
|
144 |
+
|
145 |
+
isGenerating.value = true
|
146 |
+
ElMessage.success('正在生成,预计 3 分钟,请稍候')
|
147 |
+
ElMessage.warning('若未生成完成,请不要离开页面!')
|
148 |
+
|
149 |
+
startGenProgress()
|
150 |
+
try {
|
151 |
+
const { data } = await genDigitalHuamnVideoRequest(streamerId.value, salesDoc.value)
|
152 |
+
console.log(data)
|
153 |
+
if (data.code === 0) {
|
154 |
+
infoValue.value = data.data
|
155 |
+
updateGenValue(infoValue.value)
|
156 |
+
genPercentage.value = 100
|
157 |
+
ElMessage.success('生成数字人视频成功')
|
158 |
+
} else {
|
159 |
+
ElMessage.error('生成数字人视频失败:' + data.message)
|
160 |
+
}
|
161 |
+
} catch (error: unknown) {
|
162 |
+
if (error instanceof AxiosError) {
|
163 |
+
ElMessage.error('生成数字人视频失败:' + error.message)
|
164 |
+
} else {
|
165 |
+
ElMessage.error('未知错误:' + error)
|
166 |
+
}
|
167 |
+
}
|
168 |
+
isGenerating.value = false
|
169 |
+
stopGenProgress()
|
170 |
+
}
|
171 |
+
|
172 |
+
// 生成解说文案
|
173 |
+
const handleGenSalesDocClick = async () => {
|
174 |
+
isGenerating.value = true
|
175 |
+
ElMessage.success('正在生成,请稍候')
|
176 |
+
ElMessage.warning('若未生成完成,请不要离开页面!')
|
177 |
+
try {
|
178 |
+
startGenProgress()
|
179 |
+
|
180 |
+
const { data } = await genSalesDocRequest(Number(productId.value), Number(streamerId.value))
|
181 |
+
console.log(data)
|
182 |
+
if (data.code === 0) {
|
183 |
+
infoValue.value = data.data
|
184 |
+
updateGenValue(infoValue.value) // 更新与父组件双向绑定的值
|
185 |
+
ElMessage.success('生成文案成功')
|
186 |
+
} else {
|
187 |
+
ElMessage.error('生成文案失败:' + data.message)
|
188 |
+
}
|
189 |
+
} catch (error: unknown) {
|
190 |
+
if (error instanceof AxiosError) {
|
191 |
+
ElMessage.error('生成文案失败:' + error.message)
|
192 |
+
} else {
|
193 |
+
ElMessage.error('未知错误:' + error)
|
194 |
+
}
|
195 |
+
}
|
196 |
+
isGenerating.value = false
|
197 |
+
stopGenProgress()
|
198 |
+
}
|
199 |
+
|
200 |
+
// 在组件卸载前确保计时器被清除
|
201 |
+
onBeforeUnmount(() => {
|
202 |
+
if (timerId) {
|
203 |
+
clearInterval(timerId)
|
204 |
+
}
|
205 |
+
})
|
206 |
+
|
207 |
+
defineExpose({ showItemInfoDialog })
|
208 |
+
</script>
|
209 |
+
|
210 |
+
<template>
|
211 |
+
<div>
|
212 |
+
<!-- 显示说明书 or 文案 or 数字人视频-->
|
213 |
+
<el-dialog
|
214 |
+
v-model="dialogFormVisible"
|
215 |
+
:title="titleName + title"
|
216 |
+
width="1000"
|
217 |
+
top="5vh"
|
218 |
+
:close-on-press-escape="false"
|
219 |
+
:show-close="false"
|
220 |
+
>
|
221 |
+
<!-- 说明书 -->
|
222 |
+
<template v-if="itemType === 'Instruction'">
|
223 |
+
<div style="text-align: left">
|
224 |
+
<MdPreview editorId="preview-SalesDoc" :modelValue="infoValue" />
|
225 |
+
</div>
|
226 |
+
</template>
|
227 |
+
|
228 |
+
<!-- 主播文案 -->
|
229 |
+
<template v-else-if="itemType === 'SalesDoc'">
|
230 |
+
<div>
|
231 |
+
<el-input
|
232 |
+
type="textarea"
|
233 |
+
v-model="infoValue"
|
234 |
+
maxlength="2000"
|
235 |
+
:autosize="{ minRows: 20 }"
|
236 |
+
show-word-limit
|
237 |
+
/>
|
238 |
+
<div class="progress-item">
|
239 |
+
<el-progress v-show="isGenerating" :text-inside="true" :percentage="genPercentage" />
|
240 |
+
</div>
|
241 |
+
<div class="bottom-gen-btn">
|
242 |
+
<el-button @click="handleGenSalesDocClick" :loading="isGenerating" type="primary">
|
243 |
+
AI 生成
|
244 |
+
</el-button>
|
245 |
+
</div>
|
246 |
+
</div>
|
247 |
+
</template>
|
248 |
+
|
249 |
+
<!-- 数字人视频 -->
|
250 |
+
<template v-else-if="itemType === 'DigitalHuman'">
|
251 |
+
<div class="make-center">
|
252 |
+
<VideoComponent :src="infoValue" :key="infoValue" :height="600" />
|
253 |
+
</div>
|
254 |
+
|
255 |
+
<div class="progress-item">
|
256 |
+
<el-progress v-show="isGenerating" :text-inside="true" :percentage="genPercentage" />
|
257 |
+
</div>
|
258 |
+
|
259 |
+
<div class="bottom-gen-btn">
|
260 |
+
<el-button @click="getDigitalHumanVideo" :loading="isGenerating" type="primary">
|
261 |
+
AI 生成数字人视频
|
262 |
+
</el-button>
|
263 |
+
</div>
|
264 |
+
</template>
|
265 |
+
<template #footer>
|
266 |
+
<div class="dialog-footer bottom-gen-btn">
|
267 |
+
<!-- <el-button type="primary" @click="handelEditClick"> 编辑 </el-button> -->
|
268 |
+
|
269 |
+
<el-button
|
270 |
+
v-show="itemType !== 'Instruction'"
|
271 |
+
@click="handleSaveClick"
|
272 |
+
type="success"
|
273 |
+
:disabled="isGenerating"
|
274 |
+
>
|
275 |
+
保存
|
276 |
+
</el-button>
|
277 |
+
<el-button @click="dialogFormVisible = false" :disabled="isGenerating">关闭</el-button>
|
278 |
+
</div>
|
279 |
+
</template>
|
280 |
+
</el-dialog>
|
281 |
+
</div>
|
282 |
+
</template>
|
283 |
+
|
284 |
+
<style lang="scss" scoped>
|
285 |
+
// 每个表单底部 AI 生成按钮
|
286 |
+
.bottom-gen-btn {
|
287 |
+
margin-top: 15px;
|
288 |
+
display: flex;
|
289 |
+
justify-content: center;
|
290 |
+
align-items: center;
|
291 |
+
}
|
292 |
+
|
293 |
+
// 进度条
|
294 |
+
.progress-item {
|
295 |
+
display: flex;
|
296 |
+
justify-content: center;
|
297 |
+
align-items: center;
|
298 |
+
|
299 |
+
::v-deep(.el-progress) {
|
300 |
+
height: 30px;
|
301 |
+
width: 80%;
|
302 |
+
margin: 10px;
|
303 |
+
}
|
304 |
+
|
305 |
+
::v-deep(.el-progress-bar__outer) {
|
306 |
+
height: 16px !important;
|
307 |
+
}
|
308 |
+
}
|
309 |
+
|
310 |
+
.make-center {
|
311 |
+
display: flex;
|
312 |
+
justify-content: center;
|
313 |
+
align-items: center;
|
314 |
+
}
|
315 |
+
|
316 |
+
::v-deep(.el-input__wrapper) {
|
317 |
+
border-radius: 14px;
|
318 |
+
}
|
319 |
+
|
320 |
+
// 使用 ::v-deep 选择器来覆盖 el-dialog 的默认样式。
|
321 |
+
::v-deep(.el-dialog) {
|
322 |
+
border-radius: 10px;
|
323 |
+
padding: 20px;
|
324 |
+
|
325 |
+
--el-dialog-bg-color: #f7f8fa;
|
326 |
+
--el-dialog-title-font-size: 20px;
|
327 |
+
}
|
328 |
+
</style>
|
frontend/src/components/LineChartComponent.vue
ADDED
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, onMounted } from 'vue'
|
3 |
+
import * as echarts from 'echarts'
|
4 |
+
|
5 |
+
// 创建一个引用来存储图表的 DOM 容器
|
6 |
+
const chartRef = ref(null)
|
7 |
+
|
8 |
+
const props = defineProps({
|
9 |
+
orderNumList: {
|
10 |
+
type: Array
|
11 |
+
},
|
12 |
+
totalSalesList: {
|
13 |
+
type: Array
|
14 |
+
},
|
15 |
+
newUserList: {
|
16 |
+
type: Array
|
17 |
+
},
|
18 |
+
activityUserList: {
|
19 |
+
type: Array
|
20 |
+
}
|
21 |
+
})
|
22 |
+
|
23 |
+
// 初始化图表的函数
|
24 |
+
const initChart = () => {
|
25 |
+
const chart = echarts.init(chartRef.value)
|
26 |
+
|
27 |
+
// 配置图表的选项
|
28 |
+
const option = {
|
29 |
+
title: {
|
30 |
+
text: '订单和用户数量'
|
31 |
+
},
|
32 |
+
tooltip: {
|
33 |
+
trigger: 'axis',
|
34 |
+
axisPointer: {
|
35 |
+
type: 'cross',
|
36 |
+
label: {
|
37 |
+
backgroundColor: '#6a7985'
|
38 |
+
}
|
39 |
+
}
|
40 |
+
},
|
41 |
+
legend: {
|
42 |
+
data: ['订单量', '销售额', '新增用户', '活跃用户']
|
43 |
+
},
|
44 |
+
toolbox: {
|
45 |
+
feature: {
|
46 |
+
saveAsImage: {}
|
47 |
+
}
|
48 |
+
},
|
49 |
+
grid: {
|
50 |
+
left: '3%',
|
51 |
+
right: '4%',
|
52 |
+
bottom: '3%',
|
53 |
+
containLabel: true
|
54 |
+
},
|
55 |
+
xAxis: [
|
56 |
+
{
|
57 |
+
type: 'category',
|
58 |
+
boundaryGap: false,
|
59 |
+
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
60 |
+
}
|
61 |
+
],
|
62 |
+
yAxis: [
|
63 |
+
{
|
64 |
+
type: 'value'
|
65 |
+
}
|
66 |
+
],
|
67 |
+
series: [
|
68 |
+
{
|
69 |
+
name: '订单量',
|
70 |
+
type: 'line',
|
71 |
+
stack: 'Total',
|
72 |
+
areaStyle: {},
|
73 |
+
emphasis: {
|
74 |
+
focus: 'series'
|
75 |
+
},
|
76 |
+
data: props.orderNumList
|
77 |
+
},
|
78 |
+
{
|
79 |
+
name: '销售额',
|
80 |
+
type: 'line',
|
81 |
+
stack: 'Total',
|
82 |
+
areaStyle: {},
|
83 |
+
emphasis: {
|
84 |
+
focus: 'series'
|
85 |
+
},
|
86 |
+
data: props.totalSalesList
|
87 |
+
},
|
88 |
+
{
|
89 |
+
name: '新增用户',
|
90 |
+
type: 'line',
|
91 |
+
stack: 'Total',
|
92 |
+
areaStyle: {},
|
93 |
+
emphasis: {
|
94 |
+
focus: 'series'
|
95 |
+
},
|
96 |
+
data: props.newUserList
|
97 |
+
},
|
98 |
+
{
|
99 |
+
name: '活跃用户',
|
100 |
+
type: 'line',
|
101 |
+
stack: 'Total',
|
102 |
+
areaStyle: {},
|
103 |
+
emphasis: {
|
104 |
+
focus: 'series'
|
105 |
+
},
|
106 |
+
data: props.activityUserList
|
107 |
+
}
|
108 |
+
]
|
109 |
+
}
|
110 |
+
|
111 |
+
// 使用设置的配置项渲染图表
|
112 |
+
chart.setOption(option)
|
113 |
+
}
|
114 |
+
|
115 |
+
// 在组件挂载后初始化图表
|
116 |
+
onMounted(() => {
|
117 |
+
initChart()
|
118 |
+
})
|
119 |
+
</script>
|
120 |
+
|
121 |
+
<template>
|
122 |
+
<div ref="chartRef" style="width: auto; height: 400px"></div>
|
123 |
+
</template>
|
124 |
+
|
125 |
+
<style scoped>
|
126 |
+
/* 可以根据需要调整图表容器的样式 */
|
127 |
+
</style>
|
frontend/src/components/MessageComponent.vue
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { MdPreview } from 'md-editor-v3'
|
3 |
+
import 'md-editor-v3/lib/preview.css'
|
4 |
+
|
5 |
+
// 定义组件入参
|
6 |
+
const props = defineProps({
|
7 |
+
avatar: {
|
8 |
+
type: String,
|
9 |
+
default: ''
|
10 |
+
},
|
11 |
+
role: {
|
12 |
+
type: String,
|
13 |
+
default: ''
|
14 |
+
},
|
15 |
+
userName: {
|
16 |
+
type: String,
|
17 |
+
default: ''
|
18 |
+
},
|
19 |
+
message: {
|
20 |
+
type: String,
|
21 |
+
default: ''
|
22 |
+
},
|
23 |
+
datetime: {
|
24 |
+
type: String,
|
25 |
+
default: ''
|
26 |
+
}
|
27 |
+
})
|
28 |
+
</script>
|
29 |
+
|
30 |
+
<template>
|
31 |
+
<div class="message-block">
|
32 |
+
<el-row :gutter="0">
|
33 |
+
<el-col :span="2">
|
34 |
+
<div>
|
35 |
+
<el-avatar :src="props.avatar" />
|
36 |
+
</div>
|
37 |
+
</el-col>
|
38 |
+
<el-col :span="22">
|
39 |
+
<div class="user-info">
|
40 |
+
<!-- 标签 -->
|
41 |
+
<template v-if="props.role === 'streamer'">
|
42 |
+
<el-tag type="success" style="margin-right: 5px">主播</el-tag>
|
43 |
+
</template>
|
44 |
+
<!-- 用户名 -->
|
45 |
+
<div class="message-title">{{ props.userName }}</div>
|
46 |
+
<!-- 发送时间 -->
|
47 |
+
<div class="message-title">{{ props.datetime }}</div>
|
48 |
+
</div>
|
49 |
+
<div class="message-content">
|
50 |
+
<!-- 内容 -->
|
51 |
+
<template v-if="props.role === 'streamer'">
|
52 |
+
<MdPreview
|
53 |
+
style="background-color: aquamarine"
|
54 |
+
editorId="preview-SalesDoc"
|
55 |
+
:modelValue="props.message"
|
56 |
+
/>
|
57 |
+
</template>
|
58 |
+
<template v-else>
|
59 |
+
<p>{{ props.message }}</p>
|
60 |
+
</template>
|
61 |
+
</div>
|
62 |
+
</el-col>
|
63 |
+
</el-row>
|
64 |
+
</div>
|
65 |
+
</template>
|
66 |
+
|
67 |
+
<style scoped lang="scss">
|
68 |
+
.message-block {
|
69 |
+
margin-top: 20px;
|
70 |
+
}
|
71 |
+
|
72 |
+
.message-content {
|
73 |
+
background-color: aquamarine;
|
74 |
+
border-radius: 20px;
|
75 |
+
padding: 15px;
|
76 |
+
width: 600px; // 聊天信息的宽度
|
77 |
+
}
|
78 |
+
|
79 |
+
.user-info {
|
80 |
+
display: flex;
|
81 |
+
align-items: center;
|
82 |
+
margin-left: 8px;
|
83 |
+
|
84 |
+
.message-title {
|
85 |
+
margin-left: 8px;
|
86 |
+
}
|
87 |
+
}
|
88 |
+
</style>
|
frontend/src/components/NavbarComponent.vue
ADDED
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts" setup>
|
2 |
+
import { Fold, Expand } from '@element-plus/icons-vue'
|
3 |
+
import { ref, onMounted } from 'vue'
|
4 |
+
import { isCollapse } from '@/utils/navbar'
|
5 |
+
|
6 |
+
import BreadCrumb from '@/components/BreadCrumb.vue'
|
7 |
+
import { getUserInfoRequest, type UserInfo } from '@/api/user'
|
8 |
+
import { ElMessage } from 'element-plus'
|
9 |
+
import { AxiosError } from 'axios'
|
10 |
+
|
11 |
+
const userInfoItem = ref({} as UserInfo)
|
12 |
+
|
13 |
+
onMounted(async () => {
|
14 |
+
try {
|
15 |
+
const { data } = await getUserInfoRequest()
|
16 |
+
|
17 |
+
if (data.code === 0) {
|
18 |
+
userInfoItem.value = data.data
|
19 |
+
} else {
|
20 |
+
ElMessage.error('获取用户信息失败: ' + data.message)
|
21 |
+
}
|
22 |
+
} catch (error: unknown) {
|
23 |
+
if (error instanceof AxiosError) {
|
24 |
+
ElMessage.error('获取用户信息失败: ' + error.message)
|
25 |
+
} else {
|
26 |
+
ElMessage.error('未知错误:' + error)
|
27 |
+
}
|
28 |
+
}
|
29 |
+
})
|
30 |
+
</script>
|
31 |
+
|
32 |
+
<template>
|
33 |
+
<el-header>
|
34 |
+
<!-- 菜单折叠图标 -->
|
35 |
+
<el-icon @click="isCollapse = !isCollapse">
|
36 |
+
<Fold v-show="isCollapse" />
|
37 |
+
<Expand v-show="!isCollapse" />
|
38 |
+
</el-icon>
|
39 |
+
|
40 |
+
<!-- 导航栏左侧 -->
|
41 |
+
<!-- 面包屑 -->
|
42 |
+
<BreadCrumb />
|
43 |
+
|
44 |
+
<!-- <div> -->
|
45 |
+
<!-- 导航栏右边 -->
|
46 |
+
<!-- 退出登录 -->
|
47 |
+
<el-dropdown trigger="click">
|
48 |
+
<el-avatar :src="userInfoItem.avatar" />
|
49 |
+
<template #dropdown>
|
50 |
+
<el-dropdown-menu class="logout">
|
51 |
+
<el-dropdown-item>{{ userInfoItem.username }}</el-dropdown-item>
|
52 |
+
<el-dropdown-item>用户配置</el-dropdown-item>
|
53 |
+
<el-dropdown-item>
|
54 |
+
<!-- <el-dropdown-item @click="logout"> -->
|
55 |
+
<!-- <IconifyIconOffline :icon="LogoutCircleRLine" style="margin: 5px" /> -->
|
56 |
+
退出系统
|
57 |
+
</el-dropdown-item>
|
58 |
+
</el-dropdown-menu>
|
59 |
+
</template>
|
60 |
+
</el-dropdown>
|
61 |
+
</el-header>
|
62 |
+
</template>
|
63 |
+
|
64 |
+
<style lang="scss" scoped>
|
65 |
+
.el-header {
|
66 |
+
display: flex; // 水平显示
|
67 |
+
align-items: center;
|
68 |
+
background-color: #ffffff; // 背景颜色
|
69 |
+
|
70 |
+
.el-icon {
|
71 |
+
margin-right: 15px; // 折叠按钮右边预留空隙
|
72 |
+
}
|
73 |
+
}
|
74 |
+
|
75 |
+
.el-dropdown {
|
76 |
+
margin-left: auto; //让头像往右边靠
|
77 |
+
}
|
78 |
+
</style>
|
frontend/src/components/StreamerInfoComponent.vue
ADDED
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts" setup>
|
2 |
+
import { nextTick, ref, watch } from 'vue'
|
3 |
+
import { ElInput } from 'element-plus'
|
4 |
+
import type { InputInstance } from 'element-plus'
|
5 |
+
import { Plus } from '@element-plus/icons-vue'
|
6 |
+
|
7 |
+
import VideoComponent from '@/components/VideoComponent.vue'
|
8 |
+
import { type StreamerInfo } from '@/api/streamerInfo'
|
9 |
+
import FileUpload from '@/components/FileUpload.vue'
|
10 |
+
|
11 |
+
// 定义 和父组件通信的双向绑定 model
|
12 |
+
const modelSteamerInfo = defineModel({ default: {} as StreamerInfo })
|
13 |
+
|
14 |
+
// 定义组件入参类型以及默认值
|
15 |
+
export interface Props {
|
16 |
+
disableChange: boolean
|
17 |
+
optionList: StreamerInfo[]
|
18 |
+
}
|
19 |
+
|
20 |
+
const props = withDefaults(defineProps<Props>(), {
|
21 |
+
disableChange: false,
|
22 |
+
optionList: () => [] as StreamerInfo[]
|
23 |
+
})
|
24 |
+
|
25 |
+
// 性格操作
|
26 |
+
const inputCharacterValue = ref('')
|
27 |
+
const inputCharacterVisible = ref(false)
|
28 |
+
const InputCharacterRef = ref<InputInstance>()
|
29 |
+
|
30 |
+
modelSteamerInfo.value.character = ''
|
31 |
+
let characterList = ref([] as string[])
|
32 |
+
watch(
|
33 |
+
modelSteamerInfo,
|
34 |
+
(newValue) => {
|
35 |
+
console.log(`性格:更新为: ${newValue}`)
|
36 |
+
|
37 |
+
if (
|
38 |
+
typeof modelSteamerInfo.value.character === 'string' &&
|
39 |
+
modelSteamerInfo.value.character !== ''
|
40 |
+
) {
|
41 |
+
characterList.value = modelSteamerInfo.value.character.split(';')
|
42 |
+
}
|
43 |
+
},
|
44 |
+
{ immediate: true }
|
45 |
+
)
|
46 |
+
|
47 |
+
const handleCharacterClose = (tag: string) => {
|
48 |
+
// 删除性格操作
|
49 |
+
characterList.value.splice(characterList.value.indexOf(tag), 1)
|
50 |
+
modelSteamerInfo.value.character = characterList.value.join(';')
|
51 |
+
}
|
52 |
+
|
53 |
+
const showCharacterInput = () => {
|
54 |
+
inputCharacterVisible.value = true
|
55 |
+
nextTick(() => {
|
56 |
+
InputCharacterRef.value!.input!.focus()
|
57 |
+
})
|
58 |
+
}
|
59 |
+
|
60 |
+
const handleCharacterInputConfirm = () => {
|
61 |
+
if (inputCharacterValue.value) {
|
62 |
+
characterList.value.push(inputCharacterValue.value)
|
63 |
+
modelSteamerInfo.value.character = characterList.value.join(';')
|
64 |
+
}
|
65 |
+
inputCharacterVisible.value = false
|
66 |
+
inputCharacterValue.value = ''
|
67 |
+
}
|
68 |
+
|
69 |
+
const formLabelWidth = ref(120)
|
70 |
+
const labelPosition = ref('top')
|
71 |
+
</script>
|
72 |
+
|
73 |
+
<template>
|
74 |
+
<div>
|
75 |
+
<el-row :gutter="20">
|
76 |
+
<el-col :span="12">
|
77 |
+
<el-card shadow="never" v-if="props.optionList.length >= 1">
|
78 |
+
<h2>选择主播</h2>
|
79 |
+
<el-divider />
|
80 |
+
<el-select
|
81 |
+
v-model="modelSteamerInfo"
|
82 |
+
placeholder="选择主播"
|
83 |
+
size="large"
|
84 |
+
style="width: 240px"
|
85 |
+
>
|
86 |
+
<el-option
|
87 |
+
v-for="item in props.optionList"
|
88 |
+
:key="item.streamer_id"
|
89 |
+
:label="item.name"
|
90 |
+
:value="item"
|
91 |
+
/>
|
92 |
+
</el-select>
|
93 |
+
</el-card>
|
94 |
+
|
95 |
+
<el-card shadow="never">
|
96 |
+
<h2>基本信息</h2>
|
97 |
+
<el-divider />
|
98 |
+
<el-form :label-position="labelPosition" :label-width="formLabelWidth">
|
99 |
+
<el-form-item label="姓名">
|
100 |
+
<el-input
|
101 |
+
v-model="modelSteamerInfo.name"
|
102 |
+
size="large"
|
103 |
+
:disabled="props.disableChange"
|
104 |
+
/>
|
105 |
+
</el-form-item>
|
106 |
+
<el-form-item label="主播性格">
|
107 |
+
<el-tag
|
108 |
+
v-for="(characterItem, index) in characterList"
|
109 |
+
:key="index"
|
110 |
+
:closable="!props.disableChange"
|
111 |
+
:disable-transitions="false"
|
112 |
+
@close="handleCharacterClose(characterItem)"
|
113 |
+
round
|
114 |
+
size="large"
|
115 |
+
style="margin: 3px"
|
116 |
+
>
|
117 |
+
{{ characterItem }}
|
118 |
+
</el-tag>
|
119 |
+
|
120 |
+
<el-input
|
121 |
+
v-if="inputCharacterVisible"
|
122 |
+
ref="InputCharacterRef"
|
123 |
+
v-model="inputCharacterValue"
|
124 |
+
class="w-20"
|
125 |
+
@keyup.enter="handleCharacterInputConfirm"
|
126 |
+
@blur="handleCharacterInputConfirm"
|
127 |
+
size="large"
|
128 |
+
/>
|
129 |
+
<el-button
|
130 |
+
v-else
|
131 |
+
@click="showCharacterInput"
|
132 |
+
circle
|
133 |
+
:icon="Plus"
|
134 |
+
v-show="!props.disableChange"
|
135 |
+
>
|
136 |
+
</el-button>
|
137 |
+
</el-form-item>
|
138 |
+
</el-form>
|
139 |
+
</el-card>
|
140 |
+
|
141 |
+
<el-card shadow="never">
|
142 |
+
<h2>TTS 配置</h2>
|
143 |
+
<el-divider />
|
144 |
+
<el-form :label-position="labelPosition" :label-width="formLabelWidth">
|
145 |
+
<el-form-item label="音频文件">
|
146 |
+
<div class="make-center">
|
147 |
+
<audio
|
148 |
+
v-if="modelSteamerInfo.tts_reference_audio"
|
149 |
+
:src="modelSteamerInfo.tts_reference_audio"
|
150 |
+
controls
|
151 |
+
style="margin-right: 20px"
|
152 |
+
></audio>
|
153 |
+
<el-tag v-else size="large" type="danger"> 未找到音频 </el-tag>
|
154 |
+
<FileUpload
|
155 |
+
v-show="!props.disableChange"
|
156 |
+
v-model="modelSteamerInfo.tts_reference_audio"
|
157 |
+
file-type="audio"
|
158 |
+
/>
|
159 |
+
</div>
|
160 |
+
</el-form-item>
|
161 |
+
|
162 |
+
<el-form-item label="音频对应文字">
|
163 |
+
<el-input
|
164 |
+
v-model="modelSteamerInfo.tts_reference_sentence"
|
165 |
+
size="large"
|
166 |
+
:disabled="props.disableChange"
|
167 |
+
/>
|
168 |
+
</el-form-item>
|
169 |
+
|
170 |
+
<!-- TODO 支持用户上传自己的权重 -->
|
171 |
+
<!-- <el-form-item label="TTS 权重" :label-width="formLabelWidth">
|
172 |
+
<el-input
|
173 |
+
v-model="modelSteamerInfo.tts_weight_tag"
|
174 |
+
size="large"
|
175 |
+
:disabled="props.disableChange"
|
176 |
+
/>
|
177 |
+
</el-form-item> -->
|
178 |
+
</el-form>
|
179 |
+
</el-card>
|
180 |
+
</el-col>
|
181 |
+
|
182 |
+
<el-col :span="12">
|
183 |
+
<el-card shadow="never">
|
184 |
+
<div class="make-center">
|
185 |
+
<!-- 数字人视频 -->
|
186 |
+
<VideoComponent
|
187 |
+
:src="modelSteamerInfo.base_mp4_path"
|
188 |
+
:key="modelSteamerInfo.base_mp4_path"
|
189 |
+
:height="600"
|
190 |
+
:autoplay="true"
|
191 |
+
:loop="true"
|
192 |
+
/>
|
193 |
+
</div>
|
194 |
+
<div class="make-center" style="margin-top: 10px">
|
195 |
+
<FileUpload
|
196 |
+
v-show="!props.disableChange"
|
197 |
+
v-model="modelSteamerInfo.base_mp4_path"
|
198 |
+
file-type="video"
|
199 |
+
/>
|
200 |
+
</div>
|
201 |
+
</el-card>
|
202 |
+
</el-col>
|
203 |
+
</el-row>
|
204 |
+
</div>
|
205 |
+
</template>
|
206 |
+
|
207 |
+
<style lang="scss" scoped>
|
208 |
+
.make-center {
|
209 |
+
display: flex;
|
210 |
+
justify-content: center;
|
211 |
+
align-items: center;
|
212 |
+
}
|
213 |
+
|
214 |
+
.el-form-item {
|
215 |
+
align-items: center;
|
216 |
+
}
|
217 |
+
|
218 |
+
::v-deep(.el-input__wrapper) {
|
219 |
+
border-radius: 14px;
|
220 |
+
}
|
221 |
+
|
222 |
+
.el-card {
|
223 |
+
margin-top: 10px;
|
224 |
+
border-radius: 10px;
|
225 |
+
}
|
226 |
+
|
227 |
+
::v-deep(.el-form-item__label) {
|
228 |
+
font-weight: 600;
|
229 |
+
font-size: 15px;
|
230 |
+
}
|
231 |
+
</style>
|
frontend/src/components/VideoComponent.vue
ADDED
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
3 |
+
import type PresetPlayer from 'xgplayer'
|
4 |
+
import Player, { type IPlayerOptions } from 'xgplayer'
|
5 |
+
|
6 |
+
// 定义组件入参
|
7 |
+
const props = defineProps({
|
8 |
+
src: {
|
9 |
+
type: String,
|
10 |
+
default: 'player'
|
11 |
+
},
|
12 |
+
autoplay: {
|
13 |
+
type: Boolean,
|
14 |
+
default: false
|
15 |
+
},
|
16 |
+
loop: {
|
17 |
+
type: Boolean,
|
18 |
+
default: false
|
19 |
+
},
|
20 |
+
width: {
|
21 |
+
type: Number,
|
22 |
+
default: 600
|
23 |
+
},
|
24 |
+
height: {
|
25 |
+
type: Number,
|
26 |
+
default: 337.5
|
27 |
+
},
|
28 |
+
videoAfterEnd: {
|
29 |
+
type: String,
|
30 |
+
default: ''
|
31 |
+
}
|
32 |
+
})
|
33 |
+
|
34 |
+
const player_id = ref(props.src !== '' ? props.src : 'video_player')
|
35 |
+
|
36 |
+
const playerOpts: IPlayerOptions = {
|
37 |
+
id: player_id.value, //元素id
|
38 |
+
url: props.src, //视频地址
|
39 |
+
width: props.width, // 播放器宽度
|
40 |
+
height: props.height, //播放器高度
|
41 |
+
poster: '@/assets/logo.png', //封面图
|
42 |
+
lang: 'zh-cn', //设置中文
|
43 |
+
closeVideoClick: false, // true - 禁止pc端单击暂停
|
44 |
+
videoInit: true, // 是否默认初始化video,当autoplay为true时,该配置为false无效
|
45 |
+
fluid: false, //是否启用流式布局,启用流式布局时根据width、height计算播放器宽高比,若width和height不是Number类型,默认使用16:9比例
|
46 |
+
autoplay: props.autoplay, //自动播放
|
47 |
+
loop: props.loop, //循环播放
|
48 |
+
autoplayMuted: false, // 是否自动静音自动播放,如果autoplay为false,则该属性的作用为默认静音播放
|
49 |
+
pip: false, //是否使用画中画插件,
|
50 |
+
closeVideoDblclick: true, // 是否关闭双击播放器进入全屏的能力
|
51 |
+
volume: 1, //音量 0 - 1
|
52 |
+
// playbackRate: [0.5, 0.75, 1, 1.5, 2], //传入倍速可选数组
|
53 |
+
// 删除插件,插件文档: https://h5player.bytedance.com/plugins/internalplugins/controls.html
|
54 |
+
ignores: [
|
55 |
+
'volume',
|
56 |
+
'cssFullScreen',
|
57 |
+
'fullscreen',
|
58 |
+
'enter',
|
59 |
+
'play',
|
60 |
+
'replay',
|
61 |
+
'start',
|
62 |
+
'playbackrate'
|
63 |
+
]
|
64 |
+
}
|
65 |
+
|
66 |
+
// 播放器
|
67 |
+
let player: Player | null = null
|
68 |
+
|
69 |
+
// 必须在onMounted 或 nextTick实例Xgplayer播放器
|
70 |
+
onMounted(() => {
|
71 |
+
player = new Player(playerOpts)
|
72 |
+
|
73 |
+
player.on('ended', () => {
|
74 |
+
// 播放结束后的切换为 videoAfterEnd 的视频
|
75 |
+
console.log('视频播放结束')
|
76 |
+
|
77 |
+
if (props.videoAfterEnd !== '') {
|
78 |
+
playerOpts.autoplay = true // 自动播放
|
79 |
+
playerOpts.loop = true // 循环播放
|
80 |
+
playerOpts.autoplayMuted = true // 静音
|
81 |
+
playerOpts.url = props.videoAfterEnd // 新的视频地址
|
82 |
+
|
83 |
+
// player.playNext(playerOpts) // 使用 playnext 之后不能触发 loop ,需要重新实例化
|
84 |
+
if (player) {
|
85 |
+
player.destroy()
|
86 |
+
}
|
87 |
+
player = null
|
88 |
+
player = new Player(playerOpts)
|
89 |
+
}
|
90 |
+
})
|
91 |
+
})
|
92 |
+
|
93 |
+
onBeforeUnmount(() => {
|
94 |
+
if (player) {
|
95 |
+
player.destroy()
|
96 |
+
player = null
|
97 |
+
}
|
98 |
+
})
|
99 |
+
</script>
|
100 |
+
|
101 |
+
<template>
|
102 |
+
<div :id="player_id"></div>
|
103 |
+
</template>
|
frontend/src/layouts/BaseLayout.vue
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts" setup>
|
2 |
+
import AslideComponent from '@/components/AslideComponent.vue'
|
3 |
+
import NavbarComponent from '@/components/NavbarComponent.vue'
|
4 |
+
|
5 |
+
import { RouterView } from 'vue-router'
|
6 |
+
</script>
|
7 |
+
|
8 |
+
<template>
|
9 |
+
<el-container>
|
10 |
+
<!-- 侧边栏 -->
|
11 |
+
<el-scrollbar>
|
12 |
+
<AslideComponent />
|
13 |
+
</el-scrollbar>
|
14 |
+
|
15 |
+
<el-container class="header-and-context-container">
|
16 |
+
<!-- 导航栏 -->
|
17 |
+
<NavbarComponent />
|
18 |
+
|
19 |
+
<!-- 信息页 -->
|
20 |
+
<el-main>
|
21 |
+
<el-scrollbar>
|
22 |
+
<router-view v-slot="{ Component }">
|
23 |
+
<transition name="slide-fade" mode="out-in">
|
24 |
+
<component :is="Component" />
|
25 |
+
</transition>
|
26 |
+
</router-view>
|
27 |
+
</el-scrollbar>
|
28 |
+
</el-main>
|
29 |
+
</el-container>
|
30 |
+
</el-container>
|
31 |
+
</template>
|
32 |
+
|
33 |
+
<style lang="scss" scoped>
|
34 |
+
.header-and-context-container {
|
35 |
+
flex-direction: column;
|
36 |
+
}
|
37 |
+
|
38 |
+
.el-main {
|
39 |
+
background-color: #f6f8fe;
|
40 |
+
}
|
41 |
+
</style>
|
frontend/src/main.ts
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createApp } from 'vue'
|
2 |
+
|
3 |
+
import { createPinia } from 'pinia'
|
4 |
+
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
5 |
+
|
6 |
+
import ElementPlus from 'element-plus'
|
7 |
+
|
8 |
+
import 'element-plus/dist/index.css'
|
9 |
+
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
10 |
+
|
11 |
+
import 'xgplayer/dist/index.min.css'
|
12 |
+
|
13 |
+
import '@/style/index.scss'
|
14 |
+
|
15 |
+
import App from './App.vue'
|
16 |
+
import router from './router'
|
17 |
+
|
18 |
+
const app = createApp(App)
|
19 |
+
|
20 |
+
const pinia = createPinia()
|
21 |
+
pinia.use(piniaPluginPersistedstate) // 持久化储存插件
|
22 |
+
|
23 |
+
app.use(pinia)
|
24 |
+
app.use(router)
|
25 |
+
|
26 |
+
app.use(ElementPlus, {
|
27 |
+
locale: zhCn
|
28 |
+
})
|
29 |
+
app.mount('#app')
|
frontend/src/router/index.ts
ADDED
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createRouter, createWebHistory } from 'vue-router'
|
2 |
+
|
3 |
+
const router = createRouter({
|
4 |
+
history: createWebHistory(),
|
5 |
+
routes: [
|
6 |
+
{
|
7 |
+
path: '/login',
|
8 |
+
name: 'Login',
|
9 |
+
component: () => import('@/views/login/LoginView.vue'),
|
10 |
+
meta: {
|
11 |
+
title: '登录页' // 面包屑显示标题
|
12 |
+
}
|
13 |
+
},
|
14 |
+
{
|
15 |
+
path: '/',
|
16 |
+
redirect: { name: 'Home' },
|
17 |
+
component: () => import('@/layouts/BaseLayout.vue'),
|
18 |
+
meta: {
|
19 |
+
requiresAuth: true // 是否需要登录验证,配置根路由即可,子路由会继承
|
20 |
+
},
|
21 |
+
children: [
|
22 |
+
{
|
23 |
+
path: '/home',
|
24 |
+
name: 'Home',
|
25 |
+
component: () => import('@/views/home/HomeView.vue'),
|
26 |
+
meta: { title: '主页' }
|
27 |
+
},
|
28 |
+
// ---------------------
|
29 |
+
// 商品
|
30 |
+
// ---------------------
|
31 |
+
{
|
32 |
+
path: '/product',
|
33 |
+
name: 'Product',
|
34 |
+
redirect: { name: 'ProductList' },
|
35 |
+
meta: { title: '商品管理' },
|
36 |
+
children: [
|
37 |
+
{
|
38 |
+
path: '/product/list',
|
39 |
+
name: 'ProductList',
|
40 |
+
component: () => import('@/views/product/ProductListView.vue'),
|
41 |
+
meta: { title: '商品列表' }
|
42 |
+
},
|
43 |
+
{
|
44 |
+
path: '/product/create',
|
45 |
+
name: 'ProductCreate',
|
46 |
+
component: () => import('@/views/product/ProductEditView.vue'),
|
47 |
+
meta: { title: '新增商品' }
|
48 |
+
},
|
49 |
+
{
|
50 |
+
path: '/product/:productId/edit',
|
51 |
+
name: 'ProductEdit',
|
52 |
+
component: () => import('@/views/product/ProductEditView.vue'),
|
53 |
+
meta: { title: '商品修改' },
|
54 |
+
props: true
|
55 |
+
}
|
56 |
+
]
|
57 |
+
},
|
58 |
+
// ---------------------
|
59 |
+
// 数字人管理
|
60 |
+
// ---------------------
|
61 |
+
{
|
62 |
+
path: '/digital_human',
|
63 |
+
name: 'DigitalHuman',
|
64 |
+
redirect: { name: 'DigitalHumanList' },
|
65 |
+
meta: { title: '数字人管理' },
|
66 |
+
children: [
|
67 |
+
{
|
68 |
+
path: '/digital_human/list',
|
69 |
+
name: 'DigitalHumanList',
|
70 |
+
component: () => import('@/views/digital-human/DigitalHumanView.vue'),
|
71 |
+
meta: { title: '角色管理' }
|
72 |
+
}
|
73 |
+
]
|
74 |
+
},
|
75 |
+
// ---------------------
|
76 |
+
// 直播管理
|
77 |
+
// ---------------------
|
78 |
+
|
79 |
+
{
|
80 |
+
path: '/streaming',
|
81 |
+
name: 'Streaming',
|
82 |
+
redirect: { name: 'StreamingOverview' },
|
83 |
+
meta: { title: '直播管理' },
|
84 |
+
children: [
|
85 |
+
{
|
86 |
+
path: '/streaming/overview',
|
87 |
+
name: 'StreamingOverview',
|
88 |
+
component: () => import('@/views/streaming/StreamingRoomListView.vue'),
|
89 |
+
meta: { title: '直播间管理' }
|
90 |
+
},
|
91 |
+
{
|
92 |
+
path: '/streaming/create',
|
93 |
+
name: 'StreamingCreate',
|
94 |
+
component: () => import('@/views/streaming/StreamingRoomeEditView.vue'),
|
95 |
+
meta: { title: '新建直播间' }
|
96 |
+
},
|
97 |
+
{
|
98 |
+
path: '/streaming/:roomId/edit',
|
99 |
+
name: 'StreamingEdit',
|
100 |
+
component: () => import('@/views/streaming/StreamingRoomeEditView.vue'),
|
101 |
+
meta: { title: '编辑直播间' },
|
102 |
+
props: true
|
103 |
+
},
|
104 |
+
{
|
105 |
+
path: '/streaming/:roomId/on-air',
|
106 |
+
name: 'StreamingOnAir',
|
107 |
+
component: () => import('@/views/streaming/StreamingOnAirView.vue'),
|
108 |
+
meta: { title: '直播间' },
|
109 |
+
props: true
|
110 |
+
}
|
111 |
+
]
|
112 |
+
},
|
113 |
+
// ---------------------
|
114 |
+
// 订单管理
|
115 |
+
// ---------------------
|
116 |
+
{
|
117 |
+
path: '/order',
|
118 |
+
name: 'Order',
|
119 |
+
redirect: { name: 'OrderOverview' },
|
120 |
+
meta: { title: '订单管理' },
|
121 |
+
children: [
|
122 |
+
{
|
123 |
+
path: '/order/overview',
|
124 |
+
name: 'OrderOverview',
|
125 |
+
component: () => import('@/views/order/OrderView.vue'),
|
126 |
+
meta: { title: '订单管理' }
|
127 |
+
}
|
128 |
+
]
|
129 |
+
},
|
130 |
+
// ---------------------
|
131 |
+
// 系统配置
|
132 |
+
// ---------------------
|
133 |
+
{
|
134 |
+
path: '/system',
|
135 |
+
name: 'System',
|
136 |
+
redirect: { name: 'SystemPlugins' },
|
137 |
+
meta: { title: '系统配置' },
|
138 |
+
children: [
|
139 |
+
{
|
140 |
+
path: '/system/plugins',
|
141 |
+
name: 'SystemPlugins',
|
142 |
+
component: () => import('@/views/system/SystemPluginsView.vue'),
|
143 |
+
meta: { title: '组件状态' }
|
144 |
+
}
|
145 |
+
]
|
146 |
+
}
|
147 |
+
]
|
148 |
+
},
|
149 |
+
{
|
150 |
+
path: '/:pathMatch(.*)*',
|
151 |
+
name: 'NotFound',
|
152 |
+
component: () => import('@/views/error/NotFound.vue'),
|
153 |
+
meta: { title: '404' }
|
154 |
+
}
|
155 |
+
]
|
156 |
+
})
|
157 |
+
|
158 |
+
import { useTokenStore } from '@/stores/userToken'
|
159 |
+
|
160 |
+
router.beforeEach((to, from, next) => {
|
161 |
+
if (to.matched.some((r) => r.meta?.requiresAuth)) {
|
162 |
+
// 登录状态缓存
|
163 |
+
const tokenStore = useTokenStore()
|
164 |
+
|
165 |
+
if (!tokenStore.token.access_token) {
|
166 |
+
// 没有登录,跳转登录页面,同时记录 想去的地址 to.fullPath,方便执行登陆后跳转回去
|
167 |
+
next({ name: 'Login', query: { redirect: to.fullPath } })
|
168 |
+
return
|
169 |
+
}
|
170 |
+
}
|
171 |
+
// 动态更改页面 title
|
172 |
+
to.matched.some((item) => {
|
173 |
+
if (!item.meta.title) return ''
|
174 |
+
|
175 |
+
const Title = '销冠——卖货主播大模型'
|
176 |
+
if (Title) document.title = `${item.meta.title} | ${Title}`
|
177 |
+
else document.title = item.meta.title as string
|
178 |
+
})
|
179 |
+
|
180 |
+
next()
|
181 |
+
})
|
182 |
+
|
183 |
+
export default router
|
frontend/src/stores/userToken.ts
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ref } from 'vue'
|
2 |
+
import { defineStore } from 'pinia'
|
3 |
+
|
4 |
+
interface TokenItem {
|
5 |
+
access_token: string
|
6 |
+
token_type: string
|
7 |
+
}
|
8 |
+
|
9 |
+
const useTokenStore = defineStore('user-token', {
|
10 |
+
state: () => {
|
11 |
+
const token = ref({} as TokenItem)
|
12 |
+
|
13 |
+
function saveToken(data: TokenItem) {
|
14 |
+
token.value = data
|
15 |
+
}
|
16 |
+
|
17 |
+
return { token, saveToken }
|
18 |
+
},
|
19 |
+
|
20 |
+
persist: {
|
21 |
+
paths: ['token'], // 需要持久化保存的字段名
|
22 |
+
storage: localStorage
|
23 |
+
}
|
24 |
+
})
|
25 |
+
|
26 |
+
export { type TokenItem, useTokenStore }
|
frontend/src/style/index.scss
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/*
|
2 |
+
进入和离开动画可以使用不同
|
3 |
+
持续时间和速度曲线。
|
4 |
+
*/
|
5 |
+
.slide-fade-enter-active {
|
6 |
+
transition: all 0.3s ease-out;
|
7 |
+
}
|
8 |
+
|
9 |
+
.slide-fade-leave-active {
|
10 |
+
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
|
11 |
+
}
|
12 |
+
|
13 |
+
.slide-fade-enter-from,
|
14 |
+
.slide-fade-leave-to {
|
15 |
+
transform: translateX(20px);
|
16 |
+
opacity: 0;
|
17 |
+
}
|
frontend/src/utils/navbar.ts
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ref } from 'vue'
|
2 |
+
|
3 |
+
// 侧边栏是否折叠
|
4 |
+
export const isCollapse = ref(false) // TODO 是否可以用 Pinia ?
|
frontend/src/views/digital-human/DigitalHumanEditDialogView.vue
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref } from 'vue'
|
3 |
+
import { ElMessage } from 'element-plus'
|
4 |
+
|
5 |
+
import StreamerInfoComponent from '@/components/StreamerInfoComponent.vue'
|
6 |
+
import {
|
7 |
+
streamerDetailInfoRequest,
|
8 |
+
streamerEditDetailRequest,
|
9 |
+
type StreamerInfo
|
10 |
+
} from '@/api/streamerInfo'
|
11 |
+
import { AxiosError } from 'axios'
|
12 |
+
|
13 |
+
const dialogInfoVisible = ref(false)
|
14 |
+
const saveLoading = ref(false)
|
15 |
+
const steamerInfo = ref({} as StreamerInfo)
|
16 |
+
steamerInfo.value.streamer_id = 0
|
17 |
+
|
18 |
+
const showItemInfoDialog = async (streamerId: number) => {
|
19 |
+
console.log('streamerId = ', streamerId)
|
20 |
+
dialogInfoVisible.value = true
|
21 |
+
|
22 |
+
if (streamerId === 0) {
|
23 |
+
steamerInfo.value = {} as StreamerInfo
|
24 |
+
return
|
25 |
+
}
|
26 |
+
|
27 |
+
try {
|
28 |
+
// 请求接口获取主播数据
|
29 |
+
const { data } = await streamerDetailInfoRequest(streamerId)
|
30 |
+
if (data.code === 0) {
|
31 |
+
steamerInfo.value = data.data
|
32 |
+
} else {
|
33 |
+
ElMessage.error('获取主播数据失败: ' + data.message)
|
34 |
+
}
|
35 |
+
} catch (error: unknown) {
|
36 |
+
if (error instanceof AxiosError) {
|
37 |
+
ElMessage.error('获取主播数据失败: ' + error.message)
|
38 |
+
} else {
|
39 |
+
ElMessage.error('未知错误:' + error)
|
40 |
+
}
|
41 |
+
}
|
42 |
+
}
|
43 |
+
|
44 |
+
const handelSaveClick = async () => {
|
45 |
+
try {
|
46 |
+
saveLoading.value = true
|
47 |
+
const { data } = await streamerEditDetailRequest(steamerInfo.value)
|
48 |
+
|
49 |
+
if (data.code === 0) {
|
50 |
+
steamerInfo.value.streamer_id = data.data
|
51 |
+
ElMessage.success('保存成功')
|
52 |
+
saveLoading.value = false
|
53 |
+
} else {
|
54 |
+
saveLoading.value = false
|
55 |
+
ElMessage.error('保存失败: ' + data.message)
|
56 |
+
}
|
57 |
+
} catch (error: unknown) {
|
58 |
+
saveLoading.value = false
|
59 |
+
if (error instanceof AxiosError) {
|
60 |
+
ElMessage.error('保存失败: ' + error.message)
|
61 |
+
} else {
|
62 |
+
ElMessage.error('未知错误:' + error)
|
63 |
+
}
|
64 |
+
}
|
65 |
+
}
|
66 |
+
|
67 |
+
defineExpose({ showItemInfoDialog })
|
68 |
+
</script>
|
69 |
+
|
70 |
+
<template>
|
71 |
+
<div class="dialog-container">
|
72 |
+
<el-dialog v-model="dialogInfoVisible" title="主播详情" width="80%" destroy-on-close>
|
73 |
+
<StreamerInfoComponent v-model="steamerInfo" :disable-change="false" />
|
74 |
+
|
75 |
+
<template #footer>
|
76 |
+
<div class="dialog-footer">
|
77 |
+
<el-button type="primary" @click="handelSaveClick" :loading="saveLoading">
|
78 |
+
保存
|
79 |
+
</el-button>
|
80 |
+
<el-button @click="dialogInfoVisible = false" :disabled="saveLoading"> 关闭 </el-button>
|
81 |
+
</div>
|
82 |
+
</template>
|
83 |
+
</el-dialog>
|
84 |
+
</div>
|
85 |
+
</template>
|
86 |
+
|
87 |
+
<style lang="scss" scoped>
|
88 |
+
// 使用 ::v-deep 选择器来覆盖 el-dialog 的默认样式。
|
89 |
+
::v-deep(.el-dialog) {
|
90 |
+
border-radius: 10px;
|
91 |
+
padding: 20px;
|
92 |
+
|
93 |
+
--el-dialog-bg-color: #f7f8fa;
|
94 |
+
--el-dialog-title-font-size: 24px;
|
95 |
+
}
|
96 |
+
</style>
|
frontend/src/views/digital-human/DigitalHumanView.vue
ADDED
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onMounted, ref, computed } from 'vue'
|
3 |
+
import { ElMessage, ElMessageBox } from 'element-plus/es'
|
4 |
+
import { Plus, Delete } from '@element-plus/icons-vue'
|
5 |
+
|
6 |
+
import {
|
7 |
+
streamerInfoListRequest,
|
8 |
+
deleteStreamerByIdRequest,
|
9 |
+
type StreamerInfo
|
10 |
+
} from '@/api/streamerInfo'
|
11 |
+
|
12 |
+
import showItemInfoDialog from '@/views/digital-human/DigitalHumanEditDialogView.vue'
|
13 |
+
import { AxiosError } from 'axios'
|
14 |
+
|
15 |
+
// 获取主播信息
|
16 |
+
const streamerNameOptions = ref([] as StreamerInfo[])
|
17 |
+
|
18 |
+
const DeleteDigitalHuman = async (id: number, name: string) => {
|
19 |
+
ElMessageBox.confirm(`确定要删除 "${name}" 吗?`, '警告', {
|
20 |
+
confirmButtonText: '确定',
|
21 |
+
cancelButtonText: '取消',
|
22 |
+
type: 'warning'
|
23 |
+
})
|
24 |
+
.then(async () => {
|
25 |
+
const { data } = await deleteStreamerByIdRequest(id)
|
26 |
+
if (data.code === 0) {
|
27 |
+
ElMessage.success('删除成功')
|
28 |
+
// 获取主播信息
|
29 |
+
const { data } = await streamerInfoListRequest()
|
30 |
+
if (data.code === 0) {
|
31 |
+
streamerNameOptions.value = data.data
|
32 |
+
ElMessage.success('获取主播信息成功')
|
33 |
+
}
|
34 |
+
} else {
|
35 |
+
ElMessage.error('删除失败')
|
36 |
+
}
|
37 |
+
})
|
38 |
+
.catch((error) => {
|
39 |
+
ElMessage.error('删除失败: ' + error.message)
|
40 |
+
})
|
41 |
+
}
|
42 |
+
|
43 |
+
onMounted(async () => {
|
44 |
+
// 获取主播信息
|
45 |
+
try {
|
46 |
+
const { data } = await streamerInfoListRequest()
|
47 |
+
if (data.code === 0) {
|
48 |
+
streamerNameOptions.value = data.data
|
49 |
+
ElMessage.success('获取主播信息成功')
|
50 |
+
} else {
|
51 |
+
ElMessage.error('获取主播信息失败:' + data.message)
|
52 |
+
}
|
53 |
+
} catch (error: unknown) {
|
54 |
+
if (error instanceof AxiosError) {
|
55 |
+
ElMessage.error('获取主播信息失败:' + error.message)
|
56 |
+
} else {
|
57 |
+
ElMessage.error('未知错误:' + error)
|
58 |
+
}
|
59 |
+
}
|
60 |
+
})
|
61 |
+
|
62 |
+
const chunkArray = (array: StreamerInfo[], chunkSize: number) => {
|
63 |
+
// 切割每 n 个为一行(即一个数组),方便后续进行 v-for 遍历
|
64 |
+
const result = []
|
65 |
+
for (let i = 0; i < array.length; i += chunkSize) {
|
66 |
+
result.push(array.slice(i, i + chunkSize))
|
67 |
+
}
|
68 |
+
return result
|
69 |
+
}
|
70 |
+
|
71 |
+
const chunkedArray = computed(() => chunkArray(streamerNameOptions.value, 4))
|
72 |
+
|
73 |
+
// 信息弹窗显示标识
|
74 |
+
const ShowItemInfo = ref()
|
75 |
+
</script>
|
76 |
+
|
77 |
+
<template>
|
78 |
+
<div>
|
79 |
+
<div>
|
80 |
+
<el-button
|
81 |
+
type="primary"
|
82 |
+
style="margin-bottom: 10px"
|
83 |
+
size="large"
|
84 |
+
@click="ShowItemInfo.showItemInfoDialog(0)"
|
85 |
+
>
|
86 |
+
<el-icon style="margin-right: 5px">
|
87 |
+
<Plus />
|
88 |
+
</el-icon>
|
89 |
+
新增主播
|
90 |
+
</el-button>
|
91 |
+
</div>
|
92 |
+
<div v-for="(row, rowIndex) in chunkedArray" :key="rowIndex" class="row">
|
93 |
+
<el-row :gutter="20">
|
94 |
+
<el-col v-for="(item, index) in row" :key="index" :span="6">
|
95 |
+
<el-card style="max-width: 500px">
|
96 |
+
<img :src="item.poster_image" style="width: 100%" />
|
97 |
+
<div class="streamer-info">
|
98 |
+
<p class="title">{{ item.name }}</p>
|
99 |
+
<p class="content">
|
100 |
+
{{ item.character }}
|
101 |
+
</p>
|
102 |
+
</div>
|
103 |
+
<div class="bottom-button">
|
104 |
+
<el-button type="primary" @click="ShowItemInfo.showItemInfoDialog(item.streamer_id)">
|
105 |
+
详情
|
106 |
+
</el-button>
|
107 |
+
<el-button
|
108 |
+
type="danger"
|
109 |
+
@click="DeleteDigitalHuman(item.streamer_id, item.name)"
|
110 |
+
:icon="Delete"
|
111 |
+
/>
|
112 |
+
</div>
|
113 |
+
</el-card>
|
114 |
+
</el-col>
|
115 |
+
</el-row>
|
116 |
+
</div>
|
117 |
+
|
118 |
+
<showItemInfoDialog ref="ShowItemInfo" />
|
119 |
+
</div>
|
120 |
+
</template>
|
121 |
+
|
122 |
+
<style lang="scss" scoped>
|
123 |
+
.row {
|
124 |
+
margin-bottom: 20px;
|
125 |
+
}
|
126 |
+
|
127 |
+
.bottom-button {
|
128 |
+
margin-top: 10px; // 距离上面的控件有一定的距离
|
129 |
+
display: flex;
|
130 |
+
justify-content: center;
|
131 |
+
align-items: center;
|
132 |
+
}
|
133 |
+
|
134 |
+
.el-card {
|
135 |
+
border-radius: 20px;
|
136 |
+
}
|
137 |
+
|
138 |
+
.streamer-info {
|
139 |
+
display: flex;
|
140 |
+
flex-direction: column; /* 将子元素垂直排列 */
|
141 |
+
justify-content: center; /* 垂直居中 */
|
142 |
+
align-items: center; /* 水平居中 */
|
143 |
+
|
144 |
+
.title {
|
145 |
+
font-size: 20px;
|
146 |
+
font-weight: 600;
|
147 |
+
}
|
148 |
+
|
149 |
+
.content {
|
150 |
+
font-size: 15px;
|
151 |
+
color: #b1b3b8;
|
152 |
+
margin: 15px;
|
153 |
+
}
|
154 |
+
}
|
155 |
+
</style>
|
frontend/src/views/error/NotFound.vue
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts"></script>
|
2 |
+
|
3 |
+
<template>
|
4 |
+
<div>404 Not Found</div>
|
5 |
+
</template>
|
6 |
+
|
7 |
+
<style lang="scss" scoped></style>
|
frontend/src/views/home/HomeView.vue
ADDED
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onMounted, ref } from 'vue'
|
3 |
+
import { ElMessage } from 'element-plus'
|
4 |
+
import { useTransition } from '@vueuse/core'
|
5 |
+
import { getDashboardInfoRequest, type DashboardItem } from '@/api/dashboard'
|
6 |
+
import BarChartComponent from '@/components/BarChartComponent.vue'
|
7 |
+
import LineChartComponent from '@/components/LineChartComponent.vue'
|
8 |
+
|
9 |
+
import {
|
10 |
+
OfficeBuilding,
|
11 |
+
Present,
|
12 |
+
View,
|
13 |
+
DataAnalysis,
|
14 |
+
CreditCard,
|
15 |
+
ShoppingCartFull
|
16 |
+
} from '@element-plus/icons-vue'
|
17 |
+
const systemInfo = ref({} as DashboardItem)
|
18 |
+
|
19 |
+
const registeredBrandNum = ref(0) //入驻品牌方
|
20 |
+
const productNum = ref(0) //商品数
|
21 |
+
const dailyActivity = ref(0) //日活
|
22 |
+
const todayOrder = ref(0) //订单量
|
23 |
+
const totalSales = ref(0) //销售额
|
24 |
+
const conversionRate = ref(0) //转化率
|
25 |
+
|
26 |
+
// 配置动画
|
27 |
+
const registeredBrandNumTrans = useTransition(registeredBrandNum, {
|
28 |
+
duration: 1500
|
29 |
+
})
|
30 |
+
const productNumTrans = useTransition(productNum, {
|
31 |
+
duration: 1500
|
32 |
+
})
|
33 |
+
const dailyActivityTrans = useTransition(dailyActivity, {
|
34 |
+
duration: 1500
|
35 |
+
})
|
36 |
+
const todayOrderTrans = useTransition(todayOrder, {
|
37 |
+
duration: 1500
|
38 |
+
})
|
39 |
+
const totalSalesTrans = useTransition(totalSales, {
|
40 |
+
duration: 1500
|
41 |
+
})
|
42 |
+
const conversionRateTrans = useTransition(conversionRate, {
|
43 |
+
duration: 1500
|
44 |
+
})
|
45 |
+
|
46 |
+
const iconSize = ref(50)
|
47 |
+
|
48 |
+
onMounted(async () => {
|
49 |
+
const { data } = await getDashboardInfoRequest()
|
50 |
+
if (data.code === 0) {
|
51 |
+
systemInfo.value = data.data
|
52 |
+
|
53 |
+
registeredBrandNum.value = systemInfo.value.registeredBrandNum
|
54 |
+
productNum.value = systemInfo.value.productNum
|
55 |
+
dailyActivity.value = systemInfo.value.dailyActivity
|
56 |
+
todayOrder.value = systemInfo.value.todayOrder
|
57 |
+
totalSales.value = systemInfo.value.totalSales
|
58 |
+
conversionRate.value = systemInfo.value.conversionRate
|
59 |
+
} else {
|
60 |
+
ElMessage.error('获取总览数据失败')
|
61 |
+
}
|
62 |
+
})
|
63 |
+
</script>
|
64 |
+
|
65 |
+
<template>
|
66 |
+
<div>
|
67 |
+
<el-row>
|
68 |
+
<el-col :span="8">
|
69 |
+
<el-card shadow="never">
|
70 |
+
<el-icon :size="iconSize" color="#006382"><OfficeBuilding /></el-icon>
|
71 |
+
<el-statistic title="入驻品牌方数量" :value="registeredBrandNumTrans" />
|
72 |
+
</el-card>
|
73 |
+
</el-col>
|
74 |
+
<el-col :span="8">
|
75 |
+
<el-card shadow="never">
|
76 |
+
<el-icon :size="iconSize" color="#f0bb1f"><Present /></el-icon>
|
77 |
+
<el-statistic title="商品数" :value="productNumTrans" />
|
78 |
+
</el-card>
|
79 |
+
</el-col>
|
80 |
+
<el-col :span="8">
|
81 |
+
<el-card shadow="never">
|
82 |
+
<el-icon :size="iconSize" color="#f25a2b"><View /></el-icon>
|
83 |
+
<el-statistic title="日活" :value="dailyActivityTrans" />
|
84 |
+
</el-card>
|
85 |
+
</el-col>
|
86 |
+
</el-row>
|
87 |
+
|
88 |
+
<el-row>
|
89 |
+
<el-col :span="8">
|
90 |
+
<el-card shadow="never">
|
91 |
+
<el-icon :size="iconSize" color="#97d1c8"><ShoppingCartFull /></el-icon>
|
92 |
+
<el-statistic title="今日订单数" :value="todayOrderTrans" />
|
93 |
+
</el-card>
|
94 |
+
</el-col>
|
95 |
+
<el-col :span="8">
|
96 |
+
<el-card shadow="never">
|
97 |
+
<el-icon :size="iconSize" color="#756bf2"><CreditCard /></el-icon>
|
98 |
+
<el-statistic title="销售额" :value="totalSalesTrans" />
|
99 |
+
</el-card>
|
100 |
+
</el-col>
|
101 |
+
<el-col :span="8">
|
102 |
+
<el-card shadow="never">
|
103 |
+
<el-icon :size="iconSize" color="#22c45d"><DataAnalysis /></el-icon>
|
104 |
+
<el-statistic title="转化率(%)" :value="conversionRateTrans" />
|
105 |
+
</el-card>
|
106 |
+
</el-col>
|
107 |
+
</el-row>
|
108 |
+
|
109 |
+
<el-row>
|
110 |
+
<el-col :span="16">
|
111 |
+
<el-card shadow="never">
|
112 |
+
<LineChartComponent
|
113 |
+
:orderNumList="systemInfo.orderNumList"
|
114 |
+
:totalSalesList="systemInfo.totalSalesList"
|
115 |
+
:newUserList="systemInfo.newUserList"
|
116 |
+
:activityUserList="systemInfo.activityUserList"
|
117 |
+
:key="systemInfo.registeredBrandNum"
|
118 |
+
/>
|
119 |
+
</el-card>
|
120 |
+
</el-col>
|
121 |
+
<el-col :span="8">
|
122 |
+
<el-card shadow="never">
|
123 |
+
<BarChartComponent
|
124 |
+
:knowledgeBasesNum="systemInfo.knowledgeBasesNum"
|
125 |
+
:digitalHumanNum="systemInfo.digitalHumanNum"
|
126 |
+
:LiveRoomNum="systemInfo.LiveRoomNum"
|
127 |
+
:key="systemInfo.knowledgeBasesNum"
|
128 |
+
/>
|
129 |
+
</el-card>
|
130 |
+
</el-col>
|
131 |
+
</el-row>
|
132 |
+
</div>
|
133 |
+
</template>
|
134 |
+
|
135 |
+
<style lang="scss" scoped>
|
136 |
+
.el-col {
|
137 |
+
text-align: center;
|
138 |
+
}
|
139 |
+
|
140 |
+
.el-statistic {
|
141 |
+
--el-statistic-title-font-size: 16px;
|
142 |
+
--el-statistic-title-font-weight: 400;
|
143 |
+
|
144 |
+
--el-statistic-content-font-size: 28px;
|
145 |
+
--el-statistic-content-font-weight: 800;
|
146 |
+
}
|
147 |
+
|
148 |
+
.el-card {
|
149 |
+
margin: 12px 12px 12px 12px;
|
150 |
+
border-radius: 22px;
|
151 |
+
|
152 |
+
.info-item {
|
153 |
+
margin: 12px 12px 12px 12px;
|
154 |
+
display: flex;
|
155 |
+
flex-direction: column;
|
156 |
+
align-items: center;
|
157 |
+
justify-content: center;
|
158 |
+
}
|
159 |
+
}
|
160 |
+
</style>
|
frontend/src/views/login/LoginView.vue
ADDED
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts" setup>
|
2 |
+
import { reactive, ref } from 'vue'
|
3 |
+
import { useRouter, useRoute } from 'vue-router'
|
4 |
+
import { User, Lock } from '@element-plus/icons-vue'
|
5 |
+
import { ElMessage, ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
6 |
+
|
7 |
+
import { loginRequest } from '@/api/user'
|
8 |
+
import { useTokenStore, type TokenItem } from '@/stores/userToken'
|
9 |
+
import { AxiosError } from 'axios'
|
10 |
+
|
11 |
+
const router = useRouter()
|
12 |
+
const route = useRoute()
|
13 |
+
|
14 |
+
// 登录按钮点击状态
|
15 |
+
const isLogining = ref(false)
|
16 |
+
|
17 |
+
// 登录状态缓存
|
18 |
+
const tokenStore = useTokenStore() // 直接解构会失去响应性
|
19 |
+
|
20 |
+
// 表格信息保存对象
|
21 |
+
const formRef = ref<FormInstance>()
|
22 |
+
|
23 |
+
// 声明每个字段的类型
|
24 |
+
interface FormType {
|
25 |
+
username: string
|
26 |
+
password: string
|
27 |
+
// vertify_code: string
|
28 |
+
}
|
29 |
+
|
30 |
+
// 双向绑定对象
|
31 |
+
const loginForm = reactive<FormType>({
|
32 |
+
username: 'hingwen.wong',
|
33 |
+
password: '123456'
|
34 |
+
// vertify_code: ''
|
35 |
+
})
|
36 |
+
|
37 |
+
// 定义表单规则,在 el-form 中注册
|
38 |
+
const rules = reactive<FormRules<FormType>>({
|
39 |
+
username: [
|
40 |
+
{ required: true, message: '请输入登录名', trigger: 'blur' }
|
41 |
+
// { pattern: /^1\d{10}$/, message: '请输入正确的电话号码', trigger: 'blur' }
|
42 |
+
],
|
43 |
+
password: [
|
44 |
+
{ required: true, message: '请输入密码', trigger: 'blur' },
|
45 |
+
{ min: 6, max: 18, message: '密码长度必须 6~18 位', trigger: 'blur' }
|
46 |
+
]
|
47 |
+
})
|
48 |
+
|
49 |
+
const onSubmit = async () => {
|
50 |
+
// 标记正在登录
|
51 |
+
isLogining.value = true
|
52 |
+
|
53 |
+
// 表单校验
|
54 |
+
await formRef.value?.validate().catch((err) => {
|
55 |
+
ElMessage.error('表单校验失败')
|
56 |
+
isLogining.value = false
|
57 |
+
throw err // 抛出异常
|
58 |
+
// return new Promise(() => ()) // 返回一个空的状态
|
59 |
+
})
|
60 |
+
|
61 |
+
// 校验成功,执行登录
|
62 |
+
const log_res = await loginRequest(loginForm)
|
63 |
+
.then((res) => {
|
64 |
+
console.info(res)
|
65 |
+
if (res.status !== 200) {
|
66 |
+
// 登录失败
|
67 |
+
ElMessage.error('账号名或密码错误')
|
68 |
+
isLogining.value = false
|
69 |
+
throw new Error('账号名或密码错误') // 抛出异常
|
70 |
+
}
|
71 |
+
|
72 |
+
// 弹窗提示
|
73 |
+
ElNotification({
|
74 |
+
title: '登录成功',
|
75 |
+
message: '欢迎',
|
76 |
+
type: 'success'
|
77 |
+
})
|
78 |
+
|
79 |
+
return res.data
|
80 |
+
})
|
81 |
+
.catch((error) => {
|
82 |
+
console.error(error)
|
83 |
+
isLogining.value = false
|
84 |
+
if (error.response && error.response.status === 401) {
|
85 |
+
ElMessage.error('账号名或密码错误')
|
86 |
+
throw new Error('账号名或密码错误') // 抛出异常
|
87 |
+
} else if (error instanceof AxiosError) {
|
88 |
+
ElMessage.error('登录失败:' + error.message)
|
89 |
+
throw new Error('登录失败:' + error.message) // 抛出异常
|
90 |
+
} else {
|
91 |
+
ElMessage.error('未知错误:' + error)
|
92 |
+
throw new Error('未知错误:' + error) // 抛出异常
|
93 |
+
}
|
94 |
+
})
|
95 |
+
|
96 |
+
console.log(log_res)
|
97 |
+
|
98 |
+
// 保存 token 信息
|
99 |
+
tokenStore.saveToken(log_res as TokenItem)
|
100 |
+
|
101 |
+
// 设置已经完成登录
|
102 |
+
isLogining.value = false
|
103 |
+
|
104 |
+
router.push((route.query.redirect as string) || '/') // 页面跳转,如果是被未登录拦截的话,登录成功后跳转会目标页面
|
105 |
+
}
|
106 |
+
</script>
|
107 |
+
|
108 |
+
<template>
|
109 |
+
<div class="login">
|
110 |
+
<el-form
|
111 |
+
:model="loginForm"
|
112 |
+
:rules="rules"
|
113 |
+
label-width="120px"
|
114 |
+
label-position="top"
|
115 |
+
size="large"
|
116 |
+
ref="formRef"
|
117 |
+
>
|
118 |
+
<h2>销冠 —— AI 卖货主播大模型</h2>
|
119 |
+
|
120 |
+
<!-- 校验规则的名称 -->
|
121 |
+
<el-form-item label="用户名" prop="username">
|
122 |
+
<el-input :prefix-icon="User" v-model="loginForm.username" />
|
123 |
+
</el-form-item>
|
124 |
+
|
125 |
+
<el-form-item label="密码" prop="password">
|
126 |
+
<el-input :prefix-icon="Lock" v-model="loginForm.password" type="password" />
|
127 |
+
</el-form-item>
|
128 |
+
|
129 |
+
<el-form-item>
|
130 |
+
<el-button type="primary" color="#626aef" @click="onSubmit" :loading="isLogining">
|
131 |
+
登录
|
132 |
+
</el-button>
|
133 |
+
</el-form-item>
|
134 |
+
</el-form>
|
135 |
+
</div>
|
136 |
+
</template>
|
137 |
+
|
138 |
+
<style lang="scss" scoped>
|
139 |
+
.login {
|
140 |
+
background-color: #dde5f4;
|
141 |
+
height: 100vh; // 满屏
|
142 |
+
display: flex; // 居中显示
|
143 |
+
justify-content: center;
|
144 |
+
align-items: center;
|
145 |
+
|
146 |
+
background-image: linear-gradient(to right, #dde5f4, #cadaf7); // 背景渐变色
|
147 |
+
|
148 |
+
.el-form {
|
149 |
+
width: 320px;
|
150 |
+
background-color: #f1f7fe;
|
151 |
+
padding: 30px; // 周围外加的 padding 像素
|
152 |
+
border-radius: 24px; // 边框圆角
|
153 |
+
}
|
154 |
+
|
155 |
+
.el-form-item {
|
156 |
+
margin-top: 20px; // 距离上方控件距离
|
157 |
+
}
|
158 |
+
|
159 |
+
.el-button {
|
160 |
+
width: 100%;
|
161 |
+
margin-top: 20px;
|
162 |
+
border-radius: 10px;
|
163 |
+
}
|
164 |
+
}
|
165 |
+
|
166 |
+
::v-deep(.el-input__wrapper) {
|
167 |
+
border-radius: 14px;
|
168 |
+
padding: 5px 5px 5px 5px;
|
169 |
+
}
|
170 |
+
</style>
|
frontend/src/views/order/OrderView.vue
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
const tableData = [
|
3 |
+
{
|
4 |
+
id: 1,
|
5 |
+
date: '2024-05-03',
|
6 |
+
name: '电动牙刷',
|
7 |
+
amount: 1,
|
8 |
+
address: '广东省广州市********',
|
9 |
+
status: '备货中'
|
10 |
+
},
|
11 |
+
{
|
12 |
+
id: 2,
|
13 |
+
date: '2024-05-02',
|
14 |
+
name: '平板电脑',
|
15 |
+
amount: 1,
|
16 |
+
address: '北京市朝阳区********',
|
17 |
+
status: '备货中'
|
18 |
+
},
|
19 |
+
{
|
20 |
+
id: 3,
|
21 |
+
date: '2024-05-01',
|
22 |
+
name: '唇膏',
|
23 |
+
amount: 1,
|
24 |
+
address: '浙江省杭州市********',
|
25 |
+
status: '已发货'
|
26 |
+
},
|
27 |
+
{
|
28 |
+
id: 4,
|
29 |
+
date: '2024-05-02',
|
30 |
+
name: '洗发露',
|
31 |
+
amount: 2,
|
32 |
+
address: '上海市黄浦区********',
|
33 |
+
status: '已完成'
|
34 |
+
}
|
35 |
+
]
|
36 |
+
</script>
|
37 |
+
|
38 |
+
<template>
|
39 |
+
<div>
|
40 |
+
<el-card shadow="never">
|
41 |
+
<template #header>
|
42 |
+
<!-- 头部 -->
|
43 |
+
<div class="card-header">
|
44 |
+
<div>
|
45 |
+
<el-form :inline="true">
|
46 |
+
<el-form-item label="搜索">
|
47 |
+
<el-input style="width: 240px" placeholder="商品名称" clearable />
|
48 |
+
</el-form-item>
|
49 |
+
<el-form-item>
|
50 |
+
<el-button type="primary"> 查询 </el-button>
|
51 |
+
</el-form-item>
|
52 |
+
</el-form>
|
53 |
+
</div>
|
54 |
+
</div>
|
55 |
+
</template>
|
56 |
+
|
57 |
+
<el-table :data="tableData" style="width: 100%">
|
58 |
+
<el-table-column prop="id" label="ID" width="50" />
|
59 |
+
<el-table-column prop="name" label="商品名称" />
|
60 |
+
<el-table-column prop="amount" label="数量" />
|
61 |
+
<el-table-column prop="date" label="下单时间" />
|
62 |
+
<el-table-column prop="address" label="地址" />
|
63 |
+
<el-table-column label="订单状态" v-slot="{ row }" align="center" width="250px">
|
64 |
+
<el-tag :type="row.status === '已完成' ? 'success' : 'warning'">{{ row.status }}</el-tag>
|
65 |
+
</el-table-column>
|
66 |
+
</el-table>
|
67 |
+
</el-card>
|
68 |
+
</div>
|
69 |
+
</template>
|
70 |
+
|
71 |
+
<style lang="scss" scoped>
|
72 |
+
.el-card {
|
73 |
+
border-radius: 12px;
|
74 |
+
}
|
75 |
+
|
76 |
+
.box-card {
|
77 |
+
width: auto; // 卡片宽度
|
78 |
+
}
|
79 |
+
|
80 |
+
// 查询栏
|
81 |
+
.card-header {
|
82 |
+
display: flex;
|
83 |
+
justify-content: space-between; // 将两个 div 放置在页面的两侧
|
84 |
+
align-items: center;
|
85 |
+
|
86 |
+
.el-form-item {
|
87 |
+
margin-bottom: 0px; // 查询框和下面的组件间隔大小
|
88 |
+
}
|
89 |
+
}
|
90 |
+
</style>
|
frontend/src/views/product/ProductEditView.vue
ADDED
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onMounted, ref, nextTick } from 'vue'
|
3 |
+
import { useRouter } from 'vue-router'
|
4 |
+
import { ElMessage } from 'element-plus/es'
|
5 |
+
import { ElInput } from 'element-plus'
|
6 |
+
import type { InputInstance } from 'element-plus'
|
7 |
+
import { Plus } from '@element-plus/icons-vue'
|
8 |
+
|
9 |
+
import FileUpload from '@/components/FileUpload.vue'
|
10 |
+
|
11 |
+
import { type ProductItem, getProductByIdRequest, productCreadeOrEditRequest } from '@/api/product'
|
12 |
+
import { AxiosError } from 'axios'
|
13 |
+
|
14 |
+
const router = useRouter()
|
15 |
+
|
16 |
+
// 定义 URL ID 传参
|
17 |
+
const props = defineProps({
|
18 |
+
productId: {
|
19 |
+
type: String,
|
20 |
+
default: ''
|
21 |
+
}
|
22 |
+
})
|
23 |
+
|
24 |
+
// 步骤条ID
|
25 |
+
const currentStep = ref(0)
|
26 |
+
|
27 |
+
const saveLoading = ref(false)
|
28 |
+
|
29 |
+
// 商品信息
|
30 |
+
const productInfo = ref({} as ProductItem)
|
31 |
+
productInfo.value.heighlights = '' // 初始化
|
32 |
+
productInfo.value.product_id = 0 // 初始化
|
33 |
+
|
34 |
+
const heighlights_list = ref([] as string[])
|
35 |
+
|
36 |
+
// 商品亮点操作
|
37 |
+
const inputHeighlightValue = ref('')
|
38 |
+
const inputHeighlightVisible = ref(false)
|
39 |
+
const InputHeighlightRef = ref<InputInstance>()
|
40 |
+
|
41 |
+
const handleHeighlightClose = (tag: string) => {
|
42 |
+
// 删除性格操作
|
43 |
+
heighlights_list.value.splice(heighlights_list.value.indexOf(tag), 1)
|
44 |
+
}
|
45 |
+
|
46 |
+
const showHeighlightInput = () => {
|
47 |
+
inputHeighlightVisible.value = true
|
48 |
+
nextTick(() => {
|
49 |
+
InputHeighlightRef.value!.input!.focus()
|
50 |
+
})
|
51 |
+
}
|
52 |
+
|
53 |
+
const handleHeighlightInputConfirm = () => {
|
54 |
+
if (inputHeighlightValue.value) {
|
55 |
+
heighlights_list.value.push(inputHeighlightValue.value)
|
56 |
+
}
|
57 |
+
inputHeighlightVisible.value = false
|
58 |
+
inputHeighlightValue.value = ''
|
59 |
+
}
|
60 |
+
|
61 |
+
// 表单提交
|
62 |
+
const onSubmit = async () => {
|
63 |
+
const statusInof = props.productId ? '编辑商品' : '新建商品'
|
64 |
+
|
65 |
+
try {
|
66 |
+
saveLoading.value = true
|
67 |
+
|
68 |
+
// 将 亮点 数组变成 ; 分割的字符串
|
69 |
+
productInfo.value.heighlights = heighlights_list.value.join(';')
|
70 |
+
|
71 |
+
const { data } = await productCreadeOrEditRequest(productInfo.value)
|
72 |
+
if (data.code === 0) {
|
73 |
+
ElMessage.success(`${statusInof}成功!`)
|
74 |
+
saveLoading.value = false
|
75 |
+
|
76 |
+
router.push({ name: 'Product' })
|
77 |
+
} else {
|
78 |
+
saveLoading.value = false
|
79 |
+
ElMessage.error(`${statusInof}失败, ${data.message}`)
|
80 |
+
throw new Error(`${statusInof}失败, ${data.message}`)
|
81 |
+
}
|
82 |
+
} catch (error: unknown) {
|
83 |
+
saveLoading.value = false
|
84 |
+
if (error instanceof AxiosError) {
|
85 |
+
ElMessage.error('失败:' + error.message)
|
86 |
+
} else {
|
87 |
+
ElMessage.error('未知错误:' + error)
|
88 |
+
}
|
89 |
+
}
|
90 |
+
}
|
91 |
+
|
92 |
+
onMounted(async () => {
|
93 |
+
// 获取商品信息
|
94 |
+
if (props.productId) {
|
95 |
+
try {
|
96 |
+
const { data } = await getProductByIdRequest(props.productId)
|
97 |
+
if (data.code === 0) {
|
98 |
+
productInfo.value = data.data
|
99 |
+
heighlights_list.value = productInfo.value.heighlights.split(';')
|
100 |
+
ElMessage.success('获取商品信息成功')
|
101 |
+
} else {
|
102 |
+
ElMessage.error(data.message)
|
103 |
+
}
|
104 |
+
} catch (error: unknown) {
|
105 |
+
if (error instanceof AxiosError) {
|
106 |
+
ElMessage.error('失败:' + error.message)
|
107 |
+
} else {
|
108 |
+
ElMessage.error('未知错误:' + error)
|
109 |
+
}
|
110 |
+
}
|
111 |
+
}
|
112 |
+
})
|
113 |
+
</script>
|
114 |
+
|
115 |
+
<template>
|
116 |
+
<div>
|
117 |
+
<!-- 返回栏 -->
|
118 |
+
<el-page-header @back="router.push({ name: 'ProductList' })" title="返回">
|
119 |
+
<template #content>
|
120 |
+
<div class="flex items-center">
|
121 |
+
<span class="text-large font-600 mr-3">
|
122 |
+
{{ props.productId ? '编辑' : '新建' }}商品
|
123 |
+
</span>
|
124 |
+
</div>
|
125 |
+
</template>
|
126 |
+
<template #extra>
|
127 |
+
<div class="flex items-center">
|
128 |
+
<el-button type="primary" class="ml-2" @click="onSubmit" :loading="saveLoading"
|
129 |
+
>保存</el-button
|
130 |
+
>
|
131 |
+
</div>
|
132 |
+
</template>
|
133 |
+
</el-page-header>
|
134 |
+
<el-card>
|
135 |
+
<template #header>
|
136 |
+
<!-- 步骤条 -->
|
137 |
+
<el-steps :active="currentStep" finish-status="success" align-center>
|
138 |
+
<el-step title="头图 & 说明书" @click="currentStep = 0" />
|
139 |
+
<el-step title="商品信息" @click="currentStep = 1" />
|
140 |
+
</el-steps>
|
141 |
+
</template>
|
142 |
+
<!-- 表单 -->
|
143 |
+
<!-- label-width 用于对其 el-form-item 标签 -->
|
144 |
+
<el-form :model="productInfo" label-width="120" size="large">
|
145 |
+
<div v-show="currentStep === 0">
|
146 |
+
<!-- 商品头图 & 说明书-->
|
147 |
+
<el-form-item label="商品图片">
|
148 |
+
<FileUpload v-model="productInfo.image_path" file-type="image" />
|
149 |
+
</el-form-item>
|
150 |
+
|
151 |
+
<el-form-item label="商品说明书">
|
152 |
+
<FileUpload v-model="productInfo.instruction" file-type="doc" />
|
153 |
+
</el-form-item>
|
154 |
+
</div>
|
155 |
+
|
156 |
+
<div v-show="currentStep === 1">
|
157 |
+
<!-- 商品信息 -->
|
158 |
+
|
159 |
+
<el-form-item label="商品名称">
|
160 |
+
<el-input v-model="productInfo.product_name" maxlength="50" show-word-limit />
|
161 |
+
</el-form-item>
|
162 |
+
<el-form-item label="商品分类">
|
163 |
+
<!-- TODO 改为下拉框 -->
|
164 |
+
<el-input v-model="productInfo.product_class" />
|
165 |
+
</el-form-item>
|
166 |
+
<el-form-item label="商品亮点">
|
167 |
+
<el-tag
|
168 |
+
v-for="(heighlights, index) in heighlights_list"
|
169 |
+
:key="index"
|
170 |
+
:disable-transitions="false"
|
171 |
+
closable
|
172 |
+
@close="handleHeighlightClose(heighlights)"
|
173 |
+
round
|
174 |
+
size="large"
|
175 |
+
style="margin: 3px"
|
176 |
+
>
|
177 |
+
{{ heighlights }}
|
178 |
+
</el-tag>
|
179 |
+
|
180 |
+
<el-input
|
181 |
+
v-if="inputHeighlightVisible"
|
182 |
+
ref="InputHeighlightRef"
|
183 |
+
v-model="inputHeighlightValue"
|
184 |
+
class="w-20"
|
185 |
+
@keyup.enter="handleHeighlightInputConfirm"
|
186 |
+
@blur="handleHeighlightInputConfirm"
|
187 |
+
size="large"
|
188 |
+
/>
|
189 |
+
<el-button
|
190 |
+
v-else
|
191 |
+
@click="showHeighlightInput"
|
192 |
+
circle
|
193 |
+
:icon="Plus"
|
194 |
+
type="primary"
|
195 |
+
plain
|
196 |
+
size="small"
|
197 |
+
:disabled="heighlights_list.length > 7"
|
198 |
+
>
|
199 |
+
</el-button>
|
200 |
+
</el-form-item>
|
201 |
+
<el-form-item label="价格">
|
202 |
+
<el-input-number
|
203 |
+
v-model="productInfo.selling_price"
|
204 |
+
:min="0.01"
|
205 |
+
:precision="2"
|
206 |
+
:step="0.1"
|
207 |
+
:max="99999"
|
208 |
+
size="large"
|
209 |
+
/>
|
210 |
+
</el-form-item>
|
211 |
+
<el-form-item label="库存数量">
|
212 |
+
<el-input-number
|
213 |
+
v-model="productInfo.amount"
|
214 |
+
:min="0"
|
215 |
+
:step="50"
|
216 |
+
:max="99999"
|
217 |
+
size="large"
|
218 |
+
/>
|
219 |
+
</el-form-item>
|
220 |
+
<el-form-item label="发货地">
|
221 |
+
<!-- TODO 改为下拉框选择 ? -->
|
222 |
+
<el-input v-model="productInfo.departure_place" maxlength="100" show-word-limit />
|
223 |
+
</el-form-item>
|
224 |
+
<el-form-item label="快递公司">
|
225 |
+
<!-- TODO 改为下拉框 -->
|
226 |
+
<el-input v-model="productInfo.delivery_company" maxlength="50" show-word-limit />
|
227 |
+
</el-form-item>
|
228 |
+
<div class="bottom-gen-btn">
|
229 |
+
<el-button type="success" disabled> AI 生成 (comming soon) </el-button>
|
230 |
+
</div>
|
231 |
+
</div>
|
232 |
+
</el-form>
|
233 |
+
|
234 |
+
<template #footer>
|
235 |
+
<div class="form-bottom-btn">
|
236 |
+
<el-button v-show="currentStep > 0" @click="currentStep--" :disabled="saveLoading"
|
237 |
+
>上一步</el-button
|
238 |
+
>
|
239 |
+
<el-button v-show="currentStep < 1" @click="currentStep++">下一步</el-button>
|
240 |
+
<el-button
|
241 |
+
v-show="currentStep === 1"
|
242 |
+
type="primary"
|
243 |
+
@click="onSubmit"
|
244 |
+
:loading="saveLoading"
|
245 |
+
>保存</el-button
|
246 |
+
>
|
247 |
+
</div>
|
248 |
+
</template>
|
249 |
+
</el-card>
|
250 |
+
</div>
|
251 |
+
</template>
|
252 |
+
|
253 |
+
<style lang="scss" scoped>
|
254 |
+
.el-card {
|
255 |
+
width: auto;
|
256 |
+
margin-top: 20px;
|
257 |
+
}
|
258 |
+
|
259 |
+
// 步骤条
|
260 |
+
.el-step {
|
261 |
+
cursor: pointer; // 鼠标移动过去显示鼠标变成 pointer 手指
|
262 |
+
}
|
263 |
+
|
264 |
+
// 底部按钮
|
265 |
+
.form-bottom-btn {
|
266 |
+
display: flex;
|
267 |
+
justify-content: center;
|
268 |
+
align-items: center;
|
269 |
+
}
|
270 |
+
|
271 |
+
// 中间表单控件
|
272 |
+
.el-form {
|
273 |
+
padding: 0px 180px 0px 100px; // 从上开始顺时针四个放心
|
274 |
+
}
|
275 |
+
|
276 |
+
// 每个表单底部 AI 生成按钮
|
277 |
+
.bottom-gen-btn {
|
278 |
+
margin-top: 15px;
|
279 |
+
margin-left: 70px;
|
280 |
+
display: flex;
|
281 |
+
justify-content: center;
|
282 |
+
align-items: center;
|
283 |
+
}
|
284 |
+
</style>
|
frontend/src/views/product/ProductListView.vue
ADDED
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onMounted, ref } from 'vue'
|
3 |
+
import { useRouter } from 'vue-router'
|
4 |
+
import { Plus, Delete, Edit } from '@element-plus/icons-vue'
|
5 |
+
import { ElMessage, ElMessageBox } from 'element-plus'
|
6 |
+
|
7 |
+
import InfoDialogComponents from '@/components/InfoDialogComponents.vue'
|
8 |
+
|
9 |
+
import {
|
10 |
+
productListRequest,
|
11 |
+
deleteProductByIdRequest,
|
12 |
+
type ProductListType,
|
13 |
+
type ProductData
|
14 |
+
} from '@/api/product'
|
15 |
+
import { AxiosError } from 'axios'
|
16 |
+
|
17 |
+
const router = useRouter()
|
18 |
+
|
19 |
+
// 信息弹窗显示标识
|
20 |
+
const ShowItemInfo = ref()
|
21 |
+
|
22 |
+
// 加载框
|
23 |
+
const tableLoading = ref(true)
|
24 |
+
|
25 |
+
// 查询 - 条件
|
26 |
+
const queryCondition = ref<ProductListType>({
|
27 |
+
currentPage: 1,
|
28 |
+
pageSize: 10
|
29 |
+
} as ProductListType)
|
30 |
+
|
31 |
+
// 查询 - 结果
|
32 |
+
const queriedResult = ref<ProductData>({} as ProductData)
|
33 |
+
|
34 |
+
// 查询 - 方法
|
35 |
+
const getProductList = async (params: ProductListType = {}) => {
|
36 |
+
tableLoading.value = true
|
37 |
+
|
38 |
+
Object.assign(queryCondition.value, params) // 用于外部灵活使用,传参的字典更新
|
39 |
+
|
40 |
+
try {
|
41 |
+
const { data } = await productListRequest(queryCondition.value)
|
42 |
+
tableLoading.value = false
|
43 |
+
|
44 |
+
if (data.code === 0) {
|
45 |
+
queriedResult.value = data.data
|
46 |
+
} else {
|
47 |
+
ElMessage.error('商品接口错误')
|
48 |
+
throw new Error('商品接口错误')
|
49 |
+
}
|
50 |
+
} catch (error: unknown) {
|
51 |
+
if (error instanceof AxiosError) {
|
52 |
+
ElMessage.error('商品接口失败: ' + error.message)
|
53 |
+
} else {
|
54 |
+
ElMessage.error('未知错误:' + error)
|
55 |
+
}
|
56 |
+
}
|
57 |
+
}
|
58 |
+
|
59 |
+
onMounted(() => {
|
60 |
+
// 获取商品信息
|
61 |
+
getProductList()
|
62 |
+
})
|
63 |
+
|
64 |
+
const isDelecting = ref(false)
|
65 |
+
const DeleteProduct = async (id: number, productName: string) => {
|
66 |
+
ElMessageBox.confirm(`确定要删除 "${productName}" 吗?`, '警告', {
|
67 |
+
confirmButtonText: '确定',
|
68 |
+
cancelButtonText: '取消',
|
69 |
+
type: 'warning',
|
70 |
+
showClose: false
|
71 |
+
})
|
72 |
+
.then(async () => {
|
73 |
+
ElMessage.success(`正在删除 ${productName},请稍候`)
|
74 |
+
isDelecting.value = true
|
75 |
+
const { data } = await deleteProductByIdRequest(id)
|
76 |
+
if (data.code === 0) {
|
77 |
+
ElMessage.success('删除成功')
|
78 |
+
getProductList()
|
79 |
+
} else {
|
80 |
+
ElMessage.error('删除失败')
|
81 |
+
}
|
82 |
+
isDelecting.value = false
|
83 |
+
})
|
84 |
+
.catch(() => {
|
85 |
+
// ElMessage({
|
86 |
+
// type: 'info',
|
87 |
+
// message: 'Input canceled'
|
88 |
+
// })
|
89 |
+
})
|
90 |
+
}
|
91 |
+
</script>
|
92 |
+
|
93 |
+
<template>
|
94 |
+
<div>
|
95 |
+
<el-card shadow="never">
|
96 |
+
<template #header>
|
97 |
+
<!-- 头部 -->
|
98 |
+
<div class="card-header">
|
99 |
+
<div>
|
100 |
+
<el-form :inline="true" :model="queriedResult.product_list">
|
101 |
+
<el-form-item label="搜索">
|
102 |
+
<el-input
|
103 |
+
style="width: 240px"
|
104 |
+
v-model="queryCondition.productName"
|
105 |
+
placeholder="商品名称"
|
106 |
+
clearable
|
107 |
+
/>
|
108 |
+
</el-form-item>
|
109 |
+
<!-- <el-form-item label="分类">
|
110 |
+
<el-select v-model="queriedResult.class" placeholder="">
|
111 |
+
<el-option label="全部" value="" />
|
112 |
+
<el-option label="电子" value="电子" />
|
113 |
+
</el-select>
|
114 |
+
</el-form-item> -->
|
115 |
+
<el-form-item>
|
116 |
+
<el-button type="primary" @click="() => getProductList({ currentPage: 1 })">
|
117 |
+
查询
|
118 |
+
</el-button>
|
119 |
+
</el-form-item>
|
120 |
+
</el-form>
|
121 |
+
</div>
|
122 |
+
<div>
|
123 |
+
<!-- 添加商品 -->
|
124 |
+
<el-button type="primary" @click="router.push({ name: 'ProductCreate' })">
|
125 |
+
<el-icon style="margin-right: 5px">
|
126 |
+
<Plus />
|
127 |
+
</el-icon>
|
128 |
+
添加商品
|
129 |
+
</el-button>
|
130 |
+
</div>
|
131 |
+
</div>
|
132 |
+
</template>
|
133 |
+
|
134 |
+
<!-- 中部表格信息-->
|
135 |
+
<el-table :data="queriedResult.product_list" max-height="1000" v-loading="tableLoading">
|
136 |
+
<el-table-column prop="product_id" label="ID" align="center" width="50px" />
|
137 |
+
|
138 |
+
<el-table-column prop="image_path" label="图片" align="center">
|
139 |
+
<template #default="scope">
|
140 |
+
<div style="display: flex; align-items: center">
|
141 |
+
<!-- TODO 加上 :preview-src-list="[scope.row.image_path]" -->
|
142 |
+
<el-image :src="scope.row.image_path" />
|
143 |
+
</div>
|
144 |
+
</template>
|
145 |
+
</el-table-column>
|
146 |
+
|
147 |
+
<el-table-column prop="product_name" label="名称" align="center" />
|
148 |
+
<el-table-column prop="product_class" label="分类" align="center" />
|
149 |
+
<el-table-column prop="heighlights" label="亮点" align="center" />
|
150 |
+
<el-table-column prop="selling_price" label="价格" align="center" />
|
151 |
+
<el-table-column prop="amount" label="库存" align="center" />
|
152 |
+
<el-table-column prop="departure_place" label="发货地" align="center" />
|
153 |
+
<el-table-column prop="delivery_company" label="快递公司" align="center" />
|
154 |
+
<el-table-column prop="upload_date" label="上传时��" align="center" />
|
155 |
+
<el-table-column label="操作" v-slot="{ row }" align="center" width="250px">
|
156 |
+
<div class="control-item">
|
157 |
+
<el-button
|
158 |
+
:type="row.instruction !== '' ? 'success' : 'warning'"
|
159 |
+
:disabled="row.instruction === ''"
|
160 |
+
@click="
|
161 |
+
ShowItemInfo.showItemInfoDialog(
|
162 |
+
row.product_name,
|
163 |
+
'Instruction',
|
164 |
+
row.instruction,
|
165 |
+
row.product_id
|
166 |
+
)
|
167 |
+
"
|
168 |
+
>
|
169 |
+
说明书
|
170 |
+
</el-button>
|
171 |
+
|
172 |
+
<!-- 编辑按钮 -->
|
173 |
+
<el-button
|
174 |
+
type="primary"
|
175 |
+
:icon="Edit"
|
176 |
+
@click="router.push({ name: 'ProductEdit', params: { productId: row.product_id } })"
|
177 |
+
/>
|
178 |
+
|
179 |
+
<!-- 删除按钮 -->
|
180 |
+
<el-button
|
181 |
+
type="danger"
|
182 |
+
@click="DeleteProduct(row.product_id, row.product_name)"
|
183 |
+
:icon="Delete"
|
184 |
+
:id="row.product_id"
|
185 |
+
:disabled="isDelecting"
|
186 |
+
/>
|
187 |
+
</div>
|
188 |
+
</el-table-column>
|
189 |
+
</el-table>
|
190 |
+
|
191 |
+
<!-- 信息弹窗 -->
|
192 |
+
<InfoDialogComponents ref="ShowItemInfo" />
|
193 |
+
|
194 |
+
<!-- 分页栏 -->
|
195 |
+
<template #footer>
|
196 |
+
<!-- TODO 保持在页面最低位置 -->
|
197 |
+
<el-pagination
|
198 |
+
v-model:current-page="queriedResult.currentPage"
|
199 |
+
v-model:page-size="queriedResult.pageSize"
|
200 |
+
:page-sizes="[5, 10, 15, 20]"
|
201 |
+
:background="true"
|
202 |
+
layout="total, sizes, prev, pager, next, jumper"
|
203 |
+
:total="queriedResult.totalSize || 0"
|
204 |
+
@size-change="(pageSize: number) => getProductList({ pageSize, currentPage: 1 })"
|
205 |
+
@current-change="(currentPage: number) => getProductList({ currentPage })"
|
206 |
+
/>
|
207 |
+
</template>
|
208 |
+
</el-card>
|
209 |
+
</div>
|
210 |
+
</template>
|
211 |
+
|
212 |
+
<style lang="scss" scoped>
|
213 |
+
.el-card {
|
214 |
+
border-radius: 12px;
|
215 |
+
}
|
216 |
+
|
217 |
+
.box-card {
|
218 |
+
width: auto; // 卡片宽度
|
219 |
+
}
|
220 |
+
|
221 |
+
// 查询栏
|
222 |
+
.card-header {
|
223 |
+
display: flex;
|
224 |
+
justify-content: space-between; // 将两个 div 放置在页面的两侧
|
225 |
+
align-items: center;
|
226 |
+
|
227 |
+
.el-form-item {
|
228 |
+
margin-bottom: 0px; // 查询框和下面的组件间隔大小
|
229 |
+
}
|
230 |
+
}
|
231 |
+
|
232 |
+
// 分页框
|
233 |
+
.el-pagination {
|
234 |
+
margin-top: 10px; // 距离上面的控件有一定的距离
|
235 |
+
display: flex;
|
236 |
+
justify-content: center;
|
237 |
+
align-items: center;
|
238 |
+
}
|
239 |
+
|
240 |
+
// 操作栏
|
241 |
+
.control-item {
|
242 |
+
display: flex;
|
243 |
+
align-items: center;
|
244 |
+
}
|
245 |
+
|
246 |
+
// 去掉表格下边框线
|
247 |
+
:deep(.el-table__inner-wrapper::before) {
|
248 |
+
height: 0;
|
249 |
+
}
|
250 |
+
</style>
|
frontend/src/views/streaming/StreamingOnAirView.vue
ADDED
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { nextTick, onMounted, ref } from 'vue'
|
3 |
+
import { useRouter } from 'vue-router'
|
4 |
+
import { ChatDotRound, Microphone, VideoPause } from '@element-plus/icons-vue'
|
5 |
+
|
6 |
+
import VideoComponent from '@/components/VideoComponent.vue'
|
7 |
+
import MessageComponent from '@/components/MessageComponent.vue'
|
8 |
+
import {
|
9 |
+
streamRoomOffline,
|
10 |
+
genAsrResult,
|
11 |
+
onAirRoomChatRequest,
|
12 |
+
onAirRoomInfoRequest,
|
13 |
+
onAirRoomNextProductRequest,
|
14 |
+
sendAudioToServer,
|
15 |
+
type StreamingRoomStatusItem
|
16 |
+
} from '@/api/streamingRoom'
|
17 |
+
import type { ProductItem, StreamerInfo } from '@/api/product'
|
18 |
+
import { ElMessage, ElMessageBox } from 'element-plus'
|
19 |
+
import { AxiosError } from 'axios'
|
20 |
+
import { getUserInfoRequest, type UserInfo } from '@/api/user'
|
21 |
+
|
22 |
+
const router = useRouter()
|
23 |
+
|
24 |
+
// 定义传参
|
25 |
+
const props = defineProps({
|
26 |
+
roomId: {
|
27 |
+
type: String,
|
28 |
+
default: '0'
|
29 |
+
}
|
30 |
+
})
|
31 |
+
|
32 |
+
// 用户信息
|
33 |
+
const userInfoItem = ref({} as UserInfo)
|
34 |
+
|
35 |
+
const getUserInfo = async () => {
|
36 |
+
try {
|
37 |
+
const { data } = await getUserInfoRequest()
|
38 |
+
|
39 |
+
if (data.code === 0) {
|
40 |
+
userInfoItem.value = data.data
|
41 |
+
} else {
|
42 |
+
ElMessage.error('获取用户信息失败: ' + data.message)
|
43 |
+
}
|
44 |
+
} catch (error: unknown) {
|
45 |
+
if (error instanceof AxiosError) {
|
46 |
+
ElMessage.error('获取用户信息失败: ' + error.message)
|
47 |
+
} else {
|
48 |
+
ElMessage.error('未知错误:' + error)
|
49 |
+
}
|
50 |
+
}
|
51 |
+
}
|
52 |
+
|
53 |
+
// 输入框
|
54 |
+
const inputValue = ref('')
|
55 |
+
|
56 |
+
const currentStatus = ref({} as StreamingRoomStatusItem)
|
57 |
+
currentStatus.value.currentProductInfo = {} as ProductItem
|
58 |
+
currentStatus.value.streamerInfo = {} as StreamerInfo
|
59 |
+
|
60 |
+
const getRoomInfo = async () => {
|
61 |
+
// 获取主播视频地址
|
62 |
+
// 获取商品信息,显示在右下角的商品缩略图
|
63 |
+
// 获取后端对话记录 messageList ,进行渲染
|
64 |
+
try {
|
65 |
+
const { data } = await onAirRoomInfoRequest(Number(props.roomId))
|
66 |
+
if (data.code === 0) {
|
67 |
+
currentStatus.value = data.data
|
68 |
+
console.info(currentStatus.value)
|
69 |
+
} else {
|
70 |
+
ElMessage.error('获取直播间信息失败' + data.message)
|
71 |
+
}
|
72 |
+
} catch (error: unknown) {
|
73 |
+
if (error instanceof AxiosError) {
|
74 |
+
ElMessage.error('获取直播间信息失败' + error.message)
|
75 |
+
} else {
|
76 |
+
ElMessage.error('未知错误:' + error)
|
77 |
+
}
|
78 |
+
}
|
79 |
+
}
|
80 |
+
|
81 |
+
// 主播回答提示符
|
82 |
+
const loadingStreamRes = ref(false)
|
83 |
+
|
84 |
+
// 输入框使能与否
|
85 |
+
const disableInput = ref(false)
|
86 |
+
|
87 |
+
// 发送按钮
|
88 |
+
const handelSendClick = async () => {
|
89 |
+
console.info(inputValue.value)
|
90 |
+
// 显示在对话框-用户
|
91 |
+
currentStatus.value.conversation.push({
|
92 |
+
role: 'user',
|
93 |
+
userId: userInfoItem.value.user_id,
|
94 |
+
userName: userInfoItem.value.username,
|
95 |
+
avatar: userInfoItem.value.avatar,
|
96 |
+
message: inputValue.value,
|
97 |
+
send_time: ''
|
98 |
+
})
|
99 |
+
// disable 输入框
|
100 |
+
disableInput.value = true
|
101 |
+
// 显示 loading 图标 - 主播
|
102 |
+
loadingStreamRes.value = true
|
103 |
+
|
104 |
+
// request(roomId, userId, newValue)
|
105 |
+
// 将对话记录更新到数据库
|
106 |
+
try {
|
107 |
+
const { data } = await onAirRoomChatRequest(Number(props.roomId), inputValue.value)
|
108 |
+
|
109 |
+
// 取消 loading
|
110 |
+
loadingStreamRes.value = false
|
111 |
+
if (data.code === 0) {
|
112 |
+
// 更新 list
|
113 |
+
await getRoomInfo()
|
114 |
+
} else {
|
115 |
+
ElMessage.error('更新对话信息失败' + data.message)
|
116 |
+
}
|
117 |
+
} catch (error: unknown) {
|
118 |
+
if (error instanceof AxiosError) {
|
119 |
+
ElMessage.error('更新对话信息失败' + error.message)
|
120 |
+
} else {
|
121 |
+
ElMessage.error('未知错误:' + error)
|
122 |
+
}
|
123 |
+
}
|
124 |
+
|
125 |
+
// 启动输入框
|
126 |
+
disableInput.value = false
|
127 |
+
|
128 |
+
// 在 DOM 更新后滚动到底部
|
129 |
+
nextTick(scrollToBottom)
|
130 |
+
}
|
131 |
+
|
132 |
+
// 用于滚动条
|
133 |
+
const scrollbarRef = ref<HTMLElement | null>(null)
|
134 |
+
const scrollToBottom = async () => {
|
135 |
+
// 注意:需要通过 nextTick 以等待 DOM 更新完成
|
136 |
+
await nextTick()
|
137 |
+
if (scrollbarRef.value) {
|
138 |
+
// scrollbarRef.value.setScrollTop(10000) // TODO 先设置一个比较大的值,后续需要获取控件的高度进行赋值
|
139 |
+
scrollbarRef.value.scrollTop = 10000 // TODO 先设置一个比较大的值,后续需要获取控件的高度进行赋值
|
140 |
+
}
|
141 |
+
}
|
142 |
+
|
143 |
+
// 下一个商品
|
144 |
+
const handleNextProductClick = async () => {
|
145 |
+
try {
|
146 |
+
const { data } = await onAirRoomNextProductRequest(Number(props.roomId))
|
147 |
+
if (data.code === 0) {
|
148 |
+
console.info('Next Product')
|
149 |
+
await getRoomInfo()
|
150 |
+
} else {
|
151 |
+
ElMessage.error('失败' + data.message)
|
152 |
+
}
|
153 |
+
} catch (error: unknown) {
|
154 |
+
if (error instanceof AxiosError) {
|
155 |
+
ElMessage.error('失败' + error.message)
|
156 |
+
} else {
|
157 |
+
ElMessage.error('未知错误:' + error)
|
158 |
+
}
|
159 |
+
}
|
160 |
+
}
|
161 |
+
|
162 |
+
// 结束直播按钮
|
163 |
+
const handleOffLineClick = async () => {
|
164 |
+
ElMessageBox.confirm(`确定要下播吗?`, '警告', {
|
165 |
+
confirmButtonText: '确定',
|
166 |
+
cancelButtonText: '取消',
|
167 |
+
type: 'warning'
|
168 |
+
})
|
169 |
+
.then(async () => {
|
170 |
+
try {
|
171 |
+
const { data } = await streamRoomOffline(Number(props.roomId))
|
172 |
+
if (data.code === 0) {
|
173 |
+
ElMessage.success('下���成功')
|
174 |
+
router.push({ name: 'StreamingOverview' })
|
175 |
+
} else {
|
176 |
+
ElMessage.error('下播失败' + data.message)
|
177 |
+
}
|
178 |
+
} catch (error: unknown) {
|
179 |
+
if (error instanceof AxiosError) {
|
180 |
+
ElMessage.error('失败' + error.message)
|
181 |
+
} else {
|
182 |
+
ElMessage.error('未知错误:' + error)
|
183 |
+
}
|
184 |
+
}
|
185 |
+
})
|
186 |
+
.catch((error) => {
|
187 |
+
ElMessage.error('下播失败: ' + error.message)
|
188 |
+
})
|
189 |
+
}
|
190 |
+
|
191 |
+
onMounted(() => {
|
192 |
+
// 获取用户信息
|
193 |
+
getUserInfo()
|
194 |
+
|
195 |
+
// 获取直播间实时信息格信息
|
196 |
+
getRoomInfo()
|
197 |
+
})
|
198 |
+
|
199 |
+
// 录音
|
200 |
+
// 状态管理
|
201 |
+
const isRecording = ref(false)
|
202 |
+
let mediaRecorder: MediaRecorder | null = null
|
203 |
+
let chunks: Blob[] = []
|
204 |
+
let stream: MediaStream | null = null
|
205 |
+
|
206 |
+
// 开始录音
|
207 |
+
const startRecording = () => {
|
208 |
+
navigator.mediaDevices
|
209 |
+
.getUserMedia({ audio: true })
|
210 |
+
.then((s) => {
|
211 |
+
stream = s
|
212 |
+
mediaRecorder = new MediaRecorder(s)
|
213 |
+
mediaRecorder.start()
|
214 |
+
mediaRecorder.addEventListener('dataavailable', handleDataAvailable)
|
215 |
+
mediaRecorder.addEventListener('stop', handleStop)
|
216 |
+
})
|
217 |
+
.catch((err) => {
|
218 |
+
ElMessage.error('无法访问麦克风: ' + err.message)
|
219 |
+
})
|
220 |
+
}
|
221 |
+
|
222 |
+
// 停止录音
|
223 |
+
const stopRecording = () => {
|
224 |
+
if (mediaRecorder) {
|
225 |
+
mediaRecorder.stop()
|
226 |
+
}
|
227 |
+
if (stream) {
|
228 |
+
stream.getTracks().forEach((track) => track.stop())
|
229 |
+
}
|
230 |
+
}
|
231 |
+
|
232 |
+
// 切换录音状态
|
233 |
+
const handleRecord = () => {
|
234 |
+
if (isRecording.value) {
|
235 |
+
stopRecording()
|
236 |
+
} else {
|
237 |
+
startRecording()
|
238 |
+
}
|
239 |
+
isRecording.value = !isRecording.value
|
240 |
+
}
|
241 |
+
|
242 |
+
// 处理录音数据
|
243 |
+
const handleDataAvailable = (e: BlobEvent) => {
|
244 |
+
chunks.push(e.data)
|
245 |
+
}
|
246 |
+
|
247 |
+
// 处理录音停止事件
|
248 |
+
const handleStop = async () => {
|
249 |
+
const blob = new Blob(chunks, { type: 'audio/webm' })
|
250 |
+
try {
|
251 |
+
// 将 asr 文件发送到服务器
|
252 |
+
const { data } = await sendAudioToServer(blob)
|
253 |
+
if (data.code === 0) {
|
254 |
+
ElMessage.success('正在进行语音转文字,请稍候!')
|
255 |
+
// 调取接口开始进行 asr 识别
|
256 |
+
|
257 |
+
console.info(data)
|
258 |
+
const { data: asr_data } = await genAsrResult(Number(props.roomId), data.data)
|
259 |
+
ElMessage.success('语音转文字成功!')
|
260 |
+
|
261 |
+
// 自动进行对话
|
262 |
+
if (asr_data.code === 0) {
|
263 |
+
inputValue.value = asr_data.data
|
264 |
+
handelSendClick()
|
265 |
+
}
|
266 |
+
}
|
267 |
+
} catch (error: unknown) {
|
268 |
+
if (error instanceof AxiosError) {
|
269 |
+
ElMessage.error('语音转文字失败: ' + error.message)
|
270 |
+
} else {
|
271 |
+
ElMessage.error('未知错误:' + error)
|
272 |
+
}
|
273 |
+
}
|
274 |
+
chunks = []
|
275 |
+
}
|
276 |
+
</script>
|
277 |
+
|
278 |
+
<template>
|
279 |
+
<div>
|
280 |
+
<el-row :gutter="1">
|
281 |
+
<el-col :span="14">
|
282 |
+
<!-- 主播视频 -->
|
283 |
+
<VideoComponent
|
284 |
+
:src="currentStatus.currentStreamerVideo"
|
285 |
+
:key="currentStatus.currentStreamerVideo"
|
286 |
+
:autoplay="true"
|
287 |
+
:width="1300"
|
288 |
+
:height="1300"
|
289 |
+
:videoAfterEnd="currentStatus.streamerInfo.base_mp4_path"
|
290 |
+
style="display: flex; justify-content: center; align-items: center"
|
291 |
+
/>
|
292 |
+
</el-col>
|
293 |
+
<el-col :span="10">
|
294 |
+
<div>
|
295 |
+
<el-scrollbar height="1110px" ref="scrollbarRef" id="scrollbarRef">
|
296 |
+
<!-- 对话记录显示在右上角 -->
|
297 |
+
<MessageComponent
|
298 |
+
v-for="(item, index) in currentStatus.conversation"
|
299 |
+
:key="index"
|
300 |
+
:role="item.role"
|
301 |
+
:avatar="item.avatar"
|
302 |
+
:userName="item.userName"
|
303 |
+
:message="item.message"
|
304 |
+
:datetime="item.send_time"
|
305 |
+
/>
|
306 |
+
|
307 |
+
<!-- 加载标识 -->
|
308 |
+
<!-- <el-button :loading="loadingStreamRes" v-show="loadingStreamRes" /> -->
|
309 |
+
</el-scrollbar>
|
310 |
+
|
311 |
+
<!-- 聊天记录右下角商品展示 -->
|
312 |
+
<div class="floating-card">
|
313 |
+
<el-card shadow="never">
|
314 |
+
<div class="product-info">
|
315 |
+
<p class="title">当前商品</p>
|
316 |
+
|
317 |
+
<!-- 商品图片 -->
|
318 |
+
<el-image
|
319 |
+
style="width: 100px; height: 100px"
|
320 |
+
:src="currentStatus.currentProductInfo.image_path"
|
321 |
+
fit="contain"
|
322 |
+
/>
|
323 |
+
|
324 |
+
<!-- 商品信息 -->
|
325 |
+
<p class="title">{{ currentStatus.currentProductInfo.product_name }}</p>
|
326 |
+
<p class="content">{{ currentStatus.currentProductInfo.heighlights }}</p>
|
327 |
+
<p class="price">¥ {{ currentStatus.currentProductInfo.selling_price }}</p>
|
328 |
+
</div>
|
329 |
+
</el-card>
|
330 |
+
</div>
|
331 |
+
|
332 |
+
<!-- 对话框 -->
|
333 |
+
<div class="bottom-items">
|
334 |
+
<el-button
|
335 |
+
circle
|
336 |
+
size="large"
|
337 |
+
:type="isRecording ? 'danger' : 'primary'"
|
338 |
+
@click="handleRecord"
|
339 |
+
>
|
340 |
+
<el-icon v-if="!isRecording">
|
341 |
+
<Microphone />
|
342 |
+
</el-icon>
|
343 |
+
<el-icon v-else>
|
344 |
+
<VideoPause />
|
345 |
+
</el-icon>
|
346 |
+
</el-button>
|
347 |
+
<el-input
|
348 |
+
v-model="inputValue"
|
349 |
+
style="width: 100%; border-radius: 8px; margin: 0px 10px 0px 10px"
|
350 |
+
:autosize="{ minRows: 2, maxRows: 12 }"
|
351 |
+
type="textarea"
|
352 |
+
placeholder="向主播提问"
|
353 |
+
:disabled="disableInput"
|
354 |
+
size="large"
|
355 |
+
/>
|
356 |
+
<el-button circle @click="handelSendClick" size="large">
|
357 |
+
<el-icon>
|
358 |
+
<ChatDotRound />
|
359 |
+
</el-icon>
|
360 |
+
</el-button>
|
361 |
+
</div>
|
362 |
+
|
363 |
+
<div style="margin-top: 10px">
|
364 |
+
<div class="bottom-button">
|
365 |
+
<el-button
|
366 |
+
type="primary"
|
367 |
+
@click="handleNextProductClick"
|
368 |
+
v-show="!currentStatus.finalProduct"
|
369 |
+
>
|
370 |
+
下一个商品
|
371 |
+
</el-button>
|
372 |
+
<el-button type="danger" @click="handleOffLineClick">下播</el-button>
|
373 |
+
</div>
|
374 |
+
</div>
|
375 |
+
</div>
|
376 |
+
</el-col>
|
377 |
+
</el-row>
|
378 |
+
</div>
|
379 |
+
</template>
|
380 |
+
|
381 |
+
<style lang="scss" scoped>
|
382 |
+
.bottom-items {
|
383 |
+
margin-top: 10px; // 距离上面的控件有一定的距离
|
384 |
+
display: flex;
|
385 |
+
align-items: center;
|
386 |
+
width: auto;
|
387 |
+
}
|
388 |
+
|
389 |
+
.bottom-button {
|
390 |
+
display: flex;
|
391 |
+
margin-top: 10px;
|
392 |
+
float: right;
|
393 |
+
}
|
394 |
+
|
395 |
+
::v-deep(.el-input__wrapper) {
|
396 |
+
border-radius: 14px;
|
397 |
+
}
|
398 |
+
|
399 |
+
.el-card {
|
400 |
+
margin-top: 10px;
|
401 |
+
border-radius: 10px;
|
402 |
+
}
|
403 |
+
|
404 |
+
.floating-card {
|
405 |
+
position: absolute;
|
406 |
+
right: 10px; /* 调整到右边的距离 */
|
407 |
+
bottom: 200px; /* 调整到底部的距离 */
|
408 |
+
z-index: 30; /* 确保 card 显示在最上层 */
|
409 |
+
width: 200px; /* 可以根据需要调整 */
|
410 |
+
|
411 |
+
.product-info {
|
412 |
+
display: flex;
|
413 |
+
flex-direction: column; /* 将子元素垂直排列 */
|
414 |
+
justify-content: center; /* 垂直居中 */
|
415 |
+
align-items: center; /* 水平居中 */
|
416 |
+
|
417 |
+
.title {
|
418 |
+
font-size: 18px;
|
419 |
+
font-weight: 600;
|
420 |
+
}
|
421 |
+
|
422 |
+
.content {
|
423 |
+
font-size: 15px;
|
424 |
+
color: #b1b3b8;
|
425 |
+
}
|
426 |
+
|
427 |
+
.price {
|
428 |
+
font-size: 16px;
|
429 |
+
color: #fda100;
|
430 |
+
}
|
431 |
+
}
|
432 |
+
}
|
433 |
+
</style>
|
frontend/src/views/streaming/StreamingRoomListView.vue
ADDED
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onMounted, ref, computed } from 'vue'
|
3 |
+
import { useRouter } from 'vue-router'
|
4 |
+
import { ElMessage, ElMessageBox } from 'element-plus/es'
|
5 |
+
import { Plus, Delete } from '@element-plus/icons-vue'
|
6 |
+
|
7 |
+
import {
|
8 |
+
type StreamingRoomInfo,
|
9 |
+
streamerRoomListRequest,
|
10 |
+
deleteStreamingRoomByIdRequest
|
11 |
+
} from '@/api/streamingRoom'
|
12 |
+
import { AxiosError } from 'axios'
|
13 |
+
|
14 |
+
// 获取主播信息
|
15 |
+
const streamingList = ref([] as StreamingRoomInfo[])
|
16 |
+
const router = useRouter()
|
17 |
+
|
18 |
+
onMounted(async () => {
|
19 |
+
try {
|
20 |
+
// 获取直播间信息
|
21 |
+
const { data } = await streamerRoomListRequest()
|
22 |
+
if (data.code === 0) {
|
23 |
+
streamingList.value = data.data
|
24 |
+
ElMessage.success('获取直播间信息成功')
|
25 |
+
} else {
|
26 |
+
ElMessage.error('获取直播间信息失败' + data.message)
|
27 |
+
}
|
28 |
+
} catch (error: unknown) {
|
29 |
+
if (error instanceof AxiosError) {
|
30 |
+
ElMessage.error('获取直播间信息失败' + error.message)
|
31 |
+
} else {
|
32 |
+
ElMessage.error('未知错误:' + error)
|
33 |
+
}
|
34 |
+
}
|
35 |
+
})
|
36 |
+
|
37 |
+
const DeleteStreamingRoom = async (id: number, productName: string) => {
|
38 |
+
ElMessageBox.confirm(`确定要删除 "${productName}" 吗?`, '警告', {
|
39 |
+
confirmButtonText: '确定',
|
40 |
+
cancelButtonText: '取消',
|
41 |
+
type: 'warning'
|
42 |
+
})
|
43 |
+
.then(async () => {
|
44 |
+
const { data } = await deleteStreamingRoomByIdRequest(id)
|
45 |
+
if (data.code === 0) {
|
46 |
+
ElMessage.success('删除成功')
|
47 |
+
// 获取直播间信息
|
48 |
+
const { data } = await streamerRoomListRequest()
|
49 |
+
if (data.code === 0) {
|
50 |
+
streamingList.value = data.data
|
51 |
+
ElMessage.success('获取直播间信息成功')
|
52 |
+
}
|
53 |
+
} else {
|
54 |
+
ElMessage.error('删除失败')
|
55 |
+
}
|
56 |
+
})
|
57 |
+
.catch(() => {
|
58 |
+
ElMessage.error('删除失败')
|
59 |
+
})
|
60 |
+
}
|
61 |
+
|
62 |
+
const chunkArray = (array: StreamingRoomInfo[], chunkSize: number) => {
|
63 |
+
// 切割每 n 个为一行(即一个数组),方便后续进行 v-for 遍历
|
64 |
+
const result = []
|
65 |
+
for (let i = 0; i < array.length; i += chunkSize) {
|
66 |
+
result.push(array.slice(i, i + chunkSize))
|
67 |
+
}
|
68 |
+
return result
|
69 |
+
}
|
70 |
+
|
71 |
+
const chunkedArray = computed(() => chunkArray(streamingList.value, 4))
|
72 |
+
|
73 |
+
const tagMap = { 0: '未开播', 1: '直播中', 2: '已下播' }
|
74 |
+
</script>
|
75 |
+
|
76 |
+
<template>
|
77 |
+
<div>
|
78 |
+
<div style="margin-bottom: 20px">
|
79 |
+
<el-button @click="router.push({ name: 'StreamingCreate' })" type="primary">
|
80 |
+
<el-icon style="margin-right: 5px">
|
81 |
+
<Plus />
|
82 |
+
</el-icon>
|
83 |
+
|
84 |
+
新建直播间
|
85 |
+
</el-button>
|
86 |
+
</div>
|
87 |
+
<div v-for="(row, rowIndex) in chunkedArray" :key="rowIndex" class="row">
|
88 |
+
<el-row :gutter="20">
|
89 |
+
<el-col v-for="(item, index) in row" :key="index" :span="8">
|
90 |
+
<el-card style="max-width: 480px">
|
91 |
+
<img :src="item.room_poster" style="width: 100%" />
|
92 |
+
<div class="title">
|
93 |
+
<h3>{{ item.name }}</h3>
|
94 |
+
<el-tag type="success" effect="light">
|
95 |
+
{{ tagMap[item.status.live_status as keyof typeof tagMap] }}
|
96 |
+
</el-tag>
|
97 |
+
</div>
|
98 |
+
<div>
|
99 |
+
<p>主播:{{ item.streamer_info.name }}</p>
|
100 |
+
<p>商品数:{{ item.product_list.length }}</p>
|
101 |
+
<p>
|
102 |
+
开播时间: {{ item.status.live_status === 1 ? item.status.start_time : '未开播' }}
|
103 |
+
</p>
|
104 |
+
</div>
|
105 |
+
<div class="bottom-button">
|
106 |
+
<el-button
|
107 |
+
type="primary"
|
108 |
+
@click="router.push({ name: 'StreamingEdit', params: { roomId: item.room_id } })"
|
109 |
+
>
|
110 |
+
编辑直播间
|
111 |
+
</el-button>
|
112 |
+
<el-button
|
113 |
+
type="primary"
|
114 |
+
:disabled="item.status.live_status !== 1"
|
115 |
+
@click="router.push({ name: 'StreamingOnAir', params: { roomId: item.room_id } })"
|
116 |
+
>
|
117 |
+
进入直播间
|
118 |
+
</el-button>
|
119 |
+
<el-button
|
120 |
+
type="danger"
|
121 |
+
:disabled="item.status.live_status === 1"
|
122 |
+
:icon="Delete"
|
123 |
+
@click="DeleteStreamingRoom(item.room_id, item.name)"
|
124 |
+
/>
|
125 |
+
</div>
|
126 |
+
</el-card>
|
127 |
+
</el-col>
|
128 |
+
</el-row>
|
129 |
+
</div>
|
130 |
+
</div>
|
131 |
+
</template>
|
132 |
+
|
133 |
+
<style lang="scss" scoped>
|
134 |
+
.row {
|
135 |
+
margin-bottom: 20px;
|
136 |
+
}
|
137 |
+
|
138 |
+
.el-card {
|
139 |
+
padding: 20px;
|
140 |
+
border-radius: 20px;
|
141 |
+
}
|
142 |
+
|
143 |
+
.title {
|
144 |
+
display: flex;
|
145 |
+
justify-content: space-between;
|
146 |
+
|
147 |
+
.h3 {
|
148 |
+
font-size: 50px;
|
149 |
+
font-weight: 600;
|
150 |
+
margin: 24px 0px 8px 0px;
|
151 |
+
}
|
152 |
+
}
|
153 |
+
|
154 |
+
.bottom-button {
|
155 |
+
margin-top: 20px; // 距离上面的控件有一定的距离
|
156 |
+
display: flex;
|
157 |
+
justify-content: center;
|
158 |
+
align-items: center;
|
159 |
+
}
|
160 |
+
</style>
|
frontend/src/views/streaming/StreamingRoomeEditView.vue
ADDED
@@ -0,0 +1,544 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { useRouter } from 'vue-router'
|
3 |
+
import { onMounted, ref } from 'vue'
|
4 |
+
import { ElMessage } from 'element-plus'
|
5 |
+
|
6 |
+
import { Check, Warning, Edit } from '@element-plus/icons-vue'
|
7 |
+
import {
|
8 |
+
roomDetailRequest,
|
9 |
+
roomPorductAddListRequest,
|
10 |
+
RoomCreadeOrEditRequest,
|
11 |
+
type RoomProductData,
|
12 |
+
type RoomDetailItem,
|
13 |
+
type StreamingRoomStatusItem,
|
14 |
+
type StreamingRoomProductList,
|
15 |
+
onAirRoomStartRequest
|
16 |
+
} from '@/api/streamingRoom'
|
17 |
+
import type { StreamerInfo } from '@/api/streamerInfo'
|
18 |
+
import InfoDialogComponents from '@/components/InfoDialogComponents.vue'
|
19 |
+
import { streamerInfoListRequest } from '@/api/streamerInfo'
|
20 |
+
import StreamerInfoComponent from '@/components/StreamerInfoComponent.vue'
|
21 |
+
import { AxiosError } from 'axios'
|
22 |
+
|
23 |
+
const router = useRouter()
|
24 |
+
|
25 |
+
// 定义 URL ID 传参
|
26 |
+
const props = defineProps({
|
27 |
+
roomId: {
|
28 |
+
type: String,
|
29 |
+
default: '0'
|
30 |
+
}
|
31 |
+
})
|
32 |
+
|
33 |
+
// 信息弹窗显示标识
|
34 |
+
const ShowItemInfo = ref()
|
35 |
+
|
36 |
+
// 侧边栏
|
37 |
+
const DrawerProductList = ref({} as RoomProductData)
|
38 |
+
DrawerProductList.value.product_list = [] // 设置默认值
|
39 |
+
DrawerProductList.value.totalSize = 0
|
40 |
+
|
41 |
+
const currentPage = ref(1)
|
42 |
+
const pageSize = ref(-1)
|
43 |
+
|
44 |
+
const getProductInfo = async () => {
|
45 |
+
try {
|
46 |
+
// 调用接口获取商品缩略图用于抽屉
|
47 |
+
const { data } = await roomPorductAddListRequest(
|
48 |
+
RoomDetailInfo.value.room_id,
|
49 |
+
currentPage.value,
|
50 |
+
pageSize.value
|
51 |
+
)
|
52 |
+
if (data.code === 0) {
|
53 |
+
DrawerProductList.value = data.data
|
54 |
+
|
55 |
+
// 分页获取
|
56 |
+
// if (DrawerProductList.value.product.length === 0) {
|
57 |
+
// DrawerProductList.value = data.data
|
58 |
+
// } else {
|
59 |
+
// for (let i of data.data.product) {
|
60 |
+
// // 根据返回数据继续添加商品
|
61 |
+
// DrawerProductList.value.product.push(i)
|
62 |
+
// }
|
63 |
+
// DrawerProductList.value.currentPage = data.data.currentPage
|
64 |
+
// DrawerProductList.value.pageSize = data.data.pageSize
|
65 |
+
// DrawerProductList.value.totalSize = data.data.totalSize
|
66 |
+
// }
|
67 |
+
console.info(DrawerProductList.value)
|
68 |
+
ElMessage.success('获取商品成功')
|
69 |
+
} else {
|
70 |
+
ElMessage.error('获取商品失败:' + data.message)
|
71 |
+
}
|
72 |
+
} catch (error: unknown) {
|
73 |
+
if (error instanceof AxiosError) {
|
74 |
+
ElMessage.error('获取商品失败:' + error.message)
|
75 |
+
} else {
|
76 |
+
ElMessage.error('未知错误:' + error)
|
77 |
+
}
|
78 |
+
}
|
79 |
+
}
|
80 |
+
|
81 |
+
// 获取商品表格信息
|
82 |
+
const RoomDetailInfo = ref({} as RoomDetailItem)
|
83 |
+
RoomDetailInfo.value.streamer_info = {} as StreamerInfo
|
84 |
+
RoomDetailInfo.value.streamer_info.streamer_id = 0
|
85 |
+
RoomDetailInfo.value.pageSize = 10
|
86 |
+
RoomDetailInfo.value.room_id = Number(props.roomId)
|
87 |
+
RoomDetailInfo.value.product_list = [] as StreamingRoomProductList[]
|
88 |
+
RoomDetailInfo.value.status = {} as StreamingRoomStatusItem
|
89 |
+
const EditProductList = ref({} as RoomDetailItem)
|
90 |
+
|
91 |
+
const getProductListInfo = async (currentPage: number, pageSize: number) => {
|
92 |
+
if (RoomDetailInfo.value.room_id === 0) {
|
93 |
+
return
|
94 |
+
}
|
95 |
+
|
96 |
+
try {
|
97 |
+
const { data } = await roomDetailRequest(
|
98 |
+
String(RoomDetailInfo.value.room_id),
|
99 |
+
currentPage,
|
100 |
+
pageSize
|
101 |
+
)
|
102 |
+
if (data.code === 0) {
|
103 |
+
console.info(data.data)
|
104 |
+
RoomDetailInfo.value = data.data
|
105 |
+
} else {
|
106 |
+
ElMessage.error('获取直播间详情失败' + data.message)
|
107 |
+
}
|
108 |
+
} catch (error: unknown) {
|
109 |
+
if (error instanceof AxiosError) {
|
110 |
+
ElMessage.error('获取直播间详情失败' + error.message)
|
111 |
+
} else {
|
112 |
+
ElMessage.error('未知错误:' + error)
|
113 |
+
}
|
114 |
+
}
|
115 |
+
}
|
116 |
+
|
117 |
+
// 获取主播信息
|
118 |
+
const streamerNameOptions = ref([] as StreamerInfo[])
|
119 |
+
|
120 |
+
const getSteramerInfo = async () => {
|
121 |
+
try {
|
122 |
+
// 获取主播信息
|
123 |
+
const { data } = await streamerInfoListRequest()
|
124 |
+
if (data.code === 0) {
|
125 |
+
streamerNameOptions.value = data.data
|
126 |
+
ElMessage.success('获取主播信息成功')
|
127 |
+
} else {
|
128 |
+
ElMessage.error('获取主播信息失败' + data.message)
|
129 |
+
}
|
130 |
+
} catch (error: unknown) {
|
131 |
+
if (error instanceof AxiosError) {
|
132 |
+
ElMessage.error('获取主播信息失败' + error.message)
|
133 |
+
} else {
|
134 |
+
ElMessage.error('未知错误:' + error)
|
135 |
+
}
|
136 |
+
}
|
137 |
+
}
|
138 |
+
|
139 |
+
onMounted(() => {
|
140 |
+
// 获取商品表格信息
|
141 |
+
getProductListInfo(1, 10)
|
142 |
+
|
143 |
+
// 获取主播信息
|
144 |
+
getSteramerInfo()
|
145 |
+
})
|
146 |
+
|
147 |
+
// 新增商品
|
148 |
+
const handelAddProductClick = async () => {
|
149 |
+
// 先保存商品,防止文案 or 数字人视频丢失
|
150 |
+
// onSubmit()
|
151 |
+
|
152 |
+
drawerShow.value = true
|
153 |
+
getProductInfo()
|
154 |
+
}
|
155 |
+
|
156 |
+
const drawerShow = ref(false)
|
157 |
+
function cancelClick() {
|
158 |
+
drawerShow.value = false
|
159 |
+
}
|
160 |
+
|
161 |
+
async function confirmClick() {
|
162 |
+
EditProductList.value = RoomDetailInfo.value
|
163 |
+
EditProductList.value.product_list = DrawerProductList.value.product_list
|
164 |
+
EditProductList.value.streamer_id = RoomDetailInfo.value.streamer_info.streamer_id
|
165 |
+
|
166 |
+
console.log(EditProductList.value)
|
167 |
+
try {
|
168 |
+
// 调用接口更新选择的商品
|
169 |
+
const { data } = await RoomCreadeOrEditRequest(EditProductList.value)
|
170 |
+
if (data.code === 0) {
|
171 |
+
// 新建会返回直播间后台保存 ID
|
172 |
+
RoomDetailInfo.value.room_id = data.data
|
173 |
+
ElMessage.success('操作成功')
|
174 |
+
} else {
|
175 |
+
ElMessage.error('操作失败' + data.message)
|
176 |
+
}
|
177 |
+
} catch (error: unknown) {
|
178 |
+
if (error instanceof AxiosError) {
|
179 |
+
ElMessage.error('操作失败' + error.message)
|
180 |
+
} else {
|
181 |
+
ElMessage.error('未知错误:' + error)
|
182 |
+
}
|
183 |
+
}
|
184 |
+
|
185 |
+
drawerShow.value = false
|
186 |
+
|
187 |
+
// 重新获取
|
188 |
+
getProductListInfo(1, 10)
|
189 |
+
}
|
190 |
+
|
191 |
+
// 保存
|
192 |
+
const onSubmit = async () => {
|
193 |
+
try {
|
194 |
+
// 调用接口保存商品
|
195 |
+
await RoomCreadeOrEditRequest(RoomDetailInfo.value)
|
196 |
+
ElMessage.success('保存成功')
|
197 |
+
} catch (error: unknown) {
|
198 |
+
if (error instanceof AxiosError) {
|
199 |
+
ElMessage.error('保存失败' + error.message)
|
200 |
+
} else {
|
201 |
+
ElMessage.error('未知错误:' + error)
|
202 |
+
}
|
203 |
+
}
|
204 |
+
}
|
205 |
+
|
206 |
+
const handelOnAirClick = async () => {
|
207 |
+
for (const entry of RoomDetailInfo.value.product_list) {
|
208 |
+
if (entry.start_video === '') {
|
209 |
+
ElMessage.error('必须将所有的商品都生成数字人视频才可以进行开播')
|
210 |
+
return false
|
211 |
+
}
|
212 |
+
}
|
213 |
+
|
214 |
+
// 保存商品信息
|
215 |
+
await onSubmit()
|
216 |
+
|
217 |
+
if (RoomDetailInfo.value.status.live_status !== 1) {
|
218 |
+
try {
|
219 |
+
// 调用接口执行开播
|
220 |
+
await onAirRoomStartRequest(RoomDetailInfo.value.room_id)
|
221 |
+
ElMessage.success('开播成功')
|
222 |
+
} catch (error: unknown) {
|
223 |
+
if (error instanceof AxiosError) {
|
224 |
+
ElMessage.error('开播成功' + error.message)
|
225 |
+
} else {
|
226 |
+
ElMessage.error('未知错误:' + error)
|
227 |
+
}
|
228 |
+
}
|
229 |
+
}
|
230 |
+
|
231 |
+
router.push({ name: 'StreamingOnAir', params: { roomId: String(RoomDetailInfo.value.room_id) } })
|
232 |
+
}
|
233 |
+
|
234 |
+
// 每个物品的点击按钮
|
235 |
+
const handelControlClick = (
|
236 |
+
titleName: string,
|
237 |
+
itemType: string,
|
238 |
+
itemValue: string,
|
239 |
+
productId: number,
|
240 |
+
streamerId: number,
|
241 |
+
salesDoc: string
|
242 |
+
) => {
|
243 |
+
console.info(itemType)
|
244 |
+
console.info(itemValue)
|
245 |
+
ShowItemInfo.value.showItemInfoDialog(
|
246 |
+
titleName,
|
247 |
+
itemType,
|
248 |
+
itemValue,
|
249 |
+
productId,
|
250 |
+
streamerId,
|
251 |
+
salesDoc
|
252 |
+
)
|
253 |
+
}
|
254 |
+
</script>
|
255 |
+
|
256 |
+
<template>
|
257 |
+
<div>
|
258 |
+
<!-- 返回栏 -->
|
259 |
+
<el-page-header @back="router.push({ name: 'StreamingOverview' })" title="返回">
|
260 |
+
<template #content>
|
261 |
+
<div class="flex items-center">
|
262 |
+
<span class="text-large font-600 mr-3">
|
263 |
+
{{ RoomDetailInfo.room_id !== 0 ? '编辑' : '新建' }} 直播间
|
264 |
+
</span>
|
265 |
+
</div>
|
266 |
+
</template>
|
267 |
+
</el-page-header>
|
268 |
+
|
269 |
+
<!-- 新增窗口右边抽屉弹窗 -->
|
270 |
+
<el-drawer v-model="drawerShow" direction="rtl">
|
271 |
+
<template #header>
|
272 |
+
<h1>添加商品</h1>
|
273 |
+
</template>
|
274 |
+
<template #default>
|
275 |
+
<div>
|
276 |
+
<ul class="product-list">
|
277 |
+
<li
|
278 |
+
v-for="item in DrawerProductList.product_list"
|
279 |
+
:key="item.product_id"
|
280 |
+
style="margin-bottom: 10px"
|
281 |
+
>
|
282 |
+
<el-checkbox-button
|
283 |
+
v-model="item.selected"
|
284 |
+
size="large"
|
285 |
+
border
|
286 |
+
class="item-checkbox-button"
|
287 |
+
>
|
288 |
+
<el-card shadow="never">
|
289 |
+
<el-row :gutter="0">
|
290 |
+
<el-col :span="6">
|
291 |
+
<el-image
|
292 |
+
style="width: 80px; height: 80px"
|
293 |
+
:src="item.product_info.image_path"
|
294 |
+
fit="contain"
|
295 |
+
/>
|
296 |
+
</el-col>
|
297 |
+
<el-col :span="18">
|
298 |
+
<div class="product-info">
|
299 |
+
<p class="title">{{ item.product_info.product_name }}</p>
|
300 |
+
<p class="content">{{ item.product_info.heighlights }}</p>
|
301 |
+
<p class="price">¥{{ item.product_info.selling_price }}</p>
|
302 |
+
</div>
|
303 |
+
</el-col>
|
304 |
+
</el-row>
|
305 |
+
</el-card>
|
306 |
+
</el-checkbox-button>
|
307 |
+
</li>
|
308 |
+
</ul>
|
309 |
+
</div>
|
310 |
+
</template>
|
311 |
+
<template #footer>
|
312 |
+
<div style="flex: auto">
|
313 |
+
<el-button @click="cancelClick">取消</el-button>
|
314 |
+
<el-button type="primary" @click="confirmClick">确认</el-button>
|
315 |
+
</div>
|
316 |
+
</template>
|
317 |
+
</el-drawer>
|
318 |
+
|
319 |
+
<el-card shadow="never" style="margin-top: 10px">
|
320 |
+
<h2>直播间名称</h2>
|
321 |
+
<el-divider />
|
322 |
+
|
323 |
+
<el-input v-model="RoomDetailInfo.name" size="large" />
|
324 |
+
</el-card>
|
325 |
+
|
326 |
+
<el-card shadow="never">
|
327 |
+
<StreamerInfoComponent
|
328 |
+
:disable-change="true"
|
329 |
+
v-model="RoomDetailInfo.streamer_info"
|
330 |
+
:optionList="streamerNameOptions"
|
331 |
+
/>
|
332 |
+
</el-card>
|
333 |
+
|
334 |
+
<el-card shadow="never">
|
335 |
+
<el-button
|
336 |
+
type="primary"
|
337 |
+
style="margin-bottom: 15px"
|
338 |
+
@click="handelAddProductClick"
|
339 |
+
size="large"
|
340 |
+
round
|
341 |
+
>
|
342 |
+
<el-icon style="margin-right: 5px">
|
343 |
+
<Edit />
|
344 |
+
</el-icon>
|
345 |
+
增删商品
|
346 |
+
</el-button>
|
347 |
+
|
348 |
+
<!-- TODO 商品表格可以做成 component 组件 -->
|
349 |
+
<el-table :data="RoomDetailInfo.product_list" max-height="1000" border>
|
350 |
+
<el-table-column prop="product_info.product_id" label="ID" align="center" width="50px" />
|
351 |
+
|
352 |
+
<el-table-column prop="product_info.image_path" label="图片" align="center">
|
353 |
+
<template #default="scope">
|
354 |
+
<div style="display: flex; align-items: center">
|
355 |
+
<!-- TODO 加上 :preview-src-list="[scope.row.image_path]" -->
|
356 |
+
<el-image :src="scope.row.product_info.image_path" />
|
357 |
+
</div>
|
358 |
+
</template>
|
359 |
+
</el-table-column>
|
360 |
+
|
361 |
+
<el-table-column prop="product_info.product_name" label="名称" align="center" />
|
362 |
+
<el-table-column prop="product_info.product_class" label="分类" align="center" />
|
363 |
+
<el-table-column prop="product_info.heighlights" label="亮点" align="center" />
|
364 |
+
<el-table-column prop="product_info.selling_price" label="价格" align="center" />
|
365 |
+
<el-table-column prop="product_info.amount" label="库存" align="center" />
|
366 |
+
<el-table-column prop="product_info.departure_place" label="发货地" align="center" />
|
367 |
+
<el-table-column prop="product_info.delivery_company" label="快递公司" align="center" />
|
368 |
+
<el-table-column prop="product_info.upload_date" label="上传时间" align="center" />
|
369 |
+
<el-table-column label="操作" v-slot="{ row }" align="center" width="400px">
|
370 |
+
<div class="control-item">
|
371 |
+
<el-button
|
372 |
+
size="small"
|
373 |
+
:type="row.product_info.instruction !== '' ? 'success' : 'warning'"
|
374 |
+
:icon="row.product_info.instruction !== '' ? Check : Warning"
|
375 |
+
@click="
|
376 |
+
handelControlClick(
|
377 |
+
row.product_info.product_name,
|
378 |
+
'Instruction',
|
379 |
+
row.product_info.instruction,
|
380 |
+
row.product_id,
|
381 |
+
RoomDetailInfo.streamer_info.streamer_id,
|
382 |
+
row.sales_doc
|
383 |
+
)
|
384 |
+
"
|
385 |
+
>
|
386 |
+
说明书
|
387 |
+
</el-button>
|
388 |
+
|
389 |
+
<el-button
|
390 |
+
size="small"
|
391 |
+
v-model="row.sales_doc"
|
392 |
+
:type="row.sales_doc !== '' ? 'success' : 'warning'"
|
393 |
+
:icon="row.sales_doc !== '' ? Check : Warning"
|
394 |
+
@click="
|
395 |
+
handelControlClick(
|
396 |
+
row.product_info.product_name,
|
397 |
+
'SalesDoc',
|
398 |
+
row.sales_doc,
|
399 |
+
row.product_id,
|
400 |
+
RoomDetailInfo.streamer_info.streamer_id,
|
401 |
+
row.sales_doc
|
402 |
+
)
|
403 |
+
"
|
404 |
+
>
|
405 |
+
解说文案
|
406 |
+
</el-button>
|
407 |
+
|
408 |
+
<el-button
|
409 |
+
size="small"
|
410 |
+
v-model="row.start_video"
|
411 |
+
:type="row.start_video !== '' ? 'success' : 'warning'"
|
412 |
+
:icon="row.start_video !== '' ? Check : Warning"
|
413 |
+
@click="
|
414 |
+
handelControlClick(
|
415 |
+
row.product_info.product_name,
|
416 |
+
'DigitalHuman',
|
417 |
+
row.start_video,
|
418 |
+
row.product_id,
|
419 |
+
RoomDetailInfo.streamer_info.streamer_id,
|
420 |
+
row.sales_doc
|
421 |
+
)
|
422 |
+
"
|
423 |
+
>
|
424 |
+
数字人视频
|
425 |
+
</el-button>
|
426 |
+
|
427 |
+
<el-button
|
428 |
+
size="small"
|
429 |
+
@click="router.push({ name: 'ProductEdit', params: { productId: row.product_id } })"
|
430 |
+
>
|
431 |
+
编辑
|
432 |
+
</el-button>
|
433 |
+
</div>
|
434 |
+
</el-table-column>
|
435 |
+
</el-table>
|
436 |
+
|
437 |
+
<!-- 信息弹窗 -->
|
438 |
+
<InfoDialogComponents ref="ShowItemInfo" v-model="RoomDetailInfo.product_list" />
|
439 |
+
|
440 |
+
<template #footer>
|
441 |
+
<div class="bottom-item">
|
442 |
+
<el-pagination
|
443 |
+
v-model:current-page="RoomDetailInfo.currentPage"
|
444 |
+
v-model:page-size="RoomDetailInfo.pageSize"
|
445 |
+
:page-sizes="[5, 10, 15, 20]"
|
446 |
+
:background="true"
|
447 |
+
layout="total, sizes, prev, pager, next, jumper"
|
448 |
+
:total="RoomDetailInfo.totalSize || 0"
|
449 |
+
@size-change="(pageSize: number) => getProductListInfo(1, pageSize)"
|
450 |
+
@current-change="
|
451 |
+
(currentPage: number) => getProductListInfo(currentPage, RoomDetailInfo.pageSize)
|
452 |
+
"
|
453 |
+
/>
|
454 |
+
<div>
|
455 |
+
<el-button
|
456 |
+
type="success"
|
457 |
+
@click="handelOnAirClick"
|
458 |
+
:disable="RoomDetailInfo.room_id === 0"
|
459 |
+
>
|
460 |
+
{{ RoomDetailInfo.status.live_status === 1 ? '进入' : '开始' }}直播</el-button
|
461 |
+
>
|
462 |
+
<el-button
|
463 |
+
type="primary"
|
464 |
+
@click="onSubmit"
|
465 |
+
:disabled="RoomDetailInfo.product_list.length === 0"
|
466 |
+
>保存</el-button
|
467 |
+
>
|
468 |
+
</div>
|
469 |
+
</div>
|
470 |
+
</template>
|
471 |
+
</el-card>
|
472 |
+
</div>
|
473 |
+
</template>
|
474 |
+
|
475 |
+
<style lang="scss" scoped>
|
476 |
+
.bottom-item {
|
477 |
+
margin-top: 10px; // 距离上面的控件有一定的距离
|
478 |
+
display: flex;
|
479 |
+
justify-content: space-between; // 将两个 div 放置在页面的两侧
|
480 |
+
align-items: center;
|
481 |
+
}
|
482 |
+
|
483 |
+
.product-list {
|
484 |
+
padding-left: 0; /* 去掉默认的缩进 */
|
485 |
+
margin: 0; /* 去掉默认的外边距 */
|
486 |
+
list-style-type: none; /* 去掉列表项前的默认圆点 */
|
487 |
+
|
488 |
+
.ul {
|
489 |
+
list-style-type: none;
|
490 |
+
padding-left: 0; /* 去掉默认的缩进 */
|
491 |
+
}
|
492 |
+
}
|
493 |
+
|
494 |
+
.el-card {
|
495 |
+
margin-bottom: 20px;
|
496 |
+
padding: 20px;
|
497 |
+
border-radius: 20px;
|
498 |
+
}
|
499 |
+
|
500 |
+
::v-deep(.el-input__wrapper) {
|
501 |
+
border-radius: 14px;
|
502 |
+
}
|
503 |
+
|
504 |
+
.item-checkbox-button {
|
505 |
+
::v-deep(.el-checkbox-button__inner) {
|
506 |
+
border-radius: 20px; /* 设置 checkbox button 圆角大小 */
|
507 |
+
line-height: 0;
|
508 |
+
text-align: left;
|
509 |
+
padding: 5px 5px;
|
510 |
+
}
|
511 |
+
|
512 |
+
.el-card__body {
|
513 |
+
padding: 0px !important;
|
514 |
+
}
|
515 |
+
|
516 |
+
.el-card {
|
517 |
+
margin: 0px;
|
518 |
+
width: 450px;
|
519 |
+
height: 120px;
|
520 |
+
padding: 0px;
|
521 |
+
|
522 |
+
.product-info {
|
523 |
+
display: flex;
|
524 |
+
flex-direction: column; /* 将子元素垂直排列 */
|
525 |
+
margin-left: 10px;
|
526 |
+
|
527 |
+
.title {
|
528 |
+
font-size: 14px;
|
529 |
+
font-weight: 600;
|
530 |
+
}
|
531 |
+
|
532 |
+
.content {
|
533 |
+
font-size: 12px;
|
534 |
+
color: #b1b3b8;
|
535 |
+
}
|
536 |
+
|
537 |
+
.price {
|
538 |
+
font-size: 12px;
|
539 |
+
color: #fda100;
|
540 |
+
}
|
541 |
+
}
|
542 |
+
}
|
543 |
+
}
|
544 |
+
</style>
|
frontend/src/views/system/SystemPluginsView.vue
ADDED
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { onMounted, ref } from 'vue'
|
3 |
+
import { ElMessage } from 'element-plus'
|
4 |
+
|
5 |
+
import { getSystemPluginsInfoRequest, type SystemPluginsInfo } from '@/api/system'
|
6 |
+
|
7 |
+
const pluginsInfo = ref([] as SystemPluginsInfo[])
|
8 |
+
|
9 |
+
onMounted(async () => {
|
10 |
+
// 获取组件信息
|
11 |
+
const { data } = await getSystemPluginsInfoRequest()
|
12 |
+
if (data.code === 0) {
|
13 |
+
pluginsInfo.value = data.data
|
14 |
+
ElMessage.success('获取组件信息成功')
|
15 |
+
} else {
|
16 |
+
ElMessage.error(data.message)
|
17 |
+
}
|
18 |
+
})
|
19 |
+
</script>
|
20 |
+
|
21 |
+
<template>
|
22 |
+
<div class="plugin-item">
|
23 |
+
<el-card v-for="(item, index) in pluginsInfo" :key="index" shadow="hover">
|
24 |
+
<el-row :gutter="5">
|
25 |
+
<el-col :span="4">
|
26 |
+
<div class="plugins-ico">
|
27 |
+
<el-avatar shape="square" :size="60" :style="{ backgroundColor: item.avatar_color }">
|
28 |
+
{{ item.plugin_name }}
|
29 |
+
</el-avatar>
|
30 |
+
</div>
|
31 |
+
</el-col>
|
32 |
+
<el-col :span="20">
|
33 |
+
<div class="item-content">
|
34 |
+
<div>
|
35 |
+
<p class="title">{{ item.plugin_name }}</p>
|
36 |
+
<p class="content">{{ item.describe }}</p>
|
37 |
+
</div>
|
38 |
+
<div>
|
39 |
+
<el-tag size="large" effect="dark" :type="item.enabled ? 'success' : 'danger'">
|
40 |
+
{{ item.enabled ? '已启动' : '未启动' }}
|
41 |
+
</el-tag>
|
42 |
+
</div>
|
43 |
+
</div>
|
44 |
+
</el-col>
|
45 |
+
</el-row>
|
46 |
+
</el-card>
|
47 |
+
</div>
|
48 |
+
</template>
|
49 |
+
|
50 |
+
<style lang="scss" scoped>
|
51 |
+
.el-card {
|
52 |
+
width: 800px;
|
53 |
+
margin-top: 10px;
|
54 |
+
margin-bottom: 20px;
|
55 |
+
border-radius: 20px;
|
56 |
+
}
|
57 |
+
|
58 |
+
.plugin-item {
|
59 |
+
display: flex;
|
60 |
+
flex-direction: column;
|
61 |
+
justify-content: center;
|
62 |
+
align-items: center;
|
63 |
+
}
|
64 |
+
|
65 |
+
.plugins-ico {
|
66 |
+
display: flex;
|
67 |
+
justify-content: center; /* 水平居中 */
|
68 |
+
align-items: center; /* 垂直居中 */
|
69 |
+
height: 100%; /* 设置父容器的高度,可以根据需要调整 */
|
70 |
+
|
71 |
+
font-weight: 600;
|
72 |
+
}
|
73 |
+
|
74 |
+
.item-content {
|
75 |
+
display: flex;
|
76 |
+
justify-content: space-between; // 将两个 div 放置在页面的两侧
|
77 |
+
align-items: center;
|
78 |
+
|
79 |
+
.title {
|
80 |
+
font-size: 18px;
|
81 |
+
font-weight: 600;
|
82 |
+
}
|
83 |
+
|
84 |
+
.content {
|
85 |
+
font-size: 15px;
|
86 |
+
color: #b1b3b8;
|
87 |
+
}
|
88 |
+
}
|
89 |
+
</style>
|