@deppon/create-deppon-app 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -0
- package/README.md +63 -0
- package/deppon.js +640 -0
- package/package.json +51 -0
- package/template/.env +12 -0
- package/template/.env.dev-local.example +64 -0
- package/template/.env.development.example +64 -0
- package/template/.env.example +1 -0
- package/template/.env.production.example +64 -0
- package/template/.env.test.example +64 -0
- package/template/.eslintignore +2 -0
- package/template/.eslintrc.cjs +14 -0
- package/template/.prettierrc.js +3 -0
- package/template/.vscode/settings.json +8 -0
- package/template/Dockerfile +5 -0
- package/template/README.md +149 -0
- package/template/commitlint.config.js +11 -0
- package/template/gitignore +8 -0
- package/template/index.html +18 -0
- package/template/nginx.conf +70 -0
- package/template/npmrc +2 -0
- package/template/package.json +49 -0
- package/template/preview-server.js +117 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo.png +0 -0
- package/template/src/App.vue +123 -0
- package/template/src/api/index.ts +13 -0
- package/template/src/api/prefercenter.ts +23 -0
- package/template/src/api/product.ts +16 -0
- package/template/src/api/user.ts +41 -0
- package/template/src/components/ExpandableMessage.vue +340 -0
- package/template/src/components/PageLayout.vue +43 -0
- package/template/src/config/dictionaryConfig.ts +24 -0
- package/template/src/directives/permission.ts +162 -0
- package/template/src/layouts/BaseLayout.vue +687 -0
- package/template/src/main.ts +27 -0
- package/template/src/router/index.ts +179 -0
- package/template/src/router/route.ts +61 -0
- package/template/src/stores/menu.ts +334 -0
- package/template/src/stores/product.ts +155 -0
- package/template/src/stores/route.ts +79 -0
- package/template/src/stores/user.ts +145 -0
- package/template/src/styles/index.ts +29 -0
- package/template/src/types/dictionary.d.ts +24 -0
- package/template/src/types/vite-env.d.ts +119 -0
- package/template/src/utils/dictionary.ts +188 -0
- package/template/src/utils/errorAnalyzer.ts +217 -0
- package/template/src/utils/messageVNode.ts +15 -0
- package/template/src/utils/request.ts +293 -0
- package/template/src/views/error/401.vue +30 -0
- package/template/src/views/error/403.vue +30 -0
- package/template/src/views/error/404.vue +30 -0
- package/template/src/views/home/index.vue +25 -0
- package/template/tsconfig.json +27 -0
- package/template/vite.config.ts +243 -0
- package/template/yarnrc +3 -0
- package/template/yarnrc.yml +7 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4174;
|
|
6
|
+
// 注意:需要根据实际项目路径修改 BASE_PATH
|
|
7
|
+
const BASE_PATH = '/your-app-name/';
|
|
8
|
+
const DIST_DIR = path.join(__dirname, 'dist');
|
|
9
|
+
|
|
10
|
+
// MIME 类型映射
|
|
11
|
+
const mimeTypes = {
|
|
12
|
+
'.html': 'text/html',
|
|
13
|
+
'.js': 'application/javascript',
|
|
14
|
+
'.css': 'text/css',
|
|
15
|
+
'.json': 'application/json',
|
|
16
|
+
'.png': 'image/png',
|
|
17
|
+
'.jpg': 'image/jpeg',
|
|
18
|
+
'.gif': 'image/gif',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.woff': 'font/woff',
|
|
21
|
+
'.woff2': 'font/woff2',
|
|
22
|
+
'.ttf': 'font/ttf',
|
|
23
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
24
|
+
'.otf': 'font/otf',
|
|
25
|
+
'.ico': 'image/x-icon',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const server = http.createServer((req, res) => {
|
|
29
|
+
let filePath = req.url;
|
|
30
|
+
|
|
31
|
+
// 移除查询参数和 hash
|
|
32
|
+
const queryIndex = filePath.indexOf('?');
|
|
33
|
+
if (queryIndex !== -1) {
|
|
34
|
+
filePath = filePath.substring(0, queryIndex);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 调试日志
|
|
38
|
+
console.log(`[${new Date().toISOString()}] 请求: ${req.method} ${req.url} -> ${filePath}`);
|
|
39
|
+
|
|
40
|
+
// 移除 base path
|
|
41
|
+
if (filePath.startsWith(BASE_PATH)) {
|
|
42
|
+
filePath = filePath.slice(BASE_PATH.length - 1); // 保留开头的 /
|
|
43
|
+
} else if (filePath.startsWith(BASE_PATH.slice(0, -1))) {
|
|
44
|
+
// 处理没有尾部斜杠的情况
|
|
45
|
+
filePath = filePath.replace(BASE_PATH.slice(0, -1), '') || '/';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 默认加载 index.html
|
|
49
|
+
if (filePath === '/' || filePath === '') {
|
|
50
|
+
filePath = '/index.html';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 移除开头的 /
|
|
54
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
55
|
+
const fullPath = path.join(DIST_DIR, relativePath);
|
|
56
|
+
const extname = path.extname(filePath).toLowerCase();
|
|
57
|
+
|
|
58
|
+
// 调试日志
|
|
59
|
+
console.log(` 处理后路径: ${filePath} -> ${relativePath} -> ${fullPath}`);
|
|
60
|
+
|
|
61
|
+
// 检查文件是否存在
|
|
62
|
+
fs.stat(fullPath, (err, stats) => {
|
|
63
|
+
if (err || !stats.isFile()) {
|
|
64
|
+
// 文件不存在,如果是 HTML 请求或者是没有扩展名的路径,返回 index.html(SPA 路由回退)
|
|
65
|
+
if (!extname || extname === '.html' || filePath.endsWith('/')) {
|
|
66
|
+
const indexPath = path.join(DIST_DIR, 'index.html');
|
|
67
|
+
fs.readFile(indexPath, (err, data) => {
|
|
68
|
+
if (err) {
|
|
69
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
70
|
+
res.end('404 Not Found: index.html');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
74
|
+
res.end(data);
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// 其他文件不存在,返回 404
|
|
79
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
80
|
+
res.end(`404 Not Found: ${filePath}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 文件存在,读取并返回
|
|
85
|
+
fs.readFile(fullPath, (err, data) => {
|
|
86
|
+
if (err) {
|
|
87
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
88
|
+
res.end('500 Internal Server Error');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const contentType = mimeTypes[extname] || 'application/octet-stream';
|
|
93
|
+
res.writeHead(200, {
|
|
94
|
+
'Content-Type': contentType,
|
|
95
|
+
'Cache-Control': 'no-cache',
|
|
96
|
+
});
|
|
97
|
+
res.end(data);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.on('error', err => {
|
|
103
|
+
if (err.code === 'EADDRINUSE') {
|
|
104
|
+
console.error(`\n❌ 端口 ${PORT} 已被占用!`);
|
|
105
|
+
console.error(`请先停止占用该端口的进程,或修改 PORT 变量使用其他端口。\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
} else {
|
|
108
|
+
console.error(`\n❌ 服务器启动失败: ${err.message}\n`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
114
|
+
console.log(`\n🚀 预览服务器已启动!`);
|
|
115
|
+
console.log(`\n📍 本地访问: http://localhost:${PORT}${BASE_PATH}`);
|
|
116
|
+
console.log(`\n按 Ctrl+C 停止服务器\n`);
|
|
117
|
+
});
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-config-provider :locale="zhCn">
|
|
3
|
+
<RouterView />
|
|
4
|
+
</el-config-provider>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
import { RouterView } from '@deppon/deppon-router';
|
|
9
|
+
import { ElConfigProvider } from '@deppon/deppon-ui';
|
|
10
|
+
// @ts-ignore - zhCn 导出需要构建包后生效
|
|
11
|
+
import zhCnLocale from '@deppon/deppon-ui/locale/lang/zh-cn';
|
|
12
|
+
// 将导入的 locale 赋值给常量,确保类型正确
|
|
13
|
+
const zhCn = zhCnLocale;
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<style lang="less">
|
|
17
|
+
@import '@deppon/deppon-assets/dist/css/fonts.css';
|
|
18
|
+
|
|
19
|
+
/* Element Plus 主题色设置 - 使用 !important 确保优先级 */
|
|
20
|
+
:root {
|
|
21
|
+
--el-color-primary: #1957ff !important;
|
|
22
|
+
--el-font-size: 14px !important;
|
|
23
|
+
--el-line-width: 1px !important;
|
|
24
|
+
--el-line-type: solid !important;
|
|
25
|
+
--el-text-color-primary: rgba(0, 0, 0, 0.88) !important;
|
|
26
|
+
--el-text-color-regular: rgba(0, 0, 0, 0.6) !important;
|
|
27
|
+
--el-disabled-text-color: rgba(0, 0, 0, 0.88) !important;
|
|
28
|
+
|
|
29
|
+
--el-color-primary-light-3: rgb(94, 137, 255) !important;
|
|
30
|
+
--el-color-primary-light-5: rgb(140, 171, 255) !important;
|
|
31
|
+
--el-color-primary-light-7: rgb(186, 205, 255) !important;
|
|
32
|
+
--el-color-primary-light-8: rgb(209, 221, 255) !important;
|
|
33
|
+
--el-color-primary-light-9: rgb(232, 238, 255) !important;
|
|
34
|
+
--el-text-color-placeholder: rgba(0, 0, 0, 0.25) !important;
|
|
35
|
+
--el-card-border-color: rgba(0, 0, 0, 0.2) !important;
|
|
36
|
+
--el-bg-color-page:#f4f4f4 !important;
|
|
37
|
+
|
|
38
|
+
/* ==================== 字体设置 ==================== */
|
|
39
|
+
/* 通用字体 - 用于大部分组件 */
|
|
40
|
+
--pro-font-family: AlibabaSans !important;
|
|
41
|
+
/* Logo 字体 - 用于 Logo 文字显示 */
|
|
42
|
+
--pro-font-family-logo: JDLangZhengTi !important;
|
|
43
|
+
|
|
44
|
+
/* ==================== 阴影颜色设置 ==================== */
|
|
45
|
+
/* 标准阴影 - 用于 Dialog 等组件 */
|
|
46
|
+
--pro-shadow-color: rgba(0, 0, 0, 0.1) !important;
|
|
47
|
+
/* 浅色阴影 - 用于下拉菜单等组件 */
|
|
48
|
+
--pro-shadow-color-light: rgba(0, 0, 0, 0.08) !important;
|
|
49
|
+
/* 侧边栏阴影 - 用于侧边栏组件 */
|
|
50
|
+
--pro-shadow-color-sider: rgba(0, 0, 0, 0.12) !important;
|
|
51
|
+
/* 按钮阴影 - 用于按钮组件 */
|
|
52
|
+
--pro-shadow-color-btn: rgba(0, 0, 0, 0.15) !important;
|
|
53
|
+
/* Logo 阴影 - 用于 Logo 组件 */
|
|
54
|
+
--pro-shadow-color-logo: rgba(0, 21, 41, 0.02) !important;
|
|
55
|
+
|
|
56
|
+
/* ==================== 表格相关颜色 ==================== */
|
|
57
|
+
/* 表格边框颜色 - 用于表格边框 */
|
|
58
|
+
--pro-table-border-color: rgba(0, 0, 0, 0.08) !important;
|
|
59
|
+
/* 表格底部边框颜色 - 用于表格无边框模式下的底部边框 */
|
|
60
|
+
--pro-table-border-bottom-color: #f3f4f5 !important;
|
|
61
|
+
/* 表格表头背景色 - 用于表格表头 */
|
|
62
|
+
--pro-table-header-bg: #f4f4f4 !important;
|
|
63
|
+
--pro-table-th-color-primary: rgba(0, 0, 0, 0.88) !important;
|
|
64
|
+
--pro-table-td-color-primary: rgba(0, 0, 0, 0.88) !important;
|
|
65
|
+
/* 表格单元格背景色 - 用于指定列的背景色 */
|
|
66
|
+
--pro-table-cell-bg-color: #f4f4f4 !important;
|
|
67
|
+
/* 表格红色单元格背景色 - 用于红色标记的单元格 */
|
|
68
|
+
--pro-table-cell-red-bg: #fef0f0 !important;
|
|
69
|
+
/* 表格红色单元格文字颜色 - 用于红色标记的单元格文字 */
|
|
70
|
+
--pro-table-cell-red-color: #f56c6c !important;
|
|
71
|
+
/* 表格浅红色单元格背景色 - 用于浅红色标记的单元格 */
|
|
72
|
+
--pro-table-cell-light-red-bg: #fef5f5 !important;
|
|
73
|
+
/* 表格浅红色单元格文字颜色 - 用于浅红色标记的单元格文字 */
|
|
74
|
+
--pro-table-cell-light-red-color: #f56c6c !important;
|
|
75
|
+
/* 表格错误行背景色 - 用于错误状态的行 */
|
|
76
|
+
--pro-table-row-error-bg: #fff5f5 !important;
|
|
77
|
+
/* 表格分页器背景色 - 用于分页器背景 */
|
|
78
|
+
--pro-table-pagination-bg: rgba(255, 255, 255, 0.6) !important;
|
|
79
|
+
/* 表格分页器边框颜色 - 用于分页器边框 */
|
|
80
|
+
--pro-table-pagination-border: rgba(5, 5, 5, 0.06) !important;
|
|
81
|
+
/* 表格分页器文字颜色 - 用于分页器文字 */
|
|
82
|
+
--pro-table-pagination-color: rgba(0, 0, 0, 0.88) !important;
|
|
83
|
+
/* 表格底部栏背景色 - 用于批量操作底部栏背景 */
|
|
84
|
+
--pro-table-footer-bg: rgba(255, 255, 255, 0.6) !important;
|
|
85
|
+
/* 表格底部栏边框颜色 - 用于批量操作底部栏边框 */
|
|
86
|
+
--pro-table-footer-border: rgba(5, 5, 5, 0.06) !important;
|
|
87
|
+
/* 表格底部栏文字颜色 - 用于批量操作底部栏文字 */
|
|
88
|
+
--pro-table-footer-color: rgba(0, 0, 0, 0.88) !important;
|
|
89
|
+
/* 表格选中数量文字颜色 - 用于批量操作选中数量文字 */
|
|
90
|
+
--pro-table-selected-count-color: rgba(0, 0, 0, 0.88) !important;
|
|
91
|
+
--pro-el-scrollbar-opacity: 0.5 !important;
|
|
92
|
+
--pro-el-scrollbar-bg-color: rgba(0, 0, 0, 0.6) !important;
|
|
93
|
+
--pro-el-scrollbar-hover-opacity: 0.6 !important;
|
|
94
|
+
--pro-el-scrollbar-hover-bg-color: rgba(0, 0, 0, 0.6) !important;
|
|
95
|
+
/* Element Plus 滚动条宽度配置 */
|
|
96
|
+
--pro-el-scrollbar-width: 9px !important;
|
|
97
|
+
/* 表格选中行背景色 - 用于表格选中行和悬停行背景 */
|
|
98
|
+
--pro-table-selected-bg-color: #f5f7fa !important;
|
|
99
|
+
/* 禁用状态背景色 - 用于所有禁用状态的背景色(统一使用此变量) */
|
|
100
|
+
--pro-bg-disabled-color: #f4f4f4 !important;
|
|
101
|
+
/* 输入框前缀图标颜色 - 用于输入框前缀图标颜色 */
|
|
102
|
+
--pro-prefix-color: rgba(0, 0, 0, 0.88) !important;
|
|
103
|
+
/* 输入框后缀图标颜色 - 用于输入框后缀图标颜色 */
|
|
104
|
+
--pro-suffix-color: rgba(0, 0, 0, 0.88) !important;
|
|
105
|
+
--el-border-radius-base:8px !important;
|
|
106
|
+
}
|
|
107
|
+
/* 覆盖浏览器默认样式 */
|
|
108
|
+
html,
|
|
109
|
+
body,
|
|
110
|
+
#app {
|
|
111
|
+
margin: 0;
|
|
112
|
+
padding: 0;
|
|
113
|
+
height: 100vh;
|
|
114
|
+
width: 100vw;
|
|
115
|
+
/* 确保字体变量全局生效 */
|
|
116
|
+
// font-family: var(--pro-font-family, JDLangZhengTi, sans-serif);
|
|
117
|
+
font-family: AlibabaSans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
|
118
|
+
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
119
|
+
font-size: 14px;
|
|
120
|
+
font-weight: 400;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prefercenter 系统 API
|
|
3
|
+
* 包含 /cmc-prefer-web/ 前缀的所有接口
|
|
4
|
+
*/
|
|
5
|
+
import request from '../utils/request';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 获取按钮权限
|
|
9
|
+
* @param code - 权限代码数组,格式: [{ code: '/cmc-prefer-web/pricing/request/queryAddWeb' }]
|
|
10
|
+
* @returns 返回权限信息,isAuth === true 表示有权限
|
|
11
|
+
*/
|
|
12
|
+
export const getButtonAuth = (code: Array<{ code: string }>) => {
|
|
13
|
+
return request({
|
|
14
|
+
url: '/cmc-prefer-web/login/findPermission.action',
|
|
15
|
+
method: 'post',
|
|
16
|
+
data: {
|
|
17
|
+
list: JSON.stringify(code),
|
|
18
|
+
},
|
|
19
|
+
headers: {
|
|
20
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 产品信息相关 API
|
|
3
|
+
*/
|
|
4
|
+
import request from '../utils/request';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 查询产品信息
|
|
8
|
+
* @param operType - 操作类型(可选),如 'PRODUCT_INFO_TIERED'
|
|
9
|
+
* @returns Promise 返回产品信息响应
|
|
10
|
+
*/
|
|
11
|
+
export const queryProductInfo = (operType?: string) => {
|
|
12
|
+
return request({
|
|
13
|
+
url: `/prefer/online/common/queryProductInfo${operType ? `?operType=${operType}` : ''}`,
|
|
14
|
+
method: 'get',
|
|
15
|
+
});
|
|
16
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用户信息相关 API(UAP 等)
|
|
3
|
+
*/
|
|
4
|
+
import request from '../utils/request';
|
|
5
|
+
|
|
6
|
+
/** findUserByCode 接口返回的 body 结构 */
|
|
7
|
+
export interface FindUserByCodeBody {
|
|
8
|
+
id?: number;
|
|
9
|
+
empCode?: string;
|
|
10
|
+
empName?: string;
|
|
11
|
+
gender?: number;
|
|
12
|
+
status?: number;
|
|
13
|
+
jobCode?: string;
|
|
14
|
+
position?: string;
|
|
15
|
+
deptStandCode?: string;
|
|
16
|
+
deptStandName?: string;
|
|
17
|
+
deptCode?: string;
|
|
18
|
+
deptName?: string;
|
|
19
|
+
crowdCode?: string;
|
|
20
|
+
crowdName?: string;
|
|
21
|
+
currentTime?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** findUserByCode 接口响应 */
|
|
25
|
+
export interface FindUserByCodeResponse {
|
|
26
|
+
status?: string;
|
|
27
|
+
body?: FindUserByCodeBody;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 根据员工编码查询用户信息
|
|
32
|
+
* 开发环境通过 Vite 代理请求 /uap-index-service,生产环境由 request 根据 DP_UAP_API_HOST 拼接 baseURL
|
|
33
|
+
* @param empCode - 员工编码(与 userCode 一致)
|
|
34
|
+
*/
|
|
35
|
+
export const findUserByCode = (empCode: string) => {
|
|
36
|
+
return request({
|
|
37
|
+
url: '/uap-index-service/userInfoSync/findUserByCode',
|
|
38
|
+
method: 'get',
|
|
39
|
+
params: { empCode },
|
|
40
|
+
}) as Promise<FindUserByCodeResponse>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="expandable-message-container" ref="containerRef">
|
|
3
|
+
<div
|
|
4
|
+
ref="contentRef"
|
|
5
|
+
class="expandable-message-content"
|
|
6
|
+
:class="{ 'expanded': expanded, 'clamped': !expanded && isLongText }"
|
|
7
|
+
:style="{ '--max-lines': props.maxLines }"
|
|
8
|
+
>
|
|
9
|
+
<span v-html="displayContent"></span>
|
|
10
|
+
<button
|
|
11
|
+
v-if="isLongText"
|
|
12
|
+
type="button"
|
|
13
|
+
class="expand-button"
|
|
14
|
+
:class="{ 'expanded': expanded }"
|
|
15
|
+
@click="toggleExpand"
|
|
16
|
+
>
|
|
17
|
+
<span class="button-text">{{ expanded ? '收起' : '展开' }}</span>
|
|
18
|
+
<el-icon class="arrow-icon">
|
|
19
|
+
<ArrowDown />
|
|
20
|
+
</el-icon>
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import { ref, computed, onMounted, nextTick, watch } from 'vue';
|
|
28
|
+
import { ElIcon } from '@deppon/deppon-ui';
|
|
29
|
+
import { ArrowDown } from '@deppon/deppon-ui/icons-vue';
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
message: string;
|
|
33
|
+
maxLines?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
37
|
+
maxLines: 3
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const expanded = ref(false);
|
|
41
|
+
const containerRef = ref<HTMLElement>();
|
|
42
|
+
const contentRef = ref<HTMLElement>();
|
|
43
|
+
const isLongText = ref(false);
|
|
44
|
+
|
|
45
|
+
// 检测文本是否超出指定行数
|
|
46
|
+
const checkIfOverflow = async () => {
|
|
47
|
+
await nextTick();
|
|
48
|
+
if (!contentRef.value) return;
|
|
49
|
+
|
|
50
|
+
// 临时移除行数限制来测量实际高度
|
|
51
|
+
const originalStyle = contentRef.value.style.cssText;
|
|
52
|
+
contentRef.value.style.maxHeight = 'none';
|
|
53
|
+
contentRef.value.style.webkitLineClamp = 'none';
|
|
54
|
+
contentRef.value.style.display = 'block';
|
|
55
|
+
|
|
56
|
+
const fullHeight = contentRef.value.scrollHeight;
|
|
57
|
+
const lineHeight = parseFloat(getComputedStyle(contentRef.value).lineHeight) || 24;
|
|
58
|
+
const maxHeight = lineHeight * props.maxLines;
|
|
59
|
+
|
|
60
|
+
// 恢复原始样式
|
|
61
|
+
contentRef.value.style.cssText = originalStyle;
|
|
62
|
+
|
|
63
|
+
isLongText.value = fullHeight > maxHeight;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// 智能截取文本,确保最后一行有空间显示"展开"按钮
|
|
67
|
+
const truncateText = (html: string): string => {
|
|
68
|
+
if (!contentRef.value || !isLongText.value) {
|
|
69
|
+
return html;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 移除所有 HTML 标签获取纯文本
|
|
73
|
+
const tempDiv = document.createElement('div');
|
|
74
|
+
tempDiv.innerHTML = html;
|
|
75
|
+
const plainText = tempDiv.textContent || tempDiv.innerText || '';
|
|
76
|
+
|
|
77
|
+
// 估算每行能显示的字符数(考虑容器宽度和字体大小)
|
|
78
|
+
const containerWidth = containerRef.value?.offsetWidth || 500;
|
|
79
|
+
const fontSize = parseFloat(getComputedStyle(contentRef.value).fontSize) || 14;
|
|
80
|
+
// 中文字符宽度约为字体大小的1倍,英文字符约为0.6倍,取平均值0.8
|
|
81
|
+
const avgCharWidth = fontSize * 0.8;
|
|
82
|
+
const charsPerLine = Math.floor(containerWidth / avgCharWidth);
|
|
83
|
+
const maxChars = charsPerLine * props.maxLines - 4; // 减去"展开"按钮的空间(约2-3个字符)
|
|
84
|
+
|
|
85
|
+
if (plainText.length <= maxChars) {
|
|
86
|
+
return html;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 计算纯文本长度并截取
|
|
90
|
+
let plainTextCount = 0;
|
|
91
|
+
let htmlResult = '';
|
|
92
|
+
let inTag = false;
|
|
93
|
+
let tagStartIndex = -1;
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < html.length; i++) {
|
|
96
|
+
const char = html[i];
|
|
97
|
+
|
|
98
|
+
if (char === '<') {
|
|
99
|
+
inTag = true;
|
|
100
|
+
tagStartIndex = htmlResult.length;
|
|
101
|
+
htmlResult += char;
|
|
102
|
+
} else if (char === '>') {
|
|
103
|
+
inTag = false;
|
|
104
|
+
htmlResult += char;
|
|
105
|
+
tagStartIndex = -1;
|
|
106
|
+
} else if (inTag) {
|
|
107
|
+
htmlResult += char;
|
|
108
|
+
} else {
|
|
109
|
+
// 纯文本字符
|
|
110
|
+
if (plainTextCount < maxChars) {
|
|
111
|
+
plainTextCount++;
|
|
112
|
+
htmlResult += char;
|
|
113
|
+
} else {
|
|
114
|
+
// 已达到最大长度,停止添加
|
|
115
|
+
if (inTag && tagStartIndex >= 0) {
|
|
116
|
+
htmlResult = htmlResult.substring(0, tagStartIndex);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 如果循环结束时还在标签中,移除未完成的标签
|
|
124
|
+
if (inTag && tagStartIndex >= 0) {
|
|
125
|
+
htmlResult = htmlResult.substring(0, tagStartIndex);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 移除所有未闭合的开始标签
|
|
129
|
+
const tagStack: Array<{ name: string; startIndex: number; endIndex: number }> = [];
|
|
130
|
+
const tagRegex = /<\/?(\w+)(?:\s+[^>]*)?>/g;
|
|
131
|
+
let match;
|
|
132
|
+
|
|
133
|
+
// 找到所有标签(开始标签和结束标签)
|
|
134
|
+
while ((match = tagRegex.exec(htmlResult)) !== null) {
|
|
135
|
+
const isClosingTag = htmlResult[match.index + 1] === '/';
|
|
136
|
+
const tagName = match[1];
|
|
137
|
+
const tagStart = match.index;
|
|
138
|
+
const tagEnd = match.index + match[0].length;
|
|
139
|
+
|
|
140
|
+
if (isClosingTag) {
|
|
141
|
+
// 结束标签:从栈中找到对应的开始标签并移除
|
|
142
|
+
for (let i = tagStack.length - 1; i >= 0; i--) {
|
|
143
|
+
if (tagStack[i].name === tagName) {
|
|
144
|
+
tagStack.splice(i, 1);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
// 开始标签:添加到栈中
|
|
150
|
+
tagStack.push({ name: tagName, startIndex: tagStart, endIndex: tagEnd });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 移除所有未闭合的开始标签(从后往前移除,避免索引问题)
|
|
155
|
+
for (let i = tagStack.length - 1; i >= 0; i--) {
|
|
156
|
+
const tag = tagStack[i];
|
|
157
|
+
htmlResult = htmlResult.substring(0, tag.startIndex) + htmlResult.substring(tag.endIndex);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return htmlResult;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const displayContent = computed(() => {
|
|
164
|
+
if (expanded.value || !isLongText.value) {
|
|
165
|
+
return props.message;
|
|
166
|
+
}
|
|
167
|
+
return truncateText(props.message);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const toggleExpand = () => {
|
|
171
|
+
expanded.value = !expanded.value;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
onMounted(() => {
|
|
175
|
+
checkIfOverflow();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
watch(() => props.message, () => {
|
|
179
|
+
checkIfOverflow();
|
|
180
|
+
});
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<style scoped lang="less">
|
|
184
|
+
.expandable-message-container {
|
|
185
|
+
max-width: 500px;
|
|
186
|
+
word-break: break-word;
|
|
187
|
+
line-height: 1.5em;
|
|
188
|
+
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.expandable-message-content {
|
|
192
|
+
word-break: break-word;
|
|
193
|
+
line-height: 1.5em;
|
|
194
|
+
flex: 1;
|
|
195
|
+
min-width: 0;
|
|
196
|
+
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
197
|
+
position: relative;
|
|
198
|
+
display: block;
|
|
199
|
+
|
|
200
|
+
> span {
|
|
201
|
+
display: block;
|
|
202
|
+
word-break: break-word;
|
|
203
|
+
|
|
204
|
+
// 确保 br 标签能正确换行
|
|
205
|
+
:deep(br) {
|
|
206
|
+
display: block;
|
|
207
|
+
content: "";
|
|
208
|
+
margin: 0;
|
|
209
|
+
line-height: inherit;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
&.clamped {
|
|
214
|
+
max-height: calc(1.5em * var(--max-lines, 3));
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
|
|
217
|
+
> span {
|
|
218
|
+
display: block;
|
|
219
|
+
word-break: break-word;
|
|
220
|
+
|
|
221
|
+
:deep(br) {
|
|
222
|
+
display: block;
|
|
223
|
+
content: "";
|
|
224
|
+
margin: 0;
|
|
225
|
+
line-height: inherit;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.expand-button {
|
|
230
|
+
position: absolute;
|
|
231
|
+
right: 0;
|
|
232
|
+
bottom: 0;
|
|
233
|
+
background: linear-gradient(to right, transparent 0%, rgba(255, 255, 255, 0.85) 15%, rgba(255, 255, 255, 0.98) 100%);
|
|
234
|
+
padding-left: 12px;
|
|
235
|
+
padding-right: 0;
|
|
236
|
+
z-index: 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
&.expanded {
|
|
241
|
+
max-height: none;
|
|
242
|
+
overflow: visible;
|
|
243
|
+
|
|
244
|
+
> span {
|
|
245
|
+
display: block;
|
|
246
|
+
word-break: break-word;
|
|
247
|
+
|
|
248
|
+
:deep(br) {
|
|
249
|
+
display: block;
|
|
250
|
+
content: "";
|
|
251
|
+
margin: 0;
|
|
252
|
+
line-height: inherit;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.expand-button {
|
|
257
|
+
display: inline-flex;
|
|
258
|
+
margin-left: 4px;
|
|
259
|
+
vertical-align: baseline;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.expand-button {
|
|
265
|
+
display: inline-flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
gap: 6px;
|
|
268
|
+
padding: 0;
|
|
269
|
+
margin-left: 4px;
|
|
270
|
+
color: var(--el-color-primary, #1957ff);
|
|
271
|
+
font-size: 14px;
|
|
272
|
+
font-weight: 400;
|
|
273
|
+
background: none;
|
|
274
|
+
border: none;
|
|
275
|
+
cursor: pointer;
|
|
276
|
+
outline: none;
|
|
277
|
+
white-space: nowrap;
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
transition: all 0.25s ease;
|
|
280
|
+
user-select: none;
|
|
281
|
+
position: relative;
|
|
282
|
+
vertical-align: baseline;
|
|
283
|
+
|
|
284
|
+
.button-text {
|
|
285
|
+
position: relative;
|
|
286
|
+
transition: color 0.25s ease;
|
|
287
|
+
|
|
288
|
+
&::after {
|
|
289
|
+
content: '';
|
|
290
|
+
position: absolute;
|
|
291
|
+
left: 0;
|
|
292
|
+
bottom: -2px;
|
|
293
|
+
width: 0;
|
|
294
|
+
height: 1.5px;
|
|
295
|
+
background: linear-gradient(90deg, var(--el-color-primary, #1957ff) 0%, rgba(25, 87, 255, 0.6) 100%);
|
|
296
|
+
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.arrow-icon {
|
|
301
|
+
font-size: 16px;
|
|
302
|
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
303
|
+
display: inline-flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
color: var(--el-color-primary, #1957ff);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
&:hover {
|
|
309
|
+
color: var(--el-color-primary-light-3, #5e89ff);
|
|
310
|
+
|
|
311
|
+
.button-text::after {
|
|
312
|
+
width: 100%;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.arrow-icon {
|
|
316
|
+
color: var(--el-color-primary-light-3, #5e89ff);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
&:not(.expanded) .arrow-icon {
|
|
320
|
+
transform: translateY(2px);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
&.expanded .arrow-icon {
|
|
324
|
+
transform: translateY(-2px) rotate(180deg);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
&:active {
|
|
329
|
+
color: var(--el-color-primary-dark-2, #0d3fd9);
|
|
330
|
+
|
|
331
|
+
.arrow-icon {
|
|
332
|
+
color: var(--el-color-primary-dark-2, #0d3fd9);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
&.expanded .arrow-icon {
|
|
337
|
+
transform: rotate(180deg);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
</style>
|