增加租户储存容量

This commit is contained in:
扫地僧 2025-11-02 13:17:10 +08:00
parent 01c47ccbd4
commit 494e0160b7
14 changed files with 2621 additions and 1945 deletions

View File

@ -362,7 +362,7 @@ const findMenuItemByPath = (menus, path) => {
}
h3{
line-height: 60px;
line-height: 80px;
display: flex;
align-items: center;
justify-content: center;

View File

@ -65,28 +65,22 @@ export async function loadAndAddDynamicRoutes() {
// 创建加载 Promise
routesLoadingPromise = (async () => {
try {
console.log('[路由加载] 开始从 API 加载动态路由...');
// 直接从 API 获取菜单数据
const { getAllMenus } = await import("@/api/menu");
const res = await getAllMenus();
if (res && res.success && res.data) {
console.log('[路由加载] API 返回菜单数量:', res.data.length);
// 添加动态路由
addDynamicRoutes(res.data);
console.log('[路由加载] 动态路由加载完成');
dynamicRoutesAdded = true;
routesLoadingPromise = null;
return Promise.resolve();
} else {
console.warn('[路由加载] API 返回数据格式异常:', res);
dynamicRoutesAdded = true;
routesLoadingPromise = null;
return Promise.resolve();
}
} catch (error) {
console.error('[路由加载] 加载动态路由失败:', error);
// 即使出错也标记为已加载,避免无限重试
dynamicRoutesAdded = true;
routesLoadingPromise = null;
@ -100,15 +94,11 @@ export async function loadAndAddDynamicRoutes() {
// 添加动态路由到 Main 的 children 中
function addDynamicRoutes(menus) {
if (!menus?.length) {
console.warn('[路由加载] 菜单数据为空,跳过添加动态路由');
return;
}
console.log('[路由加载] 开始添加动态路由,菜单数量:', menus.length);
// 如果已经添加过,先移除旧路由(刷新时可能需要重新添加)
if (dynamicRoutesAdded) {
console.log('[路由加载] 检测到已添加的路由,先移除旧路由');
// 移除 Main 路由以便重新添加
if (router.hasRoute('Main')) {
router.removeRoute('Main');
@ -120,18 +110,10 @@ function addDynamicRoutes(menus) {
const filteredMenus = menus.filter(menu => menu.id !== 1);
const dynamicRoutes = convertMenusToRoutes(filteredMenus);
console.log('[路由加载] 转换后的动态路由数量:', dynamicRoutes.length);
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');
if (!mainRoute) {
console.error('[路由加载] 找不到 Main 路由');
return;
}
@ -157,7 +139,6 @@ function addDynamicRoutes(menus) {
};
router.addRoute(newMainRoute);
console.log('[路由加载] Main 路由已更新,子路由总数:', newMainRoute.children.length);
// 添加知识库的子路由(详情页和编辑页)
// 直接在 Main 路由下添加完整路径的子路由
@ -227,22 +208,16 @@ router.beforeEach(async (to, from, next) => {
// 3. 已登录,确保动态路由已加载(必须在检查 404 之前)
if (!dynamicRoutesAdded) {
console.log('[路由守卫] 开始加载动态路由, 目标路径:', to.path);
await loadAndAddDynamicRoutes();
console.log('[路由守卫] 动态路由加载完成, dynamicRoutesAdded:', dynamicRoutesAdded);
// 如果路由加载后仍然未添加API 失败)
if (!dynamicRoutesAdded) {
console.warn('[路由守卫] 路由加载失败,可能是 API 错误或 token 无效');
// 重新检查 token如果 token 不存在或无效,跳转到登录页
const currentToken = localStorage.getItem('token');
if (!currentToken) {
console.log('[路由守卫] 未找到 token跳转到登录页');
next({ path: "/login", query: { redirect: to.path } });
return;
}
// 如果 token 存在但路由加载失败,可能是 token 过期或无效,清除 token 并跳转登录
console.warn('[路由守卫] Token 存在但路由加载失败,清除 token 并跳转登录页');
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
next({ path: "/login", query: { redirect: to.path } });
@ -254,23 +229,14 @@ router.beforeEach(async (to, from, next) => {
// 重新解析路径检查是否能匹配
const resolved = router.resolve(to.path);
console.log('[路由守卫] 路由解析结果:', {
path: to.path,
resolved: resolved.path,
matched: resolved.matched.length,
name: resolved.name,
matchedRoutes: resolved.matched.map(m => ({ name: m.name, path: m.path }))
});
// 如果能匹配到路由使用完整路径重新导航确保Vue Router正确更新
if (resolved.matched.length > 0 && resolved.name !== "NotFound") {
console.log('[路由守卫] 路由匹配成功,重新导航确保正确加载');
next({ path: to.fullPath || to.path, replace: true });
return;
}
// 如果无法匹配,使用原始路径重新导航
console.log('[路由守卫] 路由未匹配,重新导航到:', to.path);
next({ path: to.fullPath || to.path, replace: true });
return;
}
@ -278,29 +244,13 @@ router.beforeEach(async (to, from, next) => {
// 3.5 如果路由已加载但当前路径未匹配,可能是刷新问题
// 注意:这里需要排除根路径和已知的静态路由
if (to.matched.length === 0 && to.name !== "NotFound" && to.path !== "/" && to.path !== "/dashboard") {
console.log('[路由守卫] 路由已加载但未匹配,尝试重新解析:', to.path);
// 重新解析路径,看看是否能够匹配
const resolved = router.resolve(to.path);
console.log('[路由守卫] 重新解析结果:', {
matched: resolved.matched.length,
name: resolved.name,
matchedRoutes: resolved.matched.map(m => ({ name: m.name, path: m.path }))
});
if (resolved.matched.length > 0 && resolved.name !== "NotFound") {
// 能够匹配,说明路由表正常,使用路径重新导航
console.log('[路由守卫] 重新解析匹配成功,重新导航');
next({ path: to.path, replace: true });
return;
} else {
console.warn('[路由守卫] 路由确实无法匹配,可能是路径错误:', to.path);
// 打印所有已注册的路由用于调试
const allRoutes = router.getRoutes();
console.log('[路由守卫] 所有已注册的路由:', allRoutes.map(r => ({
name: r.name,
path: r.path,
children: r.children?.map(c => ({ name: c.name, path: c.path }))
})));
}
}

View File

@ -4,7 +4,7 @@ import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores';
import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted } from 'vue';
import { More, DArrowRight } from '@element-plus/icons-vue'
import { More, Close, CircleClose } from '@element-plus/icons-vue'
const tabsStore = useTabsStore();
const router = useRouter();
@ -175,16 +175,20 @@ function closeAllTabs() {
@contextmenu="onTabContextMenu($event, tab)"
/>
</el-tabs>
<!-- 跟随浮动到 tabs 最右侧的批量按钮 -->
<div class="floated-tabs-extra-btn">
<el-dropdown>
<span class="extra-action-btn">
<el-icon><DArrowRight /></el-icon>
</span>
<!-- 右侧操作按钮 -->
<div class="tabs-extra-actions">
<el-dropdown trigger="click">
<el-button type="primary" link size="small" class="extra-btn">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="closeOthers">关闭其他</el-dropdown-item>
<el-dropdown-item @click="closeAll">关闭全部</el-dropdown-item>
<el-dropdown-item @click="closeOthers">
关闭其他
</el-dropdown-item>
<el-dropdown-item @click="closeAll">
关闭全部
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -220,47 +224,132 @@ function closeAllTabs() {
.main-header {
background-color: var(--header-bg-color, #0081ff);
transition: background-color 0.3s ease;
height: 60px;
height: 80px;
padding: 0;
}
.right-main {
background-color: var(--bg-color-page);
color: var(--text-color-primary);
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
transition: background-color 0.3s ease, color 0.3s ease;
padding: 20px;
overflow-y: auto;
// overflow-y: auto fixed fixed viewport
.multi-tabs-wrapper {
position: relative;
zoom: 1;
min-height: 45px;
}
.floated-tabs-extra-btn {
float: right;
margin-top: -40px;
margin-right: 12px;
z-index: 10;
}
.extra-action-btn {
display: inline-flex;
display: flex;
align-items: center;
font-size: 20px;
cursor: pointer;
color: #888;
background: none;
border: none;
padding: 0 8px;
border-radius: 4px;
transition: color 0.2s;
&:hover {
color: #409eff;
background: none;
gap: 12px;
margin-bottom: 16px;
background: var(--el-bg-color);
border-radius: 8px;
padding: 8px 12px;
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.multi-tabs {
flex: 1;
min-width: 0;
:deep(.el-tabs__header) {
margin: 0;
border-bottom: none;
}
:deep(.el-tabs__nav-wrap) {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: var(--el-border-color) transparent;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 2px;
&:hover {
background: var(--el-border-color-darker);
}
}
}
:deep(.el-tabs__item) {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
margin-right: 8px;
padding: 8px 16px;
height: 36px;
line-height: 20px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-lighter);
transition: all 0.3s;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-7);
background: var(--el-color-primary-light-9);
}
&.is-active {
color: var(--el-color-primary);
border-color: #4f84ff;
background: #4f84ff;
color: #fff;
.el-icon-close {
color: rgba(255, 255, 255, 0.8);
&:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.2);
}
}
}
.el-icon-close {
margin-left: 8px;
border-radius: 50%;
width: 16px;
height: 16px;
line-height: 16px;
transition: all 0.2s;
&:hover {
background-color: var(--el-fill-color);
}
}
}
:deep(.el-tabs__nav) {
border: none;
}
:deep(.el-tabs__content) {
display: none;
}
}
.more-menu {
font-size: 13px;
margin-left: 10px;
cursor: pointer;
.tabs-extra-actions {
flex-shrink: 0;
display: flex;
align-items: center;
.extra-btn {
padding: 8px;
font-size: 18px;
color: var(--el-text-color-regular);
&:hover {
color: var(--el-color-primary);
}
}
}
}
}

View File

@ -1,13 +1,173 @@
<template>
<div>
123
<div class="category-manage">
<div class="page-header">
<h2 class="page-title">分类管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
</div>
<div class="category-content">
<el-card shadow="never">
<el-table :data="categoryList" style="width: 100%" v-loading="loading">
<el-table-column prop="categoryId" label="ID" width="80" />
<el-table-column prop="categoryName" label="分类名称" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form
:model="formData"
:rules="rules"
ref="formRef"
label-width="100px"
>
<el-form-item label="分类名称" prop="categoryName">
<el-input
v-model="formData.categoryName"
placeholder="请输入分类名称"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { getCategoryList, addCategory } from '@/api/knowledge'
const loading = ref(false)
const categoryList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('添加分类')
const formRef = ref<FormInstance>()
const formData = reactive({
categoryId: 0,
categoryName: '',
})
const rules = reactive<FormRules>({
categoryName: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
],
})
const fetchList = async () => {
loading.value = true
try {
const res = await getCategoryList()
const data = res.code === 0 && res.data ? res.data : res.data || []
categoryList.value = Array.isArray(data) ? data : []
} catch (e: any) {
ElMessage.error(e.message || '获取分类列表失败')
} finally {
loading.value = false
}
const handleAdd = () => {
dialogTitle.value = '添加分类'
formData.categoryId = 0
formData.categoryName = ''
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑分类'
formData.categoryId = row.categoryId
formData.categoryName = row.categoryName
dialogVisible.value = true
}
const handleDelete = (row: any) => {
ElMessageBox.confirm('确认删除该分类?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
try {
// TODO: API
ElMessage.success('删除成功')
fetchList()
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
}
})
}
const handleSubmit = () => {
if (!formRef.value) return
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
try {
await addCategory({
categoryName: formData.categoryName,
})
ElMessage.success('操作成功')
dialogVisible.value = false
fetchList()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
})
}
onMounted(() => {
fetchList()
})
</script>
<style lang="scss" scoped>
<style scoped lang="less">
.category-manage {
padding: 24px;
min-height: 100%;
background-color: var(--el-bg-color-page);
}
</style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.category-content {
:deep(.el-card) {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
}
</style>

View File

@ -2,68 +2,76 @@
<div class="knowledge-detail">
<!-- 顶部标题栏 -->
<div class="detail-header">
<el-button type="text" @click="goBack"
><i class="fas fa-arrow-left"></i> 返回</el-button
>
<h2>{{ knowledgeTitle }}</h2>
<el-button type="primary" link @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2 class="detail-title">{{ knowledgeTitle }}</h2>
<div class="header-actions">
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" @click="handleDelete">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
<!-- 主体内容区 -->
<div class="detail-body">
<!-- 左侧信息面板 -->
<div class="info-panel">
<el-card shadow="never">
<div slot="header" class="header">
<span>基本信息</span>
</div>
<div class="info-card">
<h3 class="info-title">基本信息</h3>
<el-divider />
<el-form label-width="80px" size="small">
<el-form-item label="标题:">
<span>{{ formData.title }}</span>
</el-form-item>
<el-form-item label="分类:">
<el-tag size="small">{{ formData.category }}</el-tag>
</el-form-item>
<el-form-item label="标签:">
<el-tag
v-for="tag in formData.tags"
:key="tag"
size="small"
style="margin-right: 4px"
>{{ tag }}</el-tag
>
</el-form-item>
<el-form-item label="作者:">
<span>{{ formData.author }}</span>
</el-form-item>
<el-form-item label="创建时间:">
<span>{{ formData.createTime }}</span>
</el-form-item>
<el-form-item label="更新时间:">
<span>{{ formData.updateTime }}</span>
</el-form-item>
</el-form>
<el-form label-width="80px" align="center">
<el-divider />
<el-button type="primary" @click="handleEdit"
><i class="fas fa-edit"></i> 编辑</el-button
>
<el-button type="danger" @click="handleDelete"
><i class="fas fa-trash"></i> 删除</el-button
>
</el-form>
</el-card>
<div class="info-content">
<div class="info-item">
<span class="info-label">标题</span>
<span class="info-value">{{ formData.title }}</span>
</div>
<div class="info-item">
<span class="info-label">分类</span>
<el-tag size="small" type="info">{{ formData.category }}</el-tag>
</div>
<div class="info-item">
<span class="info-label">标签</span>
<div class="tags-wrapper">
<el-tag
v-for="tag in formData.tags"
:key="tag"
size="small"
effect="plain"
style="margin-right: 6px; margin-bottom: 6px;"
>
{{ tag }}
</el-tag>
</div>
</div>
<div class="info-item">
<span class="info-label">作者</span>
<span class="info-value">{{ formData.author }}</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formData.createTime }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formData.updateTime }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧内容面板 -->
<div class="content-panel">
<el-card shadow="never">
<div slot="header">
<span>正文内容</span>
</div>
<div class="content-card">
<h3 class="content-title">正文内容</h3>
<el-divider />
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
</div>
</div>
</div>
@ -73,6 +81,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Edit, Delete } from '@element-plus/icons-vue'
import { marked } from "marked"
import { getKnowledgeDetail, deleteKnowledge } from "@/api/knowledge"
@ -90,6 +99,7 @@ interface FormData {
updateTime: string,
content: string,
}
const formData = ref<FormData>({
title: "",
category: "",
@ -124,7 +134,6 @@ async function fetchDetail() {
try {
const idValue = id.value as string | number
const res = await getKnowledgeDetail(idValue)
// API : { code: 0, data: {...}, message: "success" }
const data = (res.code === 0 && res.data) ? res.data : (res.data || res)
formData.value = {
title: data.title || '',
@ -142,14 +151,17 @@ async function fetchDetail() {
ElMessage.error("获取详情失败")
}
}
onMounted(fetchDetail)
function goBack() {
router.push("/apps/knowledge")
}
function handleEdit() {
router.push(`/apps/knowledge/edit/${id.value}`)
}
function handleDelete() {
ElMessageBox.confirm(
"确认删除该知识?",
@ -163,7 +175,6 @@ function handleDelete() {
try {
const idValue = id.value as string | number
const res = await deleteKnowledge(idValue)
// API : { code: 0, message: "", data: null }
if (res.code === 0) {
ElMessage.success("删除成功")
goBack()
@ -178,32 +189,35 @@ function handleDelete() {
</script>
<style scoped lang="less">
/* 知识详情页面样式 - 使用主题变量 */
.knowledge-detail {
background-color: var(--bg-color-page);
min-height: 100%;
padding: 24px;
transition: background-color 0.3s ease;
min-height: 100%;
background-color: var(--el-bg-color-page);
}
.detail-header {
background: var(--el-bg-color);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 20px;
display: flex;
align-items: center;
margin-bottom: 20px;
background-color: var(--card-bg-color);
padding: 16px 24px;
border-radius: 12px;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color-lighter);
transition: all 0.3s ease;
}
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
.detail-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-color-primary);
transition: color 0.3s ease;
.detail-title {
flex: 1;
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-actions {
display: flex;
gap: 12px;
}
}
.detail-body {
@ -215,94 +229,120 @@ function handleDelete() {
width: 320px;
flex-shrink: 0;
:deep(.el-card) {
background-color: var(--card-bg-color);
border-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
.info-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
.header {
color: var(--text-color-primary);
font-weight: 600;
transition: color 0.3s ease;
}
.info-title {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
:deep(.el-form-item__label) {
color: var(--text-color-secondary);
transition: color 0.3s ease;
}
.info-content {
.info-item {
margin-bottom: 20px;
:deep(.el-form-item__content) {
color: var(--text-color-primary);
transition: color 0.3s ease;
}
&:last-child {
margin-bottom: 0;
}
: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;
.info-label {
display: block;
font-size: 13px;
color: var(--el-text-color-regular);
margin-bottom: 8px;
font-weight: 500;
}
.info-value {
font-size: 14px;
color: var(--el-text-color-primary);
word-break: break-word;
}
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}
}
}
.content-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;
}
.content-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
.content-title {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
}
.markdown-body {
font-size: 14px;
font-size: 15px;
line-height: 1.8;
color: var(--text-color-primary);
transition: color 0.3s ease;
color: var(--el-text-color-primary);
min-height: 400px;
// Markdown
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
color: var(--text-color-primary);
margin-top: 16px;
margin-bottom: 8px;
color: var(--el-text-color-primary);
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
}
:deep(h1) { font-size: 28px; }
:deep(h2) { font-size: 24px; }
:deep(h3) { font-size: 20px; }
:deep(p), :deep(li), :deep(span), :deep(div) {
color: var(--text-color-primary);
margin: 8px 0;
color: var(--el-text-color-primary);
margin: 12px 0;
}
:deep(a) {
color: var(--primary-color);
color: #4f84ff;
text-decoration: none;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
}
:deep(code) {
background-color: var(--fill-color-light);
color: var(--text-color-primary);
border-color: var(--border-color-lighter);
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
border: 1px solid var(--el-border-color-lighter);
padding: 2px 6px;
border-radius: 3px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background-color: var(--fill-color-light);
border-color: var(--border-color-lighter);
padding: 12px;
background-color: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
code {
background-color: transparent;
@ -314,21 +354,24 @@ function handleDelete() {
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
margin: 16px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:deep(ul), :deep(ol) {
color: var(--text-color-primary);
margin: 8px 0;
color: var(--el-text-color-primary);
margin: 12px 0;
padding-left: 24px;
}
:deep(blockquote) {
border-left: 4px solid var(--primary-color);
border-left: 4px solid #4f84ff;
padding-left: 16px;
margin: 16px 0;
color: var(--text-color-secondary);
background-color: var(--fill-color-extra-light);
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-lighter);
padding: 12px 16px;
border-radius: 4px;
}
:deep(table) {
@ -337,28 +380,23 @@ function handleDelete() {
margin: 16px 0;
th, td {
border: 1px solid var(--border-color-lighter);
padding: 8px 12px;
color: var(--text-color-primary);
border: 1px solid var(--el-border-color-lighter);
padding: 12px;
color: var(--el-text-color-primary);
text-align: left;
}
th {
background-color: var(--fill-color-light);
background-color: var(--el-fill-color-light);
font-weight: 600;
}
}
}
.actions {
margin-top: 16px;
text-align: center;
}
/* 响应式布局 */
@media (max-width: 768px) {
.detail-body {
flex-direction: column;
padding: 0 16px 16px;
}
.info-panel {
@ -366,11 +404,14 @@ function handleDelete() {
}
.detail-header {
padding: 12px 16px;
}
flex-direction: column;
align-items: flex-start;
gap: 12px;
.detail-header h2 {
font-size: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
}
}
</style>

View File

@ -2,15 +2,18 @@
<div class="knowledge-edit">
<!-- 顶部标题栏 -->
<div class="edit-header">
<el-button type="text" @click="goBack"
><i class="fas fa-arrow-left"></i> 返回</el-button
>
<h2>{{ isEdit ? "编辑知识" : "新建知识" }}</h2>
<el-button type="primary" link @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2 class="edit-title">{{ isEdit ? "编辑知识" : "新建知识" }}</h2>
</div>
<!-- 基本信息 -->
<div class="edit-meta">
<el-card shadow="never">
<div class="meta-card">
<h3 class="meta-title">基本信息</h3>
<el-divider />
<el-form
:model="formData"
:rules="rules"
@ -18,16 +21,16 @@
label-width="80px"
size="default"
>
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="24">
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="分类" prop="category">
<el-form-item label="分类" prop="category">
<el-select
v-model="formData.category"
placeholder="请选择分类"
@ -44,7 +47,7 @@
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="标签" prop="tags">
<el-form-item label="标签" prop="tags">
<el-select
v-model="formData.tags"
multiple
@ -63,12 +66,12 @@
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="作者" prop="author">
<el-form-item label="作者" prop="author">
<el-input v-model="formData.author" placeholder="请输入作者" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="权限" prop="share">
<el-form-item label="权限" prop="share">
<el-radio-group v-model="formData.share">
<el-radio-button :label="0">个人</el-radio-button>
<el-radio-button :label="1">共享</el-radio-button>
@ -77,35 +80,35 @@
</el-col>
</el-row>
</el-form>
<el-divider />
<div class="meta-actions">
<el-button type="primary" @click="handleSubmit"
><i class="fas fa-save"></i> 保存</el-button
>
<el-button type="primary" @click="handleSubmit">
<el-icon><Check /></el-icon>
保存
</el-button>
<el-button @click="goBack">取消</el-button>
</div>
</el-card>
</div>
</div>
<!-- 正文内容区 - 左右结构 -->
<div class="edit-body">
<!-- 左侧编辑器 -->
<div class="editor-panel">
<el-card shadow="never">
<template #header>
<span>编辑正文</span>
</template>
<WangEditor v-model="formData.content" />
</el-card>
<div class="editor-card">
<h3 class="editor-title">编辑正文</h3>
<el-divider />
<div class="editor-content">
<WangEditor v-model="formData.content" />
</div>
</div>
</div>
<!-- 右侧预览 -->
<div class="preview-panel">
<el-card shadow="never">
<template #header>
<span>预览效果</span>
</template>
<div class="preview-card">
<h3 class="preview-title">预览效果</h3>
<el-divider />
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
</div>
</div>
</div>
@ -115,18 +118,18 @@
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { marked } from "marked";
import { ArrowLeft, Check } from '@element-plus/icons-vue'
// @ts-ignore
import { getKnowledgeDetail, createKnowledge, updateKnowledge, getCategoryList, getTagList } from "@/api/knowledge"; // Changed to getKnowledgeDetail
import { getKnowledgeDetail, createKnowledge, updateKnowledge, getCategoryList, getTagList } from "@/api/knowledge";
import { ElMessage, ElMessageBox } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import WangEditor from '@/views/components/WangEditor.vue';
// DOM
const formRef = ref<FormInstance>();
const router = useRouter();
const route = useRoute();
//
const formData = reactive<{
title: string;
category: string;
@ -145,7 +148,6 @@ const formData = reactive<{
share: 0,
});
//
const rules = reactive<FormRules>({
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择分类", trigger: "change" }],
@ -153,7 +155,6 @@ const rules = reactive<FormRules>({
content: [{ required: true, message: "请输入正文", trigger: "blur" }],
});
//
interface CategoryItem {
categoryId: number;
categoryName: string;
@ -161,18 +162,14 @@ interface CategoryItem {
const categoryList = ref<CategoryItem[]>([]);
const tagList = ref<string[]>([]);
// id: 'new'
const id = computed(() => route.params.id || route.query.id);
const isEdit = computed(() => {
const currentId = id.value;
return !!currentId && currentId !== "new" && currentId !== "";
});
// markdown
const compiledMarkdown = computed(() => marked(formData.content || ""));
// TODO: wangEditor
// 使 textarea
watch(
() => formData.content,
() => {
@ -180,7 +177,6 @@ watch(
}
);
//
const getLoginUser = () => {
const userStr = localStorage.getItem("user");
if (userStr) {
@ -194,25 +190,20 @@ const getLoginUser = () => {
return "";
};
//
const fetchDetail = async () => {
try {
const currentId = id.value as string;
if (currentId && currentId !== "new") {
const res = await getKnowledgeDetail(parseInt(currentId as string)); // Changed to getKnowledgeDetail
// API : { code: 0, data: {...}, message: "success" }
const res = await getKnowledgeDetail(parseInt(currentId as string));
const data = res.code === 0 && res.data ? res.data : res.data || res;
//
// categoryName
formData.title = data.title || "";
formData.category = data.categoryName || "";
formData.categoryId = data.categoryId || 0;
formData.author = data.author || "";
formData.content = data.content || "";
formData.share = data.share || 0; // Added share loading
formData.share = data.share || 0;
// categoryId categoryName
if (!formData.categoryId && formData.category) {
const foundCategory = categoryList.value.find(
(item) => item.categoryName === formData.category
@ -222,14 +213,12 @@ const fetchDetail = async () => {
}
}
// Tags JSON
if (data.tags) {
try {
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 : [];
}
} else {
@ -241,7 +230,6 @@ const fetchDetail = async () => {
}
};
//
const loadCategoryAndTag = async () => {
try {
const [catRes, tagRes] = await Promise.all([
@ -249,14 +237,12 @@ const loadCategoryAndTag = async () => {
getTagList(),
]);
// 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)
: [];
@ -267,7 +253,6 @@ const loadCategoryAndTag = async () => {
}
};
//
const handleCategoryChange = (categoryName: string) => {
const selectedCategory = categoryList.value.find(
(item) => item.categoryName === categoryName
@ -277,12 +262,10 @@ const handleCategoryChange = (categoryName: string) => {
}
};
//
const goBack = () => {
router.push("/apps/knowledge");
};
//
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid: boolean) => {
@ -290,14 +273,11 @@ const handleSubmit = () => {
const currentId = id.value as string;
// categoryId
if (!formData.categoryId) {
ElMessage.warning("请选择分类");
return;
}
// JSON tag
// categoryId
const submitData: any = {
title: formData.title || "",
categoryId: Number(formData.categoryId) || 0,
@ -307,21 +287,18 @@ const handleSubmit = () => {
Array.isArray(formData.tags) && formData.tags.length > 0
? JSON.stringify(formData.tags)
: "[]",
status: 1, //
share: formData.share, // Added share in params
status: 1,
share: formData.share,
};
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 {
const res = await updateKnowledge(knowledgeId, submitData);
// API : { code: 0, message: "", data: null }
if (res.code === 0) {
ElMessage.success("保存成功");
goBack();
@ -335,10 +312,8 @@ const handleSubmit = () => {
ElMessage.error(errorMessage);
}
} else {
// id
try {
const res = await createKnowledge(submitData);
// API : { code: 0, message: "", data: { id: ... } }
if (res.code === 0) {
ElMessage.success("创建成功");
goBack();
@ -355,41 +330,17 @@ const handleSubmit = () => {
});
};
//
const handleDelete = async () => {
try {
// Assuming deleteKnowledge is imported or defined elsewhere
// await deleteKnowledge(id.value as string);
ElMessage.success("删除成功");
goBack();
} catch (e: any) {
console.error("删除失败:", e);
}
ElMessageBox.confirm("确认删除该知识?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
await handleDelete();
});
};
//
onMounted(async () => {
//
const author = getLoginUser();
if (author && !isEdit.value) {
formData.author = author;
}
//
await loadCategoryAndTag();
//
if (isEdit.value) {
await fetchDetail();
} else {
//
if (categoryList.value.length > 0 && !formData.category) {
formData.category = categoryList.value[0].categoryName;
formData.categoryId = categoryList.value[0].categoryId;
@ -397,7 +348,6 @@ onMounted(async () => {
}
});
//
defineExpose({
formRef,
});
@ -405,165 +355,123 @@ defineExpose({
<style scoped lang="less">
.knowledge-edit {
background-color: var(--bg-color-page);
min-height: 100%;
padding: 24px;
transition: background-color 0.3s ease;
min-height: 100%;
background-color: var(--el-bg-color-page);
}
.edit-header {
background: var(--el-bg-color);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 20px;
display: flex;
align-items: center;
margin-bottom: 20px;
background-color: var(--card-bg-color);
padding: 16px 24px;
border-radius: 12px;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color-lighter);
transition: all 0.3s ease;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
.edit-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.edit-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
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;
}
.meta-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
}
.meta-title {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
:deep(.el-divider) {
border-color: var(--border-color-lighter);
transition: border-color 0.3s ease;
.meta-actions {
margin-top: 24px;
text-align: right;
}
}
}
.meta-actions {
margin-top: 16px;
text-align: right;
}
/* 正文编辑区 - 左右结构 */
.edit-body {
display: flex;
gap: 20px;
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;
}
}
.editor-panel,
.preview-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%;
.editor-card,
.preview-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
height: 100%;
display: flex;
flex-direction: column;
.editor-title,
.preview-title {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
:deep(.el-card__header) {
color: var(--text-color-primary);
border-bottom-color: var(--border-color-lighter);
transition: all 0.3s ease;
.editor-content {
flex: 1;
min-height: 400px;
}
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color-primary);
min-height: 400px;
padding: 14px 16px;
transition: color 0.3s ease;
: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;
.preview-card {
.markdown-body {
flex: 1;
overflow-y: auto;
font-size: 14px;
line-height: 1.8;
color: var(--el-text-color-primary);
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
overflow-x: auto;
code {
background-color: transparent;
border: none;
padding: 0;
:deep(h1), :deep(h2), :deep(h3) {
margin-top: 16px;
margin-bottom: 8px;
}
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
:deep(p), :deep(li) {
margin: 8px 0;
}
:deep(code) {
background-color: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 3px;
}
:deep(pre) {
background-color: var(--el-fill-color-light);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
}
}
}
@ -578,33 +486,5 @@ defineExpose({
.preview-panel {
width: 100%;
}
.edit-header {
padding: 12px 16px;
}
.edit-header h2 {
font-size: 16px;
}
}
: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>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,175 @@
<template>
<div class="tag-manage">
<div class="page-header">
<h2 class="page-title">标签管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加标签
</el-button>
</div>
<div class="tag-content">
<el-card shadow="never">
<el-table :data="tagList" style="width: 100%" v-loading="loading">
<el-table-column prop="tagId" label="ID" width="80" />
<el-table-column prop="tagName" label="标签名称" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form
:model="formData"
:rules="rules"
ref="formRef"
label-width="100px"
>
<el-form-item label="标签名称" prop="tagName">
<el-input
v-model="formData.tagName"
placeholder="请输入标签名称"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { getTagList, addTag } from '@/api/knowledge'
const loading = ref(false)
const tagList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('添加标签')
const formRef = ref<FormInstance>()
const formData = reactive({
tagId: 0,
tagName: '',
})
const rules = reactive<FormRules>({
tagName: [
{ required: true, message: '请输入标签名称', trigger: 'blur' },
],
})
const fetchList = async () => {
loading.value = true
try {
const res = await getTagList()
const data = res.code === 0 && res.data ? res.data : res.data || []
tagList.value = Array.isArray(data) ? data : []
} catch (e: any) {
ElMessage.error(e.message || '获取标签列表失败')
} finally {
loading.value = false
}
}
const handleAdd = () => {
dialogTitle.value = '添加标签'
formData.tagId = 0
formData.tagName = ''
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑标签'
formData.tagId = row.tagId
formData.tagName = row.tagName
dialogVisible.value = true
}
const handleDelete = (row: any) => {
ElMessageBox.confirm('确认删除该标签?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
try {
// TODO: API
ElMessage.success('删除成功')
fetchList()
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
}
})
}
const handleSubmit = () => {
if (!formRef.value) return
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
try {
await addTag({
tagName: formData.tagName,
})
ElMessage.success('操作成功')
dialogVisible.value = false
fetchList()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
})
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.tag-manage {
padding: 24px;
min-height: 100%;
background-color: var(--el-bg-color-page);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.tag-content {
:deep(.el-card) {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,227 @@
<template>
<el-dialog
title="租户审核"
v-model="visible"
width="700px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="audit-content" v-loading="loading">
<!-- 租户信息展示 -->
<div class="tenant-info-section">
<h3 class="section-title">租户信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
{{ tenantData.id }}
</el-descriptions-item>
<el-descriptions-item label="租户名称">
{{ tenantData.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ tenantData.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ tenantData.owner }}
</el-descriptions-item>
<el-descriptions-item label="联系电话">
{{ tenantData.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ tenantData.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="tenantData.status === 'enabled' ? 'success' : 'info'">
{{ tenantData.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ tenantData.created_at }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ tenantData.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 审核表单 -->
<div class="audit-form-section">
<h3 class="section-title">审核信息</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="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
提交审核
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { auditTenant } from '@/api/tenant';
interface Props {
modelValue: boolean;
tenant: any;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
tenant: () => ({}),
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
success: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const loading = ref(false);
const submitting = ref(false);
const tenantData = ref<any>({});
//
const auditForm = reactive({
result: '',
comment: '',
});
const auditFormRef = ref<FormInstance>();
const auditRules: FormRules = {
result: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
comment: [{ required: true, message: '请输入审核意见', trigger: 'blur' }],
};
//
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';
}
//
async function handleSubmit() {
if (!auditFormRef.value) return;
await auditFormRef.value.validate();
submitting.value = true;
try {
const auditData = {
audit_status: auditForm.result,
audit_comment: auditForm.comment,
audit_by: getCurrentUser(),
};
const res = await auditTenant(tenantData.value.id, auditData);
if (res.success) {
const action = auditForm.result === 'approved' ? '通过' : '拒绝';
ElMessage.success(`审核${action}成功`);
handleClose();
emit('success');
} else {
ElMessage.error(res.message || '审核失败,请重试');
}
} catch (e: any) {
ElMessage.error(e.message || '审核失败,请重试');
} finally {
submitting.value = false;
}
}
const handleClose = () => {
visible.value = false;
auditForm.result = '';
auditForm.comment = '';
auditFormRef.value?.resetFields();
};
// tenant
watch(
() => props.tenant,
(newTenant) => {
if (newTenant && Object.keys(newTenant).length > 0) {
tenantData.value = {
...newTenant,
created_at: newTenant.create_time || newTenant.created_at,
updated_at: newTenant.update_time || newTenant.updated_at,
};
}
},
{ immediate: true, deep: true }
);
</script>
<style lang="less" scoped>
.audit-content {
padding: 16px 0;
}
.tenant-info-section,
.audit-form-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
border-bottom: 2px solid #4f84ff;
padding-bottom: 8px;
}
.audit-form-section {
:deep(.el-form) {
margin-top: 16px;
}
:deep(.el-radio-group) {
display: flex;
gap: 24px;
}
:deep(.el-radio) {
margin-right: 0;
}
}
</style>

View File

@ -0,0 +1,215 @@
<template>
<el-dialog
title="租户详情"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="tenant-detail" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="租户ID">
{{ tenantData.id }}
</el-descriptions-item>
<el-descriptions-item label="租户名称">
{{ tenantData.name }}
</el-descriptions-item>
<el-descriptions-item label="租户编码">
{{ tenantData.code }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ tenantData.owner }}
</el-descriptions-item>
<el-descriptions-item label="联系电话">
{{ tenantData.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ tenantData.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="getAuditStatusType(tenantData.audit_status)">
{{ getAuditStatusText(tenantData.audit_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="tenantData.status === 'enabled' ? 'success' : 'info'">
{{ tenantData.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="存储容量">
<span>{{ formatCapacity(tenantData.capacity) }}</span>
</el-descriptions-item>
<el-descriptions-item label="已使用">
<span>{{ formatCapacity(tenantData.capacity_used || 0) }}</span>
<el-progress
:percentage="getCapacityPercentage(tenantData.capacity, tenantData.capacity_used)"
:color="getCapacityColor(tenantData.capacity, tenantData.capacity_used)"
:stroke-width="6"
style="margin-top: 8px; width: 200px;"
/>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ tenantData.created_at }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ tenantData.updated_at || '未更新' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ tenantData.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { getTenantDetail } from '@/api/tenant';
interface Props {
modelValue: boolean;
tenantId?: number | null;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
tenantId: null,
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const loading = ref(false);
const tenantData = ref<any>({});
//
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] || '未知';
}
// MB
function formatCapacity(mb: number | undefined | null): string {
if (mb === null || mb === undefined) {
return '0.00 MB';
}
const mbValue = Number(mb);
if (isNaN(mbValue) || mbValue < 0) {
return '0.00 MB';
}
// MB 1024 MB MB
if (mbValue < 1024) {
return `${mbValue.toFixed(2)} MB`;
}
// 1024 MB GB
const gb = mbValue / 1024;
return `${gb.toFixed(2)} GB`;
}
// 使
function getCapacityPercentage(capacity: number | undefined, used: number | undefined): number {
if (!capacity || capacity === 0) return 0;
const usedValue = used || 0;
return Math.min(Math.round((usedValue / capacity) * 100), 100);
}
//
function getCapacityColor(capacity: number | undefined, used: number | undefined): string {
const percentage = getCapacityPercentage(capacity, used);
if (percentage >= 90) return '#f56c6c'; //
if (percentage >= 70) return '#e6a23c'; //
return '#67c23a'; // 绿
}
//
async function fetchDetail() {
if (!props.tenantId) return;
loading.value = true;
try {
const res = await getTenantDetail(props.tenantId);
if (res.success && res.data) {
const tenant = res.data;
tenantData.value = {
...tenant,
created_at: tenant.create_time || tenant.created_at,
updated_at: tenant.update_time || tenant.updated_at,
};
} else {
ElMessage.error(res.message || '获取租户详情失败');
}
} catch (err: any) {
ElMessage.error(err.message || '获取租户详情失败');
} finally {
loading.value = false;
}
}
const handleClose = () => {
visible.value = false;
tenantData.value = {};
};
// tenantId
watch(
() => props.modelValue,
(newVal) => {
if (newVal && props.tenantId) {
fetchDetail();
}
},
{ immediate: true }
);
watch(
() => props.tenantId,
() => {
if (visible.value && props.tenantId) {
fetchDetail();
}
}
);
</script>
<style lang="less" scoped>
.tenant-detail {
padding: 16px 0;
:deep(.el-descriptions) {
margin-top: 0;
}
:deep(.el-descriptions-item__label) {
font-weight: 600;
color: var(--el-text-color-regular);
}
:deep(.el-descriptions-item__content) {
color: var(--el-text-color-primary);
}
}
</style>

View File

@ -0,0 +1,236 @@
<template>
<el-dialog
:title="isEditing ? '编辑租户' : '添加租户'"
v-model="visible"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="tenantForm"
:rules="formRules"
ref="tenantFormRef"
label-width="90px"
>
<el-form-item label="租户名称" prop="name">
<el-input v-model="tenantForm.name" placeholder="请输入租户名称" />
</el-form-item>
<el-form-item label="租户编码" prop="code">
<el-input v-model="tenantForm.code" placeholder="请输入租户编码" />
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="tenantForm.owner" placeholder="请输入负责人" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="tenantForm.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="tenantForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="tenantForm.status" placeholder="请选择状态" style="width: 100%">
<el-option label="启用" value="enabled" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="存储容量" prop="capacity">
<el-input-number
v-model="tenantForm.capacity"
:min="0"
:precision="2"
:step="100"
style="width: 100%"
placeholder="请输入存储容量MB"
/>
<div class="form-tip">单位MB1GB = 1024MB</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="tenantForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { createTenant, updateTenant } from '@/api/tenant';
interface Props {
modelValue: boolean;
tenant?: any;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
tenant: null,
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
success: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const submitting = ref(false);
const tenantFormRef = ref<FormInstance>();
//
const isEditing = computed(() => {
return !!(props.tenant && props.tenant.id);
});
//
const tenantForm = reactive({
id: null as number | null,
name: '',
code: '',
owner: '',
phone: '',
email: '',
status: 'enabled',
capacity: 0,
remark: '',
});
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' }],
capacity: [
{ required: true, message: '请输入存储容量', trigger: 'blur' },
{ type: 'number', min: 0, message: '存储容量不能小于0', trigger: 'blur' },
],
};
//
function resetForm() {
tenantForm.id = null;
tenantForm.name = '';
tenantForm.code = '';
tenantForm.owner = '';
tenantForm.phone = '';
tenantForm.email = '';
tenantForm.status = 'enabled';
tenantForm.capacity = 0;
tenantForm.remark = '';
tenantFormRef.value?.resetFields();
}
//
function initFormData() {
if (props.tenant && props.tenant.id) {
tenantForm.id = props.tenant.id;
tenantForm.name = props.tenant.name || '';
tenantForm.code = props.tenant.code || '';
tenantForm.owner = props.tenant.owner || '';
tenantForm.phone = props.tenant.phone || '';
tenantForm.email = props.tenant.email || '';
tenantForm.status = props.tenant.status || 'enabled';
// capacity MB使
tenantForm.capacity = props.tenant.capacity != null ? Number(props.tenant.capacity) : 0;
tenantForm.remark = props.tenant.remark || '';
} else {
resetForm();
}
}
//
async function handleSubmit() {
if (!tenantFormRef.value) return;
await tenantFormRef.value.validate();
submitting.value = true;
try {
const tenantData = {
name: tenantForm.name,
code: tenantForm.code,
owner: tenantForm.owner,
phone: tenantForm.phone || '',
email: tenantForm.email || '',
status: tenantForm.status,
// capacity 使 MB
capacity: tenantForm.capacity || 0,
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('租户添加成功');
}
handleClose();
emit('success');
} else {
ElMessage.error(res.message || '操作失败,请重试');
}
} catch (err: any) {
ElMessage.error(err.message || '操作失败,请重试');
} finally {
submitting.value = false;
}
}
const handleClose = () => {
visible.value = false;
resetForm();
};
// tenant
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initFormData();
}
},
{ immediate: true }
);
watch(
() => props.tenant,
() => {
if (visible.value) {
initFormData();
}
},
{ deep: true }
);
</script>
<style lang="less" scoped>
.form-tip {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 4px;
}
</style>

View File

@ -1,96 +1,117 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户管理</h2>
<div class="tenant-management">
<div class="page-header">
<h2 class="page-title">租户管理</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-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加租户
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-divider />
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载租户数据...</p>
<div v-if="loading && tenants.length === 0" class="loading-state">
<el-skeleton :rows="5" animated />
</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>
<el-button type="primary" @click="fetchTenants" style="margin-top: 16px">
重试
</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-card shadow="never">
<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 label="存储容量" min-width="240">
<template #default="{ row }">
<div class="capacity-cell">
<div class="capacity-info">
<span>{{ formatCapacity(row.capacity_used ?? 0) }}</span>
<span class="capacity-divider">/</span>
<span>{{ formatCapacity(row.capacity ?? 0) }}</span>
</div>
<el-progress
:percentage="getCapacityPercentage(row.capacity, row.capacity_used)"
:color="getCapacityColor(row.capacity, row.capacity_used)"
:stroke-width="4"
style="margin-top: 4px;"
/>
</div>
</template>
</el-table-column>
<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" fixed="right">
<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="handleAudit(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>
</el-card>
<div class="pagination-wrapper">
<el-pagination
background
:current-page="page"
@ -102,258 +123,74 @@
</div>
</div>
<!-- 租户详情查看弹窗 -->
<el-dialog
title="租户详情"
<!-- 详情组件 -->
<TenantDetail
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>
:tenant-id="currentTenant?.id"
/>
<!-- 审核弹窗 -->
<el-dialog
title="租户审核"
<!-- 审核组件 -->
<TenantAudit
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>
:tenant="currentTenant"
@success="handleAuditSuccess"
/>
<!-- 审核表单 -->
<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>
<!-- 编辑组件 -->
<TenantEdit
v-model="showEditDialog"
:tenant="currentTenant"
@success="handleEditSuccess"
/>
</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";
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Check, View, Edit, Delete, Refresh } from '@element-plus/icons-vue';
import { getAllTenants, deleteTenant } from '@/api/tenant';
import TenantDetail from './components/detail.vue';
import TenantAudit from './components/audit.vue';
import TenantEdit from './components/edit.vue';
const tenants = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
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" }]
};
const showEditDialog = ref(false);
const currentTenant = ref<any>(null);
//
async function fetchTenants() {
loading.value = true;
error.value = "";
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,
// capacity capacity_used
capacity: tenant.capacity != null ? Number(tenant.capacity) : 0,
capacity_used: tenant.capacity_used != null ? Number(tenant.capacity_used) : 0,
}));
total.value = tenants.value.length;
} else {
error.value = res.message || "获取租户列表失败";
error.value = res.message || '获取租户列表失败';
tenants.value = [];
total.value = 0;
}
} catch (err: any) {
error.value = err.message || "获取租户列表失败";
error.value = err.message || '获取租户列表失败';
tenants.value = [];
total.value = 0;
} finally {
@ -367,12 +204,15 @@ const handlePageChange = (val: number) => {
};
//
function getAuditStatusType(status: string): 'warning' | 'success' | 'danger' | 'info' {
const statusMap: Record<string, 'warning' | 'success' | 'danger' | 'info'> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
};
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';
}
@ -380,127 +220,100 @@ function getAuditStatusText(status: string) {
const statusMap: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝'
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;
// MB
function formatCapacity(mb: number | string | undefined | null): string {
// null/undefined/
if (mb === null || mb === undefined || mb === '') {
return '0.00 MB';
}
//
const mbValue = typeof mb === 'string' ? parseFloat(mb) : Number(mb);
//
if (isNaN(mbValue) || mbValue < 0) {
return '0.00 MB';
}
// MB 1024 MB MB
if (mbValue < 1024) {
return `${mbValue.toFixed(2)} MB`;
}
// 1024 MB GB
const gb = mbValue / 1024;
return `${gb.toFixed(2)} GB`;
}
// 使
function getCapacityPercentage(capacity: number | undefined, used: number | undefined): number {
if (!capacity || capacity === 0) return 0;
const usedValue = used || 0;
return Math.min(Math.round((usedValue / capacity) * 100), 100);
}
//
function getCapacityColor(capacity: number | undefined, used: number | undefined): string {
const percentage = getCapacityPercentage(capacity, used);
if (percentage >= 90) return '#f56c6c'; //
if (percentage >= 70) return '#e6a23c'; //
return '#67c23a'; // 绿
}
//
function handleView(row: any) {
currentTenant.value = row;
showViewDialog.value = true;
}
//
function handleAuditDialog(row: any) {
function handleAudit(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 handleAuditSuccess() {
fetchTenants();
}
//
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 handleAdd() {
currentTenant.value = null;
showEditDialog.value = true;
}
//
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;
currentTenant.value = { ...row };
showEditDialog.value = true;
}
//
function handleEditSuccess() {
fetchTenants();
}
//
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除租户「${row.name}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
'警告',
{ type: 'warning' }
);
loading.value = true;
try {
const res = await deleteTenant(row.id);
if (res.success) {
ElMessage.success("删除成功");
ElMessage.success('删除成功');
await fetchTenants();
} else {
ElMessage.error(res.message || "删除失败");
ElMessage.error(res.message || '删除失败');
}
} catch (err: any) {
ElMessage.error(err.message || "删除失败");
ElMessage.error(err.message || '删除失败');
} finally {
loading.value = false;
}
@ -509,138 +322,71 @@ async function handleDelete(row: any) {
}
}
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);
.tenant-management {
padding: 24px;
min-height: 100%;
background-color: var(--el-bg-color-page);
}
.header-actions {
.page-header {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
}
.loading-state, .error-state {
.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;
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
@keyframes spin {
100% {
transform: rotate(360deg);
:deep(.el-card) {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.capacity-cell {
.capacity-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--el-text-color-primary);
.capacity-divider {
color: var(--el-text-color-placeholder);
margin: 0 2px;
}
}
}
.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>

View File

@ -14,11 +14,13 @@ type Tenant struct {
Owner string `orm:"size(50)" json:"owner"`
Phone string `orm:"size(20);null" json:"phone"`
Email string `orm:"size(100);null" json:"email"`
Status string `orm:"size(20);default(enabled)" json:"status"` // enabled, disabled
AuditStatus string `orm:"size(20);default(pending)" json:"audit_status"` // pending, approved, rejected
Status string `orm:"size(20);default(enabled)" json:"status"`
AuditStatus string `orm:"size(20);default(pending)" json:"audit_status"`
AuditComment string `orm:"type(text);null" json:"audit_comment"`
AuditBy string `orm:"size(50);null" json:"audit_by"`
AuditTime *time.Time `orm:"null;type(datetime)" json:"audit_time"`
Capacity int `orm:"default(0)" json:"capacity"`
CapacityUsed int `orm:"default(0)" json:"capacity_used"`
Remark string `orm:"type(text);null" json:"remark"`
CreateTime time.Time `orm:"auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"auto_now;type(datetime)" json:"update_time"`
@ -69,7 +71,7 @@ func UpdateTenant(id int, data map[string]interface{}) error {
return err
}
// DeleteTenant 软删除租户(设置 delete_time
// DeleteTenant 软删除租户
func DeleteTenant(id int) error {
o := orm.NewOrm()
deleteTime := time.Now()
@ -77,7 +79,7 @@ func DeleteTenant(id int) error {
return err
}
// AuditTenant 审核租户(只能审核未删除的)
// AuditTenant 审核租户
func AuditTenant(id int, auditStatus, auditComment, auditBy string) error {
o := orm.NewOrm()
tenant := Tenant{}
@ -99,7 +101,7 @@ func AuditTenant(id int, auditStatus, auditComment, auditBy string) error {
return err
}
// GetTenantById 根据ID获取租户详情(只返回未删除的)
// GetTenantById 根据ID获取租户详情
func GetTenantById(id int) (*Tenant, error) {
o := orm.NewOrm()
tenant := Tenant{}
@ -111,7 +113,7 @@ func GetTenantById(id int) (*Tenant, error) {
return &tenant, err
}
// GetTenantByName 根据名称获取租户详情(只返回未删除的)
// GetTenantByName 根据名称获取租户详情
func GetTenantByName(name string) (*Tenant, error) {
o := orm.NewOrm()
tenant := Tenant{}