核心架构:添加基础架构层代码

- 添加工具函数库(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:
Leo 2025-10-08 02:25:33 +08:00
parent 80ab2736c9
commit 715270aa49
49 changed files with 4681 additions and 0 deletions

23
src/App.vue Normal file
View 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>

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

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

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

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

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

View 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) {
// 71
for (let i = 1; i <= total; i++) {
pages.push(i)
}
}
else {
// 7使
if (current <= 4) {
// 41, 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) {
// 41, ..., 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) {
// pageSizeprops
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>

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

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

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

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

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

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

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

View 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'
// naivewindow, 便
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>

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

@ -0,0 +1,5 @@
/** Gender */
export enum Gender {
male,
female,
}

2
src/constants/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './Regex'
export * from './User'

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

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

@ -0,0 +1,3 @@
export * from './useBoolean'
export * from './usePermission'
export * from './useDict'

28
src/hooks/useBoolean.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
},
}

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

@ -0,0 +1,5 @@
export * from './storage'
export * from './array'
export * from './i18n'
export * from './icon'
export * from './normalize'

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