完成租户登录

This commit is contained in:
2025-10-29 23:07:53 +08:00
parent b9938b4c0d
commit ddf90424ba
43 changed files with 7761 additions and 474 deletions
+99
View File
@@ -0,0 +1,99 @@
import request from "@/utils/request";
/**
* 获取所有文件
* @returns {Promise}
*/
export function getAllFiles() {
return request({
url: "/api/files",
method: "get",
});
}
/**
* 获取我的文件
* @returns {Promise}
*/
export function getMyFiles() {
return request({
url: "/api/files/my",
method: "get",
});
}
/**
* 根据ID获取文件
* @param {number|string} id 文件ID
* @returns {Promise}
*/
export function getFileById(id) {
return request({
url: `/api/files/${id}`,
method: "get",
});
}
/**
* 上传文件
* @param {FormData} formData 文件数据
* @param {Object} options 额外选项
* @param {string} [options.category]
* @param {string} [options.tenantId]
* @returns {Promise}
*/
export function uploadFile(formData, options = {}) {
if (options.category) {
formData.append('category', options.category);
}
if (options.tenantId) {
formData.append('tenant_id', options.tenantId);
}
return request({
url: "/api/files",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data"
}
});
}
/**
* 更新文件信息
* @param {number|string} id 文件ID
* @param {Object} fileData 更新的数据
* @returns {Promise}
*/
export function updateFile(id, fileData) {
return request({
url: `/api/files/${id}`,
method: "put",
data: fileData,
});
}
/**
* 删除文件
* @param {number|string} id 文件ID
* @returns {Promise}
*/
export function deleteFile(id) {
return request({
url: `/api/files/${id}`,
method: "delete",
});
}
/**
* 搜索文件
* @param {string} keyword 关键字
* @returns {Promise}
*/
export function searchFiles(keyword) {
return request({
url: `/api/files/search`,
method: "get",
params: { keyword },
});
}
+115
View File
@@ -0,0 +1,115 @@
import request from "@/utils/request";
/**
* 获取知识列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
export function getKnowledgeList(params) {
return request({
url: "/api/knowledge/list",
method: "get",
params,
});
}
/**
* 获取知识详情
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function getKnowledgeDetail(id) {
return request({
url: `/api/knowledge/detail`,
method: "get",
params: { id },
});
}
/**
* 创建知识
* @param {Object} data 知识数据
* @returns {Promise}
*/
export function createKnowledge(data) {
return request({
url: "/api/knowledge/create",
method: "post",
data,
});
}
/**
* 更新知识
* @param {number|string} id 知识ID
* @param {Object} data 更新的数据
* @returns {Promise}
*/
export function updateKnowledge(id, data) {
return request({
url: `/api/knowledge/update`,
method: "post",
data: { ...data, id },
});
}
/**
* 删除知识
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function deleteKnowledge(id) {
return request({
url: `/api/knowledge/delete`,
method: "post",
data: { id },
});
}
/**
* 获取分类列表
* @returns {Promise}
*/
export function getCategoryList() {
return request({
url: "/api/knowledge/categories",
method: "get",
});
}
/**
* 获取标签列表
* @returns {Promise}
*/
export function getTagList() {
return request({
url: "/api/knowledge/tags",
method: "get",
});
}
/**
* 添加分类
* @param {Object} data 分类数据
* @returns {Promise}
*/
export function addCategory(data) {
return request({
url: "/api/knowledge/category/add",
method: "post",
data,
});
}
/**
* 添加标签
* @param {Object} data 标签数据
* @returns {Promise}
*/
export function addTag(data) {
return request({
url: "/api/knowledge/tag/add",
method: "post",
data,
});
}
+63 -20
View File
@@ -1,70 +1,113 @@
import request from "@/utils/request";
// 获取知识列表
/**
* 获取知识列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
export function getKnowledgeList(params) {
return request({
url: `/api/knowledge/list`,
url: "/api/knowledge/list",
method: "get",
params: params,
params,
});
}
// 获取知识详情
/**
* 获取知识详情
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function getKnowledgeDetail(id) {
return request({
url: `/api/knowledge/detail/${id}`,
method: "get",
});
}
// 创建知识
/**
* 创建知识
* @param {Object} data 知识数据
* @returns {Promise}
*/
export function createKnowledge(data) {
return request({
url: `/api/knowledge/create`,
url: "/api/knowledge/create",
method: "post",
data: data,
data,
});
}
// 更新知识
/**
* 更新知识
* @param {number|string} id 知识ID
* @param {Object} data 更新的数据
* @returns {Promise}
*/
export function updateKnowledge(id, data) {
return request({
url: `/api/knowledge/update/${id}`,
method: "put",
data: data,
data,
});
}
// 删除知识
/**
* 删除知识
* @param {number|string} id 知识ID
* @returns {Promise}
*/
export function deleteKnowledge(id) {
return request({
url: `/api/knowledge/delete/${id}`,
method: "delete",
});
}
// 获取分类列表
/**
* 获取分类列表
* @returns {Promise}
*/
export function getCategoryList() {
return request({
url: `/api/knowledge/category/list`,
url: "/api/knowledge/category/list",
method: "get",
});
}
// 获取标签列表
/**
* 获取标签列表
* @returns {Promise}
*/
export function getTagList() {
return request({
url: `/api/knowledge/tag/list`,
url: "/api/knowledge/tag/list",
method: "get",
});
}
// 添加分类
/**
* 添加分类
* @param {Object} data 分类数据
* @returns {Promise}
*/
export function addCategory(data) {
return request({
url: `/api/knowledge/category/add`,
url: "/api/knowledge/category/add",
method: "post",
data: data,
data,
});
}
// 添加标签
/**
* 添加标签
* @param {Object} data 标签数据
* @returns {Promise}
*/
export function addTag(data) {
return request({
url: `/api/knowledge/tag/add`,
url: "/api/knowledge/tag/add",
method: "post",
data: data,
data,
});
}
+3 -2
View File
@@ -1,13 +1,14 @@
import request from "@/utils/request";
// 登录
export function login(username, password) {
// 登录(使用租户名称)
export function login(username, password, tenantName) {
return request({
url: `/api/login`,
method: "post",
data: {
username: username,
password: password,
tenant_name: tenantName, // 后端期望的字段名是 tenant_name
},
});
}
+34
View File
@@ -8,3 +8,37 @@ export function getAllMenus() {
});
}
// 更新菜单状态
export function updateMenuStatus(menuId, status) {
return request({
url: `/api/menu/status/${menuId}`,
method: "patch",
data: { status },
});
}
// 创建菜单
export function createMenu(menuData) {
return request({
url: `/api/menu`,
method: "post",
data: menuData,
});
}
// 更新菜单
export function updateMenu(menuId, menuData) {
return request({
url: `/api/menu/${menuId}`,
method: "put",
data: menuData,
});
}
// 删除菜单
export function deleteMenu(menuId) {
return request({
url: `/api/menu/${menuId}`,
method: "delete",
});
}
+55
View File
@@ -0,0 +1,55 @@
import request from "@/utils/request";
//获取所有租户
export function getAllTenants() {
return request({
url: "/api/tenant/list",
method: "get",
});
}
//新增租户
export function createTenant(tenantData) {
return request({
url: "/api/tenant",
method: "post",
data: tenantData,
});
}
// 编辑租户
export function updateTenant(tenantId, tenantData) {
return request({
url: `/api/tenant/${tenantId}`,
method: "put",
data: tenantData,
});
}
// 删除租户
export function deleteTenant(tenantId) {
return request({
url: `/api/tenant/${tenantId}`,
method: "delete",
});
}
// 审核租户
export function auditTenant(tenantId, auditData) {
return request({
url: `/api/tenant/${tenantId}/audit`,
method: "post",
data: auditData,
});
}
// 查看租户详情
export function getTenantDetail(tenantId) {
return request({
url: `/api/tenant/${tenantId}`,
method: "get",
});
}
+743 -9
View File
@@ -54,6 +54,16 @@
--card-bg-color: #ffffff;
--card-border-color: #ebeef5;
// container-box 亮色主题
--container-box-bg: #fff;
--container-box-border: #ebeef5;
--container-box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04);
// 滚动条颜色
--scrollbar-track-color: #f1f1f1;
--scrollbar-thumb-color: #c1c1c1;
--scrollbar-thumb-hover-color: #a8a8a8;
// Element Plus 组件主题适配
--el-bg-color: var(--bg-color);
--el-bg-color-page: var(--bg-color-page);
@@ -125,6 +135,16 @@
// 卡片背景
--card-bg-color: #252526;
--card-border-color: #3c3c3d;
// container-box 暗色主题
--container-box-bg: #232325;
--container-box-border: #363637;
--container-box-shadow: 0 2px 8px 0 rgba(0,0,0,0.09);
// 滚动条颜色
--scrollbar-track-color: #2d2d2d;
--scrollbar-thumb-color: #555555;
--scrollbar-thumb-hover-color: #666666;
// Element Plus 组件主题适配
--el-bg-color: var(--bg-color);
@@ -150,6 +170,12 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
// 卡片样式
.el-card {
background-color: var(--card-bg-color);
@@ -157,6 +183,19 @@ body {
color: var(--text-color-primary);
}
// container-box样式
.container-box {
background-color: var(--container-box-bg);
border: 1px solid var(--container-box-border);
box-shadow: var(--container-box-shadow);
border-radius: 8px;
padding: 24px;
transition:
background-color 0.3s,
border-color 0.3s,
box-shadow 0.3s;
}
// 输入框样式
.el-input__inner {
background-color: var(--fill-color-blank);
@@ -168,24 +207,427 @@ body {
}
}
// 按钮样式过渡
// 按钮样式(全局主题适配)
.el-button {
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
// 默认按钮
&.el-button--default {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover:not(:disabled) {
background-color: var(--fill-color-light) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
&:disabled {
background-color: var(--fill-color) !important;
border-color: var(--border-color-lighter) !important;
color: var(--text-color-disabled) !important;
}
}
// 文本按钮
&.el-button--text {
color: var(--text-color-primary) !important;
&:hover:not(:disabled) {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
&:disabled {
color: var(--text-color-disabled) !important;
}
}
}
// 表格样式
// 表格样式(全局主题适配)
.el-table {
background-color: var(--bg-color);
color: var(--text-color-primary);
background-color: var(--bg-color) !important;
color: var(--text-color-primary) !important;
border-color: var(--border-color-lighter) !important;
th {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
// 表头
th.el-table__cell {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border-bottom-color: var(--border-color) !important;
}
td {
border-color: var(--border-color-lighter);
// 表格行
tr {
background-color: var(--bg-color) !important;
// 鼠标悬停
&:hover > td {
background-color: var(--fill-color-lighter) !important;
}
}
// 表格单元格
td.el-table__cell {
border-bottom-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
// 斑马纹表格
&.el-table--striped {
.el-table__body tr.el-table__row--striped td {
background-color: var(--fill-color-extra-light) !important;
}
.el-table__body tr.el-table__row--striped:hover td {
background-color: var(--fill-color-lighter) !important;
}
}
// 边框表格
&.el-table--border {
border-color: var(--border-color-lighter) !important;
&::after,
&::before {
background-color: var(--border-color-lighter) !important;
}
th.el-table__cell,
td.el-table__cell {
border-right-color: var(--border-color-lighter) !important;
}
}
// 空状态
.el-table__empty-block {
background-color: var(--bg-color) !important;
color: var(--text-color-secondary) !important;
}
// 空状态文字
.el-table__empty-text {
color: var(--text-color-secondary) !important;
}
}
// 标签样式(全局主题适配)
.el-tag {
background-color: var(--fill-color-light) !important;
border-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
// 对话框样式(全局主题适配)
.el-dialog {
background-color: var(--bg-color) !important;
border-color: var(--border-color) !important;
.el-dialog__header {
border-bottom-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
.el-dialog__title {
color: var(--text-color-primary) !important;
}
.el-dialog__body {
background-color: var(--bg-color) !important;
color: var(--text-color-primary) !important;
}
.el-dialog__footer {
border-top-color: var(--border-color-lighter) !important;
}
}
// MessageBox 确认框样式(全局主题适配)
.el-overlay.is-message-box {
background-color: rgba(0, 0, 0, 0.5) !important;
&[data-theme="dark"] {
background-color: rgba(0, 0, 0, 0.7) !important;
}
}
.el-message-box {
background-color: var(--bg-color) !important;
.el-message-box__header {
.el-message-box__title {
color: var(--text-color-primary) !important;
}
}
.el-message-box__content {
color: var(--text-color-primary) !important;
.el-message-box__message {
color: var(--text-color-primary) !important;
p {
color: var(--text-color-primary) !important;
}
}
}
}
// 描述列表样式(全局主题适配)
.el-descriptions {
.el-descriptions__header {
.el-descriptions__title {
color: var(--text-color-primary) !important;
}
}
.el-descriptions__table {
.el-descriptions__label {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border-color: var(--border-color-lighter) !important;
}
.el-descriptions__content {
background-color: var(--bg-color) !important;
color: var(--text-color-primary) !important;
border-color: var(--border-color-lighter) !important;
}
td.el-descriptions__label,
td.el-descriptions__content {
border-color: var(--border-color-lighter) !important;
}
}
}
// 表单样式(全局主题适配)
.el-form {
.el-form-item__label {
color: var(--text-color-primary) !important;
}
.el-form-item__error {
color: var(--danger-color) !important;
}
}
// 选择器样式(全局主题适配)
.el-select {
// Element Plus 2.x 使用 el-select__wrapper
.el-select__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
border: 1px solid var(--border-color) !important;
box-shadow: none !important;
&:hover {
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
// 如果选择器内部使用了 el-input__wrapper(兼容性处理)
.el-input__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
border: 1px solid var(--border-color) !important;
box-shadow: none !important;
&:hover {
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
.el-input__inner {
background-color: transparent !important;
color: var(--text-color-primary) !important;
}
.el-input__suffix {
.el-input__suffix-inner {
.el-select__caret {
color: var(--text-color-secondary) !important;
}
}
}
}
// 选择器下拉菜单样式(全局主题适配)
.el-select-dropdown,
.el-select-dropdown__wrap {
background-color: var(--bg-color-overlay) !important;
border-color: var(--border-color) !important;
.el-select-dropdown__item {
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.selected {
color: var(--primary-color) !important;
font-weight: 700;
}
&.is-disabled {
color: var(--text-color-disabled) !important;
&:hover {
background-color: transparent !important;
}
}
}
.el-select-dropdown__empty {
color: var(--text-color-secondary) !important;
}
}
// Element Plus Popper 组件样式(全局主题适配,用于所有通过 Popper 渲染的下拉组件)
.el-popper,
.el-popper__arrow::before {
background-color: var(--bg-color-overlay) !important;
border-color: var(--border-color) !important;
}
.el-popper.is-dark {
background-color: var(--bg-color-overlay) !important;
color: var(--text-color-primary) !important;
}
// 输入框组件样式(全局主题适配,兼容 Element Plus 2.x
.el-input {
.el-input__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
border: 1px solid var(--border-color) !important;
box-shadow: none !important;
&:hover {
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
.el-input__inner {
background-color: transparent !important;
color: var(--text-color-primary) !important;
&::placeholder {
color: var(--text-color-placeholder) !important;
}
}
}
// 文本域样式(全局主题适配)
.el-textarea {
.el-textarea__inner {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&::placeholder {
color: var(--text-color-placeholder) !important;
}
&:focus {
border-color: var(--primary-color) !important;
}
}
}
// 单选按钮组样式(全局主题适配)
.el-radio-group {
.el-radio {
color: var(--text-color-primary) !important;
.el-radio__label {
color: var(--text-color-primary) !important;
}
&.is-checked {
.el-radio__label {
color: var(--primary-color) !important;
}
}
}
}
// 数字输入框样式(全局主题适配)
.el-input-number {
.el-input__wrapper {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
.el-input__inner {
background-color: transparent !important;
color: var(--text-color-primary) !important;
}
}
.el-input-number__decrease,
.el-input-number__increase {
background-color: var(--fill-color-light) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover:not(.is-disabled) {
background-color: var(--fill-color) !important;
color: var(--primary-color) !important;
}
&.is-disabled {
background-color: var(--fill-color) !important;
color: var(--text-color-disabled) !important;
}
}
}
// 开关组件样式(全局主题适配)
.el-switch {
--el-switch-on-color: var(--primary-color);
--el-switch-off-color: var(--fill-color-dark);
.el-switch__core {
background-color: var(--fill-color-dark) !important;
border-color: var(--border-color) !important;
}
&.is-checked {
.el-switch__core {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
}
&.is-disabled {
.el-switch__core {
background-color: var(--fill-color) !important;
border-color: var(--border-color-lighter) !important;
opacity: 0.6;
}
}
}
// 警告提示样式(全局主题适配)
.el-alert {
background-color: var(--fill-color-light) !important;
border-color: var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
// 菜单样式
@@ -377,3 +819,295 @@ body {
}
}
// 滚动条样式(全局)
// WebKit 浏览器(Chrome, Safari, Edge
* {
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);
// WebKit 滚动条样式
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-color);
border-radius: 4px;
transition: background-color 0.3s ease;
&:hover {
background: var(--scrollbar-thumb-hover-color);
}
}
&::-webkit-scrollbar-corner {
background: var(--scrollbar-track-color);
}
}
// 更细的滚动条样式(可选)
.thin-scrollbar {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin: 14px 0 0 0;
}
/* 标准文章样式 支持黑白主题 */
.markdown-body {
color: var(--text-color, #222);
font-size: 16px;
line-height: 1.8;
word-break: break-word;
background: var(--content-bg);
transition: color 0.3s, background 0.3s;
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--title-color, var(--text-color, #222));
margin-top: 1.6em;
margin-bottom: 0.8em;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
transition: color 0.3s;
}
h1 {
font-size: 2.2em;
border-bottom: 1px solid var(--border-color-light, #eaecef);
padding-bottom: 0.4em;
}
h2 {
font-size: 1.8em;
border-bottom: 1px solid var(--border-color-light, #eaecef);
padding-bottom: 0.3em;
}
h3 {
font-size: 1.4em;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1.06em;
}
h6 {
font-size: 1em;
color: var(--text-color-secondary, #969696);
}
p {
margin: 0.9em 0;
color: inherit;
}
ul,
ol {
padding-left: 2em;
margin: 0.9em 0;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.7em 0 0.7em 0.7em;
color: inherit;
}
a {
color: var(--primary-color, #2d8cf0);
text-decoration: underline;
transition: color 0.2s;
&:hover {
color: var(--primary-hover, #1a73e8);
}
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
border-radius: 4px;
background: var(--img-bg, #f7f7fa);
}
blockquote {
border-left: 4px solid var(--blockquote-border, #d0dde9);
background: var(--blockquote-bg, #f7f9fa);
padding: 0.6em 1.2em;
margin: 1.1em 0;
color: var(--blockquote-color, #6580a0);
font-style: italic;
transition: background 0.3s, border-color 0.3s, color 0.3s;
}
code {
font-family: var(
--code-font,
"Fira Mono",
"Menlo",
"Consolas",
"monospace"
);
background: var(--code-bg, #f4f4f4);
border-radius: 3px;
padding: 0.15em 0.4em;
color: var(--code-color, #c7254e);
font-size: 0.97em;
margin: 0 0.1em;
transition: background 0.3s, color 0.3s;
}
pre {
background: var(--pre-bg, #f6f8fa);
border-radius: 4px;
padding: 1em 1.2em;
font-family: var(
--code-font,
"Fira Mono",
"Menlo",
"Consolas",
"monospace"
);
font-size: 0.98em;
overflow-x: auto;
color: var(--pre-color, #212529);
margin: 1.2em 0;
transition: background 0.3s, color 0.3s;
code {
background: none;
color: inherit;
font-size: inherit;
padding: 0;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.2em 0;
color: inherit;
font-size: 1em;
background: var(--table-bg, #fff);
transition: background 0.3s;
}
th,
td {
border: 1px solid var(--table-border, #dee2e6);
padding: 0.5em 1em;
color: inherit;
}
th {
background: var(--table-head-bg, #f4f8fb);
font-weight: 600;
}
tr:nth-child(even) {
background: var(--table-row-even-bg, #f9fbfc);
}
hr {
border: none;
border-top: 1px solid var(--border-color-light, #eaecef);
margin: 2em 0;
}
br {
display: block;
margin: 0.2em 0;
content: "";
}
// 兼容 Element 表单
.el-form-item__label,
.el-form-item__content,
.el-form-item__error,
.el-form-item__error-tip {
color: var(--text-color, #222);
}
}
/* 深色主题支持 */
.dark .markdown-body {
color: #fff;
background: #181b21;
h1,
h2,
h3,
h4,
h5,
h6 {
color: #fff;
}
h6 {
color: #ccc;
}
a {
color: #fff;
&:hover {
color: #e6e6e6;
}
}
img {
background: #23272b;
}
blockquote {
border-left-color: #5d7896;
background: #232a33;
color: #cce3fa;
}
code {
background: #31343b;
color: #fff;
}
pre {
background: #23272d;
color: #fff;
}
table {
background: #23272b;
}
th,
td {
border-color: #333b47;
color: #fff;
}
th {
background: #273041;
}
tr:nth-child(even) {
background: #20232a;
}
hr {
border-top-color: #333b47;
}
// Element兼容
.el-form-item__label,
.el-form-item__content,
.el-form-item__error,
.el-form-item__error-tip {
color: #fff;
}
}
+3
View File
@@ -91,6 +91,9 @@ const handleCommand = (command) => {
// 清除缓存中的user数据
localStorage.removeItem('user');
sessionStorage.removeItem('user');
//清除租户数据
localStorage.removeItem('tenant');
sessionStorage.removeItem('tenant');
router.push('/login');
}
};
+5 -1
View File
@@ -8,9 +8,13 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
import { useAuthStore } from './stores/auth'
import { initTheme } from './utils/theme'
// 导入全局组件
import WangEditor from '@/views/components/WangEditor.vue';
const app = createApp(App)
const pinia = createPinia()
// 全局注册 WangEditor 组件
app.component('WangEditor', WangEditor);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
@@ -29,7 +33,7 @@ authStore.checkAuth()
// 如果用户已登录,在应用启动时加载动态路由
if (authStore.isLoggedIn) {
loadAndAddDynamicRoutes().then(() => {
console.log('应用启动时已加载动态路由');
// console.log('应用启动时已加载动态路由');
}).catch(err => {
console.error('应用启动时加载动态路由失败:', err);
});
+78 -34
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router";
import { convertMenusToRoutes } from "./dynamicRoutes";
// 静态路由:登录页独立,404 页面独立
// 仪表盘路由作为静态路由,永远放在第一个位置
const staticRoutes = [
{
path: "/login",
@@ -13,8 +14,22 @@ const staticRoutes = [
path: "/",
name: "Main",
component: () => import("@/views/Main.vue"),
redirect: "/dashboard", // 默认重定向,动态路由加载后会更新
children: [], // 所有页面路由都会作为 children 添加到这里
redirect: "/dashboard", // 默认重定向到仪表盘
children: [
// 仪表盘路由:静态路由,永远放在第一个
{
path: "dashboard",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: {
requiresAuth: true,
title: "仪表盘",
icon: "fa-solid fa-gauge",
id: 1,
isStatic: true // 标记为静态路由
}
}
],
meta: { requiresAuth: true }
},
{
@@ -76,15 +91,18 @@ function addDynamicRoutes(menus) {
return;
}
const dynamicRoutes = convertMenusToRoutes(menus);
// 过滤掉 ID 为 1 的仪表盘菜单,因为它是静态路由
const filteredMenus = menus.filter(menu => menu.id !== 1);
const dynamicRoutes = convertMenusToRoutes(filteredMenus);
// 打印路由树结构(只打印路径信息,不序列化组件)
console.log('生成的路由树:', dynamicRoutes.map(r => ({
path: r.path,
name: r.name,
hasComponent: !!r.component,
childrenCount: r.children?.length || 0
})));
// console.log('生成的路由树:', dynamicRoutes.map(r => ({
// path: r.path,
// name: r.name,
// hasComponent: !!r.component,
// childrenCount: r.children?.length || 0
// })));
// 获取主路由
const mainRoute = router.getRoutes().find(r => r.name === 'Main');
@@ -93,45 +111,71 @@ function addDynamicRoutes(menus) {
return;
}
// 获取现有的静态路由(仪表盘路由)
const staticChildren = mainRoute.children || [];
const dashboardRoute = staticChildren.find(r => r.name === 'Dashboard');
// 移除旧的主路由
router.removeRoute('Main');
// 查找第一个有效路由作为默认重定向
const firstRoute = findFirstValidRoute(dynamicRoutes);
let redirectPath = "/dashboard"; // 默认值
if (firstRoute && firstRoute.meta?.menuPath) {
redirectPath = firstRoute.meta.menuPath;
}
// 重新添加主路由,包含所有动态子路由
// 重新添加主路由,仪表盘路由放在第一个,后面跟动态路由
router.addRoute({
...mainRoute,
redirect: redirectPath,
children: dynamicRoutes // 所有动态路由作为 Main 的 children
redirect: "/dashboard", // 始终重定向到仪表盘
children: [
// 仪表盘静态路由永远放在第一个
...(dashboardRoute ? [dashboardRoute] : []),
// 动态路由跟在后面
...dynamicRoutes
]
});
// 添加知识库的子路由(详情页和编辑页)
// 直接在 Main 路由下添加完整路径的子路由
router.addRoute('Main', {
path: 'apps/knowledge/detail/:id',
name: 'apps-knowledge-detail',
component: () => import('@/views/apps/knowledge/components/detail.vue'),
meta: {
requiresAuth: true,
title: '知识详情'
}
});
router.addRoute('Main', {
path: 'apps/knowledge/edit/:id',
name: 'apps-knowledge-edit',
component: () => import('@/views/apps/knowledge/components/edit.vue'),
meta: {
requiresAuth: true,
title: '编辑知识'
}
});
dynamicRoutesAdded = true;
// 打印路由信息用于调试
const finalMainRoute = router.getRoutes().find(r => r.name === 'Main');
console.log('动态路由已添加:', {
redirect: redirectPath,
childrenCount: dynamicRoutes.length,
childrenPaths: finalMainRoute?.children?.map(c => ({
path: c.path,
name: c.name,
metaPath: c.meta?.menuPath
}))
});
// console.log('动态路由已添加:', {
// redirect: "/dashboard",
// staticRoutesCount: dashboardRoute ? 1 : 0,
// dynamicRoutesCount: dynamicRoutes.length,
// totalChildrenCount: (dashboardRoute ? 1 : 0) + dynamicRoutes.length,
// childrenPaths: finalMainRoute?.children?.map(c => ({
// path: c.path,
// name: c.name,
// isStatic: c.meta?.isStatic || false,
// metaPath: c.meta?.menuPath
// }))
// });
// 测试路由解析(使用完整路径)
const testResolve = router.resolve('/dashboard');
console.log('测试路由解析 /dashboard:', {
matched: testResolve.matched.map(m => ({ name: m.name, path: m.path })),
fullPath: testResolve.fullPath,
name: testResolve.name,
hasMatched: testResolve.matched.length > 0
});
// console.log('测试路由解析 /dashboard:', {
// matched: testResolve.matched.map(m => ({ name: m.name, path: m.path })),
// fullPath: testResolve.fullPath,
// name: testResolve.name,
// hasMatched: testResolve.matched.length > 0
// });
}
// 查找第一个有效的路由(有组件的路由)
+6
View File
@@ -13,6 +13,12 @@ service.interceptors.request.use(
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
// 对于有 body 的请求(POST、PUT、PATCH),确保设置 Content-Type
if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) {
if (!config.headers['Content-Type'] && !config.headers['content-type']) {
config.headers['Content-Type'] = 'application/json';
}
}
return config;
},
error => {
+155 -34
View File
@@ -61,6 +61,7 @@
<div slot="header">
<span>正文内容</span>
</div>
<el-divider />
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
@@ -123,7 +124,8 @@ async function fetchDetail() {
try {
const idValue = id.value as string | number
const res = await getKnowledgeDetail(idValue)
const data = res.data
// API 返回的数据结构: { code: 0, data: {...}, message: "success" }
const data = (res.code === 0 && res.data) ? res.data : (res.data || res)
formData.value = {
title: data.title || '',
category: data.categoryName || '',
@@ -133,6 +135,9 @@ async function fetchDetail() {
updateTime: data.updateTime || '',
content: data.content || '',
}
if (data.title) {
knowledgeTitle.value = data.title
}
} catch (e) {
ElMessage.error("获取详情失败")
}
@@ -143,10 +148,7 @@ function goBack() {
router.push("/apps/knowledge")
}
function handleEdit() {
router.push({
name: "apps-knowledge-edit",
params: { id: id.value as string }
})
router.push(`/apps/knowledge/edit/${id.value}`)
}
function handleDelete() {
ElMessageBox.confirm(
@@ -160,77 +162,196 @@ function handleDelete() {
).then(async () => {
try {
const idValue = id.value as string | number
await deleteKnowledge(idValue)
ElMessage.success("删除成功")
goBack()
} catch (e) {
ElMessage.error("删除失败")
const res = await deleteKnowledge(idValue)
// API 返回的数据结构: { code: 0, message: "删除成功", data: null }
if (res.code === 0) {
ElMessage.success("删除成功")
goBack()
} else {
ElMessage.error(res.message || "删除失败")
}
} catch (e: any) {
ElMessage.error(e.message || "删除失败")
}
})
}
</script>
<style scoped lang="scss">
/* 全屏显示,覆盖父容器 */
<style scoped lang="less">
/* 知识详情页面样式 - 使用主题变量 */
.knowledge-detail {
position: fixed;
top: 81px; /* 减去 header 高度 */
left: 160px; /* 侧边栏宽度,如果收起的活是 64px */
right: 0;
bottom: 81px; /* 减去 footer 高度 */
z-index: 1000;
overflow-y: auto;
background: var(--background-color);
background-color: var(--bg-color-page);
min-height: 100%;
padding: 24px;
transition: left var(--transition-base);
transition: background-color 0.3s ease;
}
.detail-header {
display: flex;
align-items: center;
margin-bottom: 20px;
background: var(--card-bg);
background-color: var(--card-bg-color);
padding: 16px 24px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color-lighter);
transition: all 0.3s ease;
}
.detail-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
color: var(--text-color-primary);
transition: color 0.3s ease;
}
.detail-body {
display: flex;
gap: 20px;
/* padding: 0 24px 0 24px; */
}
.info-panel {
width: 320px;
flex-shrink: 0;
.header{
// margin-bottom: 14px;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
.header {
color: var(--text-color-primary);
font-weight: 600;
transition: color 0.3s ease;
}
:deep(.el-form-item__label) {
color: var(--text-color-secondary);
transition: color 0.3s ease;
}
:deep(.el-form-item__content) {
color: var(--text-color-primary);
transition: color 0.3s ease;
}
:deep(.el-tag) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
color: var(--text-color-primary);
transition: all 0.3s ease;
}
}
.content-panel {
flex: 1;
}
.actions {
margin-top: 16px;
text-align: center;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color);
color: var(--text-color-primary);
transition: color 0.3s ease;
// Markdown 内容样式适配主题
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
color: var(--text-color-primary);
margin-top: 16px;
margin-bottom: 8px;
}
:deep(p), :deep(li), :deep(span), :deep(div) {
color: var(--text-color-primary);
margin: 8px 0;
}
:deep(a) {
color: var(--primary-color);
text-decoration: none;
&:hover {
opacity: 0.8;
}
}
:deep(code) {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border-color: var(--border-color-lighter);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
:deep(pre) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background-color: transparent;
border: none;
padding: 0;
}
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
}
:deep(ul), :deep(ol) {
color: var(--text-color-primary);
margin: 8px 0;
padding-left: 24px;
}
:deep(blockquote) {
border-left: 4px solid var(--primary-color);
padding-left: 16px;
margin: 16px 0;
color: var(--text-color-secondary);
background-color: var(--fill-color-extra-light);
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
th, td {
border: 1px solid var(--border-color-lighter);
padding: 8px 12px;
color: var(--text-color-primary);
}
th {
background-color: var(--fill-color-light);
font-weight: 600;
}
}
}
.actions {
margin-top: 16px;
text-align: center;
}
/* 响应式布局 */
+194 -80
View File
@@ -82,15 +82,18 @@
<!-- 左侧编辑器 -->
<div class="editor-panel">
<el-card shadow="never">
<template #header>
<span>编辑正文</span>
</template>
<WangEditor v-model="formData.content" />
</el-card>
</div>
<!-- 右侧预览 -->
<div class="preview-panel">
<el-card shadow="never">
<div slot="header">
<template #header>
<span>预览效果</span>
</div>
</template>
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
@@ -193,7 +196,8 @@ const fetchDetail = async () => {
const currentId = id.value as string;
if (currentId && currentId !== "new") {
const res = await getKnowledgeDetail(currentId);
const data = res.data;
// API 返回的数据结构: { code: 0, data: {...}, message: "success" }
const data = (res.code === 0 && res.data) ? res.data : (res.data || res);
// 映射后端数据到前端表单
// categoryName 是从数据库联查得到的分类名称
@@ -216,7 +220,8 @@ const fetchDetail = async () => {
// Tags 可能是 JSON 字符串,需要解析
if (data.tags) {
try {
formData.tags = JSON.parse(data.tags);
const parsed = typeof data.tags === 'string' ? JSON.parse(data.tags) : data.tags;
formData.tags = Array.isArray(parsed) ? parsed : [];
} catch {
// 如果解析失败,尝试作为字符串数组处理
formData.tags = Array.isArray(data.tags) ? data.tags : [];
@@ -233,15 +238,20 @@ const fetchDetail = async () => {
// 获取分类和标签
const loadCategoryAndTag = async () => {
try {
const [catRes, tagResRes] = await Promise.all([
getCategoryList
? getCategoryList()
: Promise.resolve({ data: [] }),
getTagList ? getTagList() : Promise.resolve({ data: [] }),
const [catRes, tagRes] = await Promise.all([
getCategoryList(),
getTagList(),
]);
categoryList.value = catRes.data || [];
tagList.value = tagResRes.data || [];
} catch (e) {
// API 返回的数据结构: { code: 0, data: [...], message: "success" }
const categories = (catRes.code === 0 && catRes.data) ? catRes.data : (catRes.data || []);
const tags = (tagRes.code === 0 && tagRes.data) ? tagRes.data : (tagRes.data || []);
categoryList.value = Array.isArray(categories) ? categories : [];
// 标签数据可能是 { tagId, tagName } 格式,需要提取 tagName
tagList.value = Array.isArray(tags) ? tags.map(tag => tag.tagName || tag) : [];
} catch (e: any) {
console.error("加载分类和标签失败:", e);
categoryList.value = [];
tagList.value = [];
}
@@ -270,34 +280,65 @@ const handleSubmit = () => {
const currentId = id.value as string;
// 准备提交的数据(符合后端格式)
const submitData = {
id: isEdit.value ? parseInt(currentId as string) : 0,
title: formData.title,
categoryId: formData.categoryId,
author: formData.author,
content: formData.content,
tags: JSON.stringify(formData.tags), // 转换为 JSON 字符串
// 检查 categoryId 是否已设置
if (!formData.categoryId) {
ElMessage.warning("请选择分类");
return;
}
// 准备提交的数据(符合后端格式,字段名对应后端 JSON tag)
// 确保数据类型正确:categoryId 必须是数字
const submitData: any = {
title: formData.title || "",
categoryId: Number(formData.categoryId) || 0,
author: formData.author || "",
content: formData.content || "",
tags: Array.isArray(formData.tags) && formData.tags.length > 0
? JSON.stringify(formData.tags)
: "[]",
status: 1, // 默认已发布
};
// 调试:打印提交的数据
console.log("提交的数据:", JSON.stringify(submitData, null, 2));
if (isEdit.value && currentId !== "new") {
// 编辑
// 编辑:需要添加 id(后端 JSON tag 是 "id"
const knowledgeId = parseInt(currentId as string);
if (isNaN(knowledgeId) || knowledgeId <= 0) {
ElMessage.error("知识ID无效");
return;
}
// updateKnowledge 函数会将 id 合并到 data 中,所以这里不需要手动添加
try {
await updateKnowledge(currentId, submitData);
ElMessage.success("保存成功");
goBack();
const res = await updateKnowledge(knowledgeId, submitData);
// API 返回的数据结构: { code: 0, message: "更新成功", data: null }
if (res.code === 0) {
ElMessage.success("保存成功");
goBack();
} else {
ElMessage.error(res.message || "保存失败");
}
} catch (e: any) {
ElMessage.error(e.message || "保存失败");
console.error("保存失败:", e);
const errorMessage = e.response?.data?.message || e.message || "保存失败";
ElMessage.error(errorMessage);
}
} else {
// 新建
// 新建:不包含 id 字段
try {
await createKnowledge(submitData);
ElMessage.success("创建成功");
goBack();
const res = await createKnowledge(submitData);
// API 返回的数据结构: { code: 0, message: "创建成功", data: { id: ... } }
if (res.code === 0) {
ElMessage.success("创建成功");
goBack();
} else {
ElMessage.error(res.message || "创建失败");
}
} catch (e: any) {
ElMessage.error(e.message || "创建失败");
console.error("创建失败:", e);
const errorMessage = e.response?.data?.message || e.message || "创建失败";
ElMessage.error(errorMessage);
}
}
});
@@ -332,41 +373,54 @@ defineExpose({
});
</script>
<style scoped>
<style scoped lang="less">
.knowledge-edit {
position: fixed;
top: 81px;
left: 160px;
right: 0;
bottom: 81px;
z-index: 1000;
background: var(--background-color);
background-color: var(--bg-color-page);
min-height: 100%;
padding: 24px;
overflow-y: auto;
transition: left var(--transition-base);
transition: background-color 0.3s ease;
}
.edit-header {
display: flex;
align-items: center;
margin-bottom: 20px;
background: var(--card-bg);
background-color: var(--card-bg-color);
padding: 16px 24px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color-lighter);
transition: all 0.3s ease;
}
.edit-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
color: var(--text-color-primary);
transition: color 0.3s ease;
}
/* 基本信息区 */
.edit-meta {
margin-bottom: 20px;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
:deep(.el-divider) {
border-color: var(--border-color-lighter);
transition: border-color 0.3s ease;
}
}
.meta-actions {
@@ -378,60 +432,106 @@ defineExpose({
.edit-body {
display: flex;
gap: 20px;
height: calc(100vh - 320px);
min-height: 600px;
}
.editor-panel {
flex: 1;
min-width: 0;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
height: 100%;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
}
.preview-panel {
flex: 1;
min-width: 0;
}
/* 编辑器容器样式 */
.editor-panel {
position: relative;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
height: 100%;
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color);
color: var(--text-color-primary);
min-height: 400px;
padding: 14px 16px;
}
transition: color 0.3s ease;
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(h4),
.markdown-body :deep(h5),
.markdown-body :deep(h6) {
color: var(--text-color);
margin-top: 16px;
margin-bottom: 8px;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
color: var(--text-color-primary);
margin-top: 16px;
margin-bottom: 8px;
}
.markdown-body :deep(p) {
color: var(--text-secondary);
margin: 8px 0;
}
:deep(p), :deep(li), :deep(span), :deep(div) {
color: var(--text-color-primary);
margin: 8px 0;
}
.markdown-body :deep(code) {
background-color: var(--background-hover);
color: var(--text-color);
padding: 2px 6px;
border-radius: 3px;
}
:deep(a) {
color: var(--primary-color);
text-decoration: none;
&:hover {
opacity: 0.8;
}
}
.markdown-body :deep(pre) {
background-color: var(--background-hover);
padding: 12px;
border-radius: var(--border-radius);
overflow-x: auto;
:deep(code) {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border-color: var(--border-color-lighter);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
:deep(pre) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background-color: transparent;
border: none;
padding: 0;
}
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
}
}
/* 响应式布局 */
@@ -454,9 +554,23 @@ defineExpose({
font-size: 16px;
}
}
:deep() {
.el-form-item--default {
margin-bottom: 0px;
:deep(.el-form-item--default) {
margin-bottom: 0px;
}
:deep(.el-input__wrapper), :deep(.el-textarea__inner) {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
}
:deep(.el-textarea__inner) {
&::placeholder {
color: var(--text-color-placeholder) !important;
}
&:focus {
border-color: var(--primary-color) !important;
}
}
</style>
+93 -130
View File
@@ -8,15 +8,6 @@
<!-- 搜索区域 -->
<div class="search-container">
<el-select
v-model="searchType"
placeholder="知识库"
size="default"
class="search-select"
style="width: auto"
>
<el-option label="知识库" value="knowledge" />
</el-select>
<el-input
v-model="keyword"
placeholder="请输入关键字、产品编码进行查询"
@@ -181,7 +172,7 @@
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { getKnowledgeList, deleteKnowledge } from "@/api/knowlege";
import { getKnowledgeList, deleteKnowledge } from "@/api/knowledge";
// 类型定义
interface Knowledge {
@@ -286,8 +277,15 @@ async function fetchRepoList() {
keyword: keyword.value, // 支持关键词搜索
});
repoList.value = result.list || [];
total.value = result.total || 0;
// API 返回的数据结构: { code: 0, data: { list: [...], total: 1 }, message: "success" }
if (result.code === 0 && result.data) {
repoList.value = result.data.list || [];
total.value = result.data.total || 0;
} else {
// 兼容旧的数据结构(直接返回 list 和 total
repoList.value = result.list || result.data?.list || [];
total.value = result.total || result.data?.total || 0;
}
// 更新统计数据(避免重复调用 API)
updateStats();
@@ -302,27 +300,18 @@ async function fetchRepoList() {
}
function handleCreate() {
// 跳转到新建知识库页面
router.push({
name: "apps-knowledge-edit",
params: { id: "new" },
});
// 跳转到新建知识库页面,使用路径跳转
router.push(`/apps/knowledge/edit/new`);
}
function handleView(repo: Knowledge) {
// 跳转到知识详情页面,使用 params 参数传递 id
router.push({
name: "apps-knowledge-detail",
params: { id: repo.id.toString() },
});
// 跳转到知识详情页面,使用路径跳转
router.push(`/apps/knowledge/detail/${repo.id}`);
}
function handleEdit(repo: Knowledge) {
// 跳转到编辑知识页面,使用 params 参数传递 id
router.push({
name: "apps-knowledge-edit",
params: { id: repo.id.toString() },
});
// 跳转到编辑知识页面,使用路径跳转
router.push(`/apps/knowledge/edit/${repo.id}`);
}
function handleDelete(repo: Knowledge) {
@@ -393,98 +382,74 @@ onMounted(() => {
</script>
<style lang="less" scoped>
// 顶部横幅样式
// 顶部横幅样式 - 简约大气版本
.hero.new-style {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60px 20px;
margin-bottom: 30px;
position: relative;
overflow: hidden;
background: var(--card-bg-color);
padding: 48px 24px;
margin-bottom: 32px;
border-radius: 12px;
box-shadow: var(--box-shadow-dark);
transition: box-shadow 0.3s ease;
// 背景装饰
&::before {
content: "";
position: absolute;
top: -50%;
right: -10%;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
filter: blur(80px);
}
&::after {
content: "";
position: absolute;
bottom: -30%;
left: -10%;
width: 400px;
height: 400px;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
filter: blur(60px);
}
border: 1px solid var(--border-color-lighter);
box-shadow: var(--box-shadow);
transition: all 0.3s ease;
.hero-content {
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 1;
text-align: center;
}
h1 {
font-size: 42px;
color: white;
margin: 0 0 12px;
font-weight: 700;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
letter-spacing: -0.5px;
font-size: 36px;
font-weight: 600;
color: var(--text-color-primary);
margin: 0 0 8px;
letter-spacing: -0.3px;
transition: color 0.3s ease;
}
p {
font-size: 18px;
color: rgba(255, 255, 255, 0.95);
margin: 0 0 30px;
font-weight: 300;
font-size: 15px;
color: var(--text-color-secondary);
margin: 0 0 32px;
font-weight: 400;
transition: color 0.3s ease;
}
.search-container {
display: flex;
align-items: stretch;
max-width: 900px;
margin: 0 auto 30px;
max-width: 800px;
margin: 0 auto 24px;
background: var(--fill-color-blank);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--box-shadow);
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: none;
transition: all 0.3s ease;
border: 1px solid var(--border-color-lighter);
overflow: hidden;
&:hover {
box-shadow: var(--box-shadow-dark);
transform: translateY(-2px);
border-color: var(--primary-color);
}
&:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
}
.search-select {
border-right: 1px solid var(--border-color-lighter);
border-radius: 0;
transition: border-color 0.3s ease;
:deep(.el-input__wrapper) {
border: none;
box-shadow: none;
}
:deep(.el-input__inner) {
border: none;
border-radius: 0;
padding: 12px 20px;
font-weight: 500;
background-color: var(--fill-color-blank);
padding: 12px 16px;
font-weight: 400;
background-color: transparent;
color: var(--text-color-primary);
}
}
@@ -492,30 +457,36 @@ onMounted(() => {
.search-input {
flex: 1;
:deep(.el-input__wrapper){
border: none !important;
}
:deep(.el-input__wrapper) {
border: none;
box-shadow: none;
}
:deep(.el-input__inner) {
border: none;
padding: 12px 20px;
font-size: 16px;
box-shadow: none;
&:focus {
box-shadow: none;
}
padding: 12px 16px;
font-size: 15px;
}
}
.search-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: var(--primary-color);
border: none;
padding: 0 32px;
font-size: 16px;
border-left: 1px solid;
padding: 0 28px;
font-size: 15px;
font-weight: 500;
color: white;
color: #fff;
transition: all 0.3s ease;
border-radius: 0;
&:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: scale(1.05);
background-color: var(--primary-color);
opacity: 0.9;
}
}
@@ -524,29 +495,30 @@ onMounted(() => {
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
gap: 10px;
}
.hot-label {
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
font-weight: 500;
color: var(--text-color-secondary);
font-size: 13px;
font-weight: 400;
transition: color 0.3s ease;
}
.hot-tag {
background-color: rgba(255, 255, 255, 0.25);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border: 1px solid var(--border-color-lighter);
cursor: pointer;
transition: all 0.3s ease;
padding: 6px 16px;
border-radius: 20px;
transition: all 0.2s ease;
padding: 12px;
border-radius: 4px;
font-size: 13px;
&:hover {
background-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
background-color: var(--fill-color);
border-color: var(--primary-color);
color: var(--primary-color);
}
}
}
@@ -933,43 +905,34 @@ onMounted(() => {
// 响应式调整
@media (max-width: 768px) {
.hero.new-style {
padding: 40px 15px;
border-radius: 0;
padding: 32px 16px;
border-radius: 8px;
h1 {
font-size: 32px;
font-size: 28px;
}
p {
font-size: 16px;
font-size: 14px;
}
.search-container {
flex-direction: column;
border-radius: 8px;
.search-select,
.search-input {
:deep(.el-input__inner) {
border-radius: 0;
}
}
.search-select {
:deep(.el-input__inner) {
border-radius: 8px 8px 0 0;
}
border-right: none;
border-bottom: 1px solid var(--border-color-lighter);
}
.search-button {
border-radius: 0 0 8px 8px;
width: 100%;
border-radius: 0 0 8px 8px;
}
}
.hot-search {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
}
+621
View File
@@ -0,0 +1,621 @@
<template>
<div class="wang-editor-wrapper" :class="{ focused: isFocused }">
<div ref="toolbarRef" class="toolbar-container"></div>
<div ref="editorRef" class="editor-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import '@wangeditor/editor/dist/css/style.css';
import { uploadFile } from '@/api/file';
interface Props {
modelValue: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const toolbarRef = ref<HTMLDivElement>();
const editorRef = ref<HTMLDivElement>();
const isFocused = ref(false);
let editorInstance: any = null;
let isDestroyed = false;
// 获取上传文件的 URL
const getUploadUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL;
};
// 获取 Authorization Header
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
// 上传图片处理函数
const handleUploadImage = async (file: File, insertFn: (url: string, alt?: string, href?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
// axios 拦截器已经返回了 response.data,所以直接使用 response
if (response?.success) {
const fileUrl = response.data.file_url; // 例如: /uploads/2024/01/15/xxx.jpg
const baseUrl = getUploadUrl() || window.location.origin;
// 处理URL:如果已经是完整URL则直接使用,否则拼接baseUrl
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
// 确保URL以/开头,baseUrl不以/结尾
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
console.log('图片上传成功,URL:', fullUrl);
insertFn(fullUrl, file.name, fullUrl);
ElMessage.success('图片上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
// 上传视频处理函数
const handleUploadVideo = async (file: File, insertFn: (url: string, poster?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
if (response?.success) {
const fileUrl = response.data.file_url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
console.log('视频上传成功,URL:', fullUrl);
insertFn(fullUrl, '');
ElMessage.success('视频上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
// 上传附件处理函数
const handleUploadAttachment = async (file: File, insertFn: (url: string, text?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
if (response?.success) {
const fileUrl = response.data.file_url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
console.log('附件上传成功,URL:', fullUrl);
insertFn(fullUrl, file.name);
ElMessage.success('附件上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
// 初始化编辑器
const initEditor = async () => {
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
try {
// 动态导入 wangEditor
const { createEditor, createToolbar } = await import('@wangeditor/editor');
const editorConfig = {
placeholder: '请输入内容...',
onChange: (editor: any) => {
if (!isDestroyed) {
const html = editor.getHtml();
emit('update:modelValue', html);
}
},
// 自定义上传配置
MENU_CONF: {
// 图片上传配置
uploadImage: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, alt?: string, href?: string) => void) => {
await handleUploadImage(file, insertFn);
},
allowedFileTypes: ['image/*'],
maxFileSize: 5 * 1024 * 1024, // 5MB
},
// 视频上传配置
uploadVideo: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, poster?: string) => void) => {
await handleUploadVideo(file, insertFn);
},
allowedFileTypes: ['video/*'],
maxFileSize: 100 * 1024 * 1024, // 100MB
},
// 附件上传配置
uploadAttachment: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, text?: string) => void) => {
await handleUploadAttachment(file, insertFn);
},
allowedFileTypes: ['*'],
maxFileSize: 50 * 1024 * 1024, // 50MB
},
},
};
// 创建编辑器
editorInstance = createEditor({
selector: editorRef.value,
html: props.modelValue || '',
config: editorConfig,
mode: 'default',
});
// 创建工具栏
createToolbar({
editor: editorInstance,
selector: toolbarRef.value,
config: {},
});
// 监听编辑器焦点事件(兼容不同版本的 API)
nextTick(() => {
if (editorInstance) {
// 方法1: 使用 on 方法(如果存在)
if (typeof editorInstance.on === 'function') {
editorInstance.on('focus', () => {
isFocused.value = true;
});
editorInstance.on('blur', () => {
isFocused.value = false;
});
}
// 方法2: 直接在 DOM 元素上监听
const editorDom = editorRef.value;
if (editorDom) {
const textDom = editorDom.querySelector('.w-e-text');
if (textDom) {
textDom.addEventListener('focus', () => {
isFocused.value = true;
});
textDom.addEventListener('blur', () => {
isFocused.value = false;
});
}
}
}
});
} catch (error) {
console.error('Failed to initialize editor:', error);
}
};
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (editorInstance && newVal !== editorInstance.getHtml()) {
editorInstance.setHtml(newVal || '');
}
});
// 暴露方法
defineExpose({
clear: () => {
if (editorInstance) {
editorInstance.clear();
}
},
getContent: () => {
if (editorInstance) {
return editorInstance.getHtml();
}
return props.modelValue;
},
setContent: (content: string) => {
if (editorInstance) {
editorInstance.setHtml(content || '');
}
},
});
onMounted(() => {
nextTick(() => {
setTimeout(() => {
initEditor();
}, 100);
});
});
onBeforeUnmount(() => {
isDestroyed = true;
if (editorInstance) {
editorInstance.destroy();
editorInstance = null;
}
});
</script>
<style scoped lang="less">
.wang-editor-wrapper {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
background-color: var(--fill-color-blank);
transition: all 0.3s ease;
}
.toolbar-container {
border-bottom: 1px solid var(--border-color-lighter);
background-color: var(--fill-color-light);
transition: all 0.3s ease;
}
.editor-container {
min-height: 400px;
background-color: var(--fill-color-blank);
transition: background-color 0.3s ease;
}
</style>
<style lang="less">
// 全局样式:WangEditor 编辑器主题适配
.wang-editor-wrapper {
// 工具栏样式
:deep(.w-e-toolbar) {
background-color: var(--fill-color-light) !important;
border-bottom-color: var(--border-color-lighter) !important;
border-bottom: 1px solid var(--border-color-lighter) !important;
// 工具栏按钮项
.w-e-bar-item {
button {
color: var(--text-color-primary) !important;
background-color: transparent !important;
border: none !important;
transition: all 0.2s ease !important;
&:hover:not(.disabled) {
background-color: var(--fill-color) !important;
color: var(--primary-color) !important;
}
&:active:not(.disabled) {
background-color: var(--fill-color-dark) !important;
}
&.active {
background-color: var(--fill-color-dark) !important;
color: var(--primary-color) !important;
}
&.disabled {
color: var(--text-color-disabled) !important;
cursor: not-allowed !important;
opacity: 0.5 !important;
}
}
// 按钮组分隔线
&::after {
background-color: var(--border-color-lighter) !important;
}
}
// 工具栏分割线
.w-e-bar-divider {
background-color: var(--border-color-lighter) !important;
}
}
// 编辑器文本容器
:deep(.w-e-text-container) {
background-color: var(--fill-color-blank) !important;
color: var(--text-color-primary) !important;
border: none !important;
// 编辑器内容区域
.w-e-text {
color: var(--text-color-primary) !important;
background-color: transparent !important;
min-height: 400px !important;
// 编辑器焦点状态
&:focus {
outline: none !important;
}
// 编辑器内的段落
p {
color: var(--text-color-primary) !important;
margin: 0.5em 0 !important;
}
// 编辑器内的标题
h1, h2, h3, h4, h5, h6 {
color: var(--text-color-primary) !important;
font-weight: 600 !important;
}
// 编辑器内的链接
a {
color: var(--primary-color) !important;
text-decoration: underline !important;
&:hover {
color: var(--primary-color) !important;
opacity: 0.8 !important;
}
}
// 编辑器内的代码
code {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border: 1px solid var(--border-color-lighter) !important;
padding: 2px 6px !important;
border-radius: 3px !important;
}
// 编辑器内的代码块
pre {
background-color: var(--fill-color-light) !important;
border: 1px solid var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
border-radius: 4px !important;
code {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
// 编辑器内的引用
blockquote {
border-left: 4px solid var(--border-color) !important;
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
padding: 0.6em 1.2em !important;
margin: 1em 0 !important;
}
// 编辑器内的表格
table {
border-collapse: collapse !important;
border: 1px solid var(--border-color-lighter) !important;
th, td {
border: 1px solid var(--border-color-lighter) !important;
background-color: var(--fill-color-blank) !important;
color: var(--text-color-primary) !important;
}
th {
background-color: var(--fill-color-light) !important;
}
}
// 编辑器内的列表
ul, ol {
color: var(--text-color-primary) !important;
}
li {
color: var(--text-color-primary) !important;
}
}
// 占位符样式
.placeholder {
color: var(--text-color-placeholder) !important;
}
}
// 菜单下拉框
:deep(.w-e-drop-panel) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
color: var(--text-color-primary) !important;
.w-e-list-item {
color: var(--text-color-primary) !important;
transition: all 0.2s ease !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.selected {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
&.disabled {
color: var(--text-color-disabled) !important;
cursor: not-allowed !important;
&:hover {
background-color: transparent !important;
}
}
}
// 分隔线
.w-e-drop-panel-divider {
background-color: var(--border-color-lighter) !important;
}
}
// 工具栏下拉菜单
:deep(.w-e-toolbar-menu) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
.w-e-menu-item {
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.active {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
}
}
// 模态框
:deep(.w-e-modal) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
.w-e-modal-header {
border-bottom: 1px solid var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
.w-e-modal-body {
background-color: var(--bg-color-overlay) !important;
color: var(--text-color-primary) !important;
input, textarea, select {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:focus {
border-color: var(--primary-color) !important;
}
}
}
.w-e-modal-footer {
border-top: 1px solid var(--border-color-lighter) !important;
button {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
}
}
}
// 工具栏图标颜色
:deep(.w-e-bar-item svg),
:deep(.w-e-bar-item .w-e-icon) {
fill: var(--text-color-primary) !important;
color: var(--text-color-primary) !important;
transition: fill 0.2s ease, color 0.2s ease !important;
}
:deep(.w-e-bar-item:hover:not(.disabled) svg),
:deep(.w-e-bar-item:hover:not(.disabled) .w-e-icon),
:deep(.w-e-bar-item.active svg),
:deep(.w-e-bar-item.active .w-e-icon) {
fill: var(--primary-color) !important;
color: var(--primary-color) !important;
}
// 工具栏按钮组
:deep(.w-e-bar-divider) {
background-color: var(--border-color-lighter) !important;
}
// 编辑区域边框
&.focused {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
// 深色主题额外适配
[data-theme="dark"] {
.wang-editor-wrapper {
:deep(.w-e-text-container .w-e-text) {
// 编辑器内的图片边框
img {
border: 1px solid var(--border-color-lighter) !important;
border-radius: 4px !important;
}
// 编辑器内的水平线
hr {
border-top-color: var(--border-color-lighter) !important;
}
// 编辑器内的表格行交替颜色
table tr:nth-child(even) {
background-color: var(--fill-color-extra-light) !important;
}
}
}
}
</style>
+423 -56
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { login } from "@/api/login";
@@ -7,24 +7,64 @@ import { login } from "@/api/login";
const router = useRouter()
const authStore = useAuthStore()
const tenant = ref('')
const username = ref('')
const password = ref('')
const passwordVisible = ref(false)
const rememberMe = ref(false)
const loading = ref(false)
const errorMsg = ref('')
// 自动填充记住的用户名、租户
onMounted(() => {
const savedUser = localStorage.getItem('loginUsername')
const savedTenant = localStorage.getItem('tenant')
const savedRemember = localStorage.getItem('loginRememberMe')
if (savedRemember === 'true') {
tenant.value = savedTenant || ''
username.value = savedUser || ''
rememberMe.value = true
}
})
// 写入租户数据到缓存工具函数
function cacheTenant(tenantValue) {
localStorage.setItem('tenant', tenantValue)
sessionStorage.setItem('tenant', tenantValue)
}
const handleLogin = async () => {
errorMsg.value = ''
if (!username.value || !password.value) {
errorMsg.value = '请输入用户名和密码'
if (!tenant.value || !username.value || !password.value) {
errorMsg.value = '请输入租户名称、用户名和密码'
return
}
// 验证租户名称不能为空
const tenantName = tenant.value.trim()
if (!tenantName) {
errorMsg.value = '租户名称不能为空'
return
}
// 记住我本地存储
if (rememberMe.value) {
localStorage.setItem('loginUsername', username.value)
localStorage.setItem('loginRememberMe', 'true')
localStorage.setItem('tenant', tenantName)
} else {
localStorage.removeItem('loginUsername')
localStorage.setItem('loginRememberMe', 'false')
// 仍要写tenant到localStorage(以便系统获取租户上下文)
localStorage.setItem('tenant', tenantName)
}
// 写入租户到缓存(无论记住与否都要写,便于系统获取租户上下文)
cacheTenant(tenantName)
loading.value = true
try {
const res = await login(username.value, password.value)
// 检查响应结构:code === 0 表示成功,数据在 data 中
const res = await login(username.value, password.value, tenantName)
if (res && res.code === 0 && res.data) {
// 保存登录信息(token 和用户信息)
authStore.setLoginInfo(res.data)
// 登录如果有返回租户信息,可以用res.data.tenant,但这里优先使用当前登录租户输入
cacheTenant(tenantName)
router.push({ path: '/dashboard' })
} else {
errorMsg.value = res.message || '登录失败'
@@ -35,90 +75,417 @@ const handleLogin = async () => {
loading.value = false
}
}
// 跳转注册、忘记密码页面
const goRegister = () => {
router.push({ path: '/register' })
}
const goForget = () => {
router.push({ path: '/forget' })
}
</script>
<template>
<div class="login-container">
<div class="login-form">
<h2>登录</h2>
<div class="form-group">
<input
v-model="username"
type="text"
placeholder="用户名"
autocomplete="username"
/>
<div class="login-bg">
<div class="login-card">
<div class="login-side">
<div class="brand">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="18" fill="#eef8fc"/>
<circle cx="24" cy="24" r="14" fill="#52a8ff" opacity="0.15"/>
<circle cx="24" cy="24" r="9" fill="#3b7ddd" opacity="0.12"/>
<text x="24" y="30" text-anchor="middle" fill="#2d5fa7" font-size="16" font-family="Arial" font-weight="bold">Mete</text>
</svg>
<span class="brand-title">后台管理系统</span>
</div>
<div class="illus">
<svg viewBox="0 0 300 160" style="max-width:100%;" fill="none">
<ellipse cx="150" cy="140" rx="120" ry="16" fill="#edf4fd" />
<rect x="57" y="58" width="60" height="40" rx="12" fill="#64b6f7"/>
<rect x="125" y="46" width="110" height="64" rx="14" fill="#389bf7" opacity="0.11" />
<rect x="136" y="60" width="60" height="41" rx="10" fill="#b8e1ff"/>
</svg>
</div>
<!-- 版权信息 -->
<div class="copyright">
© 2024 Mete 管理系统
</div>
</div>
<div class="form-group">
<input
v-model="password"
type="password"
placeholder="密码"
autocomplete="current-password"
/>
<div class="login-panel">
<h2 class="login-title">欢迎登录</h2>
<div class="login-desc">请填写您的账号信息</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<!-- 租户图标 -->
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
<rect x="2.2" y="4.2" width="15.6" height="11.6" rx="2" stroke="#4da1ff" stroke-width="1.4"/>
<rect x="6" y="8" width="8" height="4" rx="1" fill="#b9dbff"/>
<rect x="6.7" y="8.7" width="6.6" height="2.6" rx="0.7" fill="#fff"/>
</svg>
</span>
<input
v-model="tenant"
type="text"
placeholder="租户名称"
autocomplete="organization"
class="input input-with-icon"
/>
</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<!-- 用户图标 -->
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="7" r="3.2" stroke="#4da1ff" stroke-width="1.4"/>
<ellipse cx="10" cy="14.1" rx="5.5" ry="3.3" stroke="#4da1ff" stroke-width="1.4"/>
</svg>
</span>
<input
v-model="username"
type="text"
placeholder="用户名"
autocomplete="username"
class="input input-with-icon"
/>
</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<!-- 密码图标 -->
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
<rect x="3" y="8" width="14" height="7" rx="2" stroke="#4da1ff" stroke-width="1.4"/>
<circle cx="10" cy="11.5" r="1.5" stroke="#4da1ff" stroke-width="1.2"/>
<rect x="7" y="5" width="6" height="3" rx="1.5" stroke="#4da1ff" stroke-width="1"/>
</svg>
</span>
<input
v-model="password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="密码"
autocomplete="current-password"
class="input input-with-icon"
/>
<span class="visible-btn" @click="passwordVisible = !passwordVisible" :title="passwordVisible ? '隐藏密码' : '显示密码'">
<svg v-if="passwordVisible" width="20" height="20" fill="none" viewBox="0 0 20 20">
<!-- 可见(eye)图标 -->
<path d="M2 10c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z" stroke="#7bb7fa" stroke-width="1.4" fill="#eef5ff"/>
<circle cx="10" cy="10" r="2.5" stroke="#3794f7" stroke-width="1.4" fill="#fff"/>
</svg>
<svg v-else width="20" height="20" fill="none" viewBox="0 0 20 20">
<!-- 不可见(eye-off)图标 -->
<path d="M2 10c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z" stroke="#b7c7db" stroke-width="1.3" fill="#f2f6fd"/>
<path d="M5 15L15 5" stroke="#b7c7db" stroke-width="1.2"/>
</svg>
</span>
</div>
<div class="remember-me-row">
<label class="remember-me-label">
<input type="checkbox" v-model="rememberMe" class="remember-me-checkbox" />
<span>记住我</span>
</label>
<div class="action-links">
<a class="register-link" @click.prevent="goRegister">注册账号</a>
<span class="divider">|</span>
<a class="forget-link" @click.prevent="goForget">忘记密码?</a>
</div>
</div>
<transition name="fade">
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
</transition>
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
</div>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
<!-- 背景光效 -->
<div class="login-light light1"></div>
<div class="login-light light2"></div>
</div>
</template>
<style scoped>
.login-container {
.login-bg {
min-height: 100vh;
width: 100vw;
background: linear-gradient(120deg, #e6f0ff 0%, #f5fcff 55%, #eaf6ff 100%);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f6fa;
justify-content: center;
position: relative;
overflow: hidden;
}
.login-form {
background: #fff;
padding: 40px 32px;
border-radius: 10px;
box-shadow: 0 2px 16px rgba(0,0,0,0.09);
min-width: 320px;
.login-card {
display: flex;
min-width: 770px;
background: rgba(255,255,255, 0.95);
border-radius: 22px;
box-shadow: 0 8px 36px 0 rgba(73,150,255,0.14), 0 1.5px 4px 0 rgba(30,42,79,0.05);
overflow: hidden;
z-index: 10;
}
.login-side {
width: 320px;
background: #52a8ff;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
padding: 44px 16px 32px 16px;
box-shadow: 4px 0 32px 0 rgba(189,231,255,0.13) inset;
position: relative;
}
.login-form h2 {
margin-bottom: 16px;
.brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 42px;
user-select: none;
}
.brand-title {
font-size: 25px;
letter-spacing: 2px;
font-weight: 700;
color: #fff;
/* text-shadow: 0 0 6px #d4ecfc; */
}
.illus {
margin-top: 30px;
user-select: none;
opacity: .95;
}
/* 版权信息样式 */
.copyright {
width: 100%;
text-align: center;
font-size: 13px;
color: #fff;
margin-top: auto;
margin-bottom: 2px;
letter-spacing: 0.2px;
padding-top: 25px;
user-select: none;
}
.login-panel {
flex: 1;
padding: 52px 54px 48px 54px;
display: flex;
flex-direction: column;
justify-content: center;
background: transparent;
min-width: 320px;
}
.login-title {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
color: #2560a9;
text-align: left;
letter-spacing: 1px;
}
.login-desc {
color: #7391c4;
font-size: 15px;
margin-bottom: 28px;
letter-spacing: 0.2px;
}
.form-group {
margin-bottom: 10px;
margin-bottom: 15px;
position: relative;
}
input[type="text"],
input[type="password"] {
/* 输入框前置图标样式 */
.icon-input-group {
display: flex;
align-items: center;
position: relative;
}
.input-with-icon {
padding-left: 36px !important;
}
.input-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
z-index: 2;
width: 20px;
height: 20px;
color: #4da1ff;
opacity: 0.95;
}
.visible-btn {
position: absolute;
right: 11px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
z-index: 2;
padding: 2px 2px;
display: flex;
align-items: center;
opacity: 0.82;
user-select: none;
}
.visible-btn:hover {
opacity: 1;
}
/* 防止密码输入框可见按钮和输入内容重叠 */
.icon-input-group .input-with-icon {
padding-right: 34px;
}
.input {
width: 100%;
padding: 10px 12px;
padding: 12px 14px;
font-size: 16px;
border: 1px solid #dcdfe6;
border-radius: 5px;
border: 1.3px solid #d6e6fa;
border-radius: 7px;
box-sizing: border-box;
margin-bottom: 4px;
transition: border 0.2s, box-shadow 0.2s;
background: #f7fbfe;
margin-bottom: 3px;
}
.input:focus {
border-color: #4da1ff;
outline: none;
box-shadow: 0 0 0 2px #e3f2ffb1;
}
/* 记住我单选框 */
.remember-me-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
margin-top: 3px;
min-height: 32px;
font-size: 15px;
color: #6d8eb8;
user-select: none;
}
.remember-me-label {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
}
.remember-me-checkbox {
width: 16px;
height: 16px;
accent-color: #4da1ff;
margin-right: 2px;
}
.login-btn {
width: 100%;
padding: 10px;
font-size: 16px;
background: #005bea;
padding: 13px 0;
font-size: 17px;
background: linear-gradient(90deg, #3494e6 0%, #52a8ff 100%);
color: #fff;
border: none;
border-radius: 5px;
border-radius: 7px;
box-shadow: 0 2px 12px 0 rgba(81,173,255,0.13);
font-weight: 600;
letter-spacing: 1px;
margin-top: 15px;
cursor: pointer;
transition: background 0.2s;
transition: background 0.2s, transform 0.13s;
}
.login-btn:active {
transform: scale(0.98);
}
.login-btn:disabled {
background: #8bbcfa;
background: #b6dafc;
cursor: not-allowed;
}
.error-msg {
color: #e74c3c;
color: #e4574a;
background: #fdeceb;
text-align: center;
margin-bottom: 6px;
font-size: 14px;
border-radius: 4px;
padding: 7px 4px;
margin-bottom: 2px;
font-size: 14.5px;
letter-spacing: .3px;
animation: shake .28s;
}
@keyframes shake {
0% { transform: translateX(0);}
20% { transform: translateX(-6px);}
40% { transform: translateX(6px);}
60% { transform: translateX(-2px);}
80% { transform: translateX(2px);}
100% { transform: translateX(0);}
}
/* 注册、忘记密码链接 */
.action-links {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14.1px;
color: #6592c3;
margin-bottom: 0;
min-height: 22px;
}
.action-links a {
cursor: pointer;
color: #407ad6;
text-decoration: none;
transition: color 0.16s;
}
.action-links a:hover {
color: #165eec;
text-decoration: underline;
}
.action-links .divider {
color: #bbd3ee;
margin: 0 6px;
font-size: 13px;
}
.register-link {
margin-right: 0px;
}
.forget-link {
margin-left: 0px;
}
/* 渐隐提示 */
.fade-enter-active, .fade-leave-active {
transition: opacity .24s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* 炫彩光斑装饰 */
.login-light {
position: absolute;
border-radius: 50%;
pointer-events: none;
filter: blur(45px);
opacity: 0.4;
z-index: 1;
}
.light1 {
width: 340px;
height: 340px;
top: -90px;
left: -60px;
background: radial-gradient(circle at 60% 50%, #55b7f988 0%, #e1e8fa11 95%);
}
.light2 {
width: 260px;
height: 260px;
right: -60px;
bottom: -90px;
background: radial-gradient(circle at 55% 60%, #f3e7ff99 0%, #daf3ff10 100%);
}
@media (max-width: 940px) {
.login-card { min-width: 330px; flex-direction: column; }
.login-side { width: 100%; min-width: 0; border-radius: 0 0 18px 18px;}
.login-panel { padding: 30px 22px 34px 22px; min-width: 0; }
.copyright { padding-top: 13px; }
}
</style>
+472
View File
@@ -0,0 +1,472 @@
<template>
<div class="categories-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">分类管理</h1>
<div class="action-bar">
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
<el-button @click="$router.push('/files')">
<el-icon><ArrowLeft /></el-icon>
返回文件列表
</el-button>
</div>
</div>
<!-- 分类列表 -->
<div class="categories-list">
<el-table
:data="categories"
v-loading="loading"
style="width: 100%"
:default-sort="{ prop: 'created_at', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="分类名称" min-width="150">
<template #default="{ row }">
<div class="category-name">
<el-tag :color="getCategoryColor(row.name)" effect="dark">
{{ row.name }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="file_count" label="文件数量" width="120" sortable>
<template #default="{ row }">
<el-badge :value="row.file_count || 0" :max="99" class="item">
<el-button size="small" @click="viewCategoryFiles(row)">查看文件</el-button>
</el-badge>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="updateCategoryStatus(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editCategory(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 添加/编辑分类对话框 -->
<el-dialog
v-model="showAddDialog"
:title="editingCategory ? '编辑分类' : '添加分类'"
width="500px"
:before-close="handleDialogClose"
>
<el-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryRules"
label-width="80px"
>
<el-form-item label="分类名称" prop="name">
<el-input v-model="categoryForm.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="categoryForm.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
/>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="categoryForm.color" />
<span class="tip-text">选择分类显示颜色可选</span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="categoryForm.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitCategory">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 分类文件列表对话框 -->
<el-dialog
v-model="showFilesDialog"
:title="currentCategory ? currentCategory.name + ' - 文件列表' : '文件列表'"
width="800px"
>
<div v-if="currentCategory" class="category-files">
<el-table :data="categoryFiles" style="width: 100%" v-loading="filesLoading">
<el-table-column prop="file_name" label="文件名" min-width="200" />
<el-table-column prop="original_name" label="原始文件名" min-width="200" />
<el-table-column prop="file_size" label="大小" width="100">
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column prop="upload_time" label="上传时间" width="180">
<template #default="{ row }">
{{ formatDate(row.upload_time) }}
</template>
</el-table-column>
</el-table>
<div class="pagination" style="margin-top: 20px;">
<el-pagination
v-model:current-page="filesCurrentPage"
v-model:page-size="filesPageSize"
:total="totalFiles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleFilesSizeChange"
@current-change="handleFilesCurrentChange"
/>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFilesDialog = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Plus, ArrowLeft } from '@element-plus/icons-vue'
// 响应式数据
const loading = ref(false)
const categories = ref<any[]>([])
const showAddDialog = ref(false)
const editingCategory = ref<any>(null)
const showFilesDialog = ref(false)
const currentCategory = ref<any>(null)
const categoryFiles = ref<any[]>([])
const filesLoading = ref(false)
const filesCurrentPage = ref(1)
const filesPageSize = ref(10)
const totalFiles = ref(0)
// 表单引用
const categoryFormRef = ref<FormInstance>()
// 分类表单
const categoryForm = reactive({
name: '',
description: '',
color: '#409EFF',
status: 1
})
// 表单验证规则
const categoryRules: FormRules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 20, message: '分类名称长度在 2 到 20 个字符', trigger: 'blur' }
],
description: [
{ max: 200, message: '描述长度不能超过 200 个字符', trigger: 'blur' }
]
}
// 预定义分类颜色映射
const categoryColors: Record<string, string> = {
'文档': '#67C23A',
'图片': '#E6A23C',
'视频': '#F56C6C',
'音频': '#909399',
'其他': '#409EFF'
}
// 生命周期
onMounted(() => {
loadCategories()
})
// 方法
const loadCategories = async () => {
try {
loading.value = true
// 模拟加载分类数据
categories.value = [
{
id: 1,
name: '文档',
description: '各种文档文件',
file_count: 15,
created_at: '2024-01-01 10:00:00',
updated_at: '2024-01-10 15:30:00',
status: 1
},
{
id: 2,
name: '图片',
description: '图片文件',
file_count: 8,
created_at: '2024-01-02 09:00:00',
updated_at: '2024-01-09 14:20:00',
status: 1
},
{
id: 3,
name: '视频',
description: '视频文件',
file_count: 3,
created_at: '2024-01-03 11:00:00',
updated_at: '2024-01-08 16:45:00',
status: 1
},
{
id: 4,
name: '音频',
description: '音频文件',
file_count: 5,
created_at: '2024-01-04 08:30:00',
updated_at: '2024-01-07 13:15:00',
status: 1
},
{
id: 5,
name: '其他',
description: '其他类型文件',
file_count: 12,
created_at: '2024-01-05 14:00:00',
updated_at: '2024-01-06 10:30:00',
status: 1
}
]
} catch (error) {
console.error('加载分类失败:', error)
ElMessage.error('加载分类失败')
} finally {
loading.value = false
}
}
const getCategoryColor = (categoryName: string) => {
return categoryColors[categoryName] || '#409EFF'
}
const viewCategoryFiles = async (category: any) => {
currentCategory.value = category
showFilesDialog.value = true
await loadCategoryFiles()
}
const loadCategoryFiles = async () => {
try {
filesLoading.value = true
// 模拟加载分类文件数据
categoryFiles.value = [
{
id: 1,
file_name: 'document.pdf',
original_name: '项目文档.pdf',
file_size: 2048576,
upload_by: '管理员',
upload_time: '2024-01-10 10:00:00'
},
{
id: 2,
file_name: 'image.jpg',
original_name: '产品图片.jpg',
file_size: 1024576,
upload_by: '用户A',
upload_time: '2024-01-09 15:30:00'
}
]
totalFiles.value = categoryFiles.value.length
} catch (error) {
console.error('加载分类文件失败:', error)
ElMessage.error('加载分类文件失败')
} finally {
filesLoading.value = false
}
}
const editCategory = (category: any) => {
editingCategory.value = category
Object.assign(categoryForm, category)
showAddDialog.value = true
}
const deleteCategory = async (category: any) => {
try {
await ElMessageBox.confirm(
`确定要删除分类 "${category.name}" 吗?此操作不可恢复。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
// 模拟删除操作
categories.value = categories.value.filter(c => c.id !== category.id)
ElMessage.success('删除成功')
} catch (error) {
if (error !== 'cancel') {
console.error('删除分类失败:', error)
ElMessage.error('删除失败')
}
}
}
const updateCategoryStatus = async (category: any) => {
try {
// 模拟更新状态操作
ElMessage.success(`分类 "${category.name}" 状态已更新`)
} catch (error) {
console.error('更新状态失败:', error)
ElMessage.error('更新状态失败')
// 恢复原状态
category.status = category.status === 1 ? 0 : 1
}
}
const submitCategory = async () => {
if (!categoryFormRef.value) return
try {
await categoryFormRef.value.validate()
if (editingCategory.value) {
// 编辑分类
const index = categories.value.findIndex(c => c.id === editingCategory.value.id)
if (index !== -1) {
Object.assign(categories.value[index], { ...categoryForm })
}
ElMessage.success('分类更新成功')
} else {
// 添加分类
const newCategory = {
id: Date.now(),
...categoryForm,
file_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
categories.value.unshift(newCategory)
ElMessage.success('分类添加成功')
}
showAddDialog.value = false
resetForm()
} catch (error) {
console.error('提交分类失败:', error)
}
}
const handleDialogClose = () => {
showAddDialog.value = false
resetForm()
}
const resetForm = () => {
editingCategory.value = null
categoryForm.name = ''
categoryForm.description = ''
categoryForm.color = '#409EFF'
categoryForm.status = 1
categoryFormRef.value?.clearValidate()
}
const handleFilesSizeChange = (size: number) => {
filesPageSize.value = size
filesCurrentPage.value = 1
loadCategoryFiles()
}
const handleFilesCurrentChange = (page: number) => {
filesCurrentPage.value = page
loadCategoryFiles()
}
const formatDate = (dateString: string) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.categories-container {
padding: 20px;
background: #fff;
min-height: calc(100vh - 120px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.categories-list {
margin-bottom: 20px;
}
.category-name {
display: flex;
align-items: center;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #999;
}
.pagination {
display: flex;
justify-content: flex-end;
}
:deep(.el-badge__content) {
top: -8px;
right: -8px;
}
</style>
+673
View File
@@ -0,0 +1,673 @@
<template>
<el-card class="box-card">
<div class="header-bar">
<h2>文件管理</h2>
<!-- <el-button type="primary" @click="showProgramDialog = true">
<el-icon><Plus /></el-icon>
添加程序
</el-button> -->
</div>
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载程序数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchPrograms">重试</el-button>
</div>
<div v-else class="files-module fancy-bg">
<!-- 顶部功能卡片区域 -->
<div class="function-cards">
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/list')"
>
<div class="card-content">
<div class="card-icon">
<i class="fa-solid fa-folder-open"></i>
</div>
<div class="card-info">
<span class="card-title">文件列表</span>
<span class="card-desc">查看和管理所有文件</span>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/upload')"
>
<div class="card-content">
<div class="card-icon upload">
<i class="fa-solid fa-cloud-arrow-up"></i>
</div>
<div class="card-info">
<span class="card-title">批量上传</span>
<span class="card-desc">批量上传文件至系统</span>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/categories')"
>
<div class="card-content">
<div class="card-icon tags">
<i class="fa-solid fa-layer-group"></i>
</div>
<div class="card-info">
<span class="card-title">分类管理</span>
<span class="card-desc">管理分类/标签</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 搜索和筛选 -->
<div class="search-filter stylish-panel">
<el-input
v-model="searchKeyword"
placeholder="搜索文件名、原始文件名或分类"
clearable
@input="handleSearch"
size="large"
class="input-search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filterCategory"
placeholder="全部分类"
clearable
@change="handleFilter"
size="large"
class="select-category"
>
<el-option
v-for="category in categories"
:key="category"
:label="category"
:value="category"
/>
</el-select>
<el-switch
v-model="showMyFiles"
class="switch-mine"
active-text="仅看我的文件"
inactive-text="全部文件"
@change="handleFilter"
/>
</div>
<!-- 文件数据列表 -->
<div class="file-list stylish-panel">
<el-table
:data="filteredFiles"
v-loading="loading"
highlight-current-row
border
:default-sort="{ prop: 'upload_time', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="file_name" label="文件名" min-width="200">
<template #default="{ row }">
<div class="file-name">
<el-icon class="icon-doc"><Document /></el-icon>
<span>{{ row.file_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="original_name"
label="原始文件名"
min-width="180"
/>
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag effect="dark" type="success">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="100">
<template #default="{ row }">
<el-tag effect="plain">{{
formatFileSize(row.file_size)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_type" label="类型" width="90" />
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column
prop="upload_time"
label="上传时间"
width="170"
sortable
>
<template #default="{ row }">
<span style="color: #8ea1d6">{{
formatDate(row.upload_time)
}}</span>
</template>
</el-table-column>
<el-table-column
label="操作"
width="170"
fixed="right"
align="center"
>
<template #default="{ row }">
<el-tooltip content="查看详情">
<el-button
type="primary"
circle
plain
size="small"
@click="viewFile(row)"
>
<el-icon><View /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除">
<el-button
circle
type="danger"
plain
size="small"
@click="deleteFile(row)"
>
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalFiles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 上传文件对话框 -->
<el-dialog
v-model="showUploadDialog"
title="上传文件"
width="500px"
:before-close="handleUploadClose"
class="upload-dialog"
>
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ category: uploadForm.category }"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:show-file-list="false"
multiple
>
<el-icon
class="el-icon--upload"
style="font-size: 44px; color: #36b2fa"
><upload-filled
/></el-icon>
<div class="el-upload__text">
将文件拖到此处<em style="color: #409eff">点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持单个或批量上传单文件大小不超过
<span style="color: #f56c6c; font-weight: 600">10MB</span>
</div>
</template>
</el-upload>
<div class="upload-form upform-padding">
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="分类">
<el-select
v-model="uploadForm.category"
placeholder="请选择分类"
style="width: 100%"
>
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="是否公开">
<el-switch
v-model="uploadForm.isPublic"
active-color="#409EFF"
inactive-color="#bfbfbf"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showUploadDialog = false"> </el-button>
<el-button type="primary" @click="submitUpload">上传</el-button>
</span>
</template>
</el-dialog>
<!-- 文件详情对话框 -->
<el-dialog
v-model="showFileDetail"
:title="currentFile ? currentFile.original_name : '文件详情'"
width="650px"
class="file-detail-dialog"
>
<div v-if="currentFile" class="file-detail">
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="文件ID">
<el-tag>{{ currentFile.id }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件名">
<span class="detail-primary">{{ currentFile.file_name }}</span>
</el-descriptions-item>
<el-descriptions-item label="原始文件名">{{
currentFile.original_name
}}</el-descriptions-item>
<el-descriptions-item label="文件大小">
<el-tag type="info">{{
formatFileSize(currentFile.file_size)
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件类型">{{
currentFile.file_type
}}</el-descriptions-item>
<el-descriptions-item label="文件扩展名">{{
currentFile.file_ext
}}</el-descriptions-item>
<el-descriptions-item label="分类">
<el-tag type="success" effect="plain">{{
currentFile.category
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传人">
<el-tag>{{ currentFile.upload_by }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传时间">{{
formatDate(currentFile.upload_time)
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag
:type="currentFile.status === 1 ? 'success' : 'danger'"
effect="dark"
>
{{ currentFile.status === 1 ? "正常" : "已删除" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否公开">
<el-tag
:type="currentFile.is_public === 1 ? 'success' : 'info'"
effect="plain"
>
{{ currentFile.is_public === 1 ? "公开" : "私有" }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-btns">
<el-button type="primary" @click="downloadFile(currentFile)">
<el-icon><Download /></el-icon> 下载文件
</el-button>
<el-button @click="copyFileUrl(currentFile)">
<el-icon><Link /></el-icon> 复制链接
</el-button>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFileDetail = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import {
Download,
Delete,
Search,
Document,
View,
Link,
UploadFilled,
} from "@element-plus/icons-vue";
const router = useRouter();
const recentFiles = ref([
{
id: 1,
name: "项目报告.pdf",
size: 2456789,
type: "PDF",
uploadTime: "2024-01-15 10:30:25",
},
{
id: 2,
name: "数据统计.xlsx",
size: 123456,
type: "Excel",
uploadTime: "2024-01-14 14:20:10",
},
{
id: 3,
name: "产品图片.jpg",
size: 345678,
type: "Image",
uploadTime: "2024-01-13 09:15:45",
},
]);
const getFileIcon = (type: string) => {
const iconMap: Record<string, string> = {
PDF: "fa-solid fa-file-pdf",
Excel: "fa-solid fa-file-excel",
Word: "fa-solid fa-file-word",
Image: "fa-solid fa-file-image",
Video: "fa-solid fa-file-video",
Audio: "fa-solid fa-file-audio",
Archive: "fa-solid fa-file-archive",
};
return iconMap[type] || "fa-solid fa-file";
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// mock 下列数据和函数,由于原代码没提供全部上下文
const categories = ref(["文档", "图片", "视频", "音频", "其他"]);
const filteredFiles = ref([]);
const loading = ref(false);
const searchKeyword = ref("");
const filterCategory = ref("");
const showMyFiles = ref(false);
const pageSize = ref(10);
const currentPage = ref(1);
const totalFiles = ref(0);
const showUploadDialog = ref(false);
// 上传配置
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ;
return `${baseUrl}/api/files`;
});
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`
};
});
const uploadForm = ref({
category: "",
isPublic: false,
});
const showFileDetail = ref(false);
const currentFile = ref<any>(null);
const handleSearch = () => {};
const handleFilter = () => {};
const handleSizeChange = () => {};
const handleCurrentChange = () => {};
const handleUploadClose = () => {
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
};
const handleUploadSuccess = (response: any, file: any) => {
ElMessage.success('文件上传成功!');
// 关闭对话框
showUploadDialog.value = false;
// 可以刷新文件列表
// loadFiles();
};
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + error.message);
};
const beforeUpload = (file: File) => {
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB');
return false;
}
return true;
};
const submitUpload = () => {
// Element Plus 的 el-upload 组件会自动处理上传
ElMessage.info('开始上传文件...');
};
const viewFile = (row: any) => {};
const formatDate = (val: string | number) => val;
const copyFileUrl = (file: any) => {};
const downloadFile = (file: any) => {
// 这里实现下载逻辑
console.log("下载文件:", file.name || file.file_name);
};
const deleteFile = (file: any) => {
// 这里实现删除逻辑
console.log("删除文件:", file.name || file.file_name);
};
onMounted(() => {
console.log("文件管理模块已加载");
});
</script>
<style scoped>
.fancy-bg {
padding: 28px 14px 24px 14px;
}
.stylish-panel {
background: #fff;
border-radius: 14px;
box-shadow: 0 4px 14px 0 #e2eafe77;
margin-bottom: 28px;
padding: 28px 18px 24px 18px;
}
.function-cards {
margin-bottom: 32px;
}
.function-card {
border: none !important;
border-radius: 14px !important;
box-shadow: 0 8px 24px 0 #bedcff11 !important;
background: #fff;
cursor: pointer;
transition:
transform 0.15s cubic-bezier(0.17, 0.67, 0.83, 0.67),
box-shadow 0.18s;
}
.function-card:hover {
transform: translateY(-4px) scale(1.032);
box-shadow: 0 18px 48px 0 #6bbaf522 !important;
}
.card-gradient {
background: linear-gradient(120deg, #ecf4ff 94%, #eff8ff 100%) !important;
}
.card-content {
display: flex;
align-items: center;
padding: 26px 16px;
min-height: 95px;
}
.card-icon {
font-size: 34px;
width: 54px;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
color: #3178ea;
background: linear-gradient(133deg, #d9eaff 82%, #f2f6fd 100%);
border-radius: 12px;
margin-right: 18px;
transition: color 0.25s;
}
.card-icon.upload {
color: #13c2c2;
background: linear-gradient(145deg, #c5faea 58%, #d6f3ff 98%);
}
.card-icon.tags {
color: #a048ff;
background: linear-gradient(143deg, #e8dafe 88%, #f3e2fd 100%);
}
.card-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.card-title {
font-size: 19px;
font-weight: 700;
color: #2c3547;
letter-spacing: 1.5px;
margin-bottom: 3px;
}
.card-desc {
font-size: 13px;
color: #6c7ea5;
font-weight: 400;
}
.rc-title-bar {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recent-icon {
font-size: 20px;
color: #409eff;
margin-right: 8px;
}
.recent-title {
font-size: 17px;
font-weight: 600;
color: #293a46;
}
.recent-table {
border-radius: 6px !important;
}
.input-search {
width: 300px !important;
margin-right: 18px !important;
box-shadow: 0 2px 6px 0 #dae7fd24;
}
.select-category {
width: 170px !important;
margin-right: 18px;
}
.switch-mine {
margin-left: 8px;
}
.file-list {
margin-bottom: 28px;
background: #fff;
}
.file-name {
display: flex;
align-items: center;
}
.icon-doc {
margin-right: 8px;
color: #2090ff;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
background: none;
}
.upload-dialog .el-dialog__body {
padding-top: 20px;
}
.upform-padding {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
}
.file-detail-dialog .el-dialog__body {
padding-top: 18px;
background: #fafcff;
}
.file-detail {
max-height: 380px;
overflow-y: auto;
}
.detail-primary {
color: #416cf7;
font-weight: 600;
}
.detail-btns {
margin-top: 28px;
display: flex;
justify-content: center;
gap: 22px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
@media (max-width: 900px) {
.stylish-panel,
.file-list,
.function-cards {
padding: 10px !important;
}
.fancy-bg {
padding: 10px !important;
}
}
</style>
+369
View File
@@ -0,0 +1,369 @@
<template>
<div class="upload-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">批量上传</h1>
<div class="action-bar">
<el-button @click="$router.push('/files')">
<el-icon><ArrowLeft /></el-icon>
返回文件列表
</el-button>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-area">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
multiple
:file-list="fileList"
:auto-upload="false"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持批量上传单个文件大小不超过 10MB支持拖拽排序
</div>
</template>
</el-upload>
</div>
<!-- 上传设置 -->
<div class="upload-settings">
<el-card header="上传设置">
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="文件分类">
<el-select v-model="uploadForm.category" placeholder="请选择分类">
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="是否公开">
<el-switch v-model="uploadForm.isPublic" />
<span class="tip-text">公开文件所有用户可见私有文件仅自己可见</span>
</el-form-item>
<el-form-item label="覆盖同名">
<el-switch v-model="uploadForm.overwrite" />
<span class="tip-text">遇到同名文件时是否覆盖</span>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 文件列表预览 -->
<div class="file-preview" v-if="fileList.length > 0">
<el-card header="待上传文件列表">
<el-table :data="fileList" style="width: 100%">
<el-table-column prop="name" label="文件名" min-width="200" />
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row, $index }">
<el-button
size="small"
type="danger"
@click="removeFile($index)"
:disabled="row.status === 'uploading'"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="upload-actions">
<el-button
type="primary"
@click="submitUpload"
:loading="uploading"
:disabled="fileList.length === 0"
>
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
<el-button @click="clearFiles" :disabled="fileList.length === 0">
清空列表
</el-button>
</div>
</el-card>
</div>
<!-- 上传进度 -->
<div class="upload-progress" v-if="uploading">
<el-card header="上传进度">
<el-progress
:percentage="uploadProgress"
:status="uploadStatus"
:stroke-width="8"
/>
<div class="progress-info">
<span>已上传: {{ uploadedCount }}/{{ fileList.length }}</span>
<span>成功率: {{ successRate }}%</span>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadInstance, type UploadFile } from 'element-plus'
import { ArrowLeft, UploadFilled } from '@element-plus/icons-vue'
import { fileAPI } from '@/services/api'
const router = useRouter()
const uploadRef = ref<UploadInstance>()
// 响应式数据
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadedCount = ref(0)
const successCount = ref(0)
// 上传表单
const uploadForm = ref({
category: '文档',
isPublic: false,
overwrite: false
})
// 计算属性
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL
return `${baseUrl}/api/files`
})
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return {
Authorization: `Bearer ${token}`
}
})
const uploadStatus = computed(() => {
if (uploadProgress.value === 100) return 'success'
if (uploading.value) return undefined
return 'exception'
})
const successRate = computed(() => {
if (uploadedCount.value === 0) return 0
return Math.round((successCount.value / uploadedCount.value) * 100)
})
// 方法
const beforeUpload = (file: File) => {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
ElMessage.error(`文件 "${file.name}" 大小不能超过 10MB!`)
return false
}
// 检查是否已存在同名文件
const exists = fileList.value.some(f => f.name === file.name)
if (exists && !uploadForm.value.overwrite) {
ElMessage.warning(`文件 "${file.name}" 已存在,请启用覆盖选项或重命名文件`)
return false
}
return true
}
const handleFileChange = (file: UploadFile, fileList: UploadFile[]) => {
fileList.value = fileList
}
const handleFileRemove = (file: UploadFile, fileList: UploadFile[]) => {
fileList.value = fileList
}
const removeFile = (index: number) => {
fileList.value.splice(index, 1)
}
const clearFiles = () => {
fileList.value = []
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
}
const submitUpload = async () => {
if (fileList.value.length === 0) {
ElMessage.warning('请先选择要上传的文件')
return
}
try {
uploading.value = true
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
// 模拟批量上传进度
const totalFiles = fileList.value.length
let currentProgress = 0
for (let i = 0; i < fileList.value.length; i++) {
const file = fileList.value[i]
try {
// 这里应该调用实际的批量上传API
// 暂时使用模拟上传
await new Promise(resolve => setTimeout(resolve, 1000))
successCount.value++
ElMessage.success(`文件 "${file.name}" 上传成功`)
} catch (error) {
console.error(`文件 "${file.name}" 上传失败:`, error)
ElMessage.error(`文件 "${file.name}" 上传失败`)
}
uploadedCount.value++
currentProgress = Math.round((uploadedCount.value / totalFiles) * 100)
uploadProgress.value = currentProgress
}
if (successCount.value === totalFiles) {
ElMessage.success('所有文件上传完成')
} else {
ElMessage.warning(`上传完成,成功 ${successCount.value}/${totalFiles} 个文件`)
}
} catch (error) {
console.error('批量上传失败:', error)
ElMessage.error('批量上传失败')
} finally {
uploading.value = false
}
}
const handleUploadSuccess = (response: any, file: UploadFile) => {
if (response.success) {
file.status = 'success'
} else {
file.status = 'fail'
ElMessage.error(response.message || '上传失败')
}
}
const handleUploadError = (error: any, file: UploadFile) => {
console.error('上传失败:', error)
file.status = 'fail'
ElMessage.error('上传失败')
}
const getStatusType = (status: string) => {
switch (status) {
case 'success': return 'success'
case 'uploading': return 'warning'
case 'fail': return 'danger'
default: return 'info'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'success': return '成功'
case 'uploading': return '上传中'
case 'fail': return '失败'
default: return '等待上传'
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.upload-container {
padding: 20px;
background: #fff;
min-height: calc(100vh - 120px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.upload-area {
margin-bottom: 20px;
}
.upload-settings {
margin-bottom: 20px;
}
.file-preview {
margin-bottom: 20px;
}
.upload-actions {
margin-top: 20px;
text-align: center;
}
.upload-progress {
margin-bottom: 20px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 14px;
color: #666;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #999;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
}
</style>
+548
View File
@@ -0,0 +1,548 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>菜单管理</h2>
<el-button type="primary" @click="handleAddMenu = true">
<el-icon><Plus /></el-icon>
添加菜单
</el-button>
</div>
<el-divider></el-divider>
<!-- 树形表格 -->
<el-table
:data="menuTree"
style="width: 100%"
row-key="Id"
border
:tree-props="{
children: 'children',
hasChildren: 'hasChildren',
}"
:expand-row-keys="defaultExpandedKeys"
>
<el-table-column prop="Name" label="菜单名称" width="200">
<template #default="scope">
<div class="menu-item">
<i
v-if="scope.row.Icon"
:class="scope.row.Icon"
class="menu-icon"
></i>
<span>{{ scope.row.Name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="Path" label="路由地址"></el-table-column>
<el-table-column
prop="MenuType"
label="菜单类型"
width="120"
align="center"
>
<template #default="scope">
<el-tag :type="getMenuTypeTagType(scope.row.MenuType)">
{{ getMenuTypeName(scope.row.MenuType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="Order"
label="排序"
width="80"
align="center"
></el-table-column>
<el-table-column prop="Status" label="状态" width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.Status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)"
:disabled="!scope.row.Id"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button
size="small"
text
@click="handleAddSubMenu(scope.row)"
:disabled="scope.row.MenuType === 3"
>
<el-icon><CirclePlus /></el-icon>
<span>子菜单</span>
</el-button>
<el-button size="small" text @click="handleEditMenu(scope.row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button
size="small"
text
type="danger"
@click="handleDeleteMenu(scope.row)"
>
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑菜单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form
:model="currentMenu"
label-width="100px"
:rules="formRules"
ref="menuFormRef"
>
<el-form-item label="父级菜单" prop="ParentId">
<el-cascader
v-model="currentMenu.ParentId"
:options="parentMenuOptions"
:props="cascaderProps"
placeholder="请选择父级菜单"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="Name">
<el-input v-model="currentMenu.Name" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="MenuType">
<el-radio-group v-model="currentMenu.MenuType" style="width: 100%">
<el-radio-button :label="1">普通菜单</el-radio-button>
<el-radio-button :label="2">分组菜单</el-radio-button>
<el-radio-button :label="3">按钮菜单</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
label="路由地址"
prop="Path"
v-if="currentMenu.MenuType !== 3"
>
<el-input v-model="currentMenu.Path" placeholder="例如:/system" />
</el-form-item>
<el-form-item
label="组件路径"
prop="ComponentPath"
v-if="currentMenu.MenuType === 1"
>
<el-input
v-model="currentMenu.ComponentPath"
placeholder="例如:@/views/settings/index.vue"
/>
</el-form-item>
<el-form-item label="图标" prop="Icon">
<el-input
v-model="currentMenu.Icon"
placeholder="例如:fas fa-tachometer-alt"
/>
</el-form-item>
<el-form-item label="排序" prop="Order">
<el-input-number
v-model="currentMenu.Order"
:min="0"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="权限标识" prop="Permission">
<el-input
v-model="currentMenu.Permission"
placeholder="请输入权限标识"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveMenu">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
import { Plus, CirclePlus, Edit, Delete } from "@element-plus/icons-vue";
import { getAllMenus, updateMenuStatus, createMenu, updateMenu, deleteMenu } from "@/api/menu";
// 定义菜单数据类型(匹配后端返回的 Pascal 命名格式)
interface Menu {
Id: number;
Name: string;
Path: string;
ParentId: number;
Icon: string;
Order: number;
Status: 0 | 1;
ComponentPath: string;
IsExternal: 0 | 1;
ExternalUrl: string;
MenuType: 1 | 2 | 3; // 1:普通菜单 2:分组菜单 3:按钮菜单
Permission: string;
CreateTime: string;
UpdateTime: string;
children?: Menu[];
hasChildren?: boolean;
}
// 菜单树形数据
const menuTree = ref<Menu[]>([]);
// 默认展开的菜单ID
const defaultExpandedKeys = ref<number[]>([]);
// 对话框状态
const dialogVisible = ref(false);
const dialogTitle = ref("");
const menuFormRef = ref<InstanceType<typeof ElForm>>();
// 当前操作的菜单
const currentMenu = ref<Partial<Menu>>({
Id: 0,
Name: "",
Path: "",
ParentId: 0,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: 1,
Permission: "",
});
// 表单验证规则
const formRules = ref({
Name: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
MenuType: [{ required: true, message: "请选择菜单类型", trigger: "change" }],
Order: [{ required: true, message: "请输入排序号", trigger: "blur" }],
});
// 级联选择器配置(匹配 Pascal 命名)
const cascaderProps = ref({
value: "Id",
label: "Name",
children: "children",
checkStrictly: true,
emitPath: false,
});
// 父级菜单选项
const parentMenuOptions = ref<Menu[]>([]);
// 获取所有菜单并构建树形结构
const fetchMenus = async () => {
try {
// 使用 getAllMenus 接口获取菜单(接口返回后端格式,需映射为 Pascal 命名并补充默认字段)
const result = await getAllMenus();
if (result.success) {
// getAllMenus返回的data里每一项key是小写下划线式(见后端),需要转为Pascal命名
const data = result.data.map((item: any) => ({
Id: item.id,
Name: item.name,
Path: item.path,
ParentId: item.parentId,
Icon: item.icon,
Order: item.order,
Status: 1, // 默认开启(后端返回的只查Status=1的)
ComponentPath: item.componentPath || "",
IsExternal: item.isExternal || 0,
ExternalUrl: item.externalUrl || "",
MenuType: 1, // 如果后端未返回MenuType,默认填1
Permission: "", // 后端未返回时默认空
CreateTime: "",
UpdateTime: "",
}));
const tree = buildMenuTree(data);
menuTree.value = tree;
// 设置默认展开顶级菜单(ParentId=0)
defaultExpandedKeys.value = tree
.filter((menu) => menu.ParentId === 0)
.map((menu) => menu.Id);
// 构建父级菜单选项(添加顶级菜单选项)
parentMenuOptions.value = [
{
Id: 0,
Name: "顶级菜单",
MenuType: 1,
children: [],
} as Menu,
...tree,
];
} else {
ElMessage.error("获取菜单失败: " + result.message);
}
} catch (error) {
ElMessage.error("获取菜单数据失败: " + (error as Error).message);
}
};
// 构建菜单树(处理父子关系)
const buildMenuTree = (menuList: Menu[]): Menu[] => {
const menuMap = new Map<number, Menu>();
// 初始化所有菜单,添加children和hasChildren字段
menuList.forEach((menu) => {
menuMap.set(menu.Id, { ...menu, children: [], hasChildren: false });
});
// 构建父子关系
const tree: Menu[] = [];
menuList.forEach((menu) => {
const current = menuMap.get(menu.Id)!;
if (menu.ParentId === 0) {
// 顶级菜单直接加入根节点
tree.push(current);
} else {
// 子菜单挂载到父菜单
const parent = menuMap.get(menu.ParentId);
if (parent) {
parent.children?.push(current);
parent.hasChildren = true; // 标记有子菜单
}
}
});
return tree;
};
// 获取菜单类型名称
const getMenuTypeName = (type: number) => {
const typeMap = { 1: "普通菜单", 2: "分组菜单", 3: "按钮菜单" };
return typeMap[type as keyof typeof typeMap] || "未知类型";
};
// 获取菜单类型标签样式
const getMenuTypeTagType = (type: number) => {
const typeMap = { 1: "primary", 2: "success", 3: "info" };
return typeMap[type as keyof typeof typeMap] || "default";
};
// 处理状态变更
const handleStatusChange = async (menu: Menu) => {
try {
const result = await updateMenuStatus(menu.Id, menu.Status);
if (!result.success) {
ElMessage.error(result.message);
// 状态更新失败时回滚
menu.Status = menu.Status === 1 ? 0 : 1;
}
} catch (error) {
ElMessage.error("更新状态失败: " + (error as Error).message);
menu.Status = menu.Status === 1 ? 0 : 1;
}
};
// 添加顶级菜单
const handleAddMenu = () => {
dialogTitle.value = "添加菜单";
currentMenu.value = {
Id: 0,
Name: "",
Path: "",
ParentId: 0,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: 1,
Permission: "",
};
dialogVisible.value = true;
};
// 添加子菜单
const handleAddSubMenu = (parentMenu: Menu) => {
dialogTitle.value = `添加子菜单 (父菜单: ${parentMenu.Name})`;
currentMenu.value = {
Id: 0,
Name: "",
Path: "",
ParentId: parentMenu.Id,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: parentMenu.MenuType === 2 ? 1 : parentMenu.MenuType,
Permission: "",
};
dialogVisible.value = true;
};
// 编辑菜单
const handleEditMenu = (menu: Menu) => {
dialogTitle.value = "编辑菜单";
currentMenu.value = { ...menu };
dialogVisible.value = true;
};
// 删除菜单
const handleDeleteMenu = (menu: Menu) => {
ElMessageBox.confirm(
`确定要删除菜单 "${menu.Name}" 吗?${menu.hasChildren ? "其下所有子菜单也将被删除。" : ""}`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const result = await deleteMenu(menu.Id);
if (result.success) {
ElMessage.success("删除成功");
fetchMenus();
} else {
ElMessage.error("删除失败: " + result.message);
}
} catch (error) {
ElMessage.error("删除失败: " + (error as Error).message);
}
});
};
// 保存菜单
const saveMenu = async () => {
// 表单验证
if (!menuFormRef.value) return;
const valid = await menuFormRef.value.validate();
if (!valid) return;
try {
if (currentMenu.value.Id === 0) {
// 新增菜单
const result = await createMenu(currentMenu.value as Menu);
if (result.success) {
ElMessage.success("菜单添加成功");
dialogVisible.value = false;
fetchMenus();
} else {
ElMessage.error("添加失败: " + result.message);
}
} else {
// 编辑菜单
const result = await updateMenu(
currentMenu.value.Id!,
currentMenu.value as Menu
);
if (result.success) {
ElMessage.success("更新成功");
dialogVisible.value = false;
fetchMenus();
} else {
ElMessage.error("更新失败: " + result.message);
}
}
} catch (error) {
ElMessage.error("操作失败: " + (error as Error).message);
}
};
// 组件挂载时加载菜单
onMounted(() => {
fetchMenus();
});
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f2f3f5;
}
.card-header span {
font-size: 18px;
font-weight: 500;
}
/* 表格核心样式 */
:deep(.el-table) {
border-radius: 0;
width: 100% !important;
}
:deep(.el-table__body td) {
padding: 12px 0;
vertical-align: middle;
}
/* 展开图标与菜单内容对齐 */
:deep(.el-table__expand-icon) {
margin: 0 !important;
vertical-align: middle;
}
:deep(.el-table__expand-icon-cell) {
padding: 0 8px !important;
}
/* 菜单项样式 */
.menu-item {
display: inline-flex;
align-items: center;
gap: 8px;
vertical-align: middle;
}
.menu-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* 隐藏无子女菜单的展开图标 */
:deep(.el-table__expand-icon--hidden) {
visibility: hidden;
width: 24px;
}
:deep(.el-table__expand-icon) {
margin-right: 8px !important;
vertical-align: middle;
}
/* 对话框样式精简 */
:deep(.el-dialog__body) {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
:deep(.el-form-item) {
margin-bottom: 16px;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>权限管理</h2>
<el-button type="primary" @click="handleAddPermission = true">
<el-icon><Plus /></el-icon>
添加权限
</el-button>
</div>
<el-divider></el-divider>
<el-table :data="permissions" style="width: 100%">
<el-table-column prop="name" label="权限名称" width="180" align="center" />
<el-table-column prop="code" label="权限代码" width="200" align="center" />
<el-table-column prop="description" label="权限描述" align="center" />
<el-table-column prop="type" label="权限类型" width="120" align="center">
<template #default="scope">
<el-tag :type="scope.row.type === 'menu' ? 'primary' : 'warning'">
{{ scope.row.type === "menu" ? "菜单权限" : "功能权限" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button size="small" type="danger" @click="handleDelete(scope.row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
// const loading = ref(false);
// const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
interface Permission {
id: number;
name: string;
code: string;
description: string;
type: string;
}
const permissions = ref<Permission[]>([
{
id: 1,
name: "用户管理",
code: "user:manage",
description: "用户的增删改查权限",
type: "menu",
},
{
id: 2,
name: "角色管理",
code: "role:manage",
description: "角色的增删改查权限",
type: "menu",
},
{
id: 3,
name: "数据导出",
code: "data:export",
description: "数据导出功能权限",
type: "function",
},
]);
const handleAddPermission = () => {
console.log("添加权限");
};
const handleEdit = (permission: Permission) => {
console.log("编辑权限:", permission);
};
const handleDelete = (permission: Permission) => {
console.log("删除权限:", permission);
};
</script>
<style scoped>
.permissions-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>程序管理</h2>
<el-button type="primary" @click="showProgramDialog = true">
<el-icon><Plus /></el-icon>
添加程序
</el-button>
</div>
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载程序数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchPrograms">重试</el-button>
</div>
<!-- 程序列表 -->
<div v-else>
<el-table :data="programs" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="程序名称" min-width="160" align="center" />
<el-table-column prop="type" label="类型" width="100" align="center" />
<el-table-column prop="owner" label="负责人" width="120" align="center" />
<el-table-column prop="createdAt" label="创建时间" width="170" align="center" />
<el-table-column prop="remark" label="备注" min-width="140" align="center" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
<!-- 新增/编辑程序弹窗 -->
<el-dialog
:title="isEditing ? '编辑程序' : '添加程序'"
v-model="showProgramDialog"
width="420px"
:close-on-click-modal="false"
>
<el-form
:model="programForm"
:rules="formRules"
ref="programFormRef"
label-width="90px"
>
<el-form-item label="程序名称" prop="name">
<el-input v-model="programForm.name" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="programForm.type" placeholder="选择类型">
<el-option label="Web" value="Web" />
<el-option label="服务" value="Service" />
<el-option label="工具" value="Tool" />
<el-option label="其他" value="Other" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="programForm.owner" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="programForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showProgramDialog = false">取消</el-button>
<el-button type="primary" @click="submitProgramForm"> 保存 </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
} from "element-plus";
import { Plus } from "@element-plus/icons-vue";
// 数据与状态
const programs = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const showProgramDialog = ref(false);
const isEditing = ref(false);
const programForm = reactive({
id: null,
name: "",
type: "",
owner: "",
remark: "",
});
const programFormRef = ref<FormInstance>();
const formRules: FormRules = {
name: [
{ required: true, message: "请输入程序名称", trigger: "blur" },
{ min: 2, max: 32, message: "名称长度2-32字符", trigger: "blur" },
],
type: [{ required: true, message: "请选择类型", trigger: "change" }],
owner: [{ required: true, message: "请输入负责人", trigger: "blur" }],
};
async function fetchPrograms() {
loading.value = true;
error.value = "";
try {
// TODO: 替换为真实API
await new Promise((r) => setTimeout(r, 250));
// 假数据
const all = [
{
id: 1,
name: "门户网站",
type: "Web",
owner: "张三",
createdAt: "2023-11-08 09:22:53",
remark: "官网",
},
{
id: 2,
name: "自动备份服务",
type: "Service",
owner: "李四",
createdAt: "2024-01-16 15:40:01",
remark: "每日凌晨自动执行",
},
{
id: 3,
name: "运维工具",
type: "Tool",
owner: "王五",
createdAt: "2023-12-02 12:51:29",
remark: "",
},
// ...更多
];
total.value = all.length;
programs.value = all.slice(
(page.value - 1) * pageSize.value,
page.value * pageSize.value
);
} catch (err: any) {
error.value = err.message || "获取程序列表失败";
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchPrograms();
};
function resetProgramForm() {
programForm.id = null;
programForm.name = "";
programForm.type = "";
programForm.owner = "";
programForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
programForm.id = row.id;
programForm.name = row.name;
programForm.type = row.type;
programForm.owner = row.owner;
programForm.remark = row.remark;
showProgramDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除程序「${row.name}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
// TODO: 替换为真实API
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("删除成功");
fetchPrograms();
} catch {
// 取消删除
}
}
async function submitProgramForm() {
await programFormRef.value?.validate();
loading.value = true;
try {
if (isEditing.value) {
// TODO: 替换为真实API更新
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("程序更新成功");
} else {
// TODO: 替换为真实API新增
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("程序添加成功");
}
showProgramDialog.value = false;
fetchPrograms();
} catch (e: any) {
ElMessage.error(e.message || "操作失败,请重试");
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchPrograms();
});
</script>
<style scoped>
</style>
+290
View File
@@ -0,0 +1,290 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>角色管理</h2>
<div class="header-actions">
<el-button type="primary" @click="showRoleDialog = true">
<el-icon><Plus /></el-icon>
添加角色
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载角色数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchRoles">重试</el-button>
</div>
<!-- 角色列表 -->
<div v-else>
<el-table :data="roles" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="label" label="角色名称" align="center" />
<el-table-column prop="value" label="角色标识" align="center" />
<el-table-column prop="remark" label="备注" align="center" />
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
<!-- 新增/编辑角色弹窗 -->
<el-dialog
:title="isEditing ? '编辑角色' : '添加角色'"
v-model="showRoleDialog"
width="420px"
:close-on-click-modal="false"
>
<el-form
:model="roleForm"
:rules="formRules"
ref="roleFormRef"
label-width="90px"
>
<el-form-item label="角色名称" prop="label">
<el-input
v-model="roleForm.label"
:disabled="isEditing && roleForm.value === 'admin'"
/>
</el-form-item>
<el-form-item label="角色标识" prop="value">
<el-input
v-model="roleForm.value"
:disabled="isEditing && roleForm.value === 'admin'"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="roleForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showRoleDialog = false">取消</el-button>
<el-button type="primary" @click="submitRoleForm"> 保存 </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
} from "element-plus";
const roles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const showRoleDialog = ref(false);
const isEditing = ref(false);
const roleForm = reactive({
id: null,
label: "",
value: "",
remark: "",
});
const roleFormRef = ref<FormInstance>();
const formRules: FormRules = {
label: [
{ required: true, message: "请输入角色名称", trigger: "blur" },
{ min: 2, max: 24, message: "名称长度2-24字符", trigger: "blur" },
],
value: [
{ required: true, message: "请输入角色标识", trigger: "blur" },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "只能包含字母、数字或下划线",
trigger: "blur",
},
],
};
async function fetchRoles() {
loading.value = true;
error.value = "";
try {
// TODO: 替换为真实API
await new Promise((r) => setTimeout(r, 300));
// 假数据
const all = [
{ id: 1, label: "系统管理员", value: "admin", remark: "拥有全部权限" },
{ id: 2, label: "普通用户", value: "user", remark: "普通访问权限" },
{ id: 3, label: "运营", value: "ops", remark: "运营相关权限" },
// ...更多
];
total.value = all.length;
roles.value = all.slice(
(page.value - 1) * pageSize.value,
page.value * pageSize.value
);
} catch (err: any) {
error.value = err.message || "获取角色列表失败";
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchRoles();
};
function resetRoleForm() {
roleForm.id = null;
roleForm.label = "";
roleForm.value = "";
roleForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
roleForm.id = row.id;
roleForm.label = row.label;
roleForm.value = row.value;
roleForm.remark = row.remark;
showRoleDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除角色「${row.label}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
// TODO: 替换为真实API
await new Promise((r) => setTimeout(r, 300));
ElMessage.success("删除成功");
fetchRoles();
} catch {
// 取消删除
}
}
async function submitRoleForm() {
// 表单校验
await roleFormRef.value?.validate();
loading.value = true;
try {
if (isEditing.value) {
// TODO: 替换为真实API更新
await new Promise((r) => setTimeout(r, 300));
ElMessage.success("角色更新成功");
} else {
// TODO: 替换为真实API新增
await new Promise((r) => setTimeout(r, 300));
ElMessage.success("角色添加成功");
}
showRoleDialog.value = false;
fetchRoles();
} catch (e: any) {
ElMessage.error(e.message || "操作失败,请重试");
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchRoles();
});
</script>
<style scoped>
.role-management-module {
box-sizing: border-box;
background: #fff;
padding: 28px 22px 14px 18px;
min-height: 500px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.03);
}
.header-bar h2 {
font-size: 1.18rem;
font-weight: 600;
margin: 0;
color: #24292f;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: #f5f7fa;
border-radius: 5px;
}
.loading-spinner {
width: 36px;
height: 36px;
border: 4px solid #e5e5e5;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
margin-bottom: 14px;
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.el-dialog__body {
padding-top: 18px !important;
}
.el-form .el-form-item {
margin-bottom: 16px;
}
.el-form .el-input {
width: 100%;
}
@media (max-width: 600px) {
.role-management-module {
padding: 10px 4px;
min-height: 300px;
}
.header-bar {
flex-direction: column;
align-items: stretch;
row-gap: 8px;
padding-bottom: 2px;
}
}
</style>
+646
View File
@@ -0,0 +1,646 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户管理</h2>
<div class="header-actions">
<el-button @click="fetchTenants" title="刷新租户列表" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button type="primary" @click="showAddTenantDialog = true">
<el-icon><Plus /></el-icon>
添加租户
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载租户数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchTenants">重试</el-button>
</div>
<!-- 租户列表 -->
<div v-else>
<el-table
:data="tenants"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="租户名称" min-width="120" />
<el-table-column prop="code" label="租户编码" width="120" />
<el-table-column prop="owner" label="负责人" width="100" />
<el-table-column prop="created_at" label="创建时间" width="150" />
<el-table-column label="审核状态" width="100">
<template #default="{ row }">
<el-tag :type="getAuditStatusType(row.audit_status)">
{{ getAuditStatusText(row.audit_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'">
{{ row.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button
size="small"
type="success"
v-if="row.audit_status === 'pending'"
@click="handleAuditDialog(row)"
>
<el-icon><Check /></el-icon>
审核
</el-button>
<el-button
size="small"
@click="handleEdit(row)"
v-if="row.audit_status !== 'approved'"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(row)"
v-if="row.audit_status !== 'approved'"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
<!-- 租户详情查看弹窗 -->
<el-dialog
title="租户详情"
v-model="showViewDialog"
width="600px"
:close-on-click-modal="false"
>
<div class="tenant-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
{{ currentTenant.id }}
</el-descriptions-item>
<el-descriptions-item label="租户名称">
{{ currentTenant.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ currentTenant.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ currentTenant.owner }}
</el-descriptions-item>
<el-descriptions-item label="联系电话">
{{ currentTenant.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ currentTenant.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="getAuditStatusType(currentTenant.audit_status)">
{{ getAuditStatusText(currentTenant.audit_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentTenant.status === 'enabled' ? 'success' : 'info'">
{{ currentTenant.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ currentTenant.created_at }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ currentTenant.updated_at || '未更新' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentTenant.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="showViewDialog = false">关闭</el-button>
</template>
</el-dialog>
<!-- 审核弹窗 -->
<el-dialog
title="租户审核"
v-model="showAuditDialog"
width="700px"
:close-on-click-modal="false"
>
<div class="audit-content">
<!-- 租户信息展示 -->
<div class="tenant-info-section">
<h3>租户信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
{{ currentTenant.id }}
</el-descriptions-item>
<el-descriptions-item label="租户名称">
{{ currentTenant.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ currentTenant.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ currentTenant.owner }}
</el-descriptions-item>
<el-descriptions-item label="联系电话">
{{ currentTenant.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ currentTenant.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentTenant.status === 'enabled' ? 'success' : 'info'">
{{ currentTenant.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ currentTenant.created_at }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentTenant.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 审核表单 -->
<div class="audit-form-section">
<h3>审核信息</h3>
<el-form :model="auditForm" :rules="auditRules" ref="auditFormRef" label-width="100px">
<el-form-item label="审核结果" prop="result">
<el-radio-group v-model="auditForm.result">
<el-radio value="approved">通过</el-radio>
<el-radio value="rejected">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见" prop="comment">
<el-input
v-model="auditForm.comment"
type="textarea"
:rows="4"
placeholder="请输入审核意见"
/>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<el-button @click="showAuditDialog = false">取消</el-button>
<el-button type="primary" @click="submitAudit">提交审核</el-button>
</template>
</el-dialog>
<!-- 新增/编辑租户弹窗 -->
<el-dialog
:title="isEditing ? '编辑租户' : '添加租户'"
v-model="showAddTenantDialog"
width="450px"
:close-on-click-modal="false"
>
<el-form :model="tenantForm" :rules="formRules" ref="tenantFormRef" label-width="90px">
<el-form-item label="租户名称" prop="name">
<el-input v-model="tenantForm.name" />
</el-form-item>
<el-form-item label="租户编码" prop="code">
<el-input v-model="tenantForm.code" />
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="tenantForm.owner" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="tenantForm.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="tenantForm.email" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="tenantForm.status">
<el-option label="启用" value="enabled" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="tenantForm.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddTenantDialog = false">取消</el-button>
<el-button type="primary" @click="submitTenantForm">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus, Check, View, Edit, Delete } from "@element-plus/icons-vue";
import {
getAllTenants,
createTenant,
updateTenant,
deleteTenant,
auditTenant,
getTenantDetail,
} from "@/api/tenant";
const tenants = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
// 弹窗状态
const showAddTenantDialog = ref(false);
const showViewDialog = ref(false);
const showAuditDialog = ref(false);
const isEditing = ref(false);
// 当前查看的租户
const currentTenant = ref<any>({});
// 审核表单
const auditForm = reactive({
result: '',
comment: ''
});
const auditFormRef = ref<FormInstance>();
// 表单数据
const tenantForm = reactive({
id: null,
name: "",
code: "",
owner: "",
phone: "",
email: "",
status: "enabled",
remark: ""
});
const tenantFormRef = ref<FormInstance>();
const formRules: FormRules = {
name: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
code: [{ required: true, message: "请输入租户编码", trigger: "blur" }],
owner: [{ required: true, message: "请输入负责人", trigger: "blur" }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号", trigger: "blur" }],
email: [{ type: "email", message: "请输入正确的邮箱", trigger: "blur" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }]
};
const auditRules: FormRules = {
result: [{ required: true, message: "请选择审核结果", trigger: "change" }],
comment: [{ required: true, message: "请输入审核意见", trigger: "blur" }]
};
async function fetchTenants() {
loading.value = true;
error.value = "";
try {
const res = await getAllTenants();
// 后端返回格式: { success: true, message: "...", data: [...] }
if (res.success && res.data) {
const allTenants = Array.isArray(res.data) ? res.data : [];
// 处理字段名映射:后端返回 create_time,前端显示 created_at
tenants.value = allTenants.map((tenant: any) => ({
...tenant,
created_at: tenant.create_time || tenant.created_at,
updated_at: tenant.update_time || tenant.updated_at,
}));
total.value = tenants.value.length;
} else {
error.value = res.message || "获取租户列表失败";
tenants.value = [];
total.value = 0;
}
} catch (err: any) {
error.value = err.message || "获取租户列表失败";
tenants.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchTenants();
};
// 审核状态相关方法
function getAuditStatusType(status: string): 'warning' | 'success' | 'danger' | 'info' {
const statusMap: Record<string, 'warning' | 'success' | 'danger' | 'info'> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
};
return statusMap[status] || 'info';
}
function getAuditStatusText(status: string) {
const statusMap: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝'
};
return statusMap[status] || '未知';
}
// 查看租户详情(只读)
async function handleView(row: any) {
try {
loading.value = true;
const res = await getTenantDetail(row.id);
if (res.success && res.data) {
const tenant = res.data;
currentTenant.value = {
...tenant,
created_at: tenant.create_time || tenant.created_at,
updated_at: tenant.update_time || tenant.updated_at,
};
showViewDialog.value = true;
} else {
ElMessage.error(res.message || "获取租户详情失败");
}
} catch (err: any) {
ElMessage.error(err.message || "获取租户详情失败");
} finally {
loading.value = false;
}
}
// 打开审核弹窗
function handleAuditDialog(row: any) {
currentTenant.value = { ...row };
// 重置审核表单
auditForm.result = '';
auditForm.comment = '';
showAuditDialog.value = true;
}
// 提交审核
async function submitAudit() {
await auditFormRef.value?.validate();
loading.value = true;
try {
const auditData = {
audit_status: auditForm.result,
audit_comment: auditForm.comment,
audit_by: getCurrentUser(),
};
const res = await auditTenant(currentTenant.value.id, auditData);
if (res.success) {
const action = auditForm.result === 'approved' ? '通过' : '拒绝';
ElMessage.success(`审核${action}成功`);
showAuditDialog.value = false;
// 重新获取租户列表
await fetchTenants();
} else {
ElMessage.error(res.message || "审核失败,请重试");
}
} catch (e: any) {
ElMessage.error(e.message || "审核失败,请重试");
} finally {
loading.value = false;
}
}
// 获取当前用户
function getCurrentUser() {
const userStr = localStorage.getItem("user");
if (userStr) {
try {
const user = JSON.parse(userStr);
return user.username || user.name || user.userName || "system";
} catch {
return "system";
}
}
return "system";
}
function resetTenantForm() {
tenantForm.id = null;
tenantForm.name = "";
tenantForm.code = "";
tenantForm.owner = "";
tenantForm.phone = "";
tenantForm.email = "";
tenantForm.status = "enabled";
tenantForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
tenantForm.id = row.id;
tenantForm.name = row.name;
tenantForm.code = row.code;
tenantForm.owner = row.owner;
tenantForm.phone = row.phone || "";
tenantForm.email = row.email || "";
tenantForm.status = row.status;
tenantForm.remark = row.remark || "";
showAddTenantDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除租户「${row.name}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
loading.value = true;
try {
const res = await deleteTenant(row.id);
if (res.success) {
ElMessage.success("删除成功");
await fetchTenants();
} else {
ElMessage.error(res.message || "删除失败");
}
} catch (err: any) {
ElMessage.error(err.message || "删除失败");
} finally {
loading.value = false;
}
} catch {
// 取消删除
}
}
function submitTenantForm() {
(tenantFormRef.value as FormInstance).validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
const tenantData = {
name: tenantForm.name,
code: tenantForm.code,
owner: tenantForm.owner,
phone: tenantForm.phone || "",
email: tenantForm.email || "",
status: tenantForm.status,
remark: tenantForm.remark || "",
};
let res;
if (isEditing.value && tenantForm.id) {
// 编辑租户
res = await updateTenant(tenantForm.id, tenantData);
} else {
// 新增租户
res = await createTenant(tenantData);
}
if (res.success) {
if (isEditing.value) {
ElMessage.success("租户信息已更新");
} else {
ElMessage.success("租户添加成功");
}
showAddTenantDialog.value = false;
resetTenantForm();
isEditing.value = false;
await fetchTenants();
} else {
ElMessage.error(res.message || "操作失败,请重试");
}
} catch (err: any) {
ElMessage.error(err.message || "操作失败,请重试");
} finally {
loading.value = false;
}
});
}
onMounted(() => {
fetchTenants();
});
</script>
<style lang="less" scoped>
.tenant-management-module {
padding: 20px;
min-height: 600px;
background: var(--card-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--card-shadow);
transition: var(--transition-base);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.loading-state, .error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
}
.loading-spinner {
width: 36px;
height: 36px;
border: 4px solid var(--border-color);
border-left-color: var(--primary-color);
border-radius: var(--border-radius-full);
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.tenant-detail {
padding: 16px 0;
}
.tenant-detail .el-descriptions {
margin-top: 0;
}
.tenant-detail .el-descriptions-item__label {
font-weight: 600;
color: var(--text-secondary);
}
.tenant-detail .el-descriptions-item__content {
color: var(--text-color);
}
.audit-content {
padding: 16px 0;
}
.tenant-info-section,
.audit-form-section {
margin-bottom: 24px;
}
.tenant-info-section h3,
.audit-form-section h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
border-bottom: 2px solid var(--primary-color);
padding-bottom: 8px;
}
.audit-form-section .el-form {
margin-top: 16px;
}
.audit-form-section .el-radio-group {
display: flex;
gap: 24px;
}
.audit-form-section .el-radio {
margin-right: 0;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>用户管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAddUser = true">
<el-icon><Plus /></el-icon>
添加用户
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-table :data="users" style="width: 100%">
<el-table-column
prop="username"
label="用户名"
width="150"
align="center"
/>
<el-table-column
prop="email"
label="邮箱"
align="center"
min-width="200"
/>
<el-table-column prop="role" label="角色" width="120" align="center">
<template #default="scope">
<el-tag :type="scope.row.role === 'admin' ? 'danger' : 'primary'">
{{ scope.row.role === "admin" ? "管理员" : "普通用户" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag
:type="scope.row.status === 'active' ? 'success' : 'danger'"
>
{{ scope.row.status === "active" ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="lastLogin"
label="最后登录"
width="180"
align="center"
/>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface User {
id: number;
username: string;
email: string;
role: string;
status: string;
lastLogin: string;
}
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const users = ref<User[]>([
{
id: 1,
username: "admin",
email: "admin@example.com",
role: "admin",
status: "active",
lastLogin: "2025-01-20 10:30:00",
},
{
id: 2,
username: "user1",
email: "user1@example.com",
role: "user",
status: "active",
lastLogin: "2025-01-19 15:45:00",
},
{
id: 3,
username: "user2",
email: "user2@example.com",
role: "user",
status: "inactive",
lastLogin: "2025-01-18 09:20:00",
},
]);
const handleAddUser = () => {
console.log("添加用户");
};
const handleEdit = (user: User) => {
console.log("编辑用户:", user);
};
const handleDelete = (user: User) => {
console.log("删除用户:", user);
};
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 1.4rem;
font-weight: 700;
}
}
</style>