forked from tools/tape-springboot-sysadmin
init
This commit is contained in:
387
src/views/Login.vue
Normal file
387
src/views/Login.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 背景动画效果 -->
|
||||
<div class="background-animation">
|
||||
<div class="shape shape1"></div>
|
||||
<div class="shape shape2"></div>
|
||||
<div class="shape shape3"></div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo-container">
|
||||
<img src="@/assets/logo.svg" alt="Logo" class="logo" />
|
||||
</div>
|
||||
<h1 class="login-title">{{ config.APP_TITLE }}</h1>
|
||||
</div>
|
||||
<p class="login-subtitle">
|
||||
{{ config.APP_SUB_TITLE }}
|
||||
</p>
|
||||
<a-form :model="formState" name="login" @finish="onFinish" class="login-form">
|
||||
<a-form-item
|
||||
name="username"
|
||||
:rules="[{ required: true, message: '请输入用户名!' }]"
|
||||
>
|
||||
<a-input v-model:value="formState.username" placeholder="用户名" size="large">
|
||||
<template #prefix>
|
||||
<user-outlined class="input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
name="password"
|
||||
:rules="[{ required: true, message: '请输入密码!' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
type="password"
|
||||
>
|
||||
<template #prefix>
|
||||
<lock-outlined class="input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
class="login-button"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<div>
|
||||
<span>{{ config.BUILD_VERSION }}</span>
|
||||
<span>{{ config.APP_VERSION_NAME }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>© {{ new Date().getFullYear() }}</span>
|
||||
<span>{{ config.APP_COPYRIGHT }}</span>
|
||||
<span>版权所有</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
|
||||
import { useUserStore } from "@/store/user";
|
||||
import config from "@/config/index";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
formState.username = userStore.lastUsername || "";
|
||||
});
|
||||
|
||||
// 提交登录表单
|
||||
const onFinish = async (values) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await doLogin(values.username, values.password);
|
||||
} catch (error) {
|
||||
showErrorTips(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 执行登录操作
|
||||
const doLogin = async (username, password) => {
|
||||
const result = await userStore.login(username, password);
|
||||
if (result.success) {
|
||||
showSuccessTips("登录成功");
|
||||
const redirect = route.query.redirect;
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} else {
|
||||
let error = new Error(result.message || "登录失败,请重试");
|
||||
showErrorTips(error);
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景动画效果 */
|
||||
.background-animation {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: float 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shape1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -300px;
|
||||
right: -200px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: -200px;
|
||||
left: -100px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.shape3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: 30%;
|
||||
right: 10%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(5deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item-explain-error) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item-explain) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.terms-form-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.terms-form-item :deep(.ant-form-item-control-input) {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.terms-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.terms-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #636366;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.terms-link {
|
||||
margin: 0 2px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: #1890ff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terms-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 应用选择对话框 */
|
||||
.app-select-modal :deep(.ant-modal-content) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-select-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.app-select-tip {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-select-button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media screen and (max-width: 576px) {
|
||||
.login-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
padding: 40px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.background-animation {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shape1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.shape2,
|
||||
.shape3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
441
src/views/PageDashboard.vue
Normal file
441
src/views/PageDashboard.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<a-layout class="layout" :style="menuStyle">
|
||||
<!-- 移动设备上的遮罩层 -->
|
||||
<div v-if="isMobile && siderVisible" class="mobile-mask" @click="toggleSider"></div>
|
||||
<!-- 侧边栏 -->
|
||||
<a-layout-sider class="sider hide-scrollbar" @collapse="handleCollapse" :collapsed="collapsed"
|
||||
:class="{ 'mobile-sider': isMobile, 'mobile-sider-visible': siderVisible }">
|
||||
<div class="logo" v-if="!collapsed">
|
||||
<svg t="1779016191270" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21892"
|
||||
width="256" height="256" class="custom-svg">
|
||||
<path
|
||||
d="M886.97857168 763.55H587.02142833v112.69285753h74.95714334a37.54285751 37.54285751 0 0 1 0 75.08571415H362.02142833a37.54285751 37.54285751 0 0 1-1e-8-75.08571415h74.95714336v-112.69285753H137.02142832A75.02142833 75.02142833 0 0 1 62 688.46428583V162.8C62 121.33571417 95.55714247 87.71428583 137.02142832 87.71428583h749.95714336C928.44285753 87.71428583 962 121.33571417 962 162.8v525.66428585c0 41.46428585-33.55714249 75.08571415-75.02142833 75.08571415zM137.02142832 650.92142832h749.95714336V575.83571417H137.02142832v75.08571415z"
|
||||
p-id="21893" fill="#ffffff"></path>
|
||||
</svg>
|
||||
<div class="logo-text-container">
|
||||
<span class="logo-text">
|
||||
{{ config.APP_TITLE }}
|
||||
</span>
|
||||
<div class="logo-subtitle">
|
||||
{{ config.APP_SUB_TITLE }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logo" v-else>
|
||||
<svg t="1779016191270" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21892"
|
||||
width="256" height="256" class="custom-svg">
|
||||
<path
|
||||
d="M886.97857168 763.55H587.02142833v112.69285753h74.95714334a37.54285751 37.54285751 0 0 1 0 75.08571415H362.02142833a37.54285751 37.54285751 0 0 1-1e-8-75.08571415h74.95714336v-112.69285753H137.02142832A75.02142833 75.02142833 0 0 1 62 688.46428583V162.8C62 121.33571417 95.55714247 87.71428583 137.02142832 87.71428583h749.95714336C928.44285753 87.71428583 962 121.33571417 962 162.8v525.66428585c0 41.46428585-33.55714249 75.08571415-75.02142833 75.08571415zM137.02142832 650.92142832h749.95714336V575.83571417H137.02142832v75.08571415z"
|
||||
p-id="21893" fill="#ffffff"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<IViewMenu v-model:selectedKeys="selectedKeys" :collapsed="collapsed" :menuConfig="menuConfig"
|
||||
:bgColor="themeInfo.bgColor" :textColor="themeInfo.textColor" :selectedTextColor="themeInfo.selectedTextColor"
|
||||
:selectedBgColor="themeInfo.selectedBgColor" :hoverTextColor="themeInfo.hoverTextColor"
|
||||
:hoverBgColor="themeInfo.hoverBgColor" @item-click="handleMenuItemClick" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout class="layout-content" :class="{ collapsed: collapsed && !isMobile, mobile: isMobile }">
|
||||
<a-layout-header class="header" :class="{ 'mobile-header': isMobile }">
|
||||
<div class="header-left">
|
||||
<div class="header-left-icon">
|
||||
<menu-unfold-outlined v-if="isMobile ? !siderVisible : collapsed" class="trigger" @click="toggleSider" />
|
||||
<menu-fold-outlined v-else class="trigger" @click="toggleSider" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a class="user-dropdown">
|
||||
<span class="username" :title="unitName">{{ unitName }}</span>
|
||||
<down-outlined />
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="1" @click="handleLogout">
|
||||
<logout-outlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content hide-scrollbar">
|
||||
<router-view></router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
<IViewUrlEmbedHost />
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted, onBeforeMount, onUnmounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useUserStore } from "../store/user";
|
||||
import { showSuccessTips } from "@/iview/utils/messageTips";
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { menuConfig, getMenuKeyByPath } from "../config/menu";
|
||||
import config from "@/config/index";
|
||||
import IViewMenu from "@/iview/IViewMenu.vue";
|
||||
import IViewUrlEmbedHost from "@/iview/IViewUrlEmbedHost.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const themeInfo = ref({
|
||||
siderColor: "#1677ff",
|
||||
logoColor: "#FFFFFF",
|
||||
textColor: "#FFFFFF",
|
||||
selectedTextColor: "#ffffff",
|
||||
selectedBgColor: "#0b68ff",
|
||||
hoverTextColor: "#ffffff",
|
||||
hoverBgColor: "#0b68ff",
|
||||
});
|
||||
|
||||
// 侧边栏折叠状态(桌面端使用)
|
||||
const collapsed = ref(true); // 默认折叠
|
||||
// 是否是移动设备
|
||||
const isMobile = ref(false);
|
||||
// 侧边栏是否显示(移动端使用)
|
||||
const siderVisible = ref(false);
|
||||
|
||||
// 监听窗口大小变化
|
||||
const checkIsMobile = () => {
|
||||
const newIsMobile = window.innerWidth <= 768;
|
||||
// 如果状态变了才更新,避免不必要的渲染
|
||||
if (newIsMobile !== isMobile.value) {
|
||||
isMobile.value = newIsMobile;
|
||||
// 如果变成移动设备,确保侧边栏隐藏
|
||||
if (isMobile.value) {
|
||||
siderVisible.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 在组件挂载前初始化移动设备检测
|
||||
onBeforeMount(() => {
|
||||
checkIsMobile();
|
||||
window.addEventListener("resize", checkIsMobile);
|
||||
});
|
||||
|
||||
// 组件卸载时移除事件监听
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", checkIsMobile);
|
||||
});
|
||||
|
||||
// 当前选中的菜单项
|
||||
const selectedKeys = ref([]);
|
||||
|
||||
// 侧边栏折叠状态
|
||||
const handleCollapse = (collapsed) => {
|
||||
collapsed.value = collapsed;
|
||||
userStore.saveCollapseSidebar(collapsed);
|
||||
};
|
||||
|
||||
// 计算菜单样式
|
||||
const menuStyle = computed(() => {
|
||||
return {
|
||||
"--theme-sider-color": themeInfo.value.siderColor,
|
||||
"--theme-logo-color": themeInfo.value.logoColor,
|
||||
border: "none",
|
||||
};
|
||||
});
|
||||
|
||||
// 根据当前路由路径设置选中菜单项和展开状态
|
||||
const setSelectedMenu = () => {
|
||||
const path = route.path;
|
||||
const menuKey = getMenuKeyByPath(path);
|
||||
|
||||
// IViewMenu 只支持一级菜单,直接使用 menuKey
|
||||
selectedKeys.value = [menuKey];
|
||||
};
|
||||
|
||||
// 初始化菜单选中状态
|
||||
onMounted(() => {
|
||||
setSelectedMenu();
|
||||
// 重新获取用户信息
|
||||
if (userStore.isLoggedIn) {
|
||||
userStore.getUserProfile();
|
||||
}
|
||||
collapsed.value = userStore.isCollapseSidebar;
|
||||
});
|
||||
|
||||
// 监听路由变化,更新选中的菜单项
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
setSelectedMenu();
|
||||
// 在移动设备上,路由变化时隐藏侧边栏
|
||||
if (isMobile.value) {
|
||||
siderVisible.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 用户信息
|
||||
const unitName = computed(() => {
|
||||
const info = userStore.userInfo;
|
||||
if (!info) return "";
|
||||
return [info?.name, info?.username].filter(Boolean).join(" ");
|
||||
});
|
||||
|
||||
// 切换侧边栏状态
|
||||
const toggleSider = () => {
|
||||
if (isMobile.value) {
|
||||
// 在移动设备上,切换侧边栏显示/隐藏
|
||||
siderVisible.value = !siderVisible.value;
|
||||
} else {
|
||||
// 在桌面设备上,切换侧边栏折叠/展开
|
||||
collapsed.value = !collapsed.value;
|
||||
userStore.saveCollapseSidebar(collapsed.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
userStore.logout();
|
||||
router.push("/login");
|
||||
showSuccessTips("已退出登录");
|
||||
};
|
||||
|
||||
// 菜单点击处理,在移动端时点击菜单项后自动隐藏侧边栏
|
||||
const handleMenuClick = (path) => {
|
||||
router.push(path);
|
||||
if (isMobile.value) {
|
||||
siderVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单项点击(IViewMenu 组件使用)
|
||||
const handleMenuItemClick = (item) => {
|
||||
if (item.path) {
|
||||
handleMenuClick(item.path);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sider {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: var(--theme-sider-color, #001529);
|
||||
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
|
||||
transition: width 0.3s, transform 0.3s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mobile-sider {
|
||||
transform: translateX(-100%);
|
||||
/* 默认隐藏 */
|
||||
}
|
||||
|
||||
.mobile-sider-visible {
|
||||
transform: translateX(0);
|
||||
/* 显示 */
|
||||
}
|
||||
|
||||
.mobile-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s;
|
||||
font-size: 12px;
|
||||
color: var(--theme-logo-color, #ffffff);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.custom-svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
position: relative;
|
||||
margin-left: 200px;
|
||||
width: calc(100% - 200px);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-content.mobile {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 200px;
|
||||
z-index: 8;
|
||||
height: 64px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
width: calc(100% - 200px);
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
padding: 0 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-left-icon {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-right: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
margin-top: 64px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-view {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 当侧边栏折叠时调整布局 */
|
||||
.layout-content.collapsed {
|
||||
margin-left: 80px;
|
||||
width: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.layout-content.collapsed .header {
|
||||
left: 80px;
|
||||
width: calc(100% - 80px);
|
||||
}
|
||||
|
||||
/* 移动端适配样式 */
|
||||
@media (max-width: 768px) {
|
||||
.layout-content {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
src/views/PageNotFound.vue
Normal file
32
src/views/PageNotFound.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<a-result
|
||||
status="404"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="goHome">返回首页</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
491
src/views/business/IndexDemo.vue
Normal file
491
src/views/business/IndexDemo.vue
Normal file
@@ -0,0 +1,491 @@
|
||||
<template>
|
||||
<div class="demo-page">
|
||||
<div class="demo-intro">
|
||||
<p>本页演示项目中 <code>@/iview</code> 封装组件的常见用法,数据均为本地 Mock,无需后端接口。</p>
|
||||
</div>
|
||||
|
||||
<IViewTabs v-model:activeKey="activeSection" :animated="false" embedded>
|
||||
<IViewTabPane key="form" tab="表单 IViewForm">
|
||||
<div v-if="activeSection === 'form'" class="demo-section">
|
||||
<IViewForm
|
||||
ref="formRef"
|
||||
v-model="formData"
|
||||
:config="formConfig"
|
||||
:read-only="formReadOnly"
|
||||
/>
|
||||
<div class="demo-actions">
|
||||
<a-button type="primary" @click="handleFormSubmit">提交校验</a-button>
|
||||
<a-button @click="handleFormReset">重置</a-button>
|
||||
<a-button @click="formReadOnly = !formReadOnly">
|
||||
{{ formReadOnly ? "切换为编辑" : "切换为只读" }}
|
||||
</a-button>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="formResult"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="'表单数据:' + formResult"
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
|
||||
<IViewTabPane key="list" tab="列表 IViewTable">
|
||||
<div v-if="activeSection === 'list'" class="demo-section">
|
||||
<IViewFilter
|
||||
v-model="filterParams"
|
||||
:config="filterConfig"
|
||||
@search="handleFilterSearch"
|
||||
@reset="handleFilterReset"
|
||||
/>
|
||||
<div class="demo-list-header">
|
||||
<span class="demo-list-title">订单列表示例(共 {{ filteredList.length }} 条)</span>
|
||||
<a-button type="primary" @click="handleAddRecord">
|
||||
<template #icon><plus-outlined /></template>
|
||||
新增
|
||||
</a-button>
|
||||
</div>
|
||||
<IViewTable
|
||||
:loading="listLoading"
|
||||
:columns="listColumns"
|
||||
:data-source="filteredList"
|
||||
row-key="id"
|
||||
:pagination="tablePagination"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button size="small" type="link" @click="handleViewRecord(record)">查看</a-button>
|
||||
<IViewConfirm
|
||||
importance="normal"
|
||||
title="确认删除该记录吗?"
|
||||
@confirm="() => handleDeleteRecord(record)"
|
||||
>
|
||||
<a-button size="small" type="link" danger>删除</a-button>
|
||||
</IViewConfirm>
|
||||
</template>
|
||||
</template>
|
||||
</IViewTable>
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
|
||||
<IViewTabPane key="detail" tab="详情 IViewDescriptions">
|
||||
<div v-if="activeSection === 'detail'" class="demo-section">
|
||||
<IViewDescriptions
|
||||
:record-data="detailRecord"
|
||||
:columns-list="detailColumns"
|
||||
:column-size="2"
|
||||
:hidden-label="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</IViewDescriptions>
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
|
||||
<IViewTabPane key="layout" tab="页面 ILayoutPage">
|
||||
<div v-if="activeSection === 'layout'" class="demo-section">
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="ILayoutPage 需配置真实 API,完整示例请参考「系统设置 → 管理员账号」页面。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<pre class="demo-code">{{ layoutPageExample }}</pre>
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
</IViewTabs>
|
||||
|
||||
<IViewDrawer :open="drawerOpen" title="订单详情" @close="drawerOpen = false">
|
||||
<IViewDescriptions
|
||||
v-if="currentRecord"
|
||||
:record-data="currentRecord"
|
||||
:columns-list="detailColumns"
|
||||
:column-size="1"
|
||||
:hidden-label="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</IViewDescriptions>
|
||||
</IViewDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { PlusOutlined } from "@ant-design/icons-vue";
|
||||
import IViewTabs from "@/iview/IViewTabs.vue";
|
||||
import IViewTabPane from "@/iview/IViewTabPane.vue";
|
||||
import IViewForm from "@/iview/IViewForm.vue";
|
||||
import IViewFilter from "@/iview/IViewFilter.vue";
|
||||
import IViewTable from "@/iview/IViewTable.vue";
|
||||
import IViewDescriptions from "@/iview/IViewDescriptions.vue";
|
||||
import IViewConfirm from "@/iview/IViewConfirm.vue";
|
||||
import IViewDrawer from "@/iview/IViewDrawer.vue";
|
||||
import { showSuccessTips, showErrorTips } from "@/iview/utils/messageTips";
|
||||
|
||||
const activeSection = ref("form");
|
||||
|
||||
// ── 表单示例 ──────────────────────────────────────────
|
||||
const formRef = ref(null);
|
||||
const formReadOnly = ref(false);
|
||||
const formResult = ref("");
|
||||
const formData = ref({
|
||||
name: "张三",
|
||||
age: 28,
|
||||
gender: "male",
|
||||
tags: ["vip"],
|
||||
city: "guangzhou",
|
||||
birthday: "1998-05-20",
|
||||
enabled: 1,
|
||||
remark: "这是一段备注说明",
|
||||
});
|
||||
|
||||
const formConfig = ref({
|
||||
fields: [
|
||||
{ type: "title", label: "基础信息" },
|
||||
{
|
||||
name: "name",
|
||||
label: "姓名",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxlength: 20,
|
||||
placeholder: "请输入姓名",
|
||||
tips: "2~20 个字符",
|
||||
},
|
||||
{
|
||||
name: "age",
|
||||
label: "年龄",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 120,
|
||||
},
|
||||
{
|
||||
name: "gender",
|
||||
label: "性别",
|
||||
type: "radio",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "male", label: "男" },
|
||||
{ value: "female", label: "女" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
label: "标签",
|
||||
type: "checkbox",
|
||||
options: [
|
||||
{ value: "vip", label: "VIP" },
|
||||
{ value: "new", label: "新用户" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "city",
|
||||
label: "城市",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "guangzhou", label: "广州" },
|
||||
{ value: "shenzhen", label: "深圳" },
|
||||
{ value: "beijing", label: "北京" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "birthday",
|
||||
label: "生日",
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
label: "启用状态",
|
||||
type: "boolean",
|
||||
trueValue: 1,
|
||||
falseValue: 0,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: "remark",
|
||||
label: "备注",
|
||||
type: "textarea",
|
||||
maxlength: 200,
|
||||
textRows: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
formResult.value = JSON.stringify(formData.value);
|
||||
showSuccessTips("表单校验通过");
|
||||
} catch (error) {
|
||||
showErrorTips(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormReset = () => {
|
||||
formRef.value?.resetFormData?.();
|
||||
formResult.value = "";
|
||||
};
|
||||
|
||||
// ── 列表示例 ──────────────────────────────────────────
|
||||
const listLoading = ref(false);
|
||||
const filterParams = ref({});
|
||||
const drawerOpen = ref(false);
|
||||
const currentRecord = ref(null);
|
||||
|
||||
const mockRecords = ref([
|
||||
{ id: 1, orderNo: "ORD-20250605001", customer: "广州物流有限公司", amount: 1280.5, status: 1, createTime: "2025-06-05 09:30:00" },
|
||||
{ id: 2, orderNo: "ORD-20250605002", customer: "深圳科技股份", amount: 5600, status: 2, createTime: "2025-06-05 10:15:00" },
|
||||
{ id: 3, orderNo: "ORD-20250605003", customer: "北京贸易集团", amount: 890, status: 0, createTime: "2025-06-05 11:00:00" },
|
||||
{ id: 4, orderNo: "ORD-20250604004", customer: "上海仓储中心", amount: 3200, status: 3, createTime: "2025-06-04 16:20:00" },
|
||||
{ id: 5, orderNo: "ORD-20250604005", customer: "杭州电商公司", amount: 450, status: 1, createTime: "2025-06-04 18:45:00" },
|
||||
]);
|
||||
|
||||
const filterConfig = ref({
|
||||
fields: [
|
||||
{
|
||||
name: "keyword",
|
||||
label: "关键词",
|
||||
type: "text",
|
||||
placeholder: "订单号 / 客户名称",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: "状态",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: 0, label: "待处理" },
|
||||
{ value: 1, label: "进行中" },
|
||||
{ value: 2, label: "已完成" },
|
||||
{ value: 3, label: "已取消" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "createTime",
|
||||
label: "创建日期",
|
||||
type: "date",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const listColumns = ref([
|
||||
{ title: "订单号", dataIndex: "orderNo", width: 160 },
|
||||
{ title: "客户", dataIndex: "customer", ellipsis: true },
|
||||
{ title: "金额", dataIndex: "amount", width: 100, align: "right" },
|
||||
{ title: "状态", dataIndex: "status", width: 90, bodySlot: true },
|
||||
{ title: "创建时间", dataIndex: "createTime", width: 170 },
|
||||
{ title: "操作", dataIndex: "action", width: 130, bodySlot: true, fixed: "right" },
|
||||
]);
|
||||
|
||||
const filteredList = computed(() => {
|
||||
let rows = [...mockRecords.value];
|
||||
const { keyword, status } = filterParams.value;
|
||||
if (keyword) {
|
||||
const kw = String(keyword).trim().toLowerCase();
|
||||
rows = rows.filter(
|
||||
(r) =>
|
||||
r.orderNo.toLowerCase().includes(kw) ||
|
||||
r.customer.toLowerCase().includes(kw)
|
||||
);
|
||||
}
|
||||
if (status !== undefined && status !== null && status !== "") {
|
||||
rows = rows.filter((r) => r.status === Number(status));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
current: paginationState.value.current,
|
||||
pageSize: paginationState.value.pageSize,
|
||||
total: filteredList.value.length,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}));
|
||||
|
||||
const paginationState = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const statusLabel = (status) => {
|
||||
const map = { 0: "待处理", 1: "进行中", 2: "已完成", 3: "已取消" };
|
||||
return map[status] ?? "未知";
|
||||
};
|
||||
|
||||
const statusColor = (status) => {
|
||||
const map = { 0: "default", 1: "processing", 2: "success", 3: "error" };
|
||||
return map[status] ?? "default";
|
||||
};
|
||||
|
||||
const handleFilterSearch = () => {
|
||||
paginationState.value.current = 1;
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
filterParams.value = {};
|
||||
paginationState.value.current = 1;
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination) => {
|
||||
paginationState.value.current = pagination.current;
|
||||
paginationState.value.pageSize = pagination.pageSize;
|
||||
};
|
||||
|
||||
const handleViewRecord = (record) => {
|
||||
currentRecord.value = record;
|
||||
drawerOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteRecord = (record) => {
|
||||
mockRecords.value = mockRecords.value.filter((r) => r.id !== record.id);
|
||||
showSuccessTips(`已删除 ${record.orderNo}`);
|
||||
};
|
||||
|
||||
const handleAddRecord = () => {
|
||||
const id = mockRecords.value.length + 1;
|
||||
mockRecords.value.unshift({
|
||||
id,
|
||||
orderNo: `ORD-202506050${String(id).padStart(2, "0")}`,
|
||||
customer: "新客户示例",
|
||||
amount: 999,
|
||||
status: 0,
|
||||
createTime: "2025-06-05 12:00:00",
|
||||
});
|
||||
showSuccessTips("已添加一条示例记录");
|
||||
};
|
||||
|
||||
// ── 详情示例 ──────────────────────────────────────────
|
||||
const detailRecord = ref({
|
||||
orderNo: "ORD-20250605001",
|
||||
customer: "广州物流有限公司",
|
||||
amount: 1280.5,
|
||||
status: 1,
|
||||
createTime: "2025-06-05 09:30:00",
|
||||
remark: "优先配送,请联系收货人确认时间。",
|
||||
});
|
||||
|
||||
const detailColumns = ref([
|
||||
{ title: "订单号", dataIndex: "orderNo" },
|
||||
{ title: "客户", dataIndex: "customer" },
|
||||
{ title: "金额", dataIndex: "amount" },
|
||||
{ title: "状态", dataIndex: "status", bodySlot: true },
|
||||
{ title: "创建时间", dataIndex: "createTime" },
|
||||
{ title: "备注", dataIndex: "remark", span: 2 },
|
||||
]);
|
||||
|
||||
// ── ILayoutPage 配置示例 ──────────────────────────────
|
||||
const layoutPageExample = `const config = ref({
|
||||
listPages: [{
|
||||
title: "列表标题",
|
||||
name: "recordList",
|
||||
api: "api.xxx.list", // 列表接口
|
||||
rowKey: "guid",
|
||||
isDefault: true,
|
||||
showFilter: true,
|
||||
pagination: { pageSize: 10 },
|
||||
columns: [/* 列配置 */],
|
||||
filters: { fields: [/* 筛选项 */] },
|
||||
}],
|
||||
formPages: [{
|
||||
name: "create",
|
||||
title: "新增",
|
||||
submitApi: "api.xxx.create",
|
||||
formConfig: { fields: [/* 表单字段 */] },
|
||||
}, {
|
||||
name: "update",
|
||||
title: "编辑",
|
||||
paramsKey: ["guid"],
|
||||
recordApi: "api.xxx.detail",
|
||||
submitApi: "api.xxx.update",
|
||||
formConfig: { fields: [/* 表单字段 */] },
|
||||
}],
|
||||
detailPages: [{
|
||||
name: "detail",
|
||||
title: "详情",
|
||||
paramsKey: ["guid"],
|
||||
recordApi: "api.xxx.detail",
|
||||
columns: [/* 详情字段 */],
|
||||
}],
|
||||
});
|
||||
|
||||
// 模板中使用
|
||||
<ILayoutPage ref="pageRef" :config="config">
|
||||
<template #listActions="{ refresh }">
|
||||
<a-button type="primary" @click="pageRef.showFormPage('create')">添加</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 自定义列渲染 -->
|
||||
</template>
|
||||
</ILayoutPage>`;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-page {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.demo-intro {
|
||||
margin-bottom: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.demo-intro code {
|
||||
padding: 2px 6px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
|
||||
.demo-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.demo-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.demo-list-title {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.demo-code {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
98
src/views/business/index.vue
Normal file
98
src/views/business/index.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="module-root-page">
|
||||
<div class="module-page-header">
|
||||
<h2>业务管理</h2>
|
||||
</div>
|
||||
<IViewTabs v-model:activeKey="activeKey" :animated="false" class="module-entry-tabs">
|
||||
<IViewTabPane key="demo" tab="使用示例">
|
||||
<div class="tab-content" v-if="activeKey === 'demo'">
|
||||
<IndexDemo />
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
</IViewTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import IViewTabs from "@/iview/IViewTabs.vue";
|
||||
import IViewTabPane from "@/iview/IViewTabPane.vue";
|
||||
import IndexDemo from "./IndexDemo.vue";
|
||||
|
||||
const activeKey = ref("demo");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
if (tab && ["demo"].includes(tab)) {
|
||||
activeKey.value = tab;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(activeKey, (newKey) => {
|
||||
router.push({
|
||||
query: { ...route.query, tab: newKey },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.module-root-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.module-page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.module-root-page {
|
||||
--module-page-inset: 16px;
|
||||
width: calc(100% + 2 * var(--module-page-inset));
|
||||
margin: calc(-1 * var(--module-page-inset));
|
||||
padding: var(--module-page-inset);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.module-root-page .module-page-header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.module-root-page .module-page-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.module-root-page .module-entry-tabs {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.module-page-header h2 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 350px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.tab-content {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
252
src/views/setting/IndexAdmin.vue
Normal file
252
src/views/setting/IndexAdmin.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<ILayoutPage ref="pageRef" :config="config">
|
||||
<template #listActions="{ type, refresh }">
|
||||
<div class="page-header">
|
||||
<h2>
|
||||
管理员账号列表
|
||||
</h2>
|
||||
<a-button type="primary" @click="addClick">
|
||||
<template #icon><plus-outlined /></template>
|
||||
添加
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #bodyCell="{ column, record, type }">
|
||||
<template v-if="column.dataIndex === 'recordDetail'">
|
||||
<div>
|
||||
<div style="font-size: 14px; color: #333;">
|
||||
{{ record.nickname }}
|
||||
<a-tag v-if="record.optStatus === 0" style="margin-left: 10px" color="#FF0000">
|
||||
<span>停用</span>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<span class="item-title">账号</span>
|
||||
<span class="item-value">{{ record.username }}</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<span class="item-title">角色</span>
|
||||
<span class="item-value">{{ record.optSuper === 1 ? '超级管理员' : '普通管理员' }}</span>
|
||||
</div>
|
||||
<div v-if="!!record.optRemark" class="item-detail">
|
||||
<span class="item-title">备注</span>
|
||||
<span class="item-value">{{ record.optRemark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button size="small" type="default" @click="editClick(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<IViewConfirm importance="normal" :title="`确认要删除管理员吗?`" @confirm="() => deleteClick(record)">
|
||||
<a-button size="small" danger> 删除 </a-button>
|
||||
</IViewConfirm>
|
||||
</template>
|
||||
</template>
|
||||
</ILayoutPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||
import { apiRequest } from "@/iview/utils/request";
|
||||
import { PlusOutlined } from "@ant-design/icons-vue";
|
||||
import ILayoutPage from "@/iview/ILayoutPage.vue";
|
||||
import IViewConfirm from "@/iview/IViewConfirm.vue";
|
||||
|
||||
const pageRef = ref(null);
|
||||
|
||||
const refreshList = () => {
|
||||
pageRef.value.refreshList();
|
||||
};
|
||||
|
||||
const addClick = () => {
|
||||
pageRef.value.showFormPage("create");
|
||||
};
|
||||
|
||||
const editClick = (record) => {
|
||||
pageRef.value.showFormPage("update", record);
|
||||
};
|
||||
|
||||
const deleteClick = async (record) => {
|
||||
try {
|
||||
record.actionLoading = true;
|
||||
await apiRequest("api.system.admin.info.delete", {
|
||||
guid: record.guid,
|
||||
});
|
||||
showSuccessTips("管理员删除成功");
|
||||
refreshList();
|
||||
} catch (error) {
|
||||
showErrorTips(error);
|
||||
} finally {
|
||||
record.actionLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const recordColumns = ref([
|
||||
{
|
||||
title: "管理员信息",
|
||||
dataIndex: "recordDetail",
|
||||
bodySlot: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
width: 160,
|
||||
title: "操作",
|
||||
dataIndex: "action",
|
||||
bodySlot: true,
|
||||
fixed: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
const formConfig = ref({
|
||||
extrasFields: ["guid"],
|
||||
fields: [
|
||||
{
|
||||
name: "username",
|
||||
label: "登录账号",
|
||||
type: "text",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxlength: 20,
|
||||
},
|
||||
{
|
||||
name: "nickname",
|
||||
label: "账号昵称",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxlength: 20,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
label: "登录密码",
|
||||
type: "text",
|
||||
required: true,
|
||||
minLength: 6,
|
||||
placeholder: "密码长度至少6个字符",
|
||||
show: "!record.guid",
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
label: "重置密码",
|
||||
type: "text",
|
||||
placeholder: "不修改密码请留空",
|
||||
show: "!!record.guid",
|
||||
},
|
||||
{
|
||||
name: "optSuper",
|
||||
label: "超级管理员",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ value: 1, label: "是" },
|
||||
{ value: 0, label: "否" },
|
||||
],
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "optRemark",
|
||||
label: "备注信息",
|
||||
type: "textarea",
|
||||
maxlength: 200,
|
||||
textRows: 2,
|
||||
},
|
||||
{
|
||||
name: "optStatus",
|
||||
label: "状态开关",
|
||||
type: "boolean",
|
||||
defaultValue: 1,
|
||||
falseValue: 0,
|
||||
trueValue: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const config = ref({
|
||||
listPages: [
|
||||
{
|
||||
title: "管理员列表",
|
||||
name: "recordList",
|
||||
api: "api.system.admin.info.list",
|
||||
rowKey: "guid",
|
||||
defaultParams: {},
|
||||
showFilter: false,
|
||||
isDefault: true,
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
showTotal: (total) => total > 0 ? `共 ${total} 条` : "暂无数据",
|
||||
},
|
||||
columns: recordColumns.value,
|
||||
filters: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
detailPages: [],
|
||||
formPages: [
|
||||
{
|
||||
name: "create",
|
||||
title: "添加管理员",
|
||||
submitApi: "api.system.admin.info.create",
|
||||
formConfig: formConfig.value,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
title: "编辑管理员",
|
||||
paramsKey: ["guid"],
|
||||
recordApi: "api.system.admin.info.detail",
|
||||
submitApi: "api.system.admin.info.update",
|
||||
formConfig: formConfig.value,
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #d1d5db;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
429
src/views/setting/IndexSetting.vue
Normal file
429
src/views/setting/IndexSetting.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<div class="setting-config">
|
||||
<a-spin :spinning="loading">
|
||||
<div class="config-box" v-if="settingList">
|
||||
<template v-if="settingList && settingList.length > 0">
|
||||
<div class="config-wrap">
|
||||
<!-- 左侧设置菜单 -->
|
||||
<div class="config-left">
|
||||
<a-menu
|
||||
mode="inline"
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
class="setting-menu"
|
||||
>
|
||||
<a-menu-item
|
||||
v-for="item in settingList"
|
||||
:key="item.key"
|
||||
@click="handleMenuClick(item.key)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧配置表单 -->
|
||||
<div class="config-right">
|
||||
<a-spin :spinning="detailLoading">
|
||||
<div v-if="currentSetting && currentSetting.settingInfo">
|
||||
<div class="config-title">
|
||||
<span>
|
||||
{{ currentSetting.settingInfo.title }}
|
||||
<small v-if="currentSetting.settingInfo.desc">
|
||||
({{ currentSetting.settingInfo.desc }})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!!currentSetting.settingInfo.readme">
|
||||
<a-alert :message="currentSetting.settingInfo.readme" banner />
|
||||
</div>
|
||||
<div class="config-form">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="editModel"
|
||||
@submit.prevent
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item>
|
||||
<IViewForm
|
||||
ref="configFormRef"
|
||||
v-model="editModel.settingValue"
|
||||
model-format="json"
|
||||
:config="configFormJson"
|
||||
:read-only="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="submitLoading || detailLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
保存
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty
|
||||
v-else-if="!detailLoading"
|
||||
:description="detailErrorMsg || '请选择左侧配置项'"
|
||||
style="margin-top: 60px"
|
||||
>
|
||||
<!-- <a-button
|
||||
v-if="detailErrorMsg && currentSettingKey"
|
||||
@click="fetchSettingDetail"
|
||||
size="small"
|
||||
>
|
||||
重新加载
|
||||
</a-button> -->
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-empty v-else description="暂无可配置选项">
|
||||
<a-button @click="refresh" size="small"> 刷新列表 </a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
<a-empty v-else-if="!loading" :description="errorMsg" style="margin-top: 60px">
|
||||
<a-button v-if="errorMsg" @click="refresh"> 重新加载 </a-button>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||
import { apiRequest } from "@/iview/utils/request";
|
||||
import IViewForm from "@/iview/IViewForm.vue";
|
||||
|
||||
// 响应式变量
|
||||
const loading = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const settingList = ref(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailErrorMsg = ref(null);
|
||||
const currentSetting = ref(null);
|
||||
const currentSettingKey = ref("");
|
||||
const selectedKeys = ref([]);
|
||||
const submitLoading = ref(false);
|
||||
|
||||
// 编辑表单模型
|
||||
const editModel = reactive({
|
||||
settingKey: "",
|
||||
settingValue: "{}",
|
||||
});
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref(null);
|
||||
const configFormRef = ref(null);
|
||||
|
||||
// 表单配置字段列表
|
||||
const configFormFields = computed(() => {
|
||||
if (
|
||||
!currentSetting.value ||
|
||||
!currentSetting.value.settingInfo ||
|
||||
!currentSetting.value.settingInfo.fields
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 处理特殊的条件显示逻辑
|
||||
const processedFields = currentSetting.value.settingInfo.fields.map((field) => {
|
||||
// 克隆字段以避免修改原始数据
|
||||
const processedField = { ...field };
|
||||
// 处理条件逻辑 - 保留原始的条件表达式
|
||||
// CustomFormEditor组件会根据表达式动态处理显示/隐藏逻辑
|
||||
return processedField;
|
||||
});
|
||||
|
||||
return processedFields;
|
||||
});
|
||||
|
||||
// 表单配置JSON
|
||||
const configFormJson = computed(() => {
|
||||
return JSON.stringify({
|
||||
fields: configFormFields.value,
|
||||
});
|
||||
});
|
||||
|
||||
// 处理菜单点击事件
|
||||
const handleMenuClick = (key) => {
|
||||
currentSettingKey.value = key;
|
||||
selectedKeys.value = [key];
|
||||
fetchSettingDetail();
|
||||
};
|
||||
|
||||
// 获取配置详情
|
||||
const fetchSettingDetail = async () => {
|
||||
if (!currentSettingKey.value) return;
|
||||
|
||||
detailErrorMsg.value = null;
|
||||
detailLoading.value = true;
|
||||
|
||||
try {
|
||||
// 调用真实接口获取配置详情
|
||||
const res = await apiRequest("api.system.global.setting.detail", {
|
||||
settingKey: currentSettingKey.value,
|
||||
});
|
||||
currentSetting.value = res;
|
||||
// 初始化编辑表单
|
||||
editModel.settingKey = currentSettingKey.value;
|
||||
editModel.settingValue = res.settingValue;
|
||||
} catch (err) {
|
||||
detailErrorMsg.value =
|
||||
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||
currentSetting.value = null;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取配置列表
|
||||
const refresh = async () => {
|
||||
errorMsg.value = null;
|
||||
loading.value = true;
|
||||
try {
|
||||
// 调用真实接口获取配置列表
|
||||
const res = await apiRequest("api.system.global.setting.list", {});
|
||||
// 过滤掉隐藏的设置项
|
||||
settingList.value = (res || []).filter((item) => !item.hidden);
|
||||
if (settingList.value.length > 0) {
|
||||
// 默认选中第一项
|
||||
currentSettingKey.value = settingList.value[0].key;
|
||||
selectedKeys.value = [currentSettingKey.value];
|
||||
fetchSettingDetail();
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.value =
|
||||
(err && err.msg) || (err && err.message) || "连接服务器失败,请检查网络";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// 如果有表单校验,进行校验
|
||||
if (configFormRef.value) {
|
||||
await configFormRef.value.validate();
|
||||
}
|
||||
submitLoading.value = true;
|
||||
// 调用真实接口保存配置
|
||||
await apiRequest("api.system.global.setting.edit", {
|
||||
settingKey: editModel.settingKey,
|
||||
settingValue: editModel.settingValue,
|
||||
});
|
||||
showSuccessTips("保存成功");
|
||||
// 重新获取配置详情,确保数据最新
|
||||
await fetchSettingDetail();
|
||||
} catch (err) {
|
||||
showErrorTips(err);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setting-config {
|
||||
margin-top: 0;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.config-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.config-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 18px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.config-left {
|
||||
width: 210px;
|
||||
flex: 0 0 210px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #eef6ff 0%, #ffffff 72%);
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.config-left-header {
|
||||
padding: 18px 18px 12px;
|
||||
/* background: linear-gradient(135deg, #eef6ff 0%, #ffffff 72%); */
|
||||
}
|
||||
|
||||
.config-left-title {
|
||||
color: #1f2937;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-left-desc {
|
||||
margin-top: 4px;
|
||||
color: #8c96a8;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setting-menu {
|
||||
padding: 8px;
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
border-inline-end: none !important;
|
||||
}
|
||||
|
||||
.config-right {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
overflow: hidden;
|
||||
min-height: 300px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.config-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 18px;
|
||||
background: linear-gradient(135deg, #f7fbff 0%, #ffffff 70%);
|
||||
}
|
||||
|
||||
.config-title-text {
|
||||
color: #1f2937;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-title-desc {
|
||||
margin-top: 6px;
|
||||
color: #8c96a8;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.config-readme {
|
||||
margin: 0 24px 4px;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
padding: 20px 24px 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.config-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.setting-config,
|
||||
.config-box,
|
||||
.config-wrap,
|
||||
.config-left,
|
||||
.config-right,
|
||||
.config-panel {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 修复表单叠加问题 */
|
||||
:deep(.ant-form) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.custom-form-editor) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-control) {
|
||||
display: block;
|
||||
flex: 1 1 auto !important;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-control-input) {
|
||||
min-height: unset !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-textarea-wrapper) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-input-textarea) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:deep(.setting-menu .ant-menu-item) {
|
||||
height: 40px;
|
||||
margin: 4px 0;
|
||||
border-radius: 10px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:deep(.setting-menu .ant-menu-item-selected) {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
background: rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.config-readme .ant-alert) {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.config-wrap {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.config-left {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.config-right {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.config-btns {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
338
src/views/setting/IndexTask.vue
Normal file
338
src/views/setting/IndexTask.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="record-page">
|
||||
<ILayoutPage ref="pageRef" :config="config">
|
||||
<template #listActions="{ type, refresh }">
|
||||
<div class="page-header">
|
||||
<h2>
|
||||
异步任务列表
|
||||
</h2>
|
||||
<div :style="{ display: 'flex', gap: '10px' }">
|
||||
<a-button type="default" @click="refreshPage">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #bodyCell="{ column, record, type }">
|
||||
<template v-if="column.dataIndex === 'recordDetail'">
|
||||
<div>
|
||||
<div :class="type !== 'detail' ? 'name-click' : 'name-text'"
|
||||
@click="type !== 'detail' ? detailClick(record) : null">
|
||||
{{ record.taskType }}
|
||||
</div>
|
||||
<div class="item-detail" v-if="displayBizInfo(record)">
|
||||
<span class="item-title">业务编号</span>
|
||||
<span class="item-value">{{ displayBizInfo(record) }}</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<span class="item-title">创建时间</span>
|
||||
<span class="item-value">{{ displayTime(record.createTime) }}</span>
|
||||
</div>
|
||||
<div class="item-detail" v-if="record.startTime > 0">
|
||||
<span class="item-title">开始时间</span>
|
||||
<span class="item-value">{{ displayTime(record.startTime) }}</span>
|
||||
</div>
|
||||
<div class="item-detail" v-else-if="record.callTiming > 0">
|
||||
<span class="item-title">执行时间</span>
|
||||
<span class="item-value">{{ displayTime(record.callTiming) }}</span>
|
||||
</div>
|
||||
<div class="item-detail" v-else>
|
||||
<span class="item-title">开始时间</span>
|
||||
<span class="item-value">{{ displayTime(record.createTime) }}</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<span class="item-title">执行进度</span>
|
||||
<span class="item-value">
|
||||
<span v-html="displayProgress(record)"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<span class="item-title">执行状态</span>
|
||||
<span class="item-value">
|
||||
<span v-html="displayOptStatus(record.optStatus)"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<span class="item-title">执行用时</span>
|
||||
<span class="item-value">{{ displayUseTime(record.useTime) }}</span>
|
||||
</div>
|
||||
<div v-if="type === 'detail'" style="margin: 10px 0px;">
|
||||
<IViewConfirm importance="normal" :title="`确认要重新执行异步任务吗?`" @confirm="() => restartClick(record)">
|
||||
<a-button size="small" type="default"> 重新执行 </a-button>
|
||||
</IViewConfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button size="small" type="default" @click="detailClick(record)">
|
||||
详情
|
||||
</a-button>
|
||||
<IViewConfirm importance="normal" :title="`确认要重新执行异步任务吗?`" @confirm="() => restartClick(record)">
|
||||
<a-button size="small" type="default"> 重新执行 </a-button>
|
||||
</IViewConfirm>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #detailTab="{ record, tab, type, refresh }">
|
||||
<template v-if="tab.key === 'logs' && record">
|
||||
<TaskLogs :taskInfo="record" />
|
||||
</template>
|
||||
</template>
|
||||
</ILayoutPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ReloadOutlined } from "@ant-design/icons-vue";
|
||||
import { showErrorTips, showSuccessTips } from "@/iview/utils/messageTips";
|
||||
import { apiRequest } from "@/iview/utils/request";
|
||||
import IViewConfirm from "@/iview/IViewConfirm.vue";
|
||||
import ILayoutPage from "@/iview/ILayoutPage.vue";
|
||||
import TaskLogs from "./TaskLogs.vue";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
|
||||
const pageRef = ref(null);
|
||||
|
||||
/*
|
||||
PENDING(0, "待处理"),
|
||||
RUNNING(1, "处理中"),
|
||||
FULFILLED(2, "已结束"),
|
||||
WAIL_RETRY(3, "待重试"),
|
||||
CANCELING(4, "取消中"),
|
||||
CANCELED(5, "已取消");
|
||||
*/
|
||||
const optStatusMap = {
|
||||
0: "待处理",
|
||||
1: "处理中",
|
||||
2: "已结束",
|
||||
3: "待重试",
|
||||
4: "取消中",
|
||||
5: "已取消",
|
||||
};
|
||||
|
||||
const optStatusColorMap = {
|
||||
0: "color: #999999;",
|
||||
1: "color: #3399ff;",
|
||||
2: "color: #34C759;",
|
||||
3: "color: #ff0000;",
|
||||
4: "color: #999999;",
|
||||
5: "color: #999999;",
|
||||
};
|
||||
|
||||
const displayTime = (time) => {
|
||||
return dayjs(time).format("YYYY-MM-DD HH:mm:ss");
|
||||
};
|
||||
|
||||
const displayBizInfo = (record) => {
|
||||
if (record.bizType && record.bizId
|
||||
&& record.bizType != 'unknown' && record.bizId != 'unknown') {
|
||||
return record.bizType + '_' + record.bizId;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const displayUseTime = (useTime) => {
|
||||
// 毫秒转时分秒
|
||||
const hours = Math.floor(useTime / 3600000);
|
||||
const minutes = Math.floor((useTime % 3600000) / 60000);
|
||||
const seconds = Math.floor((useTime % 60000) / 1000);
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分钟${seconds}秒`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分钟${seconds}秒`;
|
||||
} else {
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
};
|
||||
|
||||
const displayProgress = (record) => {
|
||||
let totalCount = record.totalCount; // 总数量
|
||||
let successCount = record.successCount; // 成功数量
|
||||
let failureCount = record.failureCount; // 失败数量
|
||||
let ignoreCount = record.ignoreCount; // 忽略数量
|
||||
let retryCount = record.retryCount; // 重试次数
|
||||
let text = "";
|
||||
text += `<span style="color: #333333;">${totalCount}</span> 总数 `;
|
||||
text += `<span style="color: #34C759;">${successCount}</span> 成功 `;
|
||||
if (failureCount > 0) {
|
||||
text += `<span style="color: #ff0000;">${failureCount}</span> 失败 `;
|
||||
}
|
||||
if (ignoreCount > 0) {
|
||||
text += `<span style="color: #999999;">${ignoreCount}</span> 忽略 `;
|
||||
}
|
||||
if (retryCount > 0) {
|
||||
text += `<span style="color: #333333;">${retryCount}</span> 重试 `;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const displayOptStatus = (optStatus) => {
|
||||
return `<span style="${optStatusColorMap[optStatus]}">${optStatusMap[optStatus] || "未知"}</span>`;
|
||||
};
|
||||
|
||||
const refreshPage = () => {
|
||||
pageRef.value.refreshPage();
|
||||
};
|
||||
|
||||
const detailClick = (record) => {
|
||||
pageRef.value.showDetailPage("detail", record);
|
||||
};
|
||||
|
||||
const restartClick = async (record) => {
|
||||
try {
|
||||
record.actionLoading = true;
|
||||
await apiRequest("api.system.async.task.info.restart", {
|
||||
guid: record.guid,
|
||||
});
|
||||
showSuccessTips("异步任务重新执行成功");
|
||||
refreshPage();
|
||||
} catch (error) {
|
||||
showErrorTips(error);
|
||||
} finally {
|
||||
record.actionLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const recordColumns = ref([
|
||||
{
|
||||
title: "任务信息",
|
||||
dataIndex: "recordDetail",
|
||||
bodySlot: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
width: 160,
|
||||
title: "操作",
|
||||
dataIndex: "action",
|
||||
bodySlot: true,
|
||||
fixed: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
const config = ref({
|
||||
listPages: [
|
||||
{
|
||||
title: "异步任务列表",
|
||||
name: "recordList",
|
||||
api: "api.system.async.task.info.list",
|
||||
rowKey: "guid",
|
||||
defaultParams: {},
|
||||
emptyText: "暂无异步任务",
|
||||
showFilter: false,
|
||||
isDefault: true,
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
showTotal: (total) => total > 0 ? `共 ${total} 条` : "暂无数据",
|
||||
},
|
||||
columns: recordColumns.value,
|
||||
filters: {
|
||||
fields: [
|
||||
{
|
||||
label: "搜索关键词",
|
||||
name: "searchKeyword",
|
||||
foldable: false,
|
||||
type: "text",
|
||||
placeholder: "异步任务名称/任务类型/任务状态关键词搜索",
|
||||
styleValue: {
|
||||
width: "380px"
|
||||
},
|
||||
clearable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
detailPages: [
|
||||
{
|
||||
name: "detail",
|
||||
title: "异步任务详情",
|
||||
recordApi: "api.system.async.task.info.detail",
|
||||
paramsKey: ["guid"],
|
||||
recordMap: (record) => {
|
||||
return record;
|
||||
},
|
||||
hiddenLabel: true,
|
||||
columns: recordColumns.value,
|
||||
tabs: [
|
||||
{
|
||||
label: "执行日志",
|
||||
key: "logs",
|
||||
forceRender: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formPages: [
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #d1d5db;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name-click {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333333;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
</style>
|
||||
91
src/views/setting/TaskLogs.vue
Normal file
91
src/views/setting/TaskLogs.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="record-page">
|
||||
<ILayoutPage ref="pageRef" :config="config">
|
||||
<template #bodyCell="{ column, record, type }">
|
||||
<template v-if="column.dataIndex === 'recordDetail'">
|
||||
<pre class="log-params">{{ displayParams(taskInfo.taskParams) }}</pre>
|
||||
<LogContentPreview :content="record.logsContent" />
|
||||
</template>
|
||||
</template>
|
||||
</ILayoutPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import ILayoutPage from "@/iview/ILayoutPage.vue";
|
||||
import LogContentPreview from "@/components/LogContentPreview.vue";
|
||||
|
||||
const props = defineProps({
|
||||
taskInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageRef = ref(null);
|
||||
|
||||
const refreshPage = () => {
|
||||
pageRef.value.refreshPage();
|
||||
};
|
||||
|
||||
const displayParams = (params) => {
|
||||
try {
|
||||
let paramsObj = JSON.parse(params);
|
||||
return JSON.stringify(paramsObj, null, 2);
|
||||
} catch (_) {
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
const recordColumns = ref([
|
||||
{
|
||||
title: "执行日志",
|
||||
dataIndex: "recordDetail",
|
||||
bodySlot: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const config = ref({
|
||||
infoPages: [
|
||||
{
|
||||
title: "兽药企业信息",
|
||||
name: "info",
|
||||
recordApi: "api.system.async.task.info.logs",
|
||||
recordMap: (record) => {
|
||||
return record;
|
||||
},
|
||||
defaultParams: () => {
|
||||
return {
|
||||
guid: props.taskInfo.guid,
|
||||
};
|
||||
},
|
||||
hiddenLabel: true,
|
||||
columns: recordColumns.value,
|
||||
},
|
||||
],
|
||||
detailPages: [],
|
||||
formPages: [
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.log-params {
|
||||
color: #999;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
</style>
|
||||
117
src/views/setting/index.vue
Normal file
117
src/views/setting/index.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="module-root-page">
|
||||
<div class="module-page-header">
|
||||
<h2>系统管理</h2>
|
||||
</div>
|
||||
<IViewTabs v-model:activeKey="activeKey" :animated="false" class="module-entry-tabs">
|
||||
<IViewTabPane key="admin" tab="账号管理">
|
||||
<div class="tab-content" v-if="activeKey === 'admin'">
|
||||
<IndexAdmin />
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
<IViewTabPane key="task" tab="任务中心">
|
||||
<div class="tab-content" v-if="activeKey === 'task'">
|
||||
<IndexTask />
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
<IViewTabPane key="setting" tab="功能设置">
|
||||
<div class="tab-content" v-if="activeKey === 'setting'">
|
||||
<IndexSetting />
|
||||
</div>
|
||||
</IViewTabPane>
|
||||
</IViewTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import IViewTabs from "@/iview/IViewTabs.vue";
|
||||
import IViewTabPane from "@/iview/IViewTabPane.vue";
|
||||
import IndexAdmin from "./IndexAdmin.vue";
|
||||
import IndexSetting from "./IndexSetting.vue";
|
||||
import IndexTask from "./IndexTask.vue";
|
||||
|
||||
const activeKey = ref("admin");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
if (tab && ["admin", "task", "setting"].includes(tab)) {
|
||||
activeKey.value = tab;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(activeKey, (newKey) => {
|
||||
router.push({
|
||||
query: { ...route.query, tab: newKey },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.module-root-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.module-page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.module-root-page {
|
||||
--module-page-inset: 16px;
|
||||
width: calc(100% + 2 * var(--module-page-inset));
|
||||
margin: calc(-1 * var(--module-page-inset));
|
||||
padding: var(--module-page-inset);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.module-root-page .module-page-header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.module-root-page .module-page-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.module-root-page .module-entry-tabs {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.module-page-header h2 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 350px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(.custom-form-config),
|
||||
:deep(.custom-form-editor) {
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.tab-content {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user