This commit is contained in:
iqudoo
2026-06-05 17:22:32 +08:00
commit eb4e8f1a04
90 changed files with 21224 additions and 0 deletions

387
src/views/Login.vue Normal file
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>