FAYO commited on
Commit
77b0e0f
·
1 Parent(s): 460d4ca
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. docker/Dockerfile +25 -0
  2. frontend/.env +1 -0
  3. frontend/.eslintrc.cjs +15 -0
  4. frontend/.gitignore +30 -0
  5. frontend/.prettierrc.json +8 -0
  6. frontend/env.d.ts +1 -0
  7. frontend/index.html +13 -0
  8. frontend/package-lock.json +0 -0
  9. frontend/package.json +50 -0
  10. frontend/public/favicon.ico +0 -0
  11. frontend/src/App.vue +11 -0
  12. frontend/src/api/base.ts +16 -0
  13. frontend/src/api/dashboard.ts +29 -0
  14. frontend/src/api/digitalHuman.ts +12 -0
  15. frontend/src/api/llm.ts +28 -0
  16. frontend/src/api/product.ts +120 -0
  17. frontend/src/api/streamerInfo.ts +87 -0
  18. frontend/src/api/streamingRoom.ts +253 -0
  19. frontend/src/api/system.ts +18 -0
  20. frontend/src/api/user.ts +56 -0
  21. frontend/src/assets/github.svg +1 -0
  22. frontend/src/assets/logo.png +0 -0
  23. frontend/src/components/AslideComponent.vue +132 -0
  24. frontend/src/components/BarChartComponent.vue +64 -0
  25. frontend/src/components/BreadCrumb.vue +35 -0
  26. frontend/src/components/FileUpload.vue +169 -0
  27. frontend/src/components/InfoDialogComponents.vue +328 -0
  28. frontend/src/components/LineChartComponent.vue +127 -0
  29. frontend/src/components/MessageComponent.vue +88 -0
  30. frontend/src/components/NavbarComponent.vue +78 -0
  31. frontend/src/components/StreamerInfoComponent.vue +231 -0
  32. frontend/src/components/VideoComponent.vue +103 -0
  33. frontend/src/layouts/BaseLayout.vue +41 -0
  34. frontend/src/main.ts +29 -0
  35. frontend/src/router/index.ts +183 -0
  36. frontend/src/stores/userToken.ts +26 -0
  37. frontend/src/style/index.scss +17 -0
  38. frontend/src/utils/navbar.ts +4 -0
  39. frontend/src/views/digital-human/DigitalHumanEditDialogView.vue +96 -0
  40. frontend/src/views/digital-human/DigitalHumanView.vue +155 -0
  41. frontend/src/views/error/NotFound.vue +7 -0
  42. frontend/src/views/home/HomeView.vue +160 -0
  43. frontend/src/views/login/LoginView.vue +170 -0
  44. frontend/src/views/order/OrderView.vue +90 -0
  45. frontend/src/views/product/ProductEditView.vue +284 -0
  46. frontend/src/views/product/ProductListView.vue +250 -0
  47. frontend/src/views/streaming/StreamingOnAirView.vue +433 -0
  48. frontend/src/views/streaming/StreamingRoomListView.vue +160 -0
  49. frontend/src/views/streaming/StreamingRoomeEditView.vue +544 -0
  50. 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>