核心架构:添加基础架构层代码
- 添加工具函数库(src/utils/) - 添加TypeScript类型定义(src/typings/) - 添加全局常量定义(src/constants/) - 添加组合式函数(src/hooks/) - 添加自定义指令(src/directives/) - 添加公共组件(src/components/common/) - 添加应用入口文件(src/App.vue, src/main.ts)
This commit is contained in:
parent
80ab2736c9
commit
715270aa49
23
src/App.vue
Normal file
23
src/App.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<n-config-provider
|
||||
class="wh-full" inline-theme-disabled :theme="appStore.colorMode === 'dark' ? darkTheme : null"
|
||||
:locale="naiveLocale.locale" :date-locale="naiveLocale.dateLocale" :theme-overrides="appStore.theme"
|
||||
>
|
||||
<naive-provider>
|
||||
<router-view />
|
||||
</naive-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { naiveI18nOptions } from '@/utils'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { useAppStore } from './store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const naiveLocale = computed(() => {
|
||||
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
|
||||
},
|
||||
)
|
||||
</script>
|
||||
237
src/components/common/AppLoading.vue
Normal file
237
src/components/common/AppLoading.vue
Normal file
@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<naive-provider>
|
||||
<div id="loading-container">
|
||||
<div class="boxes">
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</naive-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#loading-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 15vh;
|
||||
position: fixed;
|
||||
background-color: aliceblue;
|
||||
z-index: 1;
|
||||
}
|
||||
.boxes {
|
||||
--size: 48px;
|
||||
--duration: 800ms;
|
||||
height: calc(var(--size) * 2);
|
||||
width: calc(var(--size) * 3);
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: 50% 50%;
|
||||
margin-top: calc(var(--size) * 1.5 * -1);
|
||||
transform: rotateX(60deg) rotateZ(45deg) rotateY(0deg) translateZ(0px);
|
||||
}
|
||||
|
||||
.boxes .box {
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(1) {
|
||||
transform: translate(100%, 0);
|
||||
-webkit-animation: box1 var(--duration) linear infinite;
|
||||
animation: box1 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(2) {
|
||||
transform: translate(0, 100%);
|
||||
-webkit-animation: box2 var(--duration) linear infinite;
|
||||
animation: box2 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(3) {
|
||||
transform: translate(100%, 100%);
|
||||
-webkit-animation: box3 var(--duration) linear infinite;
|
||||
animation: box3 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(4) {
|
||||
transform: translate(200%, 0);
|
||||
-webkit-animation: box4 var(--duration) linear infinite;
|
||||
animation: box4 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box > div {
|
||||
--background: #5c8df6;
|
||||
--top: auto;
|
||||
--right: auto;
|
||||
--bottom: auto;
|
||||
--left: auto;
|
||||
--translateZ: calc(var(--size) / 2);
|
||||
--rotateY: 0deg;
|
||||
--rotateX: 0deg;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
top: var(--top);
|
||||
right: var(--right);
|
||||
bottom: var(--bottom);
|
||||
left: var(--left);
|
||||
transform: rotateY(var(--rotateY)) rotateX(var(--rotateX)) translateZ(var(--translateZ));
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(1) {
|
||||
--top: 0;
|
||||
--left: 0;
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(2) {
|
||||
--background: #145af2;
|
||||
--right: 0;
|
||||
--rotateY: 90deg;
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(3) {
|
||||
--background: #447cf5;
|
||||
--rotateX: -90deg;
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(4) {
|
||||
--background: #dbe3f4;
|
||||
--top: 0;
|
||||
--left: 0;
|
||||
--translateZ: calc(var(--size) * 3 * -1);
|
||||
}
|
||||
|
||||
@-webkit-keyframes box1 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box1 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes box2 {
|
||||
0% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box2 {
|
||||
0% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes box3 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box3 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes box4 {
|
||||
0% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(200%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box4 {
|
||||
0% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(200%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
241
src/components/common/CoiDialog.vue
Normal file
241
src/components/common/CoiDialog.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
:mask-closable="maskClosable"
|
||||
:close-on-esc="closeOnEsc"
|
||||
:auto-focus="autoFocus"
|
||||
preset="card"
|
||||
:loading="confirmLoading"
|
||||
:show-icon="false"
|
||||
:style="{
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
}"
|
||||
class="coi-dialog"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 头部插槽 -->
|
||||
<template #header>
|
||||
<div v-if="$slots.header" class="coi-dialog-custom-header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-else class="coi-dialog-header">
|
||||
<div class="coi-dialog-title">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div
|
||||
class="coi-dialog-content"
|
||||
:style="{
|
||||
height: fullscreen ? 'auto' : (typeof height === 'number' ? `${height}px` : height),
|
||||
overflow: height === 'auto' ? 'visible' : 'auto',
|
||||
}"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区域 -->
|
||||
<template #footer>
|
||||
<div v-if="!footerHidden" class="coi-dialog-footer">
|
||||
<NSpace justify="center">
|
||||
<NButton
|
||||
v-if="showCancel"
|
||||
size="medium"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="showConfirm"
|
||||
type="primary"
|
||||
size="medium"
|
||||
:loading="confirmLoading"
|
||||
:disabled="confirmDisabled"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRefs } from 'vue'
|
||||
import { NButton, NSpace } from 'naive-ui'
|
||||
|
||||
// 定义参数的类型
|
||||
interface IDialogProps {
|
||||
/** 弹框标题 */
|
||||
title?: string
|
||||
/** 弹框宽度 */
|
||||
width?: string | number
|
||||
/** 内容区域高度 */
|
||||
height?: string | number
|
||||
/** 确认按钮文本 */
|
||||
confirmText?: string
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string
|
||||
/** 全屏显示 */
|
||||
fullscreen?: boolean
|
||||
/** 确认按钮加载状态 */
|
||||
loading?: boolean
|
||||
/** 确认按钮禁用状态 */
|
||||
confirmDisabled?: boolean
|
||||
/** 隐藏底部按钮 */
|
||||
footerHidden?: boolean
|
||||
/** 显示确认按钮 */
|
||||
showConfirm?: boolean
|
||||
/** 显示取消按钮 */
|
||||
showCancel?: boolean
|
||||
/** 自动聚焦 */
|
||||
autoFocus?: boolean
|
||||
/** 居中显示 */
|
||||
center?: boolean
|
||||
/** ESC键关闭 */
|
||||
closeOnEsc?: boolean
|
||||
/** 点击遮罩层关闭 */
|
||||
maskClosable?: boolean
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
const props = withDefaults(defineProps<IDialogProps>(), {
|
||||
title: '提示',
|
||||
width: 500,
|
||||
height: 'auto',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
fullscreen: false,
|
||||
loading: false,
|
||||
confirmDisabled: false,
|
||||
footerHidden: false,
|
||||
showConfirm: true,
|
||||
showCancel: true,
|
||||
autoFocus: true,
|
||||
center: true,
|
||||
closeOnEsc: true,
|
||||
maskClosable: false,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emits = defineEmits<{
|
||||
coiConfirm: []
|
||||
coiCancel: []
|
||||
coiClose: []
|
||||
}>()
|
||||
|
||||
// 弹框显示状态
|
||||
const visible = ref(false)
|
||||
|
||||
// 确认按钮加载状态,使用toRefs保持响应式
|
||||
const { loading } = toRefs(props)
|
||||
const confirmLoading = ref(loading)
|
||||
|
||||
/** 打开弹框 */
|
||||
function coiOpen() {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
/** 关闭弹框 */
|
||||
function coiClose() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 快速关闭弹框 */
|
||||
function coiQuickClose() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 确认事件处理 */
|
||||
function handleConfirm() {
|
||||
emits('coiConfirm')
|
||||
}
|
||||
|
||||
/** 取消事件处理 */
|
||||
function handleCancel() {
|
||||
emits('coiCancel')
|
||||
}
|
||||
|
||||
/** 关闭事件处理 */
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
emits('coiClose')
|
||||
}
|
||||
|
||||
/** 暴露给父组件的方法 */
|
||||
defineExpose({
|
||||
coiOpen,
|
||||
coiClose,
|
||||
coiQuickClose,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 弹框标题样式 */
|
||||
.coi-dialog :deep(.n-card-header) {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.coi-dialog-header {
|
||||
padding: 12px 16px 8px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coi-dialog-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coi-dialog-custom-header {
|
||||
padding: 12px 16px 8px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 弹框整体样式 */
|
||||
.coi-dialog :deep(.n-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 内容区域样式 */
|
||||
.coi-dialog :deep(.n-card__content) {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.coi-dialog-content {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 底部样式 */
|
||||
.coi-dialog :deep(.n-card__footer) {
|
||||
padding: 8px 16px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.coi-dialog-footer {
|
||||
/* 底部操作区域样式已由 n-space 处理 */
|
||||
}
|
||||
</style>
|
||||
505
src/components/common/CoiEmpty.vue
Normal file
505
src/components/common/CoiEmpty.vue
Normal file
@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div class="coi-empty" :class="sizeClass">
|
||||
<div class="coi-empty__content">
|
||||
<!-- 图标区域 -->
|
||||
<div class="coi-empty__icon-wrapper">
|
||||
<div class="coi-empty__icon-bg" />
|
||||
<NIcon class="coi-empty__icon" :size="iconSize" :color="iconColor">
|
||||
<icon-park-outline-inbox v-if="currentType === 'default'" />
|
||||
<icon-park-outline-search v-else-if="currentType === 'search'" />
|
||||
<icon-park-outline-folder-close v-else-if="currentType === 'network'" />
|
||||
<icon-park-outline-lock v-else-if="currentType === 'permission'" />
|
||||
<icon-park-outline-folder-close v-else-if="currentType === 'custom'" />
|
||||
<icon-park-outline-inbox v-else />
|
||||
</NIcon>
|
||||
</div>
|
||||
|
||||
<!-- 文字区域 -->
|
||||
<div class="coi-empty__text">
|
||||
<h3 class="coi-empty__title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="coi-empty__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div v-if="showAction" class="coi-empty__actions">
|
||||
<slot name="action">
|
||||
<NButton
|
||||
v-if="actionText"
|
||||
:type="actionType"
|
||||
:size="actionButtonSize"
|
||||
round
|
||||
@click="handleAction"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon class="coi-empty__action-icon">
|
||||
<icon-park-outline-refresh v-if="currentType === 'search'" />
|
||||
<icon-park-outline-plus v-else />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ actionText }}
|
||||
</NButton>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性背景元素 -->
|
||||
<div class="coi-empty__decorations">
|
||||
<div class="decoration-circle decoration-circle--1" />
|
||||
<div class="decoration-circle decoration-circle--2" />
|
||||
<div class="decoration-circle decoration-circle--3" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
|
||||
export interface CoiEmptyProps {
|
||||
type?: 'default' | 'search' | 'network' | 'permission' | 'custom'
|
||||
title?: string
|
||||
description?: string
|
||||
iconSize?: number
|
||||
iconColor?: string
|
||||
icon?: any
|
||||
showAction?: boolean
|
||||
actionText?: string
|
||||
actionType?: 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'
|
||||
actionSize?: 'tiny' | 'small' | 'medium' | 'large'
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
interface CoiEmptyEmits {
|
||||
action: []
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CoiEmptyProps>(), {
|
||||
type: 'default',
|
||||
title: '',
|
||||
description: '',
|
||||
iconSize: 0,
|
||||
iconColor: '#d1d5db',
|
||||
icon: undefined,
|
||||
showAction: false,
|
||||
actionText: '',
|
||||
actionType: 'primary',
|
||||
actionSize: 'medium',
|
||||
size: 'medium',
|
||||
})
|
||||
|
||||
const emit = defineEmits<CoiEmptyEmits>()
|
||||
|
||||
// 预定义的类型配置
|
||||
const typeConfigs = {
|
||||
default: {
|
||||
title: '暂无数据',
|
||||
description: '当前没有可显示的数据',
|
||||
},
|
||||
search: {
|
||||
title: '搜索无结果',
|
||||
description: '未找到符合条件的数据,请尝试调整搜索条件',
|
||||
},
|
||||
network: {
|
||||
title: '网络异常',
|
||||
description: '网络连接出现问题,请检查网络连接后重试',
|
||||
},
|
||||
permission: {
|
||||
title: '暂无权限',
|
||||
description: '您没有访问此内容的权限,请联系管理员',
|
||||
},
|
||||
custom: {
|
||||
title: '自定义状态',
|
||||
description: '这是一个自定义的空状态',
|
||||
},
|
||||
}
|
||||
|
||||
// 当前类型
|
||||
const currentType = computed(() => props.type)
|
||||
|
||||
// 计算标题
|
||||
const title = computed(() => {
|
||||
if (props.title) {
|
||||
return props.title
|
||||
}
|
||||
return typeConfigs[props.type].title
|
||||
})
|
||||
|
||||
// 计算描述
|
||||
const description = computed(() => {
|
||||
if (props.description) {
|
||||
return props.description
|
||||
}
|
||||
return typeConfigs[props.type].description
|
||||
})
|
||||
|
||||
// 尺寸相关计算属性
|
||||
const sizeClass = computed(() => `coi-empty--${props.size}`)
|
||||
|
||||
const iconSize = computed(() => {
|
||||
if (props.iconSize > 0) {
|
||||
return props.iconSize
|
||||
}
|
||||
const sizeMap = {
|
||||
small: 60,
|
||||
medium: 80,
|
||||
large: 100,
|
||||
}
|
||||
return sizeMap[props.size]
|
||||
})
|
||||
|
||||
const actionButtonSize = computed(() => {
|
||||
if (props.actionSize !== 'medium') {
|
||||
return props.actionSize
|
||||
}
|
||||
const sizeMap = {
|
||||
small: 'small',
|
||||
medium: 'medium',
|
||||
large: 'large',
|
||||
}
|
||||
return sizeMap[props.size] as any
|
||||
})
|
||||
|
||||
// 处理操作按钮点击
|
||||
function handleAction() {
|
||||
emit('action')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coi-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.coi-empty__content {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.coi-empty__icon-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.coi-empty__icon-bg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--primary-color) 10%, transparent) 0%, color-mix(in srgb, var(--primary-color) 5%, transparent) 100%);
|
||||
opacity: 0.8;
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.coi-empty__icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--primary-color);
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.1));
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.coi-empty__text {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.coi-empty__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.5px;
|
||||
animation: slideInDown 0.6s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.coi-empty__description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
animation: slideInDown 0.6s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
.coi-empty__actions {
|
||||
animation: slideInUp 0.6s ease-out 0.6s both;
|
||||
}
|
||||
|
||||
.coi-empty__action-icon {
|
||||
margin-right: 4px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 装饰性背景元素 */
|
||||
.coi-empty__decorations {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--primary-color) 5%, transparent) 0%, color-mix(in srgb, var(--primary-color) 3%, transparent) 100%);
|
||||
opacity: 0.6;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.decoration-circle--1 {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
top: 20%;
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.decoration-circle--2 {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
top: 60%;
|
||||
right: 15%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.decoration-circle--3 {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 80%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
/* 小尺寸 */
|
||||
.coi-empty--small {
|
||||
min-height: 300px;
|
||||
padding: 30px 15px;
|
||||
}
|
||||
|
||||
.coi-empty--small .coi-empty__content {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.coi-empty--small .coi-empty__icon-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.coi-empty--small .coi-empty__icon-bg {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.coi-empty--small .coi-empty__text {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.coi-empty--small .coi-empty__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.coi-empty--small .coi-empty__description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 中尺寸(默认) */
|
||||
.coi-empty--medium {
|
||||
min-height: 400px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.coi-empty--medium .coi-empty__content {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.coi-empty--medium .coi-empty__icon-wrapper {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.coi-empty--medium .coi-empty__icon-bg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.coi-empty--medium .coi-empty__text {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.coi-empty--medium .coi-empty__title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.coi-empty--medium .coi-empty__description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 大尺寸 */
|
||||
.coi-empty--large {
|
||||
min-height: 500px;
|
||||
padding: 50px 25px;
|
||||
}
|
||||
|
||||
.coi-empty--large .coi-empty__content {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.coi-empty--large .coi-empty__icon-wrapper {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.coi-empty--large .coi-empty__icon-bg {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.coi-empty--large .coi-empty__text {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.coi-empty--large .coi-empty__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.coi-empty--large .coi-empty__description {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 动画定义 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.coi-empty {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
|
||||
}
|
||||
|
||||
.coi-empty__icon-bg {
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, color-mix(in srgb, var(--primary-color) 10%, transparent) 100%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.coi-empty__icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.coi-empty__title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.coi-empty__description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--primary-color) 8%, transparent) 0%, color-mix(in srgb, var(--primary-color) 5%, transparent) 100%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.coi-empty {
|
||||
min-height: 320px;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.coi-empty__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.coi-empty__description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coi-empty__icon-bg {
|
||||
width: 100px !important;
|
||||
height: 100px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画模式 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.coi-empty__content,
|
||||
.coi-empty__title,
|
||||
.coi-empty__description,
|
||||
.coi-empty__actions,
|
||||
.coi-empty__icon-bg,
|
||||
.coi-empty__icon,
|
||||
.decoration-circle {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
46
src/components/common/CoiIcon.vue
Normal file
46
src/components/common/CoiIcon.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<n-icon
|
||||
v-if="icon"
|
||||
:size="size"
|
||||
:depth="depth"
|
||||
:color="color"
|
||||
>
|
||||
<template v-if="isLocal">
|
||||
<i v-html="getLocalIcon(icon)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon :icon="icon" />
|
||||
</template>
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
interface iconPorps {
|
||||
/* 图标名称 */
|
||||
icon?: string
|
||||
/* 图标颜色 */
|
||||
color?: string
|
||||
/* 图标大小 */
|
||||
size?: number
|
||||
/* 图标深度 */
|
||||
depth?: 1 | 2 | 3 | 4 | 5
|
||||
}
|
||||
const { size = 18, icon } = defineProps<iconPorps>()
|
||||
|
||||
const isLocal = computed(() => {
|
||||
return icon && icon.startsWith('local:')
|
||||
})
|
||||
|
||||
function getLocalIcon(icon: string) {
|
||||
const svgName = icon.replace('local:', '')
|
||||
const svg = import.meta.glob<string>('@/assets/svg-icons/*.svg', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
|
||||
return svg[`/src/assets/svg-icons/${svgName}.svg`]
|
||||
}
|
||||
</script>
|
||||
395
src/components/common/CoiImageViewer.vue
Normal file
395
src/components/common/CoiImageViewer.vue
Normal file
@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div class="coi-image-viewer">
|
||||
<!-- 缩略图展示 -->
|
||||
<div
|
||||
class="thumbnail-container"
|
||||
:style="{ width: `${width}px`, height: `${height}px` }"
|
||||
@click="handlePreview"
|
||||
>
|
||||
<img
|
||||
v-if="!imageError && src"
|
||||
:src="src"
|
||||
:alt="alt || 'image'"
|
||||
class="thumbnail-image"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="error-placeholder">
|
||||
<NIcon size="16" class="error-icon">
|
||||
<IconParkOutlinePic />
|
||||
</NIcon>
|
||||
<span class="error-text">加载失败</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬停遮罩层 -->
|
||||
<div class="hover-overlay">
|
||||
<NIcon size="18" class="preview-icon">
|
||||
<IconParkOutlinePreviewOpen />
|
||||
</NIcon>
|
||||
<span class="preview-text">点击预览</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 大图预览弹框 -->
|
||||
<NModal
|
||||
v-model:show="previewVisible"
|
||||
preset="card"
|
||||
:title="modalTitle"
|
||||
:style="{ width: '90%', maxWidth: '1200px' }"
|
||||
:mask-closable="true"
|
||||
:closable="true"
|
||||
:auto-focus="false"
|
||||
:trap-focus="false"
|
||||
>
|
||||
<div class="preview-container">
|
||||
<img
|
||||
v-if="!previewError"
|
||||
:src="src"
|
||||
:alt="alt || 'preview'"
|
||||
class="preview-image"
|
||||
@load="handlePreviewLoad"
|
||||
@error="handlePreviewError"
|
||||
>
|
||||
<div v-else class="preview-error">
|
||||
<NIcon size="48" class="error-icon">
|
||||
<IconParkOutlinePic />
|
||||
</NIcon>
|
||||
<p class="error-message">
|
||||
图片加载失败
|
||||
</p>
|
||||
<p class="error-url">
|
||||
{{ src }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 预览加载状态 -->
|
||||
<div v-if="previewLoading" class="preview-loading">
|
||||
<NSpin size="large" />
|
||||
<p class="loading-text">
|
||||
图片加载中...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹框底部操作 -->
|
||||
<template #action>
|
||||
<NSpace justify="space-between">
|
||||
<NSpace>
|
||||
<NButton size="small" @click="handleDownload">
|
||||
<template #icon>
|
||||
<NIcon><IconParkOutlineDownload /></NIcon>
|
||||
</template>
|
||||
下载图片
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleCopyUrl">
|
||||
<template #icon>
|
||||
<NIcon><IconParkOutlineCopy /></NIcon>
|
||||
</template>
|
||||
复制链接
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NButton type="primary" @click="previewVisible = false">
|
||||
关闭
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
|
||||
import IconParkOutlinePic from '~icons/icon-park-outline/pic'
|
||||
import IconParkOutlinePreviewOpen from '~icons/icon-park-outline/preview-open'
|
||||
import IconParkOutlineDownload from '~icons/icon-park-outline/download'
|
||||
import IconParkOutlineCopy from '~icons/icon-park-outline/copy'
|
||||
|
||||
interface Props {
|
||||
/** 图片链接 */
|
||||
src: string | undefined
|
||||
/** 图片描述 */
|
||||
alt?: string
|
||||
/** 缩略图宽度 */
|
||||
width?: number
|
||||
/** 缩略图高度 */
|
||||
height?: number
|
||||
/** 是否可以预览 */
|
||||
previewable?: boolean
|
||||
/** 预览弹框标题 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alt: '',
|
||||
width: 50,
|
||||
height: 40,
|
||||
previewable: true,
|
||||
title: '图片预览',
|
||||
})
|
||||
|
||||
// 缩略图状态
|
||||
const loading = ref(true)
|
||||
const imageError = ref(false)
|
||||
|
||||
// 预览状态
|
||||
const previewVisible = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref(false)
|
||||
|
||||
// 计算弹框标题
|
||||
const modalTitle = computed(() => {
|
||||
return props.title || props.alt || '图片预览'
|
||||
})
|
||||
|
||||
// 缩略图加载完成
|
||||
function handleImageLoad() {
|
||||
loading.value = false
|
||||
imageError.value = false
|
||||
}
|
||||
|
||||
// 缩略图加载错误
|
||||
function handleImageError() {
|
||||
loading.value = false
|
||||
imageError.value = true
|
||||
}
|
||||
|
||||
// 点击预览
|
||||
function handlePreview() {
|
||||
if (!props.previewable || imageError.value) {
|
||||
return
|
||||
}
|
||||
|
||||
previewVisible.value = true
|
||||
previewLoading.value = true
|
||||
previewError.value = false
|
||||
}
|
||||
|
||||
// 预览图片加载完成
|
||||
function handlePreviewLoad() {
|
||||
previewLoading.value = false
|
||||
previewError.value = false
|
||||
}
|
||||
|
||||
// 预览图片加载错误
|
||||
function handlePreviewError() {
|
||||
previewLoading.value = false
|
||||
previewError.value = true
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
async function handleDownload() {
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = props.src
|
||||
link.download = props.alt || 'image'
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
coiMsgSuccess('下载已开始')
|
||||
}
|
||||
catch {
|
||||
coiMsgError('下载失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制图片链接
|
||||
async function handleCopyUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.src)
|
||||
coiMsgSuccess('链接已复制到剪贴板')
|
||||
}
|
||||
catch {
|
||||
coiMsgError('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态(当src变化时)
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
loading.value = true
|
||||
imageError.value = false
|
||||
previewError.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coi-image-viewer {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.thumbnail-container {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--card-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.thumbnail-container:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.thumbnail-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.thumbnail-container:hover .thumbnail-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 当组件被设置为100%宽高时的样式调整 */
|
||||
.coi-image-viewer.w-full.h-full .thumbnail-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.error-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-color);
|
||||
color: var(--text-color-3);
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.hover-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.thumbnail-container:hover .hover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-icon {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--card-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--body-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color-3);
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 16px;
|
||||
margin: 16px 0 8px 0;
|
||||
color: var(--text-color-2);
|
||||
}
|
||||
|
||||
.error-url {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-3);
|
||||
word-break: break-all;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--modal-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
color: var(--text-color-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
474
src/components/common/CoiPagination.vue
Normal file
474
src/components/common/CoiPagination.vue
Normal file
@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div class="coi-pagination">
|
||||
<div class="coi-pagination__container">
|
||||
<!-- 数据统计信息 -->
|
||||
<div class="coi-pagination__info">
|
||||
<span class="coi-pagination__text">共 {{ itemCount }} 条</span>
|
||||
<span v-if="showCurrentInfo" class="coi-pagination__text">
|
||||
当前第 {{ (currentPage - 1) * pageSize + 1 }} - {{ Math.min(currentPage * pageSize, itemCount) }} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页器容器 -->
|
||||
<div class="coi-pagination__controls">
|
||||
<!-- 每页显示数量选择器 -->
|
||||
<div v-if="showSizePicker" class="coi-pagination__size-picker">
|
||||
<span class="coi-pagination__size-label">每页</span>
|
||||
<NSelect
|
||||
v-model:value="internalPageSize"
|
||||
:options="pageSizeOptions"
|
||||
size="small"
|
||||
class="coi-pagination__size-select"
|
||||
@update:value="handlePageSizeChange"
|
||||
/>
|
||||
<span class="coi-pagination__size-label">条</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<div class="coi-pagination__pager">
|
||||
<!-- 首页按钮 -->
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="coi-pagination__button coi-pagination__button--first"
|
||||
@click="handlePageChange(1)"
|
||||
>
|
||||
<NIcon size="14">
|
||||
<icon-park-outline-go-start />
|
||||
</NIcon>
|
||||
</button>
|
||||
|
||||
<!-- 上一页按钮 -->
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="coi-pagination__button coi-pagination__button--prev"
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
>
|
||||
<NIcon size="14">
|
||||
<icon-park-outline-left />
|
||||
</NIcon>
|
||||
</button>
|
||||
|
||||
<!-- 页码按钮 -->
|
||||
<div class="coi-pagination__pages">
|
||||
<template v-for="pageNumber in visiblePages" :key="pageNumber">
|
||||
<!-- 省略号 -->
|
||||
<span v-if="pageNumber < 0" class="coi-pagination__ellipsis">
|
||||
...
|
||||
</span>
|
||||
<!-- 正常页码按钮 -->
|
||||
<button
|
||||
v-else
|
||||
class="coi-pagination__button coi-pagination__button--page"
|
||||
:class="{ 'coi-pagination__button--active': pageNumber === currentPage }"
|
||||
@click="handlePageChange(pageNumber)"
|
||||
>
|
||||
{{ pageNumber }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 下一页按钮 -->
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="coi-pagination__button coi-pagination__button--next"
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
>
|
||||
<NIcon size="14">
|
||||
<icon-park-outline-right />
|
||||
</NIcon>
|
||||
</button>
|
||||
|
||||
<!-- 尾页按钮 -->
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="coi-pagination__button coi-pagination__button--last"
|
||||
@click="handlePageChange(totalPages)"
|
||||
>
|
||||
<NIcon size="14">
|
||||
<icon-park-outline-go-end />
|
||||
</NIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 快速跳转 -->
|
||||
<div v-if="showQuickJumper" class="coi-pagination__jumper">
|
||||
<span class="coi-pagination__jumper-label">跳至</span>
|
||||
<NInput
|
||||
v-model:value="jumpValue"
|
||||
size="small"
|
||||
type="number"
|
||||
class="coi-pagination__jumper-input"
|
||||
:min="1"
|
||||
:max="totalPages"
|
||||
@keydown.enter="handleQuickJump"
|
||||
@blur="handleQuickJump"
|
||||
/>
|
||||
<span class="coi-pagination__jumper-label">页</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NIcon, NInput, NSelect } from 'naive-ui'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
interface Props {
|
||||
/** 当前页码 */
|
||||
page?: number
|
||||
/** 每页显示数量 */
|
||||
pageSize?: number
|
||||
/** 总条数 */
|
||||
itemCount: number
|
||||
/** 是否显示每页数量选择器 */
|
||||
showSizePicker?: boolean
|
||||
/** 每页数量选项 */
|
||||
pageSizes?: number[]
|
||||
/** 是否显示快速跳转 */
|
||||
showQuickJumper?: boolean
|
||||
/** 是否显示当前数据范围信息 */
|
||||
showCurrentInfo?: boolean
|
||||
/** 分页器尺寸 */
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(event: 'update:page', page: number): void
|
||||
(event: 'update:pageSize', pageSize: number): void
|
||||
(event: 'pageChange', page: number): void
|
||||
(event: 'pageSizeChange', pageSize: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
showQuickJumper: true,
|
||||
showCurrentInfo: false,
|
||||
size: 'medium',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 获取应用store以访问主题色
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 获取主题色
|
||||
const primaryColor = computed(() => appStore.primaryColor)
|
||||
const primaryColorHover = computed(() => appStore.theme.common.primaryColorHover)
|
||||
|
||||
// 内部状态
|
||||
const internalPage = ref(props.page)
|
||||
const internalPageSize = ref(props.pageSize)
|
||||
const jumpValue = ref('')
|
||||
|
||||
// 计算属性
|
||||
const currentPage = computed(() => internalPage.value)
|
||||
const totalPages = computed(() => Math.ceil(props.itemCount / internalPageSize.value))
|
||||
|
||||
// 每页数量选项
|
||||
const pageSizeOptions = computed(() => {
|
||||
return props.pageSizes.map(size => ({
|
||||
label: `${size} 条/页`,
|
||||
value: size,
|
||||
}))
|
||||
})
|
||||
|
||||
// 可见页码
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = []
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (total <= 7) {
|
||||
// 总页数少于等于7页,显示所有页码(包括只有1页的情况)
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 总页数大于7页,使用省略号逻辑
|
||||
if (current <= 4) {
|
||||
// 当前页在前4页:1, 2, 3, 4, 5, ..., total
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
if (total > 6) {
|
||||
pages.push(-1) // 省略号
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
else if (current >= total - 3) {
|
||||
// 当前页在后4页:1, ..., total-4, total-3, total-2, total-1, total
|
||||
pages.push(1)
|
||||
if (total > 6) {
|
||||
pages.push(-1) // 省略号
|
||||
}
|
||||
for (let i = Math.max(2, total - 4); i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 当前页在中间:1, ..., current-1, current, current+1, ..., total
|
||||
pages.push(1)
|
||||
pages.push(-1) // 省略号
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push(-2) // 省略号
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
watch(() => props.page, (newPage) => {
|
||||
internalPage.value = newPage
|
||||
})
|
||||
|
||||
watch(() => props.pageSize, (newPageSize) => {
|
||||
internalPageSize.value = newPageSize
|
||||
})
|
||||
|
||||
// 处理页码变化
|
||||
function handlePageChange(page: number) {
|
||||
if (page < 1 || page > totalPages.value || page === currentPage.value) {
|
||||
return
|
||||
}
|
||||
|
||||
internalPage.value = page
|
||||
emit('update:page', page)
|
||||
emit('pageChange', page)
|
||||
}
|
||||
|
||||
// 处理每页数量变化
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
// 如果新的pageSize与当前内部状态相同,但与props不同,仍然需要触发事件
|
||||
const shouldSkip = pageSize === internalPageSize.value && pageSize === props.pageSize
|
||||
if (shouldSkip) {
|
||||
return
|
||||
}
|
||||
|
||||
internalPageSize.value = pageSize
|
||||
|
||||
// 调整当前页码,确保不超出范围
|
||||
const newTotalPages = Math.ceil(props.itemCount / pageSize)
|
||||
if (currentPage.value > newTotalPages) {
|
||||
internalPage.value = newTotalPages
|
||||
emit('update:page', newTotalPages)
|
||||
emit('pageChange', newTotalPages)
|
||||
}
|
||||
|
||||
emit('update:pageSize', pageSize)
|
||||
emit('pageSizeChange', pageSize)
|
||||
}
|
||||
|
||||
// 处理快速跳转
|
||||
function handleQuickJump() {
|
||||
const page = Number.parseInt(jumpValue.value)
|
||||
if (Number.isNaN(page) || page < 1 || page > totalPages.value) {
|
||||
jumpValue.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
handlePageChange(page)
|
||||
jumpValue.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coi-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.coi-pagination__container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.coi-pagination__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--text-color-3);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coi-pagination__text {
|
||||
color: var(--text-color-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.coi-pagination__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.coi-pagination__size-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coi-pagination__size-label {
|
||||
color: var(--text-color-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.coi-pagination__size-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.coi-pagination__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.coi-pagination__pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.coi-pagination__ellipsis {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-color-3);
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.coi-pagination__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--card-color);
|
||||
color: var(--text-color-1);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.coi-pagination__button:hover:not(:disabled) {
|
||||
border-color: v-bind(primaryColor);
|
||||
color: v-bind(primaryColor);
|
||||
background: v-bind(`${primaryColor}15`);
|
||||
}
|
||||
|
||||
.coi-pagination__button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
background: var(--card-color);
|
||||
color: var(--text-color-3);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.coi-pagination__button--active {
|
||||
border-color: v-bind(primaryColor) !important;
|
||||
background-color: v-bind(primaryColor) !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.coi-pagination__button--active:hover {
|
||||
border-color: v-bind(primaryColorHover) !important;
|
||||
background-color: v-bind(primaryColorHover) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.coi-pagination__button--first,
|
||||
.coi-pagination__button--prev,
|
||||
.coi-pagination__button--next,
|
||||
.coi-pagination__button--last {
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.coi-pagination__jumper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coi-pagination__jumper-label {
|
||||
color: var(--text-color-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.coi-pagination__jumper-input {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* 深色主题适配 */
|
||||
.coi-pagination :deep(.n-select) {
|
||||
--n-color: var(--card-color);
|
||||
--n-color-hover: var(--card-color);
|
||||
--n-border-color: var(--border-color);
|
||||
--n-border-color-hover: v-bind(primaryColor);
|
||||
--n-border-color-focus: v-bind(primaryColor);
|
||||
--n-text-color: var(--text-color-1);
|
||||
}
|
||||
|
||||
.coi-pagination :deep(.n-input) {
|
||||
--n-color: var(--card-color);
|
||||
--n-color-focus: var(--card-color);
|
||||
--n-border-color: var(--border-color);
|
||||
--n-border-color-hover: v-bind(primaryColor);
|
||||
--n-border-color-focus: v-bind(primaryColor);
|
||||
--n-text-color: var(--text-color-1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.coi-pagination__container {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.coi-pagination__controls {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.coi-pagination__pager {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.coi-pagination__button {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
src/components/common/CommonWrapper.vue
Normal file
28
src/components/common/CommonWrapper.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<n-el
|
||||
tag="div"
|
||||
class="el p-3 cursor-pointer rounded"
|
||||
>
|
||||
<n-flex
|
||||
align="center"
|
||||
:wrap="false"
|
||||
class="h-full"
|
||||
>
|
||||
<slot />
|
||||
</n-flex>
|
||||
</n-el>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el {
|
||||
color: var(--n-text-color);
|
||||
transition: 0.3s var(--cubic-bezier-ease-in-out);
|
||||
}
|
||||
.el:hover {
|
||||
background-color: var(--button-color-2-hover);
|
||||
color: var(--n-text-color-hover);
|
||||
}
|
||||
</style>
|
||||
52
src/components/common/DarkModeSwitch.vue
Normal file
52
src/components/common/DarkModeSwitch.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<n-popselect :value="appStore.storeColorMode" :render-label="renderLabel" :options="options" trigger="click" @update:value="appStore.setColorMode">
|
||||
<CommonWrapper>
|
||||
<icon-park-outline-moon v-if="appStore.storeColorMode === 'dark'" />
|
||||
<icon-park-outline-sun-one v-if="appStore.storeColorMode === 'light'" />
|
||||
<icon-park-outline-laptop-computer v-if="appStore.storeColorMode === 'auto'" />
|
||||
</CommonWrapper>
|
||||
</n-popselect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
import IconAuto from '~icons/icon-park-outline/laptop-computer'
|
||||
import IconMoon from '~icons/icon-park-outline/moon'
|
||||
import IconSun from '~icons/icon-park-outline/sun-one'
|
||||
import { NFlex } from 'naive-ui'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const options = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('app.light'),
|
||||
value: 'light',
|
||||
icon: IconSun,
|
||||
},
|
||||
{
|
||||
label: t('app.dark'),
|
||||
value: 'dark',
|
||||
icon: IconMoon,
|
||||
},
|
||||
{
|
||||
label: t('app.system'),
|
||||
value: 'auto',
|
||||
icon: IconAuto,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function renderLabel(option: any) {
|
||||
return h(NFlex, { align: 'center' }, {
|
||||
default: () => [
|
||||
h(option.icon),
|
||||
option.label,
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
71
src/components/common/DictTag.vue
Normal file
71
src/components/common/DictTag.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<n-tag
|
||||
v-if="option"
|
||||
:type="tagType"
|
||||
:style="tagStyle"
|
||||
:size="size"
|
||||
:bordered="!option.dictColor"
|
||||
class="dict-tag"
|
||||
>
|
||||
<slot :option="option" :label="option.dictLabel">
|
||||
{{ option.dictLabel }}
|
||||
</slot>
|
||||
</n-tag>
|
||||
<span v-else class="dict-tag__placeholder">
|
||||
<slot name="placeholder" :value="value">
|
||||
{{ fallbackLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TagProps } from 'naive-ui'
|
||||
import { useDict } from '@/hooks'
|
||||
|
||||
type DictValue = string | number | boolean | null | undefined
|
||||
|
||||
interface DictTagProps {
|
||||
dictType: string
|
||||
value: DictValue
|
||||
size?: NonNullable<TagProps['size']>
|
||||
fallbackLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DictTagProps>(), {
|
||||
size: 'small',
|
||||
fallbackLabel: '-',
|
||||
})
|
||||
|
||||
const { getDictOption, getDictLabel } = useDict(computed(() => [props.dictType]))
|
||||
|
||||
const option = computed(() => getDictOption(props.dictType, props.value))
|
||||
|
||||
const tagType = computed<TagProps['type']>(() => {
|
||||
if (!option.value)
|
||||
return 'default'
|
||||
|
||||
return option.value.dictColor ? 'default' : (option.value.dictTag as TagProps['type']) ?? 'default'
|
||||
})
|
||||
|
||||
const tagStyle = computed(() => {
|
||||
if (!option.value?.dictColor)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
borderColor: option.value.dictColor,
|
||||
backgroundColor: option.value.dictColor,
|
||||
color: '#fff',
|
||||
} satisfies Record<string, string>
|
||||
})
|
||||
|
||||
const fallbackLabel = computed(() => getDictLabel(props.dictType, props.value, props.fallbackLabel))
|
||||
|
||||
const value = computed(() => props.value)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-tag__placeholder {
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
</style>
|
||||
246
src/components/common/ErrorTip.vue
Normal file
246
src/components/common/ErrorTip.vue
Normal file
@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="flex-col-center h-full">
|
||||
<img
|
||||
v-if="type === '403'"
|
||||
src="@/assets/svg/error-403.svg"
|
||||
alt=""
|
||||
class="w-1/3"
|
||||
>
|
||||
<img
|
||||
v-if="type === '404'"
|
||||
src="@/assets/svg/error-404.svg"
|
||||
alt=""
|
||||
class="w-1/3"
|
||||
>
|
||||
<img
|
||||
v-if="type === '500'"
|
||||
src="@/assets/svg/error-500.svg"
|
||||
alt=""
|
||||
class="w-1/3"
|
||||
>
|
||||
|
||||
<!-- 网络状态检测提示 -->
|
||||
<div v-if="!isOnline || !isBackendAvailable" class="mt-4 text-center">
|
||||
<n-alert type="warning" :show-icon="false" class="mb-4">
|
||||
<template v-if="!isOnline">
|
||||
网络连接失败,请检查网络状态
|
||||
</template>
|
||||
<template v-else-if="!isBackendAvailable">
|
||||
服务暂时不可用,请稍后重试
|
||||
</template>
|
||||
</n-alert>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
<template v-if="!isOnline">
|
||||
正在检测网络连接...
|
||||
</template>
|
||||
<template v-else-if="!isBackendAvailable">
|
||||
正在检测服务状态...
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex justify-center space-x-1 mb-4">
|
||||
<div class="loading-dot" style="animation-delay: 0s;" />
|
||||
<div class="loading-dot" style="animation-delay: 0.1s;" />
|
||||
<div class="loading-dot" style="animation-delay: 0.2s;" />
|
||||
</div>
|
||||
|
||||
<!-- 服务检测提示 -->
|
||||
<div v-if="(!isBackendAvailable || shouldAutoRedirect) && type === '404'" class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400">
|
||||
<template v-if="!isBackendAvailable">
|
||||
检测到后端服务问题,正在重定向到登录页面...
|
||||
</template>
|
||||
<template v-else-if="shouldAutoRedirect">
|
||||
检测到可能的服务问题,即将自动跳转到登录页面...
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<n-button
|
||||
v-if="!isOnline || !isBackendAvailable"
|
||||
type="primary"
|
||||
:loading="isRetrying"
|
||||
@click="handleRetry"
|
||||
>
|
||||
{{ isRetrying ? '重试中...' : '重试连接' }}
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="isOnline && isBackendAvailable"
|
||||
type="primary"
|
||||
@click="handleBackHome"
|
||||
>
|
||||
{{ $t('app.backHome') }}
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="!isOnline || !isBackendAvailable"
|
||||
text
|
||||
@click="handleBackToLogin"
|
||||
>
|
||||
返回登录页面
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
|
||||
import { local } from '@/utils'
|
||||
|
||||
defineProps<{
|
||||
/** 异常类型 403 404 500 */
|
||||
type: '403' | '404' | '500'
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const isRetrying = ref(false)
|
||||
const isBackendAvailable = ref(true)
|
||||
const shouldAutoRedirect = ref(false)
|
||||
|
||||
// 监听网络状态变化
|
||||
function updateOnlineStatus() {
|
||||
isOnline.value = navigator.onLine
|
||||
}
|
||||
|
||||
// 检测后端服务可用性
|
||||
async function checkBackendAvailability() {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000)
|
||||
|
||||
await fetch('/api/login/captchaPng', {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
isBackendAvailable.value = true
|
||||
}
|
||||
catch {
|
||||
isBackendAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 智能状态检测
|
||||
async function checkServiceStatus() {
|
||||
const currentRoute = router.currentRoute.value
|
||||
|
||||
// 如果是404页面,很可能是后端服务问题
|
||||
if (currentRoute.name === '404' || currentRoute.name === 'notFound') {
|
||||
// 检查是否有认证信息(说明用户之前是登录状态)
|
||||
const hasAuthToken = Boolean(local.get('accessToken'))
|
||||
|
||||
if (hasAuthToken) {
|
||||
// 先尝试检查后端服务状态
|
||||
try {
|
||||
await checkBackendAvailability()
|
||||
|
||||
// 如果后端服务不可用,立即跳转到登录页面
|
||||
if (!isBackendAvailable.value) {
|
||||
handleBackToLogin()
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 检测异常,忽略错误继续执行后续逻辑
|
||||
}
|
||||
|
||||
// 简化方案:如果用户在一个受保护的页面但显示404,直接跳转
|
||||
const isProtectedPath = currentRoute.fullPath.includes('/system/')
|
||||
|| currentRoute.fullPath.includes('/management/')
|
||||
|| currentRoute.fullPath.includes('/tools/')
|
||||
|
||||
if (isProtectedPath) {
|
||||
shouldAutoRedirect.value = true
|
||||
setTimeout(() => {
|
||||
handleBackToLogin()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重试连接
|
||||
async function handleRetry() {
|
||||
isRetrying.value = true
|
||||
|
||||
try {
|
||||
// 检查网络状态
|
||||
updateOnlineStatus()
|
||||
|
||||
// 检查后端服务可用性
|
||||
await checkBackendAvailability()
|
||||
|
||||
if (isOnline.value && isBackendAvailable.value) {
|
||||
coiMsgSuccess('服务连接已恢复')
|
||||
// 尝试重新加载当前页面
|
||||
window.location.reload()
|
||||
}
|
||||
else if (!isOnline.value) {
|
||||
coiMsgError('网络连接失败,请检查网络设置')
|
||||
}
|
||||
else if (!isBackendAvailable.value) {
|
||||
coiMsgError('后端服务暂时不可用,请稍后重试')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
coiMsgError('重试失败,请稍后再试')
|
||||
}
|
||||
finally {
|
||||
isRetrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回首页
|
||||
function handleBackHome() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 返回登录页面
|
||||
function handleBackToLogin() {
|
||||
// 清除认证信息
|
||||
local.remove('accessToken')
|
||||
local.remove('refreshToken')
|
||||
|
||||
// 跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 网络状态监听
|
||||
onMounted(async () => {
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
|
||||
// 初始检查服务状态
|
||||
await checkServiceStatus()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Loading点动画 */
|
||||
.loading-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
src/components/common/HelpInfo.vue
Normal file
16
src/components/common/HelpInfo.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<n-tooltip :show-arrow="false" trigger="hover">
|
||||
<template #trigger>
|
||||
<icon-park-outline-help class="op-50 cursor-help" />
|
||||
</template>
|
||||
{{ message }}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
const { message } = defineProps<Props>()
|
||||
</script>
|
||||
190
src/components/common/IconSelect.vue
Normal file
190
src/components/common/IconSelect.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<n-input-group>
|
||||
<n-input :value="value" readonly placeholder="请选择图标">
|
||||
<template v-if="value" #prefix>
|
||||
<CoiIcon :icon="value" :size="16" />
|
||||
</template>
|
||||
</n-input>
|
||||
<n-button type="primary" ghost :disabled="disabled" @click="showModal = true">
|
||||
选择
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<n-modal
|
||||
v-model:show="showModal" preset="card" title="选择图标" size="small" class="w-800px" :bordered="false"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button type="warning" size="small" ghost @click="clearIcon">
|
||||
清除图标
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-tabs :value="currentTab" type="line" animated placement="left" @update:value="handleChangeTab">
|
||||
<n-tab-pane v-for="(list, index) in iconList" :key="list.prefix" :name="index" :tab="list.title">
|
||||
<n-flex vertical>
|
||||
<n-flex size="small">
|
||||
<n-tag
|
||||
v-for="(_v, k) in list.categories" :key="k"
|
||||
:checked="currentTag === k" round checkable size="small"
|
||||
@update:checked="handleSelectIconTag(k)"
|
||||
>
|
||||
{{ k }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<n-input
|
||||
v-model:value="searchValue" type="text" clearable
|
||||
placeholder="搜索图标"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<n-flex :size="2">
|
||||
<n-el
|
||||
v-for="(icon) in visibleIcons" :key="icon"
|
||||
class="hover:(text-[var(--primary-color)] ring-1) ring-[var(--primary-color)] p-1 rounded flex-center"
|
||||
:title="`${list.prefix}:${icon}`"
|
||||
@click="handleSelectIcon(`${list.prefix}:${icon}`)"
|
||||
>
|
||||
<CoiIcon :icon="`${list.prefix}:${icon}`" :size="24" />
|
||||
</n-el>
|
||||
<n-empty v-if="visibleIcons.length === 0" class="w-full" />
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<n-flex justify="center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
:item-count="filteredIcons.length"
|
||||
:page-size="200"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
} = defineProps<Props>()
|
||||
|
||||
interface IconList {
|
||||
prefix: string
|
||||
icons: string[]
|
||||
title: string
|
||||
total: number
|
||||
categories?: Record<string, string[]>
|
||||
uncategorized?: string[]
|
||||
}
|
||||
const value = defineModel('value', { type: String })
|
||||
|
||||
// 包含的图标库系列名,更多:https://icon-sets.iconify.design/
|
||||
const nameList = ['icon-park-outline', 'carbon', 'ant-design']
|
||||
|
||||
// 获取单个图标库数据
|
||||
async function fetchIconList(name: string): Promise<IconList> {
|
||||
return await fetch(`https://api.iconify.design/collection?prefix=${name}`).then(res => res.json())
|
||||
}
|
||||
|
||||
// 获取所有图标库数据
|
||||
async function fetchIconAllList(nameList: string[]) {
|
||||
// 并行请求所有图标列表
|
||||
const targets = await Promise.all(nameList.map(fetchIconList))
|
||||
|
||||
// 处理每个返回的图标数据
|
||||
const iconList = targets.map((item) => {
|
||||
const icons = [
|
||||
...(item.categories ? Object.values(item.categories).flat() : []),
|
||||
...(item.uncategorized ? Object.values(item.uncategorized).flat() : []),
|
||||
]
|
||||
return { ...item, icons }
|
||||
})
|
||||
|
||||
// 处理本地图标
|
||||
const svgNames = Object.keys(import.meta.glob('@/assets/svg-icons/*.svg')).map(
|
||||
path => path.split('/').pop()?.replace('.svg', ''),
|
||||
).filter(Boolean) as string[] // 过滤掉 undefined 并断言为 string[]
|
||||
|
||||
// 在数组开头添加
|
||||
iconList.unshift({
|
||||
prefix: 'local',
|
||||
title: 'Local Icons',
|
||||
icons: svgNames,
|
||||
total: svgNames.length,
|
||||
uncategorized: svgNames,
|
||||
})
|
||||
|
||||
return iconList
|
||||
}
|
||||
|
||||
const iconList = shallowRef<IconList[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
iconList.value = await fetchIconAllList(nameList)
|
||||
})
|
||||
|
||||
// 当前tab
|
||||
const currentTab = shallowRef(0)
|
||||
// 当前tag
|
||||
const currentTag = shallowRef('')
|
||||
|
||||
// 搜索图标输入框值
|
||||
const searchValue = ref('')
|
||||
|
||||
// 当前页数
|
||||
const currentPage = shallowRef(1)
|
||||
|
||||
// 切换tab
|
||||
function handleChangeTab(index: number) {
|
||||
currentTab.value = index
|
||||
currentTag.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 选择分类tag
|
||||
function handleSelectIconTag(icon: string) {
|
||||
currentTag.value = currentTag.value === icon ? '' : icon
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 包含当前分类或所有图标列表
|
||||
const icons = computed(() => {
|
||||
if (!iconList.value[currentTab.value])
|
||||
return []
|
||||
const hasTag = !!currentTag.value
|
||||
return hasTag
|
||||
? iconList.value[currentTab.value]?.categories?.[currentTag.value] || [] // 使用可选链
|
||||
: iconList.value[currentTab.value].icons || []
|
||||
})
|
||||
|
||||
// 符合搜索条件的图标列表
|
||||
const filteredIcons = computed(() => {
|
||||
return icons.value?.filter(i => i.includes(searchValue.value)) || []
|
||||
})
|
||||
|
||||
// 当前页显示的图标
|
||||
const visibleIcons = computed(() => {
|
||||
return filteredIcons.value.slice((currentPage.value - 1) * 200, currentPage.value * 200)
|
||||
})
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
// 选择图标
|
||||
function handleSelectIcon(icon: string) {
|
||||
value.value = icon
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// 清除图标
|
||||
function clearIcon() {
|
||||
value.value = ''
|
||||
showModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
25
src/components/common/LangsSwitch.vue
Normal file
25
src/components/common/LangsSwitch.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<n-popselect :value="appStore.lang" :options="options" trigger="click" @update:value="appStore.setAppLang">
|
||||
<CommonWrapper>
|
||||
<icon-park-outline-translate />
|
||||
</CommonWrapper>
|
||||
</n-popselect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const options = [
|
||||
{
|
||||
label: 'English',
|
||||
value: 'enUS',
|
||||
},
|
||||
{
|
||||
label: '中文',
|
||||
value: 'zhCN',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
36
src/components/common/NaiveProvider.vue
Normal file
36
src/components/common/NaiveProvider.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-message-provider>
|
||||
<slot />
|
||||
<NaiveProviderContent />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui'
|
||||
|
||||
// 挂载naive组件的方法至window, 以便在路由钩子函数和请求函数里面调用
|
||||
function registerNaiveTools() {
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$dialog = useDialog()
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
name: 'NaiveProviderContent',
|
||||
setup() {
|
||||
registerNaiveTools()
|
||||
},
|
||||
render() {
|
||||
return h('div')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
112
src/components/common/UserCenter.vue
Normal file
112
src/components/common/UserCenter.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<n-dropdown
|
||||
placement="bottom-end"
|
||||
:options="[
|
||||
{
|
||||
label: '个人中心',
|
||||
key: 'personal-center',
|
||||
icon: () => h(IconUser),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
label: '退出登录',
|
||||
key: 'logout',
|
||||
icon: () => h(IconLogout),
|
||||
},
|
||||
]"
|
||||
@select="(key) => {
|
||||
if (key === 'personal-center') {
|
||||
handlePersonalCenter()
|
||||
}
|
||||
else if (key === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-50 hover:bg-opacity-80 rounded-lg px-3 py-2 transition-colors">
|
||||
<!-- 用户头像 -->
|
||||
<n-avatar
|
||||
:size="32"
|
||||
:src="avatar"
|
||||
fallback-src=""
|
||||
round
|
||||
class="border border-gray-200"
|
||||
>
|
||||
<template #placeholder>
|
||||
<icon-park-outline-user class="text-lg" />
|
||||
</template>
|
||||
</n-avatar>
|
||||
|
||||
<!-- 用户名称 -->
|
||||
<span class="text-sm font-medium text-gray-700 max-w-20 truncate">
|
||||
{{ displayName }}
|
||||
</span>
|
||||
|
||||
<!-- 下拉箭头 -->
|
||||
<icon-park-outline-down class="text-xs text-gray-500" />
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { coiMsgBox } from '@/utils/coi'
|
||||
import IconUser from '~icons/icon-park-outline/user'
|
||||
import IconLogout from '~icons/icon-park-outline/logout'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = computed(() => authStore.userInfo)
|
||||
|
||||
// 获取用户显示名称
|
||||
const displayName = computed(() => {
|
||||
if (!userInfo.value)
|
||||
return '未知用户'
|
||||
return userInfo.value.userName || '未知用户'
|
||||
})
|
||||
|
||||
// 获取用户头像
|
||||
const avatar = computed(() => {
|
||||
if (!userInfo.value?.avatar)
|
||||
return ''
|
||||
return userInfo.value.avatar
|
||||
})
|
||||
|
||||
// 处理个人中心点击
|
||||
function handlePersonalCenter() {
|
||||
router.push('/personal-center/index')
|
||||
}
|
||||
|
||||
// 处理退出登录
|
||||
function handleLogout() {
|
||||
coiMsgBox('确定要退出登录吗?', '退出确认').then(() => {
|
||||
authStore.logout()
|
||||
}).catch(() => {
|
||||
// 取消操作
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dark .hover\:bg-gray-50:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
border-color: #374151;
|
||||
}
|
||||
</style>
|
||||
12
src/constants/Regex.ts
Normal file
12
src/constants/Regex.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @description Some common rules
|
||||
* @link https://any-rule.vercel.app/
|
||||
*/
|
||||
|
||||
export enum Regex {
|
||||
Url = '^(((ht|f)tps?):\\\/\\\/)?([^!@#$%^&*?.\\s-]([^!@#$%^&*?.\\s]{0,63}[^!@#$%^&*?.\\s])?\\.)+[a-z]{2,6}\\\/?',
|
||||
|
||||
Email = '^(([^<>()[\\]\\\\.,;:\\s@"]+(\\.[^<>()[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$',
|
||||
|
||||
RouteName = '^[\\w_!@#$%^&*~-]+$',
|
||||
}
|
||||
5
src/constants/User.ts
Normal file
5
src/constants/User.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/** Gender */
|
||||
export enum Gender {
|
||||
male,
|
||||
female,
|
||||
}
|
||||
2
src/constants/index.ts
Normal file
2
src/constants/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Regex'
|
||||
export * from './User'
|
||||
138
src/constants/permissions.ts
Normal file
138
src/constants/permissions.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 权限常量映射
|
||||
* 根据后端接口权限标识定义前端权限常量
|
||||
*/
|
||||
|
||||
// 系统管理模块权限
|
||||
export const PERMISSIONS = {
|
||||
// 用户管理权限
|
||||
USER: {
|
||||
LIST: 'system:user:list',
|
||||
ADD: 'system:user:add',
|
||||
UPDATE: 'system:user:update',
|
||||
DELETE: 'system:user:delete',
|
||||
RESET_PWD: 'system:user:resetPwd',
|
||||
EXPORT: 'system:user:export',
|
||||
IMPORT: 'system:user:import',
|
||||
ROLE: 'system:user:role', // 分配角色权限
|
||||
},
|
||||
|
||||
// 角色管理权限
|
||||
ROLE: {
|
||||
LIST: 'system:role:list',
|
||||
ADD: 'system:role:add',
|
||||
UPDATE: 'system:role:update',
|
||||
DELETE: 'system:role:delete',
|
||||
MENU: 'system:role:menu', // 分配菜单权限
|
||||
},
|
||||
|
||||
// 菜单管理权限
|
||||
MENU: {
|
||||
LIST: 'system:menu:list',
|
||||
ADD: 'system:menu:add',
|
||||
UPDATE: 'system:menu:update',
|
||||
DELETE: 'system:menu:delete',
|
||||
},
|
||||
|
||||
// 登录日志权限
|
||||
LOGIN_LOG: {
|
||||
LIST: 'system:loginlog:list',
|
||||
ADD: 'system:loginlog:add',
|
||||
UPDATE: 'system:loginlog:update',
|
||||
DELETE: 'system:loginlog:delete',
|
||||
},
|
||||
|
||||
// 操作日志权限
|
||||
OPER_LOG: {
|
||||
SEARCH: 'system:operlog:search',
|
||||
DELETE: 'system:operlog:delete',
|
||||
},
|
||||
|
||||
// 文件管理权限
|
||||
FILE: {
|
||||
LIST: 'system:file:list',
|
||||
ADD: 'system:file:add',
|
||||
UPDATE: 'system:file:update',
|
||||
DELETE: 'system:file:delete',
|
||||
UPLOAD: 'system:file:upload',
|
||||
},
|
||||
|
||||
// 图库管理权限
|
||||
PICTURE: {
|
||||
LIST: 'system:picture:list',
|
||||
ADD: 'system:sysPicture:add',
|
||||
UPDATE: 'system:sysPicture:update',
|
||||
DELETE: 'system:sysPicture:delete',
|
||||
},
|
||||
|
||||
// 在线用户监控权限
|
||||
ONLINE: {
|
||||
LIST: 'monitor:online:list',
|
||||
LOGOUT: 'monitor:online:logout',
|
||||
},
|
||||
} as const
|
||||
|
||||
// 权限类型推断
|
||||
export type PermissionType = typeof PERMISSIONS[keyof typeof PERMISSIONS][keyof typeof PERMISSIONS[keyof typeof PERMISSIONS]]
|
||||
|
||||
// 常用权限组合
|
||||
export const PERMISSION_GROUPS = {
|
||||
// 用户管理相关权限
|
||||
USER_MANAGEMENT: [
|
||||
PERMISSIONS.USER.LIST,
|
||||
PERMISSIONS.USER.ADD,
|
||||
PERMISSIONS.USER.UPDATE,
|
||||
PERMISSIONS.USER.DELETE,
|
||||
],
|
||||
|
||||
// 角色管理相关权限
|
||||
ROLE_MANAGEMENT: [
|
||||
PERMISSIONS.ROLE.LIST,
|
||||
PERMISSIONS.ROLE.ADD,
|
||||
PERMISSIONS.ROLE.UPDATE,
|
||||
PERMISSIONS.ROLE.DELETE,
|
||||
],
|
||||
|
||||
// 菜单管理相关权限
|
||||
MENU_MANAGEMENT: [
|
||||
PERMISSIONS.MENU.LIST,
|
||||
PERMISSIONS.MENU.ADD,
|
||||
PERMISSIONS.MENU.UPDATE,
|
||||
PERMISSIONS.MENU.DELETE,
|
||||
],
|
||||
|
||||
// 文件管理相关权限
|
||||
FILE_MANAGEMENT: [
|
||||
PERMISSIONS.FILE.LIST,
|
||||
PERMISSIONS.FILE.ADD,
|
||||
PERMISSIONS.FILE.UPDATE,
|
||||
PERMISSIONS.FILE.DELETE,
|
||||
PERMISSIONS.FILE.UPLOAD,
|
||||
],
|
||||
|
||||
// 图库管理相关权限
|
||||
PICTURE_MANAGEMENT: [
|
||||
PERMISSIONS.PICTURE.LIST,
|
||||
PERMISSIONS.PICTURE.ADD,
|
||||
PERMISSIONS.PICTURE.UPDATE,
|
||||
PERMISSIONS.PICTURE.DELETE,
|
||||
],
|
||||
|
||||
// 在线用户监控相关权限
|
||||
ONLINE_MANAGEMENT: [
|
||||
PERMISSIONS.ONLINE.LIST,
|
||||
PERMISSIONS.ONLINE.LOGOUT,
|
||||
],
|
||||
|
||||
// 系统管理员权限(包含所有权限)
|
||||
SYSTEM_ADMIN: [
|
||||
...Object.values(PERMISSIONS.USER),
|
||||
...Object.values(PERMISSIONS.ROLE),
|
||||
...Object.values(PERMISSIONS.MENU),
|
||||
...Object.values(PERMISSIONS.LOGIN_LOG),
|
||||
...Object.values(PERMISSIONS.OPER_LOG),
|
||||
...Object.values(PERMISSIONS.FILE),
|
||||
...Object.values(PERMISSIONS.PICTURE),
|
||||
...Object.values(PERMISSIONS.ONLINE),
|
||||
],
|
||||
} as const
|
||||
50
src/directives/copy.ts
Normal file
50
src/directives/copy.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { App, Directive } from 'vue'
|
||||
import { $t } from '@/utils'
|
||||
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
|
||||
|
||||
interface CopyHTMLElement extends HTMLElement {
|
||||
_copyText: string
|
||||
}
|
||||
|
||||
export function install(app: App) {
|
||||
const { isSupported, copy } = useClipboard()
|
||||
const permissionWrite = usePermission('clipboard-write')
|
||||
|
||||
function clipboardEnable() {
|
||||
if (!isSupported.value) {
|
||||
coiMsgError($t('components.copyText.unsupportedError'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (permissionWrite.value === 'denied') {
|
||||
coiMsgError($t('components.copyText.unpermittedError'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function copyHandler(this: any) {
|
||||
if (!clipboardEnable())
|
||||
return
|
||||
copy(this._copyText)
|
||||
coiMsgSuccess($t('components.copyText.message'))
|
||||
}
|
||||
|
||||
function updataClipboard(el: CopyHTMLElement, text: string) {
|
||||
el._copyText = text
|
||||
el.addEventListener('click', copyHandler)
|
||||
}
|
||||
|
||||
const copyDirective: Directive<CopyHTMLElement, string> = {
|
||||
mounted(el, binding) {
|
||||
updataClipboard(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updataClipboard(el, binding.value)
|
||||
},
|
||||
unmounted(el) {
|
||||
el.removeEventListener('click', copyHandler)
|
||||
},
|
||||
}
|
||||
app.directive('copy', copyDirective)
|
||||
}
|
||||
89
src/directives/permission.ts
Normal file
89
src/directives/permission.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { App, Directive } from 'vue'
|
||||
import { usePermission } from '@/hooks'
|
||||
|
||||
export function install(app: App) {
|
||||
// 角色权限指令
|
||||
function updateRolePermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
|
||||
if (!permission)
|
||||
throw new Error('v-role Directive with no explicit role attached')
|
||||
|
||||
// 每次检查时重新获取权限函数,确保使用最新的用户状态
|
||||
const { hasRole } = usePermission()
|
||||
|
||||
// 使用显示/隐藏而不是删除元素
|
||||
if (hasRole(permission)) {
|
||||
el.style.display = ''
|
||||
}
|
||||
else {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮权限指令
|
||||
function updateButtonPermission(el: HTMLElement, permission: string | string[]) {
|
||||
if (!permission)
|
||||
throw new Error('v-button Directive with no explicit permission attached')
|
||||
|
||||
// 每次检查时重新获取权限函数,确保使用最新的用户状态
|
||||
const { hasButton } = usePermission()
|
||||
|
||||
// 使用显示/隐藏而不是删除元素
|
||||
if (hasButton(permission)) {
|
||||
el.style.display = ''
|
||||
}
|
||||
else {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// 通用权限指令(向后兼容)
|
||||
function updatePermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
|
||||
if (!permission)
|
||||
throw new Error('v-permission Directive with no explicit role attached')
|
||||
|
||||
// 每次检查时重新获取权限函数,确保使用最新的用户状态
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// 使用显示/隐藏而不是删除元素
|
||||
if (hasPermission(permission)) {
|
||||
el.style.display = ''
|
||||
}
|
||||
else {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// 角色权限指令
|
||||
const roleDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
|
||||
mounted(el, binding) {
|
||||
updateRolePermission(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updateRolePermission(el, binding.value)
|
||||
},
|
||||
}
|
||||
|
||||
// 按钮权限指令
|
||||
const buttonDirective: Directive<HTMLElement, string | string[]> = {
|
||||
mounted(el, binding) {
|
||||
updateButtonPermission(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updateButtonPermission(el, binding.value)
|
||||
},
|
||||
}
|
||||
|
||||
// 通用权限指令(向后兼容)
|
||||
const permissionDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
|
||||
mounted(el, binding) {
|
||||
updatePermission(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updatePermission(el, binding.value)
|
||||
},
|
||||
}
|
||||
|
||||
app.directive('permission', permissionDirective)
|
||||
app.directive('role', roleDirective)
|
||||
app.directive('button', buttonDirective)
|
||||
}
|
||||
3
src/hooks/index.ts
Normal file
3
src/hooks/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './useBoolean'
|
||||
export * from './usePermission'
|
||||
export * from './useDict'
|
||||
28
src/hooks/useBoolean.ts
Normal file
28
src/hooks/useBoolean.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* boolean组合式函数
|
||||
* @param initValue 初始值
|
||||
*/
|
||||
export function useBoolean(initValue = false) {
|
||||
const bool = ref(initValue)
|
||||
|
||||
function setBool(value: boolean) {
|
||||
bool.value = value
|
||||
}
|
||||
function setTrue() {
|
||||
setBool(true)
|
||||
}
|
||||
function setFalse() {
|
||||
setBool(false)
|
||||
}
|
||||
function toggle() {
|
||||
setBool(!bool.value)
|
||||
}
|
||||
|
||||
return {
|
||||
bool,
|
||||
setBool,
|
||||
setTrue,
|
||||
setFalse,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
75
src/hooks/useDict.ts
Normal file
75
src/hooks/useDict.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { computed, unref, watch } from 'vue'
|
||||
import { toSelectOptions } from '@/utils/dict'
|
||||
import { useDictStore } from '@/store'
|
||||
import type { DictDataOption } from '@/service/api/system/dict'
|
||||
|
||||
interface UseDictOptions {
|
||||
/** 是否在创建时立即加载,默认为 true */
|
||||
immediate?: boolean
|
||||
/** 监听类型变化时是否强制刷新 */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export function useDict(dictTypes: MaybeRef<string[] | undefined>, options: UseDictOptions = {}) {
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const normalizedTypes = computed(() => {
|
||||
const value = unref(dictTypes) ?? []
|
||||
return value.filter((item): item is string => Boolean(item))
|
||||
})
|
||||
|
||||
const load = async (force = false) => {
|
||||
const types = normalizedTypes.value
|
||||
if (!types.length)
|
||||
return
|
||||
|
||||
await dictStore.fetchDicts(types, force)
|
||||
}
|
||||
|
||||
watch(
|
||||
normalizedTypes,
|
||||
(types) => {
|
||||
if (!types.length)
|
||||
return
|
||||
void dictStore.fetchDicts(types, options.force ?? false)
|
||||
},
|
||||
{ immediate: options.immediate ?? true },
|
||||
)
|
||||
|
||||
const dictOptions = computed<Record<string, DictDataOption[]>>(() => {
|
||||
const result: Record<string, DictDataOption[]> = {}
|
||||
normalizedTypes.value.forEach((type) => {
|
||||
result[type] = dictStore.getDictOptions(type)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const isLoading = computed(() => normalizedTypes.value.some(type => dictStore.isLoading(type)))
|
||||
|
||||
const getDictLabel = (dictType: string, value: unknown, fallback?: string) =>
|
||||
dictStore.getDictLabel(dictType, value, fallback)
|
||||
|
||||
const getDictOption = (dictType: string, value: unknown) =>
|
||||
dictStore.getDictOption(dictType, value)
|
||||
|
||||
const getSelectOptions = (dictType: string) => toSelectOptions(dictStore.getDictOptions(dictType))
|
||||
|
||||
const reload = async (targetTypes?: string[]) => {
|
||||
const types = targetTypes && targetTypes.length ? targetTypes : normalizedTypes.value
|
||||
if (!types.length)
|
||||
return
|
||||
|
||||
await dictStore.fetchDicts(types, true)
|
||||
}
|
||||
|
||||
return {
|
||||
dictOptions,
|
||||
isLoading,
|
||||
load,
|
||||
reload,
|
||||
getDictLabel,
|
||||
getDictOption,
|
||||
getSelectOptions,
|
||||
}
|
||||
}
|
||||
102
src/hooks/usePermission.ts
Normal file
102
src/hooks/usePermission.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { useAuthStore } from '@/store'
|
||||
import { isArray, isString } from 'radash'
|
||||
|
||||
/** 权限判断 */
|
||||
export function usePermission() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/**
|
||||
* 检查是否为超级管理员
|
||||
*/
|
||||
function isSuperAdmin() {
|
||||
if (!authStore.userInfo)
|
||||
return false
|
||||
|
||||
const { role } = authStore.userInfo
|
||||
|
||||
// 支持多种超级管理员标识
|
||||
const superAdminIdentifiers = ['super', 'admin', '超级管理员', 'ADMIN', 'coder_ADMIN']
|
||||
return superAdminIdentifiers.some(identifier => role.includes(identifier))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查角色权限
|
||||
* @param permission 角色类型或角色类型数组
|
||||
*/
|
||||
function hasRole(
|
||||
permission?: Entity.RoleType | Entity.RoleType[],
|
||||
) {
|
||||
if (!permission)
|
||||
return true
|
||||
|
||||
if (!authStore.userInfo)
|
||||
return false
|
||||
const { role } = authStore.userInfo
|
||||
|
||||
// 超级管理员可直接通过
|
||||
if (isSuperAdmin())
|
||||
return true
|
||||
|
||||
let has = false
|
||||
if (isArray(permission))
|
||||
// 角色为数组, 判断是否有交集
|
||||
has = permission.some(i => role.includes(i))
|
||||
|
||||
if (isString(permission))
|
||||
// 角色为字符串, 判断是否包含
|
||||
has = role.includes(permission)
|
||||
|
||||
return has
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查按钮权限
|
||||
* @param permission 权限标识或权限标识数组
|
||||
*/
|
||||
function hasButton(
|
||||
permission?: string | string[],
|
||||
) {
|
||||
if (!permission)
|
||||
return true
|
||||
|
||||
if (!authStore.userInfo)
|
||||
return false
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (isSuperAdmin())
|
||||
return true
|
||||
|
||||
const { buttons } = authStore.userInfo
|
||||
|
||||
// 检查具体权限标识
|
||||
if (!buttons || buttons.length === 0)
|
||||
return false
|
||||
|
||||
if (isArray(permission))
|
||||
// 权限为数组, 判断是否有交集
|
||||
return permission.some(i => buttons.includes(i))
|
||||
|
||||
if (isString(permission))
|
||||
// 权限为字符串, 判断是否包含
|
||||
return buttons.includes(permission)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用权限检查 (向后兼容)
|
||||
* @param permission 角色类型或角色类型数组
|
||||
*/
|
||||
function hasPermission(
|
||||
permission?: Entity.RoleType | Entity.RoleType[],
|
||||
) {
|
||||
return hasRole(permission)
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
hasRole,
|
||||
hasButton,
|
||||
isSuperAdmin,
|
||||
}
|
||||
}
|
||||
66
src/hooks/useTabScroll.ts
Normal file
66
src/hooks/useTabScroll.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { NScrollbar } from 'naive-ui'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { throttle } from 'radash'
|
||||
|
||||
export function useTabScroll(currentTabPath: Ref<string>) {
|
||||
const scrollbar = ref<InstanceType<typeof NScrollbar>>()
|
||||
const safeArea = ref(150)
|
||||
|
||||
const handleTabSwitch = (distance: number) => {
|
||||
scrollbar.value?.scrollTo({
|
||||
left: distance,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToCurrentTab = () => {
|
||||
nextTick(() => {
|
||||
const currentTabElement = document.querySelector(`[data-tab-path="${currentTabPath.value}"]`) as HTMLElement
|
||||
const tabBarScrollWrapper = document.querySelector('.tab-bar-scroller-wrapper .n-scrollbar-container')
|
||||
const tabBarScrollContent = document.querySelector('.tab-bar-scroller-content')
|
||||
|
||||
if (currentTabElement && tabBarScrollContent && tabBarScrollWrapper) {
|
||||
const tabLeft = currentTabElement.offsetLeft
|
||||
const tabBarLeft = tabBarScrollWrapper.scrollLeft
|
||||
const wrapperWidth = tabBarScrollWrapper.getBoundingClientRect().width
|
||||
const tabWidth = currentTabElement.getBoundingClientRect().width
|
||||
const containerPR = Number.parseFloat(window.getComputedStyle(tabBarScrollContent).paddingRight)
|
||||
|
||||
if (tabLeft + tabWidth + safeArea.value + containerPR > wrapperWidth + tabBarLeft) {
|
||||
handleTabSwitch(tabLeft + tabWidth + containerPR - wrapperWidth + safeArea.value)
|
||||
}
|
||||
else if (tabLeft - safeArea.value < tabBarLeft) {
|
||||
handleTabSwitch(tabLeft - safeArea.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = throttle({ interval: 120 }, (step: number) => {
|
||||
scrollbar.value?.scrollBy({
|
||||
left: step * 400,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
handleScroll(e.deltaY > 0 ? 1 : -1)
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (currentTabPath.value) {
|
||||
scrollToCurrentTab()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
scrollbar,
|
||||
onWheel,
|
||||
safeArea,
|
||||
handleTabSwitch,
|
||||
}
|
||||
}
|
||||
35
src/main.ts
Normal file
35
src/main.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { App } from 'vue'
|
||||
import { installRouter } from '@/router'
|
||||
import { installPinia } from '@/store'
|
||||
import AppVue from './App.vue'
|
||||
import AppLoading from './components/common/AppLoading.vue'
|
||||
|
||||
async function setupApp() {
|
||||
// 载入全局loading加载状态
|
||||
const appLoading = createApp(AppLoading)
|
||||
appLoading.mount('#appLoading')
|
||||
|
||||
// 创建vue实例
|
||||
const app = createApp(AppVue)
|
||||
|
||||
// 注册模块Pinia
|
||||
await installPinia(app)
|
||||
|
||||
// 注册模块 Vue-router
|
||||
await installRouter(app)
|
||||
|
||||
/* 注册模块 指令/静态资源 */
|
||||
Object.values(
|
||||
import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
|
||||
eager: true,
|
||||
}),
|
||||
).map(i => app.use(i))
|
||||
|
||||
// 卸载载入动画
|
||||
appLoading.unmount()
|
||||
|
||||
// 挂载
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
setupApp()
|
||||
33
src/typings/api/login.d.ts
vendored
Normal file
33
src/typings/api/login.d.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
/// <reference path="../global.d.ts"/>
|
||||
|
||||
namespace Api {
|
||||
namespace Login {
|
||||
/* 登录返回的用户字段, 该数据是根据用户表扩展而来, 部分字段可能需要覆盖,例如id */
|
||||
interface Info extends Entity.User {
|
||||
/** 用户id */
|
||||
id: number
|
||||
/** 用户角色类型 */
|
||||
role: Entity.RoleType[]
|
||||
/** 用户权限按钮列表 */
|
||||
buttons: string[]
|
||||
/** 访问token */
|
||||
accessToken: string
|
||||
/** 访问token */
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
/* 获取登录用户信息接口返回的数据结构 */
|
||||
interface UserInfoResponse {
|
||||
/** 登录用户基本信息 */
|
||||
loginUser: {
|
||||
userId: number
|
||||
userName: string
|
||||
avatar?: string
|
||||
}
|
||||
/** 用户角色列表 */
|
||||
roles: string[]
|
||||
/** 用户权限按钮列表 */
|
||||
buttons: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/typings/entities/dict.d.ts
vendored
Normal file
13
src/typings/entities/dict.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
/// <reference path="../global.d.ts"/>
|
||||
|
||||
/* 字典数据库表字段 */
|
||||
namespace Entity {
|
||||
|
||||
interface Dict {
|
||||
id?: number
|
||||
isRoot?: 0 | 1
|
||||
code: string
|
||||
label: string
|
||||
value?: number
|
||||
}
|
||||
}
|
||||
16
src/typings/entities/message.d.ts
vendored
Normal file
16
src/typings/entities/message.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/// <reference path="../global.d.ts"/>
|
||||
|
||||
/* 角色数据库表字段 */
|
||||
namespace Entity {
|
||||
interface Message {
|
||||
id: number
|
||||
type: 0 | 1 | 2
|
||||
title: string
|
||||
icon: string
|
||||
tagTitle?: string
|
||||
tagType?: 'error' | 'info' | 'success' | 'warning'
|
||||
description?: string
|
||||
isRead?: boolean
|
||||
date: string
|
||||
}
|
||||
}
|
||||
13
src/typings/entities/role.d.ts
vendored
Normal file
13
src/typings/entities/role.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
/// <reference path="../global.d.ts"/>
|
||||
|
||||
/* 角色数据库表字段 */
|
||||
namespace Entity {
|
||||
type RoleType = 'super' | 'admin' | 'user'
|
||||
|
||||
interface Role {
|
||||
/** 用户id */
|
||||
id?: number
|
||||
/** 用户名 */
|
||||
role?: RoleType
|
||||
}
|
||||
}
|
||||
30
src/typings/entities/user.d.ts
vendored
Normal file
30
src/typings/entities/user.d.ts
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
/// <reference path="../global.d.ts"/>
|
||||
|
||||
/** 用户数据库表字段 */
|
||||
namespace Entity {
|
||||
interface User {
|
||||
/** 用户id */
|
||||
id?: number
|
||||
/** 用户id (后端字段) */
|
||||
userId?: number
|
||||
/** 用户名 */
|
||||
userName?: string
|
||||
/* 用户头像 */
|
||||
avatar?: string
|
||||
/* 用户性别 */
|
||||
gender?: 0 | 1
|
||||
/* 用户邮箱 */
|
||||
email?: string
|
||||
/* 用户昵称 */
|
||||
nickname?: string
|
||||
/* 用户电话 */
|
||||
tel?: string
|
||||
/** 用户角色类型 */
|
||||
role?: Entity.RoleType[]
|
||||
/** 用户状态 */
|
||||
status?: 0 | 1
|
||||
/** 备注 */
|
||||
remark?: string
|
||||
}
|
||||
|
||||
}
|
||||
42
src/typings/env.d.ts
vendored
Normal file
42
src/typings/env.d.ts
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
*后台服务的环境类型
|
||||
* - dev: 后台开发环境
|
||||
* - test: 后台测试环境
|
||||
* - prod: 后台生产环境
|
||||
*/
|
||||
type ServiceEnvType = 'dev' | 'test' | 'prod'
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 项目基本地址 */
|
||||
readonly VITE_BASE_URL: string
|
||||
/** 项目标题 */
|
||||
readonly VITE_APP_NAME: string
|
||||
/** 开启请求代理 */
|
||||
readonly VITE_HTTP_PROXY?: 'Y' | 'N'
|
||||
/** 是否开启打包压缩 */
|
||||
readonly VITE_BUILD_COMPRESS?: 'Y' | 'N'
|
||||
/** 压缩算法类型 */
|
||||
readonly VITE_COMPRESS_TYPE?:
|
||||
| 'gzip'
|
||||
| 'brotliCompress'
|
||||
| 'deflate'
|
||||
| 'deflateRaw'
|
||||
/** 路由模式 */
|
||||
readonly VITE_ROUTE_MODE?: 'hash' | 'web'
|
||||
/** 路由加载模式 */
|
||||
readonly VITE_ROUTE_LOAD_MODE: 'static' | 'dynamic'
|
||||
/** 首次加载页面 */
|
||||
readonly VITE_HOME_PATH: string
|
||||
/** 版权信息 */
|
||||
readonly VITE_COPYRIGHT_INFO: string
|
||||
/** 是否自动刷新token */
|
||||
readonly VITE_AUTO_REFRESH_TOKEN: 'Y' | 'N'
|
||||
/** 默认语言 */
|
||||
readonly VITE_DEFAULT_LANG: App.lang
|
||||
/** 后端服务的环境类型 */
|
||||
readonly MODE: ServiceEnvType
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
56
src/typings/global.d.ts
vendored
Normal file
56
src/typings/global.d.ts
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
/* 存放数据库实体表类型, 具体内容在 ./entities */
|
||||
declare namespace Entity {
|
||||
}
|
||||
|
||||
/* 各类接口返回的数据类型, 具体内容在 ./api */
|
||||
declare namespace Api {
|
||||
|
||||
}
|
||||
|
||||
interface Window {
|
||||
$loadingBar: import('naive-ui').LoadingBarApi
|
||||
$dialog: import('naive-ui').DialogApi
|
||||
$message: import('naive-ui').MessageApi
|
||||
$notification: import('naive-ui').NotificationApi
|
||||
}
|
||||
|
||||
declare const AMap: any
|
||||
declare const BMap: any
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent
|
||||
export default component
|
||||
}
|
||||
|
||||
declare namespace NaiveUI {
|
||||
type ThemeColor = 'default' | 'error' | 'primary' | 'info' | 'success' | 'warning'
|
||||
}
|
||||
|
||||
declare namespace Storage {
|
||||
interface Session {
|
||||
dict: DictMap
|
||||
}
|
||||
|
||||
interface Local {
|
||||
/* 存储用户信息 */
|
||||
userInfo: Api.Login.Info
|
||||
/* 存储访问token */
|
||||
accessToken: string
|
||||
/* 存储刷新token */
|
||||
refreshToken: string
|
||||
/* 存储登录账号 */
|
||||
loginAccount: any
|
||||
/* 存储当前语言 */
|
||||
lang: App.lang
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace App {
|
||||
type lang = 'zhCN' | 'enUS'
|
||||
}
|
||||
|
||||
interface DictMap {
|
||||
[key: string]: Entity.Dict[]
|
||||
}
|
||||
105
src/typings/route.d.ts
vendored
Normal file
105
src/typings/route.d.ts
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
declare namespace AppRoute {
|
||||
|
||||
type MenuType = '1' | '2' | '3' // 1-目录 2-菜单 3-按钮
|
||||
|
||||
/** 单个路由所携带的meta标识 */
|
||||
interface RouteMeta {
|
||||
/* 页面标题,通常必选。 */
|
||||
title: string
|
||||
/* 图标,一般配合菜单使用 */
|
||||
icon?: string
|
||||
/* 是否需要登录权限。 */
|
||||
requiresAuth?: boolean
|
||||
/* 可以访问的角色 */
|
||||
roles?: Entity.RoleType[]
|
||||
/* 权限标识,用于按钮权限验证 */
|
||||
auth?: string
|
||||
/* 是否开启页面缓存 */
|
||||
keepAlive?: boolean
|
||||
/* 有些路由我们并不想在菜单中显示,比如某些编辑页面。 */
|
||||
hide?: boolean
|
||||
/* 菜单排序。 */
|
||||
order?: number
|
||||
/* 嵌套外链 */
|
||||
href?: string
|
||||
/** 当前路由不在左侧菜单显示,但需要高亮某个菜单的情况 */
|
||||
activeMenu?: string
|
||||
/** 当前路由是否会被添加到Tab中 */
|
||||
withoutTab?: boolean
|
||||
/** 当前路由是否会被固定在Tab中,用于一些常驻页面 */
|
||||
pinTab?: boolean
|
||||
/** 当前路由在左侧菜单是目录还是页面,不设置默认为page */
|
||||
menuType?: MenuType
|
||||
}
|
||||
|
||||
type MetaKeys = keyof RouteMeta
|
||||
|
||||
// 后端返回的菜单数据结构
|
||||
interface BackendRoute {
|
||||
/** 菜单ID */
|
||||
menuId: number
|
||||
/** 菜单名称 */
|
||||
menuName: string
|
||||
/** 英文名称 */
|
||||
enName?: string
|
||||
/** 父菜单ID */
|
||||
parentId: number
|
||||
/** 菜单类型 1-目录 2-菜单 3-按钮 */
|
||||
menuType: string
|
||||
/** 路由名称 */
|
||||
name: string
|
||||
/** 路由路径 */
|
||||
path: string
|
||||
/** 组件路径 */
|
||||
component?: string
|
||||
/** 菜单图标 */
|
||||
icon?: string
|
||||
/** 权限标识 */
|
||||
auth?: string
|
||||
/** 是否隐藏 0-隐藏 1-显示 */
|
||||
isHide: string
|
||||
/** 是否外链 */
|
||||
isLink?: string
|
||||
/** 是否缓存 0-是 1-否 */
|
||||
isKeepAlive: string
|
||||
/** 是否全屏 0-是 1-否 */
|
||||
isFull: string
|
||||
/** 是否固定 0-是 1-否 */
|
||||
isAffix: string
|
||||
/** 重定向地址 */
|
||||
redirect?: string | null
|
||||
/** 选中路由 */
|
||||
activeMenu?: string | null
|
||||
}
|
||||
|
||||
interface baseRoute {
|
||||
/** 路由名称(路由唯一标识) */
|
||||
name: string
|
||||
/** 路由路径 */
|
||||
path: string
|
||||
/** 路由重定向 */
|
||||
redirect?: string
|
||||
/* 页面组件地址 */
|
||||
componentPath?: string | null
|
||||
/* 路由id */
|
||||
id: number
|
||||
/* 父级路由id,顶级页面为null */
|
||||
pid: number | null
|
||||
}
|
||||
|
||||
/** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */
|
||||
type RowRoute = RouteMeta & baseRoute
|
||||
|
||||
/**
|
||||
* 挂载到项目上的真实路由结构
|
||||
*/
|
||||
interface Route extends baseRoute {
|
||||
/** 子路由 */
|
||||
children?: Route[]
|
||||
/* 页面组件 */
|
||||
component: any
|
||||
/** 路由描述 */
|
||||
meta: RouteMeta
|
||||
}
|
||||
|
||||
}
|
||||
5
src/typings/router.d.ts
vendored
Normal file
5
src/typings/router.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta extends AppRoute.RouteMeta {}
|
||||
}
|
||||
65
src/typings/service.d.ts
vendored
Normal file
65
src/typings/service.d.ts
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
/** 请求的相关类型 */
|
||||
declare namespace Service {
|
||||
import type { Method } from 'alova'
|
||||
|
||||
interface AlovaConfig {
|
||||
baseURL: string
|
||||
timeout?: number
|
||||
beforeRequest?: (method: Method<globalThis.Ref<unknown>>) => void
|
||||
}
|
||||
|
||||
/** 后端接口返回的数据结构配置 */
|
||||
interface BackendConfig {
|
||||
/** 表示后端请求状态码的属性字段 */
|
||||
codeKey?: string
|
||||
/** 表示后端请求数据的属性字段 */
|
||||
dataKey?: string
|
||||
/** 表示后端消息的属性字段 */
|
||||
msgKey?: string
|
||||
/** 后端业务上定义的成功请求的状态 */
|
||||
successCode?: number | string
|
||||
}
|
||||
|
||||
type RequestErrorType = 'Response Error' | 'Business Error' | null
|
||||
type RequestCode = string | number
|
||||
|
||||
interface RequestError {
|
||||
/** 请求服务的错误类型 */
|
||||
errorType: RequestErrorType
|
||||
/** 错误码 */
|
||||
code: RequestCode
|
||||
/** 错误信息 */
|
||||
message: string
|
||||
/** 返回的数据 */
|
||||
data?: any
|
||||
}
|
||||
|
||||
interface ResponseResult<T> extends RequestError {
|
||||
/** 请求服务是否成功 */
|
||||
isSuccess: boolean
|
||||
/** 请求服务的错误类型 */
|
||||
errorType: RequestErrorType
|
||||
/** 错误码 */
|
||||
code: RequestCode
|
||||
/** 错误信息 */
|
||||
message: string
|
||||
/** 返回的数据 */
|
||||
data: T
|
||||
/** 错误信息别名 */
|
||||
msg?: string
|
||||
}
|
||||
|
||||
/** 分页结果类型 */
|
||||
interface PageResult<T> {
|
||||
/** 当前页数据 */
|
||||
records: T[]
|
||||
/** 总记录数 */
|
||||
total: number
|
||||
/** 当前页 */
|
||||
current: number
|
||||
/** 每页大小 */
|
||||
size: number
|
||||
/** 总页数 */
|
||||
pages: number
|
||||
}
|
||||
}
|
||||
37
src/utils/array.ts
Normal file
37
src/utils/array.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 将给定的数组转换为树形结构。
|
||||
* @param arr - 原始数组,其中每个元素包含id和pid属性,pid表示父级id。
|
||||
* @returns 返回转换后的树形结构数组。
|
||||
*/
|
||||
export function arrayToTree(arr: any[]) {
|
||||
// 初始化结果数组
|
||||
const res: any = []
|
||||
// 使用Map存储数组元素,以id为键,元素本身为值
|
||||
const map = new Map()
|
||||
|
||||
// 遍历数组,将每个元素以id为键存储到Map中
|
||||
arr.forEach((item) => {
|
||||
map.set(item.id, item)
|
||||
})
|
||||
|
||||
// 再次遍历数组,根据pid将元素组织成树形结构
|
||||
arr.forEach((item) => {
|
||||
// 获取当前元素的父级元素
|
||||
const parent = item.pid && map.get(item.pid)
|
||||
// 如果有父级元素
|
||||
if (parent) {
|
||||
// 如果父级元素已有子元素,则将当前元素追加到子元素数组中
|
||||
if (parent?.children)
|
||||
parent.children.push(item)
|
||||
// 如果父级元素没有子元素,则创建子元素数组,并将当前元素作为第一个元素
|
||||
else
|
||||
parent.children = [item]
|
||||
}
|
||||
// 如果没有父级元素,则将当前元素直接添加到结果数组中
|
||||
else {
|
||||
res.push(item)
|
||||
}
|
||||
})
|
||||
// 返回组织好的树形结构数组
|
||||
return res
|
||||
}
|
||||
395
src/utils/coi.ts
Executable file
395
src/utils/coi.ts
Executable file
@ -0,0 +1,395 @@
|
||||
// 工具类提示信息
|
||||
import { createDiscreteApi, darkTheme, lightTheme } from 'naive-ui'
|
||||
|
||||
type MessageType = 'info' | 'success' | 'error' | 'warning'
|
||||
|
||||
// 缓存 API 实例
|
||||
let naiveApiCache: any = null
|
||||
let currentTheme: 'light' | 'dark' | null = null
|
||||
|
||||
// 检测当前主题
|
||||
function isDark(): boolean {
|
||||
return document.documentElement.classList.contains('dark') || document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}
|
||||
|
||||
// 获取当前主题
|
||||
function getCurrentTheme(): 'light' | 'dark' {
|
||||
return isDark() ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// 创建或获取 Naive UI API 实例
|
||||
function getNaiveApi() {
|
||||
const theme = getCurrentTheme()
|
||||
|
||||
// 如果主题没有变化且已有缓存,直接返回
|
||||
if (naiveApiCache && currentTheme === theme) {
|
||||
return naiveApiCache
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建完整的 API 实例,包括 message, notification, dialog
|
||||
const api = createDiscreteApi(['message', 'notification', 'dialog'], {
|
||||
configProviderProps: {
|
||||
theme: theme === 'dark' ? darkTheme : lightTheme,
|
||||
},
|
||||
})
|
||||
|
||||
naiveApiCache = api
|
||||
currentTheme = theme
|
||||
return api
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to create Naive UI API:', error)
|
||||
// 返回 fallback 实现,避免应用崩溃
|
||||
return {
|
||||
message: {
|
||||
info: console.info,
|
||||
success: console.log,
|
||||
warning: console.warn,
|
||||
error: console.error,
|
||||
loading: console.log,
|
||||
destroyAll: () => {},
|
||||
},
|
||||
notification: {
|
||||
info: console.info,
|
||||
success: console.log,
|
||||
warning: console.warn,
|
||||
error: console.error,
|
||||
destroyAll: () => {},
|
||||
},
|
||||
dialog: {
|
||||
info: (_options: any) => Promise.resolve(true),
|
||||
success: (_options: any) => Promise.resolve(true),
|
||||
warning: (_options: any) => Promise.resolve(true),
|
||||
error: (_options: any) => Promise.resolve(true),
|
||||
destroyAll: () => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 安全的 API 调用函数
|
||||
function safeApiCall(apiType: 'message' | 'notification' | 'dialog', method: string, ...args: any[]) {
|
||||
// 延迟执行函数
|
||||
const executeCall = () => {
|
||||
try {
|
||||
const api = getNaiveApi()
|
||||
return api[apiType][method](...args)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Failed to call ${apiType}.${method}:`, error)
|
||||
// fallback 到 console
|
||||
if (apiType === 'message' || apiType === 'notification') {
|
||||
const logMethod = method === 'success' ? 'log' : method
|
||||
if (logMethod === 'info' || logMethod === 'log' || logMethod === 'warn' || logMethod === 'error') {
|
||||
console[logMethod](args[0])
|
||||
}
|
||||
}
|
||||
return apiType === 'dialog' ? Promise.resolve(false) : null
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 document 还未准备好,延迟执行
|
||||
if (typeof document === 'undefined' || document.readyState === 'loading') {
|
||||
if (apiType === 'dialog') {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(executeCall()), 100)
|
||||
})
|
||||
}
|
||||
setTimeout(executeCall, 100)
|
||||
return null
|
||||
}
|
||||
|
||||
return executeCall()
|
||||
}
|
||||
|
||||
/** 封装任意提示类型通知,默认info */
|
||||
export function coiNotice(message: any, title = '温馨提示', duration = 2000, type: MessageType = 'info', parseHtml = false) {
|
||||
const api = getNaiveApi()
|
||||
api.notification.destroyAll()
|
||||
|
||||
return safeApiCall('notification', type, {
|
||||
title,
|
||||
content: message,
|
||||
duration,
|
||||
closable: true,
|
||||
// Naive UI 不支持 dangerouslyUseHTMLString,需要处理 HTML
|
||||
...(parseHtml
|
||||
&& {
|
||||
// 如果需要解析HTML,可以考虑使用 render 函数或其他方式
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装提示通知,默认success */
|
||||
export function coiNoticeSuccess(
|
||||
message: any,
|
||||
title = '温馨提示',
|
||||
duration = 2000,
|
||||
type: MessageType = 'success',
|
||||
parseHtml = false,
|
||||
) {
|
||||
return coiNotice(message, title, duration, type, parseHtml)
|
||||
}
|
||||
|
||||
/** 封装提示通知,默认error */
|
||||
export function coiNoticeError(
|
||||
message: any,
|
||||
title = '温馨提示',
|
||||
duration = 2000,
|
||||
type: MessageType = 'error',
|
||||
parseHtml = false,
|
||||
) {
|
||||
return coiNotice(message, title, duration, type, parseHtml)
|
||||
}
|
||||
|
||||
/** 封装提示通知,默认warning */
|
||||
export function coiNoticeWarning(
|
||||
message: any,
|
||||
title = '温馨提示',
|
||||
duration = 2000,
|
||||
type: MessageType = 'warning',
|
||||
parseHtml = false,
|
||||
) {
|
||||
return coiNotice(message, title, duration, type, parseHtml)
|
||||
}
|
||||
|
||||
/** 封装提示通知,默认info */
|
||||
export function coiNoticeInfo(message: any, title = '温馨提示', duration = 2000, type: MessageType = 'info', parseHtml = false) {
|
||||
return coiNotice(message, title, duration, type, parseHtml)
|
||||
}
|
||||
|
||||
/** 封装提示信息,默认info */
|
||||
export function coiMsg(message: any, _plain = false, duration = 2000, type: MessageType = 'info', _parseHtml = false) {
|
||||
const api = getNaiveApi()
|
||||
api.message.destroyAll()
|
||||
|
||||
return safeApiCall('message', type, message, {
|
||||
duration,
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装提示信息,默认success */
|
||||
export function coiMsgSuccess(message: any, _plain = false, duration = 2000, _type: MessageType = 'success', _parseHtml = false) {
|
||||
const api = getNaiveApi()
|
||||
api.message.destroyAll()
|
||||
|
||||
return safeApiCall('message', 'success', message, {
|
||||
duration,
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装提示信息,默认error */
|
||||
export function coiMsgError(message: any, _plain = false, duration = 2000, _type: MessageType = 'error', _parseHtml = false) {
|
||||
const api = getNaiveApi()
|
||||
api.message.destroyAll()
|
||||
|
||||
return safeApiCall('message', 'error', message, {
|
||||
duration,
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装提示信息,默认warning */
|
||||
export function coiMsgWarning(message: any, _plain = false, duration = 2000, _type: MessageType = 'warning', _parseHtml = false) {
|
||||
const api = getNaiveApi()
|
||||
api.message.destroyAll()
|
||||
|
||||
return safeApiCall('message', 'warning', message, {
|
||||
duration,
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装提示信息,默认info */
|
||||
export function coiMsgInfo(message: any, _plain = false, duration = 2000, _type: MessageType = 'info', _parseHtml = false) {
|
||||
const api = getNaiveApi()
|
||||
api.message.destroyAll()
|
||||
|
||||
return safeApiCall('message', 'info', message, {
|
||||
duration,
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装确认信息,默认warning */
|
||||
export function coiMsgBox(
|
||||
message: any = '您确定进行关闭么?',
|
||||
title: string = '温馨提示:',
|
||||
confirmButtonText: string = '确定',
|
||||
cancelButtonText: string = '取消',
|
||||
type: string = 'warning',
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const executeDialog = () => {
|
||||
try {
|
||||
const api = getNaiveApi()
|
||||
api.dialog[type as keyof typeof api.dialog]({
|
||||
title,
|
||||
content: message,
|
||||
positiveText: confirmButtonText,
|
||||
negativeText: cancelButtonText,
|
||||
onPositiveClick: () => {
|
||||
resolve(true)
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
reject(false)
|
||||
},
|
||||
onClose: () => {
|
||||
reject(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to show dialog:', error)
|
||||
reject(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || document.readyState === 'loading') {
|
||||
setTimeout(executeDialog, 100)
|
||||
}
|
||||
else {
|
||||
executeDialog()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 封装确认信息,默认warning - HTML 版本 */
|
||||
export function coiMsgBoxHtml(
|
||||
message: any = `<p style="color: teal">您确定进行关闭么?</p>`,
|
||||
title: string = '温馨提示:',
|
||||
confirmButtonText: string = '确定',
|
||||
cancelButtonText: string = '取消',
|
||||
type: string = 'warning',
|
||||
): Promise<boolean> {
|
||||
// Naive UI 的 dialog 可以通过 render 函数支持 HTML
|
||||
// 这里先使用纯文本,如果需要 HTML 可以进一步优化
|
||||
const textMessage = message.replace(/<[^>]*>/g, '') // 简单去除HTML标签
|
||||
return coiMsgBox(textMessage, title, confirmButtonText, cancelButtonText, type)
|
||||
}
|
||||
|
||||
/** Prompt 类型的消息框 */
|
||||
export function coiMsgBoxPrompt(
|
||||
message: any = '请输入需要修改的数据?',
|
||||
_title: string = '温馨提示:',
|
||||
_confirmButtonText: string = '确定',
|
||||
_cancelButtonText: string = '取消',
|
||||
_type: string = 'info',
|
||||
_inputPattern: string = '',
|
||||
_inputErrorMessage: string = '无效输入',
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const executeDialog = () => {
|
||||
try {
|
||||
// Naive UI 没有直接的 prompt,需要使用自定义 dialog
|
||||
// 这里先简化实现,返回一个输入的结果
|
||||
const userInput = prompt(message) // 使用原生 prompt 作为临时方案
|
||||
if (userInput !== null) {
|
||||
resolve({ value: userInput })
|
||||
}
|
||||
else {
|
||||
reject(false)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to show prompt dialog:', error)
|
||||
reject(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || document.readyState === 'loading') {
|
||||
setTimeout(executeDialog, 100)
|
||||
}
|
||||
else {
|
||||
executeDialog()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Alert 类型的消息框 */
|
||||
export function coiMsgBoxAlert(
|
||||
message: any = '请输入需要修改的数据?',
|
||||
title: string = '温馨提示:',
|
||||
confirmButtonText: string = '确定',
|
||||
type: string = 'info',
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const executeDialog = () => {
|
||||
try {
|
||||
const api = getNaiveApi()
|
||||
api.dialog[type as keyof typeof api.dialog]({
|
||||
title,
|
||||
content: message,
|
||||
positiveText: confirmButtonText,
|
||||
onPositiveClick: () => {
|
||||
resolve(true)
|
||||
},
|
||||
onClose: () => {
|
||||
resolve(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to show alert dialog:', error)
|
||||
reject(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || document.readyState === 'loading') {
|
||||
setTimeout(executeDialog, 100)
|
||||
}
|
||||
else {
|
||||
executeDialog()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 导出 naiveMessage 对象,保持向后兼容
|
||||
export const naiveMessage = {
|
||||
info: (content: string, options?: any) => {
|
||||
return safeApiCall('message', 'info', content, {
|
||||
duration: 3000,
|
||||
closable: true,
|
||||
...options,
|
||||
})
|
||||
},
|
||||
success: (content: string, options?: any) => {
|
||||
return safeApiCall('message', 'success', content, {
|
||||
duration: 3000,
|
||||
closable: true,
|
||||
...options,
|
||||
})
|
||||
},
|
||||
warning: (content: string, options?: any) => {
|
||||
return safeApiCall('message', 'warning', content, {
|
||||
duration: 3000,
|
||||
closable: true,
|
||||
...options,
|
||||
})
|
||||
},
|
||||
error: (content: string, options?: any) => {
|
||||
return safeApiCall('message', 'error', content, {
|
||||
duration: 3000,
|
||||
closable: true,
|
||||
...options,
|
||||
})
|
||||
},
|
||||
loading: (content: string, options?: any) => {
|
||||
return safeApiCall('message', 'loading', content, {
|
||||
duration: 0, // loading 默认不自动关闭
|
||||
...options,
|
||||
})
|
||||
},
|
||||
destroyAll: () => {
|
||||
try {
|
||||
const api = getNaiveApi()
|
||||
api.message.destroyAll()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to destroy all messages:', error)
|
||||
}
|
||||
},
|
||||
}
|
||||
109
src/utils/component-guard.ts
Normal file
109
src/utils/component-guard.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import type { AsyncComponentLoader, Component } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
/**
|
||||
* 安全的异步组件加载器
|
||||
* 防止在组件卸载时继续加载导致的内存泄漏和错误
|
||||
*/
|
||||
export function safeAsyncComponent(
|
||||
loader: AsyncComponentLoader,
|
||||
options?: {
|
||||
loadingComponent?: Component
|
||||
errorComponent?: Component
|
||||
delay?: number
|
||||
timeout?: number
|
||||
suspensible?: boolean
|
||||
onError?: (error: Error, retry: () => void, fail: () => void, attempts: number) => any
|
||||
},
|
||||
) {
|
||||
const safeLoader: AsyncComponentLoader = () => {
|
||||
return loader().catch((error) => {
|
||||
console.error('异步组件加载失败:', error)
|
||||
|
||||
// 如果是网络错误或者加载错误,返回一个空的组件
|
||||
if (error.name === 'ChunkLoadError' || error.message?.includes('Loading chunk')) {
|
||||
console.warn('检测到代码分割加载错误,尝试重新加载页面')
|
||||
// 延迟重新加载页面,避免无限循环
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 返回一个错误组件
|
||||
return Promise.resolve({
|
||||
template: '<div class="error-component">组件加载失败</div>',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return defineAsyncComponent({
|
||||
loader: safeLoader,
|
||||
loadingComponent: options?.loadingComponent,
|
||||
errorComponent: options?.errorComponent,
|
||||
delay: options?.delay ?? 200,
|
||||
timeout: options?.timeout ?? 30000,
|
||||
suspensible: options?.suspensible ?? false,
|
||||
onError: options?.onError || ((error, retry, fail, attempts) => {
|
||||
console.error(`异步组件加载错误 (第${attempts}次尝试):`, error)
|
||||
if (attempts <= 3) {
|
||||
retry()
|
||||
}
|
||||
else {
|
||||
fail()
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建路由组件的安全加载器
|
||||
*/
|
||||
export function createSafeRouteComponent(componentPath: string) {
|
||||
return safeAsyncComponent(
|
||||
() => import(/* @vite-ignore */ `/src/views${componentPath}.vue`),
|
||||
{
|
||||
delay: 100,
|
||||
timeout: 10000,
|
||||
onError: (error, retry, fail, attempts) => {
|
||||
console.error(`路由组件加载失败: ${componentPath}`, error)
|
||||
|
||||
// 对于路由组件,最多重试2次
|
||||
if (attempts <= 2) {
|
||||
console.warn(`重试加载组件: ${componentPath} (第${attempts}次)`)
|
||||
retry()
|
||||
}
|
||||
else {
|
||||
console.error(`组件加载最终失败: ${componentPath}`)
|
||||
fail()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理组件缓存,用于解决热更新时的问题
|
||||
*/
|
||||
export function clearComponentCache() {
|
||||
// 在开发环境下清理模块缓存
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件安全性检查
|
||||
*/
|
||||
export function validateComponent(component: any): boolean {
|
||||
if (!component) {
|
||||
console.error('组件为空或未定义')
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof component !== 'object' && typeof component !== 'function') {
|
||||
console.error('组件类型不正确:', typeof component)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
65
src/utils/dict.ts
Normal file
65
src/utils/dict.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { DictDataOption } from '@/service/api/system/dict'
|
||||
|
||||
export type DictValue = string | number | boolean | null | undefined
|
||||
|
||||
/**
|
||||
* 将字典选项转换为 Naive UI Select 所需结构
|
||||
*/
|
||||
export function toSelectOptions(dictOptions: DictDataOption[] = []) {
|
||||
return dictOptions.map(option => ({
|
||||
label: option.dictLabel,
|
||||
value: option.dictValue,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过字典值获取对应的完整选项对象
|
||||
*/
|
||||
export function findDictOption(dictOptions: DictDataOption[] = [], value: DictValue) {
|
||||
if (value === undefined || value === null)
|
||||
return undefined
|
||||
|
||||
const target = String(value)
|
||||
return dictOptions.find(option => option.dictValue === target)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字典标签,找不到时返回默认值或原始值
|
||||
*/
|
||||
export function findDictLabel(dictOptions: DictDataOption[] = [], value: DictValue, fallback?: string) {
|
||||
const target = findDictOption(dictOptions, value)
|
||||
if (target)
|
||||
return target.dictLabel
|
||||
|
||||
if (fallback !== undefined)
|
||||
return fallback
|
||||
|
||||
if (value === undefined || value === null || value === '')
|
||||
return ''
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字典颜色配置
|
||||
*/
|
||||
export function findDictColor(dictOptions: DictDataOption[] = [], value: DictValue) {
|
||||
const target = findDictOption(dictOptions, value)
|
||||
if (!target)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
tag: target.dictTag,
|
||||
color: target.dictColor,
|
||||
label: target.dictLabel,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字典数组转为值 -> 选项的 Map,方便重复查询
|
||||
*/
|
||||
export function createDictMap(dictOptions: DictDataOption[] = []) {
|
||||
const map = new Map<string, DictDataOption>()
|
||||
dictOptions.forEach(option => map.set(option.dictValue, option))
|
||||
return map
|
||||
}
|
||||
20
src/utils/i18n.ts
Normal file
20
src/utils/i18n.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { NDateLocale, NLocale } from 'naive-ui'
|
||||
import { i18n } from '@/modules/i18n'
|
||||
import { dateZhCN, zhCN } from 'naive-ui'
|
||||
|
||||
export function setLocale(locale: App.lang) {
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
|
||||
export const $t = i18n.global.t
|
||||
|
||||
export const naiveI18nOptions: Record<App.lang, { locale: NLocale | null, dateLocale: NDateLocale | null }> = {
|
||||
zhCN: {
|
||||
locale: zhCN,
|
||||
dateLocale: dateZhCN,
|
||||
},
|
||||
enUS: {
|
||||
locale: null,
|
||||
dateLocale: null,
|
||||
},
|
||||
}
|
||||
15
src/utils/icon.ts
Normal file
15
src/utils/icon.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import CoiIcon from '@/components/common/CoiIcon.vue'
|
||||
|
||||
export function renderIcon(icon?: string, size?: number) {
|
||||
if (!icon)
|
||||
return
|
||||
|
||||
return () => createIcon(icon, size)
|
||||
}
|
||||
|
||||
export function createIcon(icon?: string, size: number = 18) {
|
||||
if (!icon)
|
||||
return
|
||||
|
||||
return h(CoiIcon, { icon, size })
|
||||
}
|
||||
5
src/utils/index.ts
Normal file
5
src/utils/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './storage'
|
||||
export * from './array'
|
||||
export * from './i18n'
|
||||
export * from './icon'
|
||||
export * from './normalize'
|
||||
147
src/utils/navigation-guard.ts
Normal file
147
src/utils/navigation-guard.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import type { RouteLocationNormalized, Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 导航防护类,用于防止快速路由切换导致的问题
|
||||
*/
|
||||
export class NavigationGuard {
|
||||
private router: Router
|
||||
private isNavigating = false
|
||||
private pendingNavigation: string | null = null
|
||||
private navigationTimer: NodeJS.Timeout | null = null
|
||||
private readonly NAVIGATION_DEBOUNCE = 100 // 100ms防抖
|
||||
private lastNavigationTime = 0
|
||||
|
||||
constructor(router: Router) {
|
||||
this.router = router
|
||||
this.setupGuards()
|
||||
}
|
||||
|
||||
private setupGuards() {
|
||||
// 在路由开始时设置导航状态
|
||||
this.router.beforeEach((to, from, next) => {
|
||||
const targetPath = to.fullPath
|
||||
const currentTime = Date.now()
|
||||
|
||||
// 检查是否是页面刷新或首次加载(from.name为null或undefined)
|
||||
const isPageRefresh = !from.name || from.fullPath === '/'
|
||||
|
||||
// 如果是页面刷新,直接重置状态并允许导航
|
||||
if (isPageRefresh) {
|
||||
this.resetNavigationState()
|
||||
this.lastNavigationTime = currentTime
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是快速重复点击(时间间隔小于防抖时间且目标路径相同)
|
||||
const timeSinceLastNavigation = currentTime - this.lastNavigationTime
|
||||
const isQuickDuplicate = timeSinceLastNavigation < this.NAVIGATION_DEBOUNCE
|
||||
&& this.pendingNavigation === targetPath
|
||||
|
||||
if (isQuickDuplicate) {
|
||||
console.warn('快速重复导航被阻止:', targetPath)
|
||||
return next(false)
|
||||
}
|
||||
|
||||
// 更新导航状态
|
||||
this.isNavigating = true
|
||||
this.pendingNavigation = targetPath
|
||||
this.lastNavigationTime = currentTime
|
||||
|
||||
// 清除之前的定时器
|
||||
if (this.navigationTimer) {
|
||||
clearTimeout(this.navigationTimer)
|
||||
}
|
||||
|
||||
// 设置导航完成的定时器
|
||||
this.navigationTimer = setTimeout(() => {
|
||||
this.isNavigating = false
|
||||
this.pendingNavigation = null
|
||||
}, this.NAVIGATION_DEBOUNCE)
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// 在路由完成或取消时重置状态
|
||||
this.router.afterEach(() => {
|
||||
this.resetNavigationState()
|
||||
})
|
||||
|
||||
// 监听导航错误
|
||||
this.router.onError((error) => {
|
||||
console.error('Navigation error:', error)
|
||||
this.resetNavigationState()
|
||||
})
|
||||
}
|
||||
|
||||
private resetNavigationState() {
|
||||
if (this.navigationTimer) {
|
||||
clearTimeout(this.navigationTimer)
|
||||
this.navigationTimer = null
|
||||
}
|
||||
this.isNavigating = false
|
||||
this.pendingNavigation = null
|
||||
// 注意:不重置 lastNavigationTime,保持防抖效果
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由跳转
|
||||
*/
|
||||
async safePush(to: string | RouteLocationNormalized): Promise<boolean> {
|
||||
try {
|
||||
// 如果正在导航到相同路由,则忽略
|
||||
if (this.pendingNavigation === (typeof to === 'string' ? to : to.fullPath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
await this.router.push(to)
|
||||
return true
|
||||
}
|
||||
catch (error: any) {
|
||||
// 忽略重复导航错误
|
||||
if (error.name === 'NavigationDuplicated') {
|
||||
return true
|
||||
}
|
||||
console.error('Navigation failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由替换
|
||||
*/
|
||||
async safeReplace(to: string | RouteLocationNormalized): Promise<boolean> {
|
||||
try {
|
||||
await this.router.replace(to)
|
||||
return true
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error.name === 'NavigationDuplicated') {
|
||||
return true
|
||||
}
|
||||
console.error('Navigation replace failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在导航
|
||||
*/
|
||||
isNavigatingTo(path: string): boolean {
|
||||
return this.isNavigating && this.pendingNavigation === path
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
destroy() {
|
||||
this.resetNavigationState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建导航防护实例
|
||||
*/
|
||||
export function createNavigationGuard(router: Router): NavigationGuard {
|
||||
return new NavigationGuard(router)
|
||||
}
|
||||
22
src/utils/normalize.ts
Normal file
22
src/utils/normalize.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 统一化存储单位,字节转化为英文缩写`bytes`, `KB`, `MB`, `GB`
|
||||
*
|
||||
* @param {number} bytes 需要转换的字节大小
|
||||
* @returns {string} 转化后的字节字符串
|
||||
* @example
|
||||
* ```
|
||||
* // Output: '1 MB'
|
||||
* normalizeSizeUnits(1048576)
|
||||
* ```
|
||||
*/
|
||||
export function normalizeSizeUnits(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return '0 bytes'
|
||||
|
||||
const units = ['bytes', 'KB', 'MB', 'GB']
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = +(bytes / 1024 ** index).toFixed(2)
|
||||
const unit = units[index]
|
||||
|
||||
return `${size} ${unit}`
|
||||
}
|
||||
100
src/utils/router-safety.ts
Normal file
100
src/utils/router-safety.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 路由安全包装器,用于处理路由操作中的错误
|
||||
*/
|
||||
export class RouterSafetyWrapper {
|
||||
private router: Router
|
||||
|
||||
constructor(router: Router) {
|
||||
this.router = router
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由跳转
|
||||
*/
|
||||
async safePush(to: string | object): Promise<boolean> {
|
||||
try {
|
||||
await this.router.push(to)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由跳转失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由替换
|
||||
*/
|
||||
async safeReplace(to: string | object): Promise<boolean> {
|
||||
try {
|
||||
await this.router.replace(to)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由替换失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由回退
|
||||
*/
|
||||
async safeBack(): Promise<boolean> {
|
||||
try {
|
||||
this.router.back()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由回退失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由前进
|
||||
*/
|
||||
async safeForward(): Promise<boolean> {
|
||||
try {
|
||||
this.router.forward()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由前进失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建路由安全包装器实例
|
||||
*/
|
||||
export function createRouterSafety(router: Router): RouterSafetyWrapper {
|
||||
return new RouterSafetyWrapper(router)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局错误处理函数
|
||||
*/
|
||||
export function handleRouterError(error: any, operation: string = '路由操作') {
|
||||
console.error(`${operation}发生错误:`, error)
|
||||
|
||||
// 如果是导航被阻止的错误,不需要特殊处理
|
||||
if (error.name === 'NavigationDuplicated' || error.message?.includes('redundant navigation')) {
|
||||
return
|
||||
}
|
||||
|
||||
// 其他路由错误的处理
|
||||
if (error.name === 'NavigationAborted') {
|
||||
console.warn('导航被中止')
|
||||
return
|
||||
}
|
||||
|
||||
// 未知错误,记录详细信息
|
||||
console.error('未知路由错误:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
})
|
||||
}
|
||||
86
src/utils/storage.ts
Normal file
86
src/utils/storage.ts
Normal file
@ -0,0 +1,86 @@
|
||||
const STORAGE_PREFIX = import.meta.env.VITE_STORAGE_PREFIX
|
||||
|
||||
interface StorageData<T> {
|
||||
value: T
|
||||
expire: number | null
|
||||
}
|
||||
/**
|
||||
* LocalStorage部分操作
|
||||
*/
|
||||
function createLocalStorage<T extends Storage.Local>() {
|
||||
// 默认缓存期限为7天
|
||||
|
||||
function set<K extends keyof T>(key: K, value: T[K], expire: number = 60 * 60 * 24 * 7) {
|
||||
const storageData: StorageData<T[K]> = {
|
||||
value,
|
||||
expire: new Date().getTime() + expire * 1000,
|
||||
}
|
||||
const json = JSON.stringify(storageData)
|
||||
window.localStorage.setItem(`${STORAGE_PREFIX}${String(key)}`, json)
|
||||
}
|
||||
|
||||
function get<K extends keyof T>(key: K) {
|
||||
const json = window.localStorage.getItem(`${STORAGE_PREFIX}${String(key)}`)
|
||||
if (!json)
|
||||
return null
|
||||
|
||||
const storageData: StorageData<T[K]> | null = JSON.parse(json)
|
||||
|
||||
if (storageData) {
|
||||
const { value, expire } = storageData
|
||||
if (expire === null || expire >= Date.now())
|
||||
return value
|
||||
}
|
||||
remove(key)
|
||||
return null
|
||||
}
|
||||
|
||||
function remove(key: keyof T) {
|
||||
window.localStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`)
|
||||
}
|
||||
|
||||
const clear = window.localStorage.clear
|
||||
|
||||
return {
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* sessionStorage部分操作
|
||||
*/
|
||||
|
||||
function createSessionStorage<T extends Storage.Session>() {
|
||||
function set<K extends keyof T>(key: K, value: T[K]) {
|
||||
const json = JSON.stringify(value)
|
||||
window.sessionStorage.setItem(`${STORAGE_PREFIX}${String(key)}`, json)
|
||||
}
|
||||
function get<K extends keyof T>(key: K) {
|
||||
const json = sessionStorage.getItem(`${STORAGE_PREFIX}${String(key)}`)
|
||||
if (!json)
|
||||
return null
|
||||
|
||||
const storageData: T[K] | null = JSON.parse(json)
|
||||
|
||||
if (storageData)
|
||||
return storageData
|
||||
|
||||
return null
|
||||
}
|
||||
function remove(key: keyof T) {
|
||||
window.sessionStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`)
|
||||
}
|
||||
const clear = window.sessionStorage.clear
|
||||
|
||||
return {
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
export const local = createLocalStorage()
|
||||
export const session = createSessionStorage()
|
||||
Loading…
Reference in New Issue
Block a user