【调整】增加部署雨云

This commit is contained in:
cai
2026-01-13 17:47:39 +08:00
parent 4e49ca075a
commit 367c1a1096
1094 changed files with 179074 additions and 45 deletions

View File

@@ -0,0 +1,14 @@
<template>
<div class="min-h-screen bg-white">
<div class="container mx-auto py-8">
<!-- 页面标题 -->
<h1 class="text-2xl font-bold mb-8 text-center">Form Builder</h1>
<!-- 表单构建器组件 -->
<Demo />
</div>
</div>
</template>
<script lang="ts" setup>
import Demo from './views/Demo'
</script>

View File

@@ -0,0 +1,711 @@
import { defineComponent, onMounted, ref, watch, PropType, onUnmounted } from 'vue'
/**
* 渐变色停止点接口定义
* @interface ColorStop
* @property {number} offset - 渐变停止点位置0-1
* @property {string} color - 渐变颜色值
*/
interface ColorStop {
offset: number
color: string
}
/**
* 文字位置类型
*/
type TextPosition = 'front' | 'back' | 'follow'
/**
* 自定义插槽参数接口
*/
interface SlotProps {
percent: number
color: string
}
/**
* 环形进度条组件
* 支持自定义大小、颜色、进度和渐变色
* @component CircleProgress
* @example
* ```vue
* <!-- 基础用法 -->
* <CircleProgress :percent="75" />
*
* <!-- 横向进度条 -->
* <CircleProgress type="horizontal" :percent="75" />
*
* <!-- 使用渐变色 -->
* <CircleProgress
* :percent="75"
* :progress-color="[
* { offset: 0, color: '#ff0000' },
* { offset: 0.5, color: '#00ff00' },
* { offset: 1, color: '#0000ff' }
* ]"
* />
*
* <!-- 自定义样式 -->
* <CircleProgress
* :percent="75"
* :size="200"
* :stroke-width="10"
* :text-color-follow-progress="true"
* />
* ```
*/
export const CircleProgress = defineComponent({
name: 'CircleProgress',
props: {
/**
* 进度条类型
* @default 'circle'
* @type {'circle' | 'horizontal'}
*/
type: {
type: String as PropType<'circle' | 'horizontal'>,
default: 'circle',
},
/**
* 进度值,范围 0-100
* @default 0
* @type {number}
*/
percent: {
type: Number,
default: 0,
validator: (value: number) => value >= 0 && value <= 100,
},
/**
* 组件大小,单位像素
* @default 300
* @type {number}
*/
size: {
type: Number,
default: 300,
},
/**
* 进度文字大小
* @default 30
* @type {number}
*/
textSize: {
type: Number,
default: 30,
},
/**
* 进度条宽度
* @default 20
* @type {number}
*/
strokeWidth: {
type: Number,
default: 10,
},
/**
* 轨道颜色
* @default '#e5f1fa'
* @type {string}
*/
trackColor: {
type: String,
default: '#e5f1fa',
},
/**
* 进度条颜色,支持纯色或渐变色数组
* @default '#2ba0fb'
* @type {string | ColorStop[]}
*/
progressColor: {
type: [String, Array] as PropType<string | ColorStop[]>,
default: '#2ba0fb',
},
/**
* 进度文字颜色
* @default '#333'
* @type {string}
*/
textColor: {
type: String,
default: '#333',
},
/**
* 文字颜色是否跟随进度条颜色变化
* @default false
* @type {boolean}
*/
textColorFollowProgress: {
type: Boolean,
default: false,
},
/**
* 起始角度(弧度)
* @default -Math.PI / 2
* @type {number}
*/
startAngle: {
type: Number,
default: -Math.PI / 2, // 默认从12点钟方向开始
},
/**
* 是否顺时针旋转
* @default true
* @type {boolean}
*/
clockwise: {
type: Boolean,
default: true,
},
/**
* 动画过渡速度0-1之间值越大动画越快
* @default 0.1
* @type {number}
*/
animationSpeed: {
type: Number,
default: 0.1,
validator: (value: number) => value > 0 && value <= 1,
},
/**
* 组件宽度,单位像素(仅横向进度条生效)
* @default 300
* @type {number}
*/
width: {
type: Number,
default: 300,
},
/**
* 组件高度,单位像素(仅横向进度条生效)
* @default 20
* @type {number}
*/
height: {
type: Number,
default: 20,
},
/**
* 是否启用圆角
* @default true
* @type {boolean}
*/
rounded: {
type: Boolean,
default: true,
},
/**
* 进度条颜色是否跟随进度变化
* @default false
* @type {boolean}
*/
colorFollowProgress: {
type: Boolean,
default: false,
},
/**
* 横向进度条文字位置
* @default 'follow'
* @type {'front' | 'back' | 'follow'}
*/
textPosition: {
type: String as PropType<TextPosition>,
default: 'follow',
},
/**
* 自定义进度文字插槽
* @type {(props: SlotProps) => any}
*/
progressText: {
type: Function as PropType<(props: SlotProps) => JSX.Element>,
default: undefined,
},
},
setup(props) {
const canvasRef = ref<HTMLCanvasElement | null>(null) // 画布引用
const currentNum = ref(0) // 当前进度
const targetNum = ref(0) // 目标进度
const animationFrame = ref<number | null>(null) // 动画帧
/**
* 计算圆角导致的进度偏差值
* @returns {number} 角度偏差值(弧度)
* @description
* 1. 计算整个圆的长度,以进度线段中心作为圆的长度
* 2. 获取进度线段线帽的半径(线段宽度的一半)
* 3. 计算线帽旋转需要的角度偏差
* 4. 如果未启用圆角或未使用渐变色则返回0
* 5. 当圆弧长度大于圆的长度时,根据进度值计算额外偏移
*/
const roundDeviation = (): number => {
if (props.type === 'horizontal') return 0
// 如果未启用圆角或未使用渐变色返回0
if (!props.rounded || !Array.isArray(props.progressColor) || props.percent === 100) {
return 0
}
// 计算圆的半径(以进度线段中心为基准)
const radius = (props.size - props.strokeWidth) / 2
// 获取线帽半径(线段宽度的一半)
const capRadius = props.strokeWidth / 2
// 计算线帽旋转需要的角度偏差
// 使用弧长公式:弧长 = 半径 * 角度
// 因此:角度 = 弧长 / 半径
// 这里使用线帽半径作为弧长,因为线帽旋转时走过的距离等于线帽半径
const deviation = capRadius / radius
// 计算当前圆的长度
// const circleLength = 2 * Math.PI * radius
// const progressLength = circleLength * (props.percent / 100) + props.strokeWidth
// 如果当前圆弧的长度大于圆的长度且进度小于100%,则增加偏差
// if (progressLength > circleLength && props.percent <= 100) {
// deviation = deviation + (progressLength - circleLength) / radius
// }
return deviation
}
/**
* 创建渐变对象
* @param ctx - Canvas上下文
* @param centerX - 圆心X坐标
* @param centerY - 圆心Y坐标
* @param colorStops - 渐变色停止点数组
* @returns {CanvasGradient} 锥形渐变对象
* @description
* 创建一个锥形渐变,并添加颜色停止点。
* 确保渐变的起点和终点颜色正确,使渐变效果更加平滑。
*/
const createGradient = (
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
colorStops: ColorStop[],
): CanvasGradient => {
const deviation = roundDeviation()
console.log(deviation)
// 创建锥形渐变,起始角度为-90度12点钟方向增加一个偏差值解决进度显示不完整的问题同时排除显卡
const gradient = ctx.createConicGradient(props.startAngle - deviation, centerX, centerY)
// 添加颜色停止点
colorStops.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color)
})
// 确保渐变闭合
const firstStop = colorStops[0]
// 获取最后一个颜色停止点
const lastStop = colorStops[colorStops.length - 1]
console.log(firstStop, lastStop)
// 如果第一个颜色停止点不是0则添加一个0偏移的颜色停止点
if (firstStop && firstStop.offset !== 0) {
gradient.addColorStop(0, firstStop.color)
}
// 如果最后一个颜色停止点不是1则添加一个1偏移的颜色停止点
if (lastStop && lastStop.offset !== 1) {
gradient.addColorStop(1, lastStop.color)
}
return gradient
}
/**
* 获取当前进度的颜色或渐变
* @param ctx - Canvas上下文
* @param centerX - 圆心X坐标
* @param centerY - 圆心Y坐标
* @returns {string | CanvasGradient} 颜色值或渐变对象
* @description
* 根据progressColor属性的类型返回对应的颜色或渐变对象。
* 如果是字符串则返回纯色,如果是数组则创建渐变。
*/
const getProgressColor = (
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
): string | CanvasGradient => {
if (!Array.isArray(props.progressColor)) {
return props.progressColor
}
// 如果是横向进度条,使用线性渐变
if (props.type === 'horizontal') {
const gradient = ctx.createLinearGradient(0, centerY, props.width, centerY)
props.progressColor.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color)
})
return gradient
}
// 圆形进度条使用锥形渐变
return createGradient(ctx, centerX, centerY, props.progressColor)
}
/**
* 获取当前进度的颜色
* @param progress - 当前进度值0-100
* @returns {string} 当前进度的颜色
*/
const getCurrentProgressColor = (progress: number): string => {
// 如果不是渐变色数组,直接返回颜色
if (!Array.isArray(props.progressColor)) {
return typeof props.progressColor === 'string' ? props.progressColor : props.textColor
}
// 如果颜色停止点为空,返回默认颜色
const colorStops = props.progressColor as ColorStop[]
if (colorStops.length === 0) {
return props.textColor
}
// 如果进度达到100%,返回最后一个颜色
if (progress >= 100) {
const lastStop = colorStops[colorStops.length - 1]
return lastStop?.color || props.textColor
}
// 将进度转换为0-1之间的值
const normalizedProgress = progress / 100
// 找到当前进度所在的两个颜色停止点
for (let i = 0; i < colorStops.length - 1; i++) {
const currentStop = colorStops[i]
const nextStop = colorStops[i + 1]
if (
currentStop &&
nextStop &&
normalizedProgress >= currentStop.offset &&
normalizedProgress <= nextStop.offset
) {
// 计算两个颜色之间的插值
const range = nextStop.offset - currentStop.offset
const ratio = (normalizedProgress - currentStop.offset) / range
return currentStop.color
}
}
// 如果进度超出范围,返回最后一个颜色
const lastStop = colorStops[colorStops.length - 1]
return lastStop?.color || props.textColor
}
/**
* 格式化进度显示值
* @param value - 进度值
* @returns {string} 格式化后的进度值
*/
const formatProgressValue = (value: number): string => {
// 如果进度达到100直接返回100%
if (value >= 100) return '100%'
// 将进度值转换为两位小数
// const decimalValue = Math.round(value * 100) / 100
// 如果是整数,直接返回
if (Number.isInteger(value)) {
return `${value}%`
}
// 否则返回两位小数
return `${value.toFixed(2)}%`
}
/**
* 获取文字位置样式
* @returns {object} 文字位置样式对象
*/
const getTextPositionStyle = (): object => {
if (props.type !== 'horizontal') {
return {
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}
}
const progress = currentNum.value / 100
const width = props.width
const textWidth = 60 // 预估文字宽度
// 根据文字位置类型返回对应的样式
switch (props.textPosition) {
// 文字在进度条前面
case 'front':
return {
left: `${textWidth / 2}px`,
top: '50%',
transform: 'translateY(-50%)',
}
// 文字在进度条后面
case 'back':
return {
right: `${textWidth / 2}px`,
top: '50%',
transform: 'translateY(-50%)',
}
// 文字跟随进度条
case 'follow':
default:
return {
left: `${Math.max(textWidth / 2, Math.min(width - textWidth / 2, width * progress))}px`, // 文字位置
top: '50%',
transform: 'translateX(-50%) translateY(-50%)',
}
}
}
/**
* 绘制圆弧
* @param ctx - Canvas上下文
* @param color - 填充颜色或渐变
* @param x - 圆心X坐标
* @param y - 圆心Y坐标
* @param radius - 半径
* @param start - 起始角度(弧度)
* @param end - 结束角度(弧度)
* @description
* 使用Canvas绘制圆弧支持纯色和渐变填充。
* 使用butt线帽和miter连接样式确保线条无圆角。
*/
const drawCircle = (
ctx: CanvasRenderingContext2D,
color: string | CanvasGradient,
x: number,
y: number,
radius: number,
start: number,
end: number,
) => {
ctx.save() // 保存当前状态
// 设置线条样式
ctx.lineCap = props.rounded ? 'round' : 'butt' // 根据rounded属性设置线帽
ctx.lineJoin = props.rounded ? 'round' : 'miter' // 根据rounded属性设置连接样式
ctx.lineWidth = props.strokeWidth // 设置线宽
ctx.strokeStyle = color // 设置线条颜色
// 创建路径
ctx.beginPath() // 开始绘制路径
ctx.arc(x, y, radius, start, end, !props.clockwise) // 绘制圆弧
// 绘制线条
ctx.stroke() // 绘制线条
ctx.closePath() // 关闭路径
ctx.restore() // 恢复状态
}
/**
* 绘制横向进度条
* @param ctx - Canvas上下文
* @param color - 填充颜色或渐变
* @param x - 起始X坐标
* @param y - 起始Y坐标
* @param width - 宽度
* @param height - 高度
* @param progress - 进度值0-1
* @description
* 使用Canvas绘制横向进度条支持纯色和渐变填充。
*/
const drawHorizontal = (
ctx: CanvasRenderingContext2D,
color: string | CanvasGradient,
x: number,
y: number,
width: number,
height: number,
progress: number,
) => {
ctx.save()
// 设置线条样式
ctx.lineCap = props.rounded ? 'round' : 'butt'
ctx.lineJoin = props.rounded ? 'round' : 'miter'
ctx.lineWidth = height
ctx.strokeStyle = color
// 计算圆角半径
const radius = props.rounded ? height / 2 : 0
// 计算实际进度宽度,考虑圆角
const actualWidth = Math.max(radius * 2, width * progress)
// 绘制进度条
ctx.beginPath()
// 从圆角中心点开始绘制
ctx.moveTo(x + radius, y + height / 2)
// 到圆角中心点结束
ctx.lineTo(x + actualWidth - radius, y + height / 2)
ctx.stroke()
ctx.closePath()
// 只在启用圆角时绘制起点和终点圆角
if (props.rounded) {
// 绘制起点圆角
ctx.beginPath()
ctx.arc(x + radius, y + height / 2, radius, -Math.PI / 2, Math.PI / 2)
ctx.fillStyle = color
ctx.fill()
ctx.closePath()
// 只在进度大于0时绘制终点圆角
if (progress > 0) {
ctx.beginPath()
ctx.arc(x + actualWidth - radius, y + height / 2, radius, Math.PI / 2, -Math.PI / 2)
ctx.fillStyle = color
ctx.fill()
ctx.closePath()
}
}
ctx.restore()
}
/**
* 执行动画绘制
* @description
* 使用requestAnimationFrame实现平滑的进度动画。
* 支持高DPI设备确保显示清晰。
* 包含背景轨道、进度条和进度文字的绘制。
*/
const animate = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// 设置画布的实际尺寸为显示尺寸的2倍以支持高DPI设备
const dpr = window.devicePixelRatio || 1
const displayWidth = props.type === 'horizontal' ? props.width : props.size
const displayHeight = props.type === 'horizontal' ? props.height : props.size
canvas.width = displayWidth * dpr
canvas.height = displayHeight * dpr
ctx.scale(dpr, dpr)
const draw = () => {
// 平滑过渡到目标值
const diff = targetNum.value - currentNum.value
if (Math.abs(diff) > 0.1) {
currentNum.value += diff * props.animationSpeed
animationFrame.value = requestAnimationFrame(draw)
} else {
currentNum.value = targetNum.value
}
ctx.clearRect(0, 0, displayWidth, displayHeight)
if (props.type === 'horizontal') {
// 绘制背景轨道
drawHorizontal(ctx, props.trackColor, 0, 0, displayWidth, displayHeight, 1)
// 获取当前进度的颜色或渐变
const progressColor = getProgressColor(ctx, displayWidth / 2, displayHeight / 2)
// 绘制进度条
drawHorizontal(ctx, progressColor, 0, 0, displayWidth, displayHeight, currentNum.value / 100)
} else {
// 原有的圆形进度条绘制逻辑
const centerX = props.size / 2
const centerY = props.size / 2
const radius = (props.size - props.strokeWidth) / 2
// 绘制背景轨道
drawCircle(ctx, props.trackColor, centerX, centerY, radius, 0, 2 * Math.PI)
// 获取当前进度的颜色或渐变
const progressColor = getProgressColor(ctx, centerX, centerY)
// 绘制进度条
const progressAngle = ((2 * currentNum.value) / 100) * Math.PI
const adjustedStartAngle = props.startAngle
const adjustedEndAngle = props.startAngle + progressAngle
// 绘制进度条
drawCircle(ctx, progressColor, centerX, centerY, radius, adjustedStartAngle, adjustedEndAngle)
}
}
draw()
}
// 组件挂载时初始化
onMounted(() => {
targetNum.value = props.percent
currentNum.value = props.percent
animate()
})
// 监听进度值变化
watch(
() => props.percent,
(newValue) => {
// 限制进度值不超过100
const limitedValue = Math.min(newValue, 100)
// 如果已经达到100%,不再更新
if (currentNum.value >= 100 && limitedValue >= 100) {
return
}
targetNum.value = limitedValue
// 取消之前的动画帧
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
animationFrame.value = null
}
animate()
},
)
// 组件卸载时清理动画帧
onUnmounted(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
animationFrame.value = null
}
})
return () => {
const currentColor = getCurrentProgressColor(currentNum.value)
const textContent = props.progressText
? props.progressText({ percent: Math.round(currentNum.value), color: currentColor })
: formatProgressValue(currentNum.value)
return (
<div
style={{
width: props.type === 'horizontal' ? `${props.width}px` : `${props.size}px`,
height: props.type === 'horizontal' ? `${props.height}px` : `${props.size}px`,
position: 'relative',
}}
>
<canvas
ref={canvasRef}
style={{
width: props.type === 'horizontal' ? `${props.width}px` : `${props.size}px`,
height: props.type === 'horizontal' ? `${props.height}px` : `${props.size}px`,
display: 'block',
}}
/>
<div
style={{
position: 'absolute',
...getTextPositionStyle(),
fontSize: props.type === 'horizontal' ? `${props.height * 0.8}px` : `${props.size / 7.5}px`,
fontFamily: 'Helvetica',
color: props.textColorFollowProgress ? currentColor : props.textColor,
}}
>
{textContent}
</div>
</div>
)
}
},
})
export default CircleProgress

View File

@@ -0,0 +1,56 @@
import { defineComponent } from 'vue'
import {
NConfigProvider,
NDialogProvider,
NMessageProvider,
NModalProvider,
NNotificationProvider,
zhCN,
dateZhCN,
} from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useTheme } from '../theme'
import { useNaiveI18nSync } from '../i18n'
// 全局配置组件
export default defineComponent({
name: 'NCustomProvider',
setup(_, { slots }) {
const { locale } = useI18n() // 国际化
const { naiveLocale, naiveDateLocale } = useNaiveI18nSync(locale) // i18n 同步
const { theme, themeOverrides } = useTheme() // 主题
// 国际化配置
return () => (
<NConfigProvider
theme={theme.value}
theme-overrides={themeOverrides.value}
locale={naiveLocale.value || zhCN}
date-locale={naiveDateLocale.value || dateZhCN}
>
<NDialogProvider>
<NMessageProvider>
<NNotificationProvider>
<NModalProvider>{slots.default?.()}</NModalProvider>
</NNotificationProvider>
</NMessageProvider>
</NDialogProvider>
</NConfigProvider>
)
},
})
// 主题配置组件
const themeProvider = defineComponent({
name: 'NThemeProvider',
setup(_, { slots }) {
const { theme, themeOverrides } = useTheme() // 主题
return () => (
<NConfigProvider theme={theme.value} theme-overrides={themeOverrides.value}>
{slots.default?.()}
</NConfigProvider>
)
},
})
export { themeProvider }

View File

@@ -0,0 +1,39 @@
import { defineComponent } from 'vue'
import { NSwitch, NTooltip } from 'naive-ui'
import { useTheme } from '../theme/index'
/**
* @description 暗黑模式切换组件
*/
export const DarkModeSwitch = defineComponent({
name: 'DarkModeSwitch',
setup() {
const { isDark, cutDarkMode } = useTheme()
return () => (
<div>
<NTooltip trigger="hover">
{{
trigger: () => (
<NSwitch
value={isDark.value}
onUpdateValue={() => cutDarkMode()}
rail-style={() => ({
background: isDark.value ? '#333' : '#eee',
transition: 'background .3s',
})}
>
{{
checked: () => '🌙',
unchecked: () => '☀️',
}}
</NSwitch>
),
default: () => (isDark.value ? '切换到亮色模式' : '切换到暗色模式'),
}}
</NTooltip>
</div>
)
},
})
export default DarkModeSwitch

View File

@@ -0,0 +1,244 @@
/**
* 一个可自定义的环形进度条组件,支持渐变色效果。
*
* @component CircleProgressCSS
*
* @example 基础用法
* ```tsx
* <CircleProgressCSS
* percent={75}
* size={200}
* strokeWidth={20}
* progressColor="#2ba0fb"
* />
* ```
*
* @example 使用渐变色
* ```tsx
* <CircleProgressCSS
* percent={75}
* progressColor={[
* { offset: 0, color: '#ff0000' },
* { offset: 1, color: '#00ff00' }
* ]}
* />
* ```
*
* @props
* @prop {number} percent - 进度百分比(0-100)
* @prop {number} [size=200] - 圆环大小(像素)
* @prop {number} [strokeWidth=20] - 进度条宽度
* @prop {string} [textSize='24px'] - 百分比文字大小
* @prop {string} [trackColor='#e5f1fa'] - 背景轨道颜色
* @prop {string} [textColor='#333'] - 百分比文字颜色
* @prop {string} [holeColor='var(--n-color-modal)'] - 中心圆孔颜色
* @prop {string|ColorStop[]} [progressColor='#2ba0fb'] - 进度条颜色或渐变色配置
* @prop {boolean} [rounded=true] - 是否使用圆角
* @prop {boolean} [animated=true] - 是否启用动画效果
*
* @interface ColorStop
* @property {number} offset - 渐变色位置(0-1)
* @property {string} color - 十六进制颜色值(#RRGGBB)
*/
import { defineComponent, ref, computed, watch, PropType, CSSProperties } from 'vue'
import style from './index.module.css'
interface ColorStop {
offset: number
color: string
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = hex
.trim()
.toLowerCase()
.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (!match) return null
return {
r: parseInt(match[1] as string, 16),
g: parseInt(match[2] as string, 16),
b: parseInt(match[3] as string, 16),
}
}
function polarToCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) {
const angleInRadians = (angleInDegrees - 90) * (Math.PI / 180)
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
}
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3)
}
export default defineComponent({
name: 'CircleProgressCSS',
props: {
percent: {
type: Number,
required: true,
validator: (v: number) => v >= 0 && v <= 100,
},
size: {
type: Number,
default: 200,
},
strokeWidth: {
type: Number,
default: 20,
},
textSize: {
type: String,
default: '24px',
},
trackColor: {
type: String,
default: '#e5f1fa',
},
textColor: {
type: String,
default: '#333',
},
holeColor: {
type: String,
default: 'var(--n-color-modal)',
},
progressColor: {
type: [String, Array] as PropType<string | ColorStop[]>,
default: '#2ba0fb',
},
rounded: {
type: Boolean,
default: true,
},
animated: {
type: Boolean,
default: true,
},
},
setup(props) {
const animatedPercent = ref(props.percent)
// 判断是否支持conic-gradient
const supportsConicGradient = () => CSS.supports('background-image', 'conic-gradient(red, yellow)')
watch(
() => props.percent,
(newVal) => {
if (!props.animated) {
animatedPercent.value = newVal
return
}
const duration = 300
const frameRate = 60
const frameCount = Math.round(duration / (1000 / frameRate))
const start = animatedPercent.value
const delta = newVal - start
let frame = 0
const animate = () => {
frame++
const progress = frame / frameCount
animatedPercent.value = start + delta * easeOutCubic(progress)
if (frame < frameCount) {
requestAnimationFrame(animate)
}
}
animate()
},
{ immediate: true },
)
const conicGradient = computed(() => {
if (!supportsConicGradient() && Array.isArray(props.progressColor) && props.progressColor.length > 0) {
return (props.progressColor[0] as ColorStop).color
}
if (typeof props.progressColor === 'string') {
return `conic-gradient(${props.progressColor} 0% 100%)`
}
const stops = (props.progressColor as ColorStop[]).map((s) => `${s.color} ${s.offset * 100}%`).join(', ')
return `conic-gradient(${stops})`
})
const getStartColor = (): string => {
if (animatedPercent.value === 0) return 'transparent'
if (typeof props.progressColor === 'string') return props.progressColor
return (props.progressColor[0] as ColorStop).color
}
const getEndColor = (): string => {
if (typeof props.progressColor === 'string') return props.progressColor
if (!supportsConicGradient() && Array.isArray(props.progressColor) && props.progressColor.length > 0) {
return (props.progressColor[0] as ColorStop).color
}
if (animatedPercent.value === 0 || animatedPercent.value === 100) return 'transparent'
const percent = animatedPercent.value / 100
const colorStops = props.progressColor as ColorStop[]
let prev = colorStops[0]
let next = colorStops[colorStops.length - 1]
for (let i = 0; i < colorStops.length - 1; i++) {
if (percent >= (colorStops[i] as ColorStop).offset && percent <= (colorStops[i + 1] as ColorStop).offset) {
prev = colorStops[i]
next = colorStops[i + 1]
break
}
}
const range = (next as ColorStop).offset - (prev as ColorStop).offset
const localPercent = range === 0 ? 0 : (percent - (prev as ColorStop).offset) / range
const prevRGB = hexToRgb((prev as ColorStop).color)
const nextRGB = hexToRgb((next as ColorStop).color)
if (!prevRGB || !nextRGB) return (next as ColorStop).color
const r = Math.round(prevRGB.r + (nextRGB.r - prevRGB.r) * localPercent)
const g = Math.round(prevRGB.g + (nextRGB.g - prevRGB.g) * localPercent)
const b = Math.round(prevRGB.b + (nextRGB.b - prevRGB.b) * localPercent)
return `rgb(${r}, ${g}, ${b})`
}
const capPosition = computed(() => {
const angle = (animatedPercent.value / 100) * 360
const center = props.size / 2
const radius = center - props.strokeWidth / 2
const pos = polarToCartesian(center, center, radius, angle)
return {
left: `${pos.x}px`,
top: `${pos.y}px`,
}
})
const containerStyle = computed<CSSProperties>(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
'--track-color': props.trackColor,
'--text-color': props.textColor,
'--text-size': props.textSize,
'--percent': (animatedPercent.value / 100).toFixed(2),
'--progress-gradient': conicGradient.value,
'--stroke-width': `${props.strokeWidth}px`,
'--cap-start-color': getStartColor(),
'--cap-end-color': getEndColor(),
'--hole-color': props.holeColor,
}))
const displayText = computed(() => `${Math.round(animatedPercent.value)}%`)
return () => (
<div class={style['circle-progress']} style={containerStyle.value}>
<div class={style['circle-track']} />
<div class={style['circle-fill']} />
<div class={style['circle-hole']} />
<div class={[style['circle-cap'], style['start-cap']].join(' ')} />
<div class={[style['circle-cap'], style['end-cap']].join(' ')} style={capPosition.value} />
<div class={style['circle-text']}>{displayText.value}</div>
</div>
)
},
})

View File

@@ -0,0 +1,86 @@
/**
* 渐变进度条组件
*
* @description
* 一个支持渐变色的横向进度条组件,可自定义进度文本和样式
*
* @example
* ```tsx
* // 基础用法
* <HorizontalProgress :value="50" />
*
* // 自定义颜色
* <HorizontalProgress
* :value="75"
* color="linear-gradient(to right, #108ee9, #87d068)"
* />
*
* // 自定义进度文本
* <HorizontalProgress :value="30">
* <template #default>30%</template>
* </HorizontalProgress>
* ```
*
* @property {number} value - 进度值(0-100)
* @property {string} progressTextStyle - 进度文本的自定义样式
* @property {string} color - 进度条的渐变色,支持 CSS 渐变语法
*
* @slots
* default - 默认插槽,用于自定义进度条内的内容
*
* @requires vue
* @requires naive-ui
* @requires @vueuse/core
*/
import { defineComponent, ref, onMounted, useTemplateRef } from 'vue'
import style from './index.module.css'
import { useThemeVars } from 'naive-ui'
import { useResizeObserver, ResizeObserverEntry } from '@vueuse/core'
export default defineComponent({
props: {
value: {
type: Number,
required: true,
},
progressTextStyle: {
type: String,
default: '',
},
color: {
type: String,
default: 'linear-gradient(to left, #ff0000 0%, #ff7f00 50%, #20a53a 100%)',
},
},
setup(props, { slots }) {
const proContainer = useTemplateRef<HTMLElement | null>('proContainer')
const provWidth = ref(0)
const themeVars = useThemeVars()
onMounted(() => {
if (proContainer.value) {
provWidth.value = proContainer.value.clientWidth
}
})
useResizeObserver(proContainer, (entries) => {
const entry = entries[0] as ResizeObserverEntry
const { width } = entry.contentRect
provWidth.value = width
})
return () => (
<div
class={style['pro-container']}
style={{ width: '100%', backgroundColor: themeVars.value.progressRailColor }}
ref="proContainer"
>
<div class={style['probg']} style={{ width: `${props.value}%` }}>
<div class={style['prov']} style={{ width: `${provWidth.value}px`, background: props.color }}></div>
<div class={style['proText']} style={props.progressTextStyle}>
{slots.default ? slots.default() : ''}
</div>
</div>
</div>
)
},
})

View File

@@ -0,0 +1,102 @@
.pro-container{
border-radius: 50px;
}
.probg {
height: 12px;
border: none;
border-radius: 50px;
position: relative;
overflow: hidden;
transition: width 0.3s ease; /* 添加过渡效果 */
}
.prov {
height: 100%;
position: absolute;
z-index: 12;
top: 0;
border-radius: 50px;
left: 0;
overflow: hidden; /* 隐藏多余部分 */
transition: width 0.3s ease; /* 添加过渡效果 */
}
.proText{
height: 100%;
position: absolute;
z-index: 13;
font-size: 12px;
line-height: 12px;
margin-left: 20px;
color: #fff;
}
.circle-progress {
position: relative;
border-radius: 50%;
overflow: hidden;
}
.circle-track {
position: absolute;
width: 100%;
height: 100%;
background-color: var(--track-color);
border-radius: 50%;
}
.circle-fill {
position: absolute;
width: 100%;
height: 100%;
background-image: var(--progress-gradient);
mask-image: conic-gradient(black calc(var(--percent) * 100%), transparent 0);
-webkit-mask-image: conic-gradient(black calc(var(--percent) * 100%), transparent 0);
mask-composite: intersect;
-webkit-mask-composite: destination-in;
transition: all 0.3s ease;
border-radius: 50%;
}
.circle-hole {
position: absolute;
top: var(--stroke-width);
left: var(--stroke-width);
right: var(--stroke-width);
bottom: var(--stroke-width);
background-color: var(--hole-color);
border-radius: 50%;
z-index: 1;
}
.circle-cap {
position: absolute;
width: var(--stroke-width);
height: var(--stroke-width);
border-radius: 50%;
z-index: 2;
}
/* 起点圆点12点方向 */
.start-cap {
top: 0;
left: 50%;
transform: translate(-50%, 0);
background-color: var(--cap-start-color, #2ba0fb);
}
/* 终点圆点(动态位置) */
.end-cap {
transform: translate(-50%, -50%);
background-color: var(--cap-end-color, #2ba0fb);
}
.circle-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-color);
z-index: 2;
}

View File

@@ -0,0 +1,44 @@
import { defineComponent, PropType } from 'vue'
import { NDropdown, NIcon, NButton } from 'naive-ui'
import { Language } from '@vicons/ionicons5'
import { useLocalStorage } from '@vueuse/core'
import { localeList } from '../i18n'
import type { DropdownOption } from 'naive-ui'
interface Props {
type?: 'button' | 'link'
}
export default defineComponent({
props: {
type: {
type: String as PropType<'button' | 'link'>,
default: 'button',
},
},
setup(props: Props) {
const locale = useLocalStorage('locales-active', 'zhCN')
const dropdownOptions: DropdownOption[] = localeList.map((item) => ({
label: item.name,
key: item.type,
}))
return () => (
<NDropdown options={dropdownOptions} onSelect={(key: string) => (locale.value = key)} value={locale.value}>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
{props.type === 'button' ? (
<NButton quaternary strong circle type="primary">
<NIcon size={20}>
<Language />
</NIcon>
</NButton>
) : (
<NIcon size={20}>
<Language />
</NIcon>
)}
</div>
</NDropdown>
)
},
})

View File

@@ -0,0 +1,38 @@
import { defineComponent } from 'vue'
import { NSelect, NSpace, NText } from 'naive-ui'
import { useTheme } from '../theme/index'
import DarkModeSwitch from './darkModeSwitch'
/**
* @description 主题管理组件
*/
export const ThemeManage = defineComponent({
name: 'ThemeManage',
setup() {
const { themeActive, getThemeList, setTheme } = useTheme()
// 获取主题列表
const themeList = getThemeList()
// 主题选项
const themeOptions = themeList.map((item) => ({
label: item.title,
value: item.name,
}))
return () => (
<NSpace>
<NText></NText>
<NSelect
style={{ width: '200px' }}
value={themeActive.value}
options={themeOptions}
onUpdateValue={(value: string) => setTheme(value)}
/>
<DarkModeSwitch />
</NSpace>
)
},
})
export default ThemeManage

View File

@@ -0,0 +1,20 @@
import { defineComponent } from 'vue'
import { NSpace, NText } from 'naive-ui'
import DarkModeSwitch from './darkModeSwitch'
/**
* @description 主题模式切换组件
*/
export const ThemeMode = defineComponent({
name: 'ThemeMode',
setup() {
return () => (
<NSpace align="center">
<NText></NText>
<DarkModeSwitch />
</NSpace>
)
},
})
export default ThemeMode

View File

@@ -0,0 +1,69 @@
import { computed, defineComponent, PropType } from 'vue'
import { NDropdown, NIcon, NButton } from 'naive-ui'
import { Sunny, Moon } from '@vicons/ionicons5'
import { useTheme } from '../theme/index'
import type { DropdownOption } from 'naive-ui'
interface Props {
type?: 'button' | 'link'
size?: 'small' | 'medium' | 'large'
text?: boolean
}
export default defineComponent({
props: {
type: {
type: String as PropType<'button' | 'link'>,
default: 'button',
},
size: {
type: String as PropType<'small' | 'medium' | 'large'>,
default: 'medium',
},
text: {
type: Boolean,
default: false,
},
},
setup(props: Props) {
const { isDark, cutDarkMode, themeActive } = useTheme()
const dropdownOptions: DropdownOption[] = [
{
label: '亮色模式',
key: 'defaultLight',
},
{
label: '暗色模式',
key: 'defaultDark',
},
]
const iconSize = computed(() => {
return props.size === 'small' ? 16 : props.size === 'large' ? 24 : 20
})
const buttonSize = computed(() => {
return props.size === 'small' ? 'tiny' : props.size === 'large' ? 'large' : 'medium'
})
const text = computed(() => {
return !isDark.value ? '亮色模式' : '暗色模式'
})
return () => (
<NDropdown options={dropdownOptions} onSelect={() => cutDarkMode(true, this)} value={themeActive.value}>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
{props.type === 'button' ? (
<NButton quaternary strong circle type="primary" size={buttonSize.value}>
<NIcon size={iconSize.value}>{isDark.value ? <Moon /> : <Sunny />}</NIcon>
</NButton>
) : (
<NIcon size={iconSize.value}>{isDark.value ? <Moon /> : <Sunny />}</NIcon>
)}
<span class="ml-[0.6rem]">{props.text && text.value}</span>
</div>
</NDropdown>
)
},
})

View File

@@ -0,0 +1,124 @@
import { defineComponent } from 'vue'
import { NSpace, NButton } from 'naive-ui'
import { useMessage, createAllApi } from '../hooks'
// 创建全局API实例可以在组件外使用
const globalApi = createAllApi()
// 组件外使用示例
function showGlobalMessage(type: 'success' | 'error' | 'info' | 'warning') {
switch (type) {
case 'success':
globalApi.message.success('这是一条全局成功消息')
break
case 'error':
globalApi.message.error('这是一条全局错误消息')
break
case 'info':
globalApi.message.info('这是一条全局信息消息')
break
case 'warning':
globalApi.message.warning('这是一条全局警告消息')
break
}
}
// API请求结果示例
function handleApiResult(success: boolean) {
const result = {
status: success,
message: success ? '操作成功!' : '操作失败,请重试',
}
// 使用统一的request方法处理
globalApi.message.request(result)
}
export default defineComponent({
name: 'MessageExample',
setup() {
// 在组件内使用useMessage
const message = useMessage()
// 组件内显示消息
const showComponentMessage = (type: 'success' | 'error' | 'info' | 'warning') => {
switch (type) {
case 'success':
message.success('这是一条组件内成功消息')
break
case 'error':
message.error('这是一条组件内错误消息')
break
case 'info':
message.info('这是一条组件内信息消息')
break
case 'warning':
message.warning('这是一条组件内警告消息')
break
}
}
// 组件内处理API请求结果
const handleComponentApiResult = (success: boolean) => {
const result = {
status: success,
message: success ? '操作成功!' : '操作失败,请重试',
}
// 使用统一的request方法处理
message.request(result)
}
return {
showComponentMessage,
handleComponentApiResult,
}
},
render() {
return (
<div>
<h2>Message </h2>
<h3>使 useMessage</h3>
<NSpace>
<NButton type="primary" onClick={() => this.showComponentMessage('success')}>
</NButton>
<NButton type="error" onClick={() => this.showComponentMessage('error')}>
</NButton>
<NButton onClick={() => this.showComponentMessage('info')}></NButton>
<NButton type="warning" onClick={() => this.showComponentMessage('warning')}>
</NButton>
<NButton type="success" onClick={() => this.handleComponentApiResult(true)}>
API成功结果
</NButton>
<NButton type="error" onClick={() => this.handleComponentApiResult(false)}>
API失败结果
</NButton>
</NSpace>
<h3>使 createAllApi</h3>
<NSpace>
<NButton type="primary" onClick={() => showGlobalMessage('success')}>
</NButton>
<NButton type="error" onClick={() => showGlobalMessage('error')}>
</NButton>
<NButton onClick={() => showGlobalMessage('info')}></NButton>
<NButton type="warning" onClick={() => showGlobalMessage('warning')}>
</NButton>
<NButton type="success" onClick={() => handleApiResult(true)}>
API成功结果
</NButton>
<NButton type="error" onClick={() => handleApiResult(false)}>
API失败结果
</NButton>
</NSpace>
</div>
)
},
})

View File

@@ -0,0 +1,44 @@
import useForm, { useFormHooks } from './useForm' // 表单
import useTable, { useTableOperation } from './useTable' // 表格
import useSearch from './useSearch' // 搜索
import useTabs from './useTabs' // 标签页
import useDialog from './useDialog' // 对话框
import useMessage from './useMessage' // 消息
import useLoadingMask from './useLoadingMask' // 加载遮罩
import useModal, {
useModalHooks,
useModalOptions,
useModalClose,
useModalConfirm,
useModalCancel,
useModalCloseable,
useModalMessage,
useModalLoading,
useModalUseDiscrete,
} from './useModal' // 模态框
import useBatchTable from './useBatch' // 批量表格
import useFullScreen from './useFullScreen' // 全屏
export {
useForm,
useTable,
useSearch,
useTabs,
useDialog,
useMessage,
useModal,
useModalHooks,
useModalOptions,
useModalClose,
useModalConfirm,
useModalCancel,
useModalCloseable,
useModalMessage,
useModalLoading,
useModalUseDiscrete,
useFormHooks,
useBatchTable,
useFullScreen,
useLoadingMask,
useTableOperation,
}

View File

@@ -0,0 +1,147 @@
import { ref, computed, type Ref } from 'vue'
import { NButton, NSelect, NCheckbox } from 'naive-ui'
import { translation, TranslationLocale, TranslationModule } from '../locals/translation'
interface BatchOptions {
label: string
value: string
callback?: (rows: Ref<any[]>, rowKeys: Ref<(string | number)[]>) => void
}
/**
* 批量操作表格 Hook
* @param options 表格配置选项
* @returns 表格实例,包含批量操作相关功能
*/
export default function useBatchTable<T = any>(tableOptions: any, batchOptions: BatchOptions[]) {
// 获取当前语言
const currentLocale = localStorage.getItem('locale-active') || 'zhCN'
// 获取翻译文本
const hookT = (key: string, params?: string) => {
const locale = currentLocale.replace('-', '_').replace(/"/g, '') as TranslationLocale
const translationFn =
(translation[locale as TranslationLocale] as TranslationModule).useForm[
key as keyof TranslationModule['useForm']
] || translation.zhCN.useForm[key as keyof typeof translation.zhCN.useForm]
return typeof translationFn === 'function' ? translationFn(params || '') : translationFn
}
// 表格组件
const { TableComponent, tableRef, ...tableAttrs } = useTable(tableOptions)
const batchTableRef = ref<any>(null)
// 选中项状态
const selectedRows = ref<T[]>([])
const selectedRowKeys = ref<(string | number)[]>([])
const totalData = computed(() => batchTableRef.value?.data || [])
// 计算全选状态
const isAllSelected = computed(() => {
return totalData.value.length > 0 && selectedRowKeys.value.length === totalData.value.length
})
// 计算半选状态
const isIndeterminate = computed(() => {
return selectedRowKeys.value.length > 0 && selectedRowKeys.value.length < totalData.value.length
})
/**
* 处理选择变化
* @param rowKeys 选中的行键值
* @param rows 选中的行数据
*/
const handleSelectionChange: any = (rowKeys: (string | number)[], rows: T[]) => {
selectedRowKeys.value = rowKeys
selectedRows.value = rows
}
/**
* 处理全选变化
*/
const handleCheckAll = (checked: boolean) => {
if (checked) {
selectedRows.value = [...totalData.value]
selectedRowKeys.value = totalData.value.map((item: T) => batchTableRef.value.rowKey(item))
} else {
clearSelection()
}
}
/**
* 获取表格组件
*/
const BatchTableComponent = (props: any, context: any) => {
return TableComponent(
{
ref: batchTableRef,
rowKey: (row: T) => (row as any).id,
checkedRowKeys: selectedRowKeys.value,
onUpdateCheckedRowKeys: handleSelectionChange,
...props,
},
context,
)
}
/**
* 批量操作组件
*/
const selectedAction = ref<string | null>(null)
const BatchOperationComponent = () => {
const setValue = (value: string) => {
selectedAction.value = value
}
const startBatch = async () => {
const option = batchOptions.find((item) => item.value === selectedAction.value)
if (option) {
const batchStatus = await option.callback?.(selectedRows, selectedRowKeys)
if (batchStatus) {
// 重置选择
selectedAction.value = null
clearSelection()
}
}
}
return (
<div class="batch-operation" style="display: flex; align-items: center; gap: 16px;">
<NCheckbox
checked={isAllSelected.value}
indeterminate={isIndeterminate.value}
onUpdateChecked={handleCheckAll}
></NCheckbox>
<NSelect
options={batchOptions}
value={selectedAction.value}
onUpdateValue={setValue}
placeholder={hookT('placeholder')}
style="width: 120px"
disabled={selectedRows.value.length === 0}
/>
<NButton type="primary" disabled={selectedRows.value.length === 0} onClick={startBatch}>
{hookT('startBatch')}
</NButton>
<span>{hookT('selectedItems')(selectedRows.value.length)}</span>
</div>
)
}
/**
* 清空选择
*/
const clearSelection = () => {
selectedRowKeys.value = []
selectedRows.value = []
}
return {
selectedRows,
clearSelection,
BatchTableComponent,
BatchOperationComponent,
...tableAttrs,
tableRef: batchTableRef,
}
}

View File

@@ -0,0 +1,164 @@
import { getCurrentInstance, h, ref, shallowRef } from 'vue'
import { useDialog as useNaiveDialog, createDiscreteApi, type DialogOptions, NIcon } from 'naive-ui'
import { Info24Filled, ErrorCircle24Filled, CheckmarkCircle24Filled } from '@vicons/fluent'
import { themeProvider as ThemeProvider } from '../components/customProvider'
import type { CustomDialogOptions } from '../types/dialog'
// 自定义Dialog钩子函数
export default function useDialog(options?: CustomDialogOptions) {
// 判断是否在setup中使用
const instance = getCurrentInstance()
// 创建响应式数据
const optionsRef = ref<CustomDialogOptions>(options || {})
// 创建Dialog实例
const dialogInstance = shallowRef()
// 创建Dialog方法
const create = (optionsNew: CustomDialogOptions) => {
const {
type = 'warning',
title,
area,
content,
draggable = true,
confirmText = '确定',
cancelText = '取消',
confirmButtonProps = { type: 'primary' },
cancelButtonProps = { type: 'default' },
maskClosable = false,
closeOnEsc = false,
autoFocus = false,
onConfirm,
onCancel,
onClose,
onMaskClick,
...dialogOptions
} = optionsNew
// 转换area
const areaConvert = () => {
if (!area) return { width: '35rem', height: 'auto' }
if (typeof area === 'string') return { width: area, height: 'auto' }
return { width: area[0], height: area[1] }
}
// 转换content
const contentConvert = () => {
if (!content) return ''
const Icon = (type: string) => {
const typeIcon = {
info: [<Info24Filled class="text-primary" />],
success: [<CheckmarkCircle24Filled class="text-success" />],
warning: [<Info24Filled class="text-warning" />],
error: [<ErrorCircle24Filled class="text-error" />],
}
return h(NIcon, { size: 30, class: `n-dialog__icon` }, () => typeIcon[type as keyof typeof typeIcon][0])
}
const contentNew = h('div', { class: 'flex pt-[0.4rem]' }, [
Icon(type),
h('div', { class: 'w-full pt-1 flex items-center' }, typeof content === 'string' ? content : content()),
])
// 如果不在setup中使用
if (!instance) return h(ThemeProvider, { type }, () => contentNew)
return contentNew
}
// 合并Dialog配置
const config: DialogOptions = {
title,
content: () => contentConvert(),
style: areaConvert(),
draggable,
maskClosable,
showIcon: false,
closeOnEsc,
autoFocus,
positiveText: confirmText,
negativeText: cancelText,
positiveButtonProps: confirmButtonProps,
negativeButtonProps: cancelButtonProps,
onPositiveClick: onConfirm,
onNegativeClick: onCancel,
onClose,
onMaskClick,
...dialogOptions,
}
if (instance) {
// 创建Dialog实例
const naiveDialog = useNaiveDialog()
dialogInstance.value = naiveDialog.create(config)
return dialogInstance.value
}
// 创建discreteDialog实例
const { dialog } = createDiscreteApi(['dialog'])
dialogInstance.value = dialog.create(config)
return dialogInstance.value
}
/**
* 成功-对话框
* @param options - 提示配置
* @returns 提示实例
*/
const success = (content: string, options: CustomDialogOptions = {}) => {
return create({ ...options, type: 'success', content, showIcon: true })
}
/**
* 警告-对话框
* @param options - 提示配置
* @returns 提示实例
*/
const warning = (content: string, options: CustomDialogOptions = {}) => {
return create({ ...options, type: 'warning', content })
}
/**
* 错误 - 对话框
* @param options - 提示配置
* @returns 提示实例
*/
const error = (content: string, options: CustomDialogOptions = {}) => {
return create({ ...options, type: 'error', content })
}
/**
* 信息提示
* @param options - 提示配置
* @returns 提示实例
*/
const info = (content: string, options: CustomDialogOptions = {}) => {
return create({ ...options, type: 'info', content })
}
/**
* 更新Dialog实例
* @param options - 提示配置
* @returns 提示实例
*/
const update = (options: CustomDialogOptions) => {
optionsRef.value = options
return create(options)
}
/**
* 请求结果提示
* @param options - 提示配置
* @param data - 请求结果
* @returns 提示实例
*/
const request = (data: Record<string, unknown>, options: CustomDialogOptions = {}) => {
return create({ ...options, type: data.status ? 'success' : 'error', content: data.message as string })
}
// 销毁所有Dialog实例方法
const destroyAll = () => {
dialogInstance.value?.destroyAll()
}
const newReturn = { create, options: optionsRef, update, success, warning, error, info, request, destroyAll }
// 如果配置为空
if (!options) return newReturn
return Object.assign(create(options), newReturn)
}

View File

@@ -0,0 +1,846 @@
import { ref, Ref, toRef, effectScope, onScopeDispose, shallowRef, toRefs, isRef } from 'vue'
import {
NForm,
NFormItem,
NGrid,
NInput,
NInputNumber,
NInputGroup,
NSelect,
NRadio,
NRadioGroup,
NRadioButton,
NCheckbox,
NCheckboxGroup,
NSwitch,
NDatePicker,
NTimePicker,
NColorPicker,
NSlider,
NRate,
NTransfer,
NMention,
NDynamicInput,
NDynamicTags,
NAutoComplete,
NCascader,
NTreeSelect,
NUpload,
NUploadDragger,
type FormInst,
NFormItemGi,
NIcon,
NDivider,
type InputProps,
type InputNumberProps,
type SelectProps,
type RadioProps,
type RadioButtonProps,
type SwitchProps,
type DatePickerProps,
type TimePickerProps,
type SliderProps,
type SelectOption,
type FormProps,
type FormItemProps,
type CheckboxGroupProps,
} from 'naive-ui'
import { DownOutlined, UpOutlined } from '@vicons/antd'
import { translation, TranslationModule, type TranslationLocale } from '../locals/translation'
import type {
FormInstanceWithComponent,
UseFormOptions,
FormItemConfig,
GridItemConfig,
FormElement,
SlotFormElement,
RenderFormElement,
FormElementType,
BaseFormElement,
FormItemGiConfig,
RadioOptionItem,
CheckboxOptionItem,
FormConfig,
FormElementPropsMap,
} from '../types/form'
// 获取当前语言
const currentLocale = localStorage.getItem('locale-active') || 'zhCN'
// 获取翻译文本
const hookT = (key: string, params?: string) => {
const locale = currentLocale.replace('-', '_').replace(/"/g, '') as TranslationLocale
const translationFn =
(translation[locale as TranslationLocale] as TranslationModule).useForm[
key as keyof TranslationModule['useForm']
] || translation.zhCN.useForm[key as keyof typeof translation.zhCN.useForm]
return typeof translationFn === 'function' ? translationFn(params || '') : translationFn
}
/**
* 组件映射表:将表单元素类型映射到对应的 Naive UI 组件
* 包含所有支持的表单控件组件
*/
const componentMap = {
input: NInput, // 输入框
inputNumber: NInputNumber, // 数字输入框
inputGroup: NInputGroup, // 输入框组
select: NSelect, // 选择器
radio: NRadio, // 单选框组
radioButton: NRadioButton, // 单选按钮
checkbox: NCheckbox, // 复选框组
switch: NSwitch, // 开关
datepicker: NDatePicker, // 日期选择器
timepicker: NTimePicker, // 时间选择器
colorPicker: NColorPicker, // 颜色选择器
slider: NSlider, // 滑块
rate: NRate, // 评分
transfer: NTransfer, // 穿梭框
mention: NMention, // 提及
dynamicInput: NDynamicInput, // 动态输入
dynamicTags: NDynamicTags, // 动态标签
autoComplete: NAutoComplete, // 自动完成
cascader: NCascader, // 级联选择
treeSelect: NTreeSelect, // 树选择
upload: NUpload, // 上传
uploadDragger: NUploadDragger, // 拖拽上传
} as const
/**
* 表单插槽类型定义
* 用于定义表单中可以使用的插槽,每个插槽都是一个函数
* 函数接收表单数据和表单实例引用作为参数返回JSX元素
*/
type FormSlots<T> = Record<string, (formData: Ref<T>, formRef: Ref<FormInst | null>) => JSX.Element>
/**
* 处理表单项的前缀和后缀插槽
* @param slot 插槽配置对象
* @returns 处理后的前缀和后缀元素数组
*/
const processFormItemSlots = (slot?: { prefix?: Array<() => JSX.Element>; suffix?: Array<() => JSX.Element> }) => {
const prefixElements = slot?.prefix
? slot.prefix.map((item: () => JSX.Element) => ({
type: 'render' as const,
render: item,
}))
: []
const suffixElements = slot?.suffix
? slot.suffix.map((item: () => JSX.Element) => ({
type: 'render' as const,
render: item,
}))
: []
return { prefixElements, suffixElements }
}
/**
* 创建标准表单项配置
* @param label 标签文本
* @param key 表单字段名
* @param type 表单元素类型
* @param props 组件属性
* @param itemAttrs 表单项属性
* @param slot 插槽配置
* @returns 标准化的表单项配置
*/
const createFormItem = <T extends keyof typeof componentMap>(
label: string,
key: string,
type: T,
props: FormElementPropsMap[T],
itemAttrs?: FormItemProps,
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
const { prefixElements, suffixElements } = processFormItemSlots(slot)
return {
type: 'formItem' as const,
label,
path: key,
required: true,
children: [
...prefixElements,
{
type,
field: key,
...(type === 'input' ? { placeholder: hookT('placeholder', label) } : {}),
...props,
},
...suffixElements,
],
...itemAttrs,
}
}
/**
* 表单钩子函数
* 用于创建一个动态表单实例,提供表单的状态管理和渲染能力
* @param options 表单配置选项,包含表单配置、请求函数和默认值等
* @returns 返回统一的表单实例接口
*/
export default function useForm<T>(options: UseFormOptions<T>) {
// 创建 effectScope 用于管理响应式副作用
const scope = effectScope()
return scope.run(() => {
const { config, request, defaultValue = ref({}), rules: rulesVal } = options
// 表单响应式状态
const loading = ref(false) // 表单加载状态
const formRef = ref<FormInst | null>(null) // 表单实例引用
const data: Ref<T> = isRef(defaultValue) ? (defaultValue as Ref<T>) : (ref(defaultValue) as Ref<T>) // 使用ref而不是reactive避免响应丢失
const formConfig = ref<FormConfig>(config) // 表单配置
const rules = shallowRef({ ...rulesVal }) // 表单验证规则
// 表单属性配置
const props = ref<FormProps>({
labelPlacement: 'left',
labelWidth: '8rem',
// 其他可配置的表单属性
})
/**
* 渲染基础表单元素
* 根据配置渲染对应的Naive UI表单控件
* @param element 基础表单元素配置,包含类型、字段名和组件属性等
* @returns 返回渲染后的JSX元素如果找不到对应组件则返回null
*/
const renderBaseElement = <T extends FormElementType, K extends Record<string, any>>(
element: BaseFormElement<T>,
) => {
let type = element.type
if (['textarea', 'password'].includes(type)) type = 'input'
// 获取对应的 Naive UI 组件
const Component = componentMap[type as keyof typeof componentMap]
if (!Component) return null
// 解构出组件属性,分离类型和字段名
const { field, ...componentProps } = element
// 处理Radio、Checkbox
if (['radio', 'radioButton'].includes(type)) {
// 类型断言以访问options属性
const radioElement = element as BaseFormElement<'radio' | 'radioButton'> & { options?: RadioOptionItem[] }
return (
<NRadioGroup
value={getNestedValue(data.value as K, field)}
onUpdateValue={(val: any) => {
setNestedValue(data.value as K, field, val)
}}
>
{radioElement.options?.map((option: RadioOptionItem) =>
type === 'radio' ? (
<NRadio value={option.value} {...componentProps}>
{option.label}
</NRadio>
) : (
<NRadioButton value={option.value} {...componentProps}>
{option.label}
</NRadioButton>
),
)}
</NRadioGroup>
)
}
if (['checkbox'].includes(type)) {
// 类型断言以访问options属性
const checkboxElement = element as BaseFormElement<'checkbox'> & {
options?: CheckboxOptionItem[]
}
return (
<NCheckboxGroup
value={getNestedValue(data.value as K, field)}
onUpdateValue={(val: any) => {
setNestedValue(data.value as K, field, val)
}}
{...componentProps}
>
{checkboxElement.options?.map((option: CheckboxOptionItem) => (
<NCheckbox value={option.value} {...componentProps}>
{option.label}
</NCheckbox>
))}
</NCheckboxGroup>
)
}
// 根据是否有字段名决定是否使用v-model双向绑定
return (
<Component
value={getNestedValue(data.value as K, field)}
onUpdateValue={(val: any) => {
setNestedValue(data.value as K, field, val)
}}
{...componentProps}
/>
)
}
/**
* 渲染表单元素
* 统一处理所有类型的表单元素,包括插槽、自定义渲染和基础表单元素
* @param element 表单元素配置
* @param slots 插槽配置对象
* @returns 返回渲染后的JSX元素或null
*/
const renderFormElement = (element: FormElement, slots?: FormSlots<T>): JSX.Element | null => {
// 是否是插槽元素
const isSlotElement = (el: FormElement): el is SlotFormElement => el.type === 'slot'
// 是否是渲染函数
const isRenderElement = (el: FormElement): el is RenderFormElement => el.type === 'custom'
// 是否是自定义渲染元素
const isBaseElement = (el: FormElement): el is BaseFormElement => !isSlotElement(el) && !isRenderElement(el)
// 处理插槽元素:使用配置的插槽函数渲染内容
if (isSlotElement(element)) {
console.log(element, 'element')
return slots?.[element.slot]?.(data as unknown as Ref<T>, formRef) ?? null
}
// 处理自定义渲染元素:调用自定义渲染函数
if (isRenderElement(element)) {
return element.render(data as unknown as Ref<T>, formRef)
}
// 处理基础表单元素:使用组件映射表渲染对应组件
if (isBaseElement(element)) return renderBaseElement(element)
return null
}
/**
* 渲染表单项
* 创建表单项容器,可以是普通表单项或栅格布局中的表单项
* @param item 表单项配置,包含子元素和属性
* @param slots 插槽配置对象
* @returns 返回渲染后的表单项JSX元素
*/
const renderFormItem = (
item: FormItemConfig | FormItemGiConfig | RenderFormElement | SlotFormElement,
slots?: FormSlots<T>,
) => {
if (item.type === 'custom') return item.render(data as Ref<T>, formRef)
if (item.type === 'slot') return renderFormElement(item, slots)
const { children, type, ...itemProps } = item
if (type === 'formItemGi') {
return <NFormItemGi {...itemProps}>{children.map((child) => renderFormElement(child, slots))}</NFormItemGi>
}
return <NFormItem {...itemProps}>{children.map((child) => renderFormElement(child, slots))}</NFormItem>
}
/**
* 渲染栅格布局
* 创建栅格布局容器,并渲染其中的表单项
* @param grid 栅格配置,包含布局属性和子元素
* @param slots 插槽配置对象
* @returns 返回渲染后的栅格布局JSX元素
*/
const renderGrid = (grid: GridItemConfig, slots?: FormSlots<T>) => {
const { children, ...gridProps } = grid
return <NGrid {...gridProps}>{children.map((item) => renderFormItem(item, slots))}</NGrid>
}
/**
* 渲染完整表单组件
* 创建最外层的表单容器,并根据配置渲染内部的栅格或表单项
* @param attrs 组件属性,包含插槽配置
* @param context 组件上下文
* @returns 返回渲染后的完整表单JSX元素
*/
const component = (attrs: FormProps, context: { slots?: FormSlots<T> }) => (
<NForm ref={formRef} model={data.value} rules={rules.value} labelPlacement="left" {...props} {...attrs}>
{formConfig.value.map((item: FormConfig[0]) =>
item.type === 'grid' ? renderGrid(item, context.slots) : renderFormItem(item, context.slots),
)}
</NForm>
)
/**
* 验证表单
* 触发表单的验证流程,检查所有字段的有效性
* @returns 返回一个Promise解析为验证是否通过的布尔值
*/
const validate = async () => {
if (!formRef.value) return false
try {
await formRef.value.validate()
return true
} catch {
return false
}
}
/**
* 提交表单
* 验证表单并调用提交请求函数
* @param
* @returns 返回一个Promise解析为请求的响应结果
*/
const fetch = async (isValid = true) => {
if (!request) return
try {
loading.value = true
if (!isValid) return await request(data.value, formRef)
const valid = await validate()
if (!valid) throw new Error('表单验证失败')
return await request(data.value, formRef)
} catch (error) {
console.log(error)
throw new Error('表单验证失败')
} finally {
loading.value = false
}
}
/**
* 重置表单
* 清除表单的验证状态,并将所有字段值重置为默认值
*/
const reset = () => {
formRef.value?.restoreValidation()
data.value = Object.assign({}, isRef(defaultValue) ? defaultValue.value : defaultValue) // 重置为默认值,使用新对象以确保触发响应
}
// 当组件卸载时,清理所有副作用
onScopeDispose(() => {
scope.stop()
})
// 返回标准化的表单实例接口
return {
component, // 表单渲染组件
example: formRef, // 当前组件实例
data, // 响应式数据
loading, // 加载状态
config: formConfig, // 表单配置
props, // 表单属性
rules, // 验证规则
dataToRef: () => toRefs(data.value), // 响应式数据转ref
fetch, // 提交方法
reset, // 重置方法
validate, // 验证方法
}
}) as FormInstanceWithComponent<T>
}
/**
* 创建一个表单输入项
* @param {string} label 标签文本
* @param {string} key 表单字段名
* @param {InputProps & { class?: string }} other 输入框的额外属性
* @param {FormItemProps & { class?: string }} itemAttrs 表单项的额外属性
* @param {Object} slot 插槽配置
* @returns {FormItemConfig} 表单项配置
*/
const useFormInput = (
label: string,
key: string,
other?: InputProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => createFormItem(label, key, 'input', { placeholder: hookT('placeholder', label), ...other }, itemAttrs, slot)
/**
* 创建一个表单textarea
* @param {string} label 标签文本
* @param {string} key 表单字段名
* @param {InputProps & { class?: string }} other 输入框的额外属性
* @param {FormItemProps & { class?: string }} itemAttrs 表单项的额外属性
* @param {Object} slot 插槽配置
* @returns {FormItemConfig} 表单项配置
*/
const useFormTextarea = (
label: string,
key: string,
other?: InputProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) =>
createFormItem(
label,
key,
'input',
{ type: 'textarea', placeholder: hookT('placeholder', label), ...other },
itemAttrs,
slot,
)
/**
* 创建一个表单密码输入项
* @param {string} label 标签文本
* @param {string} key 表单字段名
* @param {InputProps & { class?: string }} other 输入框的额外属性
* @param {FormItemProps & { class?: string }} itemAttrs 表单项的额外属性
* @param {Object} slot 插槽配置
* @returns {FormItemConfig} 表单项配置
*/
const useFormPassword = (
label: string,
key: string,
other?: InputProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) =>
createFormItem(
label,
key,
'input',
{ type: 'password', placeholder: hookT('placeholder', label), ...other },
itemAttrs,
slot,
)
/**
* 创建一个表单数字输入项
* @param {string} label 标签文本
* @param {string} key 表单字段名
* @param {InputNumberProps & { class?: string }} other 输入框的额外属性
* @param {FormItemProps & { class?: string }} itemAttrs 表单项的额外属性
* @param {Object} slot 插槽配置
* @returns {FormItemConfig} 表单项配置
*/
const useFormInputNumber = (
label: string,
key: string,
other?: InputNumberProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => createFormItem(label, key, 'inputNumber', { showButton: false, ...other }, itemAttrs, slot)
/**
* 定义嵌套值获取函数用于控制台日志
* @param {Record<string, any>} obj 对象
* @param {string} path 路径
* @returns {any} 嵌套对象的值
*/
function getNestedValue(obj: Record<string, any>, path: string): any {
return path.includes('.')
? path.split('.').reduce((prev, curr) => (prev && prev[curr] !== undefined ? prev[curr] : undefined), obj)
: obj[path]
}
/**
* 设置嵌套对象的值
* @param obj 对象
* @param path 路径
* @param value 要设置的值
*/
const setNestedValue = (obj: Record<string, any>, path: string, value: any): void => {
if (path.includes('.')) {
const parts = path.split('.')
const lastPart = parts.pop()!
const target = parts.reduce((prev, curr) => {
if (prev[curr] === undefined) {
prev[curr] = {}
}
return prev[curr]
}, obj)
target[lastPart] = value
} else {
obj[path] = value
}
}
/**
* 创建一个表单组
* @param group 表单项组
*/
const useFormGroup = <T extends Record<string, any>>(group: Record<string, any>[]) => {
return {
type: 'custom',
render: (formData: Ref<T>, formRef: Ref<FormInst | null>) => {
return (
<div class="flex">
{group.map((item) => {
if (item.type === 'custom') return item.render(formData, formRef)
const { children, ...itemProps } = item
return (
<NFormItem {...itemProps}>
{children.map((child: BaseFormElement | RenderFormElement | SlotFormElement) => {
if (child.type === 'render' || child.type === 'custom')
return (child as RenderFormElement).render(formData, formRef)
let type = child.type
if (['textarea', 'password'].includes(child.type)) type = 'input'
// 获取对应的 Naive UI 组件
const Component = componentMap[type as keyof typeof componentMap]
if (!Component) return null
// 解构出组件属性,分离类型和字段名
const { field, ...componentProps } = child as BaseFormElement
return (
<Component
value={getNestedValue(formData.value as T, field)}
onUpdateValue={(val: any) => {
setNestedValue(formData.value as T, field, val)
}}
{...componentProps}
/>
)
})}
</NFormItem>
)
})}
</div>
)
},
}
}
/**
* 创建一个表单选择器
* @param label 标签文本
* @param key 表单字段名
* @param other 选择器的额外属性
* @param itemAttrs 表单项的额外属性
*/
const useFormSelect = (
label: string,
key: string,
options: SelectOption[],
other?: SelectProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'select', { options, ...other }, itemAttrs, slot)
}
/**
* 创建一个表单插槽
* @param label 标签文本
* @param key 表单字段名
*/
const useFormSlot = (key?: string) => {
return {
type: 'slot',
slot: key || 'default',
}
}
/**
* 创建一个表单自定义渲染
* @param render 自定义渲染函数
*/
const useFormCustom = <T,>(render: (formData: Ref<T>, formRef: Ref<FormInst | null>) => JSX.Element) => {
return {
type: 'custom' as const,
render,
}
}
/**
* 创建一个表单单选框
* @param label 标签文本
* @param key 表单字段名
*/
const useFormRadio = (
label: string,
key: string,
options: RadioOptionItem[],
other?: RadioProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'radio', { options, ...other }, itemAttrs, slot || {})
}
/**
* 创建一个表单单选按钮
* @param label 标签文本
* @param key 表单字段名
*/
const useFormRadioButton = (
label: string,
key: string,
options: RadioOptionItem[],
other?: RadioButtonProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'radioButton', { options, ...other }, itemAttrs, slot || {})
}
/**
* 创建一个表单复选框
* @param label 标签文本
* @param key 表单字段名
*/
const useFormCheckbox = (
label: string,
key: string,
options: CheckboxOptionItem[],
other?: Partial<CheckboxGroupProps> & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'checkbox', { options, ...other } as any, itemAttrs, slot || {})
}
/**
* 创建一个表单开关
* @param label 标签文本
* @param key 表单字段名
*/
const useFormSwitch = (
label: string,
key: string,
other?: SwitchProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'switch', { ...other }, itemAttrs, slot)
}
/**
* 创建一个表单日期选择器
* @param label 标签文本
* @param key 表单字段名
*/
const useFormDatepicker = (
label: string,
key: string,
other?: DatePickerProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'datepicker', { ...other }, itemAttrs, slot)
}
/**
* 创建一个表单时间选择器
* @param label 标签文本
* @param key 表单字段名
*/
const useFormTimepicker = (
label: string,
key: string,
other?: TimePickerProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'timepicker', { ...other }, itemAttrs, slot)
}
/**
* 创建一个表单滑块
* @param label 标签文本
* @param key 表单字段名
*/
const useFormSlider = (
label: string,
key: string,
other?: SliderProps & { class?: string },
itemAttrs?: FormItemProps & { class?: string },
slot?: {
prefix?: Array<() => JSX.Element>
suffix?: Array<() => JSX.Element>
},
) => {
return createFormItem(label, key, 'slider', { ...other }, itemAttrs, slot)
}
/**
* @description 表单行hook 更多配置
* @param { Ref<boolean> } isMore 是否展开
* @param { string } content 内容
*/
const useFormMore = (isMore: Ref<boolean>, content?: string) => {
const color = `var(--n-color-target)`
return {
type: 'custom',
render: () => (
<NDivider class="cursor-pointer w-full" style={{ marginTop: '0' }} onClick={() => (isMore.value = !isMore.value)}>
<div class="flex items-center w-full" style={{ color }}>
<span class="mr-[4px] text-[1.4em]">
{!isMore.value ? hookT('expand') : hookT('collapse')}
{content || hookT('moreConfig')}
</span>
<NIcon>{isMore.value ? <DownOutlined /> : <UpOutlined />}</NIcon>
</div>
</NDivider>
),
}
}
/**
* @description 表单行hook 帮助文档
* @param { Ref<boolean> } isMore 是否展开
* @param { string } content 内容
*/
const useFormHelp = (
options: { content: string | JSX.Element; isHtml?: boolean }[],
other?: { listStyle?: string },
) => {
const helpList = toRef(options)
return {
type: 'custom',
render: () => (
<ul
class={`mt-[2px] leading-[2rem] text-[1.4rem] list-${other?.listStyle || 'disc'}`}
style="color: var(--n-close-icon-color);margin-left: 1.6rem; line-height:2.2rem;"
{...other}
>
{helpList.value.map(
(
item: {
content: string | JSX.Element
isHtml?: boolean
},
index: number,
) => (item.isHtml ? <li key={index} v-html={item.content}></li> : <li key={index}>{item.content}</li>),
)}
</ul>
),
}
}
// 导出所有表单钩子函数
export const useFormHooks = () => ({
useFormInput,
useFormTextarea,
useFormPassword,
useFormInputNumber,
useFormSelect,
useFormSlot,
useFormCustom,
useFormGroup,
useFormRadio,
useFormRadioButton,
useFormCheckbox,
useFormSwitch,
useFormDatepicker,
useFormTimepicker,
useFormSlider,
useFormMore,
useFormHelp,
})

View File

@@ -0,0 +1,203 @@
import { h, ref, Ref, defineComponent, onBeforeUnmount } from 'vue'
import { NButton } from 'naive-ui'
/**
* 扩展Document接口以支持各浏览器的全屏API
* 处理不同浏览器前缀版本的全屏元素获取和退出全屏方法
*/
interface FullscreenDocument extends Document {
webkitFullscreenElement?: Element // Webkit浏览器的全屏元素属性
mozFullScreenElement?: Element // Mozilla浏览器的全屏元素属性
msFullscreenElement?: Element // IE浏览器的全屏元素属性
webkitExitFullscreen?: () => Promise<void> // Webkit浏览器退出全屏方法
mozCancelFullScreen?: () => Promise<void> // Mozilla浏览器退出全屏方法
msExitFullscreen?: () => Promise<void> // IE浏览器退出全屏方法
}
/**
* 扩展HTMLElement接口以支持各浏览器的全屏请求方法
* 处理不同浏览器前缀版本的请求全屏方法
*/
interface FullscreenElement extends HTMLElement {
webkitRequestFullscreen?: () => Promise<void> // Webkit浏览器请求全屏方法
mozRequestFullScreen?: () => Promise<void> // Mozilla浏览器请求全屏方法
msRequestFullscreen?: () => Promise<void> // IE浏览器请求全屏方法
}
/**
* 全屏钩子配置选项接口
*/
interface UseFullScreenOptions {
onEnter?: () => void // 进入全屏时的回调函数
onExit?: () => void // 退出全屏时的回调函数
}
/**
* 全屏钩子返回值接口
*/
interface UseFullScreenReturn {
isFullscreen: Ref<boolean> // 是否处于全屏状态
toggle: () => void // 切换全屏状态的方法
FullScreenButton: ReturnType<typeof defineComponent> // 全屏切换按钮组件
}
/**
* 全屏功能钩子函数
* 提供全屏状态管理、切换控制和内置全屏按钮组件
*
* @param targetRef - 目标元素引用可以是HTMLElement或包含$el属性的Vue组件实例
* @param options - 配置选项,包含进入和退出全屏的回调函数
* @returns 包含全屏状态、切换方法和按钮组件的对象
*/
export default function useFullScreen(
targetRef: Ref<HTMLElement | null | { $el: HTMLElement }>,
options: UseFullScreenOptions = {},
): UseFullScreenReturn {
const isFullscreen = ref(false) // 全屏状态引用
const fullscreenDoc = document as FullscreenDocument
/**
* 获取当前处于全屏状态的元素
* 兼容不同浏览器的全屏API
* @returns 全屏元素或null
*/
const getFullscreenElement = (): Element | null => {
return (
fullscreenDoc.fullscreenElement ||
fullscreenDoc.webkitFullscreenElement ||
fullscreenDoc.mozFullScreenElement ||
fullscreenDoc.msFullscreenElement ||
null
)
}
/**
* 进入全屏模式
* 尝试使用不同浏览器支持的方法请求全屏
*/
const enterFullscreen = async (): Promise<void> => {
// 处理Vue组件实例和普通DOM元素两种情况
const target = targetRef.value && '$el' in targetRef.value ? targetRef.value.$el : targetRef.value
if (!target) return
try {
const element = target as FullscreenElement
const requestMethods = [
element.requestFullscreen,
element.webkitRequestFullscreen,
element.mozRequestFullScreen,
element.msRequestFullscreen,
]
// 找到并使用第一个可用的方法
for (const method of requestMethods) {
if (method) {
await method.call(element)
break
}
}
isFullscreen.value = true
// 调用进入全屏回调(如果提供)
options.onEnter?.()
} catch (error) {
console.error('Failed to enter fullscreen:', error)
}
}
/**
* 退出全屏模式
* 尝试使用不同浏览器支持的方法退出全屏
*/
const exitFullscreen = async (): Promise<void> => {
try {
const exitMethods = [
fullscreenDoc.exitFullscreen,
fullscreenDoc.webkitExitFullscreen,
fullscreenDoc.mozCancelFullScreen,
fullscreenDoc.msExitFullscreen,
]
// 找到并使用第一个可用的方法
for (const method of exitMethods) {
if (method) {
await method.call(document)
break
}
}
isFullscreen.value = false
// 调用退出全屏回调(如果提供)
options.onExit?.()
} catch (error) {
console.error('Failed to exit fullscreen:', error)
}
}
/**
* 切换全屏状态
* 根据当前状态决定是进入还是退出全屏
*/
const toggle = (): void => {
if (isFullscreen.value) {
exitFullscreen()
} else {
enterFullscreen()
}
}
/**
* 处理全屏状态变化事件
* 在全屏状态变化时更新状态并调用回调
*/
const handleFullscreenChange = (): void => {
isFullscreen.value = !!getFullscreenElement()
// 当退出全屏时调用退出回调
if (!isFullscreen.value) {
options.onExit?.()
}
}
// 支持不同浏览器的全屏变化事件名称
const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
// 为所有全屏事件添加监听器
fullscreenEvents.forEach((event) => {
document.addEventListener(event, handleFullscreenChange)
})
// 组件卸载前清理:移除所有事件监听器
onBeforeUnmount(() => {
fullscreenEvents.forEach((event) => {
document.removeEventListener(event, handleFullscreenChange)
})
})
/**
* 全屏切换按钮组件
* 提供一个内置的UI组件用于切换全屏状态
*/
const FullScreenButton = defineComponent({
name: 'FullScreenButton',
setup() {
return () =>
h(
NButton,
{
onClick: toggle,
type: 'primary',
ghost: true,
},
// 根据全屏状态显示不同的按钮文本
() => (isFullscreen.value ? '退出全屏' : '进入全屏'),
)
},
})
// 返回钩子的公开API
return {
isFullscreen,
toggle,
FullScreenButton,
}
}

View File

@@ -0,0 +1,222 @@
import { ref, createVNode, render, type VNode } from 'vue'
import { NSpin } from 'naive-ui'
import { LoadingMaskOptions, LoadingMaskInstance } from '../types/loadingMask'
/**
* 默认配置选项
*/
const defaultOptions: LoadingMaskOptions = {
text: '正在加载中,请稍后 ...',
description: '',
color: '',
size: 'small',
stroke: '',
show: true,
fullscreen: true,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 2000,
}
/**
* 加载遮罩钩子函数
* @param options 遮罩层初始配置
* @returns LoadingMaskInstance 遮罩层控制实例
*/
const useLoadingMask = (options: LoadingMaskOptions = {}): LoadingMaskInstance => {
// 合并配置
const mergedOptions = ref<LoadingMaskOptions>({
...defaultOptions,
...options,
})
// 控制显示状态
const visible = ref(false)
// VNode实例
let loadingInstance: VNode | null = null
// 挂载容器
let container: HTMLElement | null = null
/**
* 创建遮罩层DOM元素
* 负责创建和配置遮罩层的容器元素
*/
const createLoadingElement = () => {
// 如果已经存在,先销毁
if (container) {
document.body.removeChild(container)
container = null
}
container = document.createElement('div')
const targetElement = getTargetElement()
// 设置样式
const style: Record<string, unknown> = {
position: mergedOptions.value.fullscreen ? 'fixed' : 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: mergedOptions.value.background,
zIndex: mergedOptions.value.zIndex,
...(mergedOptions.value.customStyle || {}),
}
// 非全屏模式下,计算目标元素的位置和尺寸
if (!mergedOptions.value.fullscreen && targetElement && targetElement !== document.body) {
const rect = targetElement.getBoundingClientRect()
Object.assign(style, {
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
position: 'fixed',
})
}
// 应用样式
Object.keys(style).forEach((key) => {
container!.style[key as any] = style[key] as string
})
// 添加自定义类名
if (mergedOptions.value.customClass) {
container.className = mergedOptions.value.customClass
}
document.body.appendChild(container)
return container
}
/**
* 获取目标元素
* 根据配置返回目标DOM元素如果没有指定或找不到则返回body
*/
const getTargetElement = (): HTMLElement => {
const { target } = mergedOptions.value
if (!target) {
return document.body
}
if (typeof target === 'string') {
const element = document.querySelector(target) as HTMLElement
return element || document.body
}
return target
}
/**
* 渲染遮罩层
* 创建NSpin组件并渲染到容器中
*/
const renderLoading = () => {
if (!visible.value) return
const container = createLoadingElement()
// 创建内容容器
const contentContainer = createVNode(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
padding: '16px 24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
},
},
[
// 加载组件VNode
createVNode(NSpin, {
description: mergedOptions.value.description,
size: mergedOptions.value.size,
stroke: mergedOptions.value.stroke,
style: { marginRight: '12px' },
...(mergedOptions.value.spinProps || {}),
}),
// 文字内容
createVNode(
'span',
{
style: {
fontSize: '14px',
color: '#333',
},
},
mergedOptions.value.text,
),
],
)
loadingInstance = contentContainer
// 渲染到容器
render(loadingInstance, container)
}
/**
* 打开遮罩层
* @param newOptions 可选的新配置,会与现有配置合并
*/
const open = (newOptions?: LoadingMaskOptions) => {
if (newOptions) {
mergedOptions.value = {
...mergedOptions.value,
...newOptions,
}
}
visible.value = true
renderLoading()
}
/**
* 关闭遮罩层
* 隐藏并移除DOM元素同时调用onClose回调
*/
const close = () => {
console.log('close', '测试内容')
visible.value = false
if (container) {
render(null, container)
document.body.removeChild(container)
container = null
}
mergedOptions.value.onClose?.()
}
/**
* 更新遮罩层配置
* @param newOptions 新的配置选项
*/
const update = (newOptions: LoadingMaskOptions) => {
mergedOptions.value = {
...mergedOptions.value,
...newOptions,
}
if (visible.value) {
renderLoading()
}
}
/**
* 销毁遮罩层实例
* 关闭并清理资源
*/
const destroy = () => {
close()
loadingInstance = null
}
return {
open,
close,
update,
destroy,
}
}
export default useLoadingMask

View File

@@ -0,0 +1,83 @@
import { computed, getCurrentInstance } from 'vue'
import { useMessage as useNaiveMessage, createDiscreteApi, type MessageOptions } from 'naive-ui'
import { useTheme } from '../theme'
import type { MessageApiExtended } from '../types/message'
/**
* 消息提示钩子函数,兼容组件内和非组件环境
*
* 在组件中使用时,使用 Naive UI 的 useMessage
* 在非组件环境中,使用 createDiscreteApi 创建消息实例
*/
export function useMessage(): MessageApiExtended {
// 判断是否在setup中使用
const instance = getCurrentInstance()
// 在setup中使用原生useMessage
if (instance && instance?.setupContext) {
const naiveMessage = useNaiveMessage()
return {
...naiveMessage,
request: (data: { status: boolean; message: string }, options?: MessageOptions) => {
if (data.status) {
return naiveMessage.success(data.message, options)
} else {
return naiveMessage.error(data.message, options)
}
},
}
}
// 在非组件环境中使用createDiscreteApi
const { theme, themeOverrides } = useTheme()
// 创建configProviderProps
const configProviderProps = computed(() => ({
theme: theme.value,
themeOverrides: themeOverrides.value,
}))
// 创建discreteMessage实例
const { message, unmount } = createDiscreteApi(['message'], { configProviderProps })
// 创建包装函数添加unmount到onAfterLeave
const wrapMethod =
(method: any) =>
(content: string, options: MessageOptions = {}) => {
const newOptions = {
...options,
onAfterLeave: () => {
options.onAfterLeave?.()
},
}
return method(content, newOptions)
}
// 包装所有消息方法
const wrappedMessage = {
...message,
info: wrapMethod(message.info),
success: wrapMethod(message.success),
warning: wrapMethod(message.warning),
error: wrapMethod(message.error),
loading: wrapMethod(message.loading),
request: (data: { status: boolean; message: string }, options: MessageOptions = {}) => {
const newOptions = {
...options,
onAfterLeave: () => {
options.onAfterLeave?.()
},
}
if (data.status) {
return wrapMethod(message.success)(data.message, newOptions)
} else {
return wrapMethod(message.error)(data.message, newOptions)
}
},
} as MessageApiExtended
return wrappedMessage
}
export default useMessage

View File

@@ -0,0 +1,517 @@
import {
type Component,
type VNodeChild,
ref,
h,
getCurrentInstance,
provide,
defineComponent,
inject,
App,
Ref,
computed,
ComputedRef,
} from "vue";
import {
useModal as useNaiveModal,
createDiscreteApi,
type ButtonProps,
type ModalReactive,
NButton,
ModalOptions,
} from "naive-ui";
import { isBoolean } from "@baota/utils/type";
import { useTheme } from "../theme";
import { translation } from "../locals/translation";
import customProvider from "../components/customProvider";
// 定义provide/inject的key
export const MODAL_CLOSE_KEY = Symbol("modal-close");
export const MODAL_CLOSEABLE_KEY = Symbol("modal-closeable");
export const MODAL_LOADING_KEY = Symbol("modal-loading");
export const MODAL_CONFIRM_KEY = Symbol("modal-confirm");
export const MODAL_CANCEL_KEY = Symbol("modal-cancel");
// export const MODAL_I18N_KEY = Symbol('modal-i18n')e')
export const MODAL_MESSAGE_KEY = Symbol("modal-message");
export const MODAL_OPTIONS_KEY = Symbol("modal-options");
// 自定义Modal配置类型
export interface CustomModalOptions {
title?: string | (() => VNodeChild); // 标题
area?: string | string[] | number | number[]; // 视图大小
maskClosable?: boolean; // 是否可通过遮罩层关闭
destroyOnClose?: boolean; // 是否在关闭时销毁
draggable?: boolean; // 是否可拖拽
closable?: boolean; // 是否显示关闭按钮
footer?: boolean | (() => VNodeChild); // 是否显示底部按钮
confirmText?: string | Ref<string> | ComputedRef<string>; // 确认按钮文本
cancelText?: string | Ref<string> | ComputedRef<string>; // 取消按钮文本
modalStyle?: Record<string, any>; // 弹窗样式
confirmButtonProps?: ButtonProps; // 确认按钮props
cancelButtonProps?: ButtonProps; // 取消按钮props
component?: (() => Promise<Component>) | Component; // 组件
componentProps?: Record<string, unknown>; // 组件props
onConfirm?: (close: () => void) => Promise<unknown> | void; // 确认回调
onCancel?: (close: () => void) => Promise<unknown> | void; // 取消回调
onClose?: (close: () => void) => void; // 关闭回调
onUpdateShow?: (show: boolean) => void; // 更新显示状态回调
modelOptions?: ModalOptions; // Modal配置
"z-index"?: number;
}
const appsUseList = {
router: null,
i18n: null,
pinia: null,
};
// 挂载资源
const mountApps = (app: App, resources: any) => {
console.log(app && resources);
if (app && resources) app.use(resources);
};
// 自定义Modal钩子函数
const useModal = (options: CustomModalOptions) => {
const { theme, themeOverrides } = useTheme();
// 创建discreteModal实例 - 这个可以在任何地方使用
const { modal, message, unmount, app } = createDiscreteApi(
["modal", "message"],
{
configProviderProps: {
theme: theme.value,
themeOverrides: themeOverrides.value,
},
}
);
mountApps(app, appsUseList["i18n"]);
mountApps(app, appsUseList["router"]);
mountApps(app, appsUseList["pinia"]);
// 判断是否在setup中使用
const instance = getCurrentInstance();
// 控制Modal显示状态
const visible = ref(false);
// Modal实例引用
const modalInstance = ref<ModalReactive | null>(null);
// 获取naiveModal实例 - 只在setup中使用
const getNaiveModal = () => {
if (instance) {
return useNaiveModal();
}
return null;
};
// const naiveModal = getNaiveModal()
// 获取组件实例引用
const wrapperRef = ref();
// 创建Modal方法
const create = async (optionsNew: CustomModalOptions) => {
const {
component,
componentProps,
onConfirm,
onCancel,
footer = false,
confirmText,
cancelText,
confirmButtonProps = { type: "primary" },
cancelButtonProps = { type: "default" },
...modelOptions
} = optionsNew;
const optionsRef = ref({
footer,
confirmText,
cancelText,
confirmButtonProps,
cancelButtonProps,
});
// 处理视图高度和宽度
const getViewSize = (
areaNew: string | string[] | number | number[] = "50%"
) => {
if (Array.isArray(areaNew)) {
return {
width:
typeof areaNew[0] === "number" ? areaNew[0] + "px" : areaNew[0],
height:
typeof areaNew[1] === "number" ? areaNew[1] + "px" : areaNew[1],
};
}
return {
width: typeof areaNew === "number" ? areaNew + "px" : areaNew,
height: "auto",
};
};
// 处理组件
const content = async () => {
if (typeof component === "function") {
try {
// 处理异步组件函数
const syncComponent = await (component as () => Promise<Component>)();
return syncComponent.default || syncComponent;
} catch (e) {
// 处理普通函数组件
return component;
}
}
return component;
};
// 组件
const componentNew = (await content()) as Component;
// 视图大小
const { width, height } = await getViewSize(optionsNew.area);
// 存储组件内部注册的方法
const confirmHandler = ref<(close: () => void) => Promise<void> | void>();
const cancelHandler = ref<(close: () => void) => Promise<void> | void>();
const closeable = ref(true);
const loading = ref(false);
// 获取当前语言
const currentLocale = localStorage.getItem("activeLocales") || '"zhCN"';
// 获取翻译文本
const hookT = (key: string) => {
const locale = currentLocale.replace("-", "_").replace(/"/g, "");
return (
translation[locale as keyof typeof translation]?.useModal?.[
key as keyof typeof translation.zhCN.useModal
] ||
translation.zhCN.useModal[key as keyof typeof translation.zhCN.useModal]
);
};
const closeMessage = ref(hookT("cannotClose"));
// 合并Modal配置
const config: ModalOptions = {
preset: "card",
style: { width, height, ...modelOptions.modalStyle },
closeOnEsc: false,
maskClosable: false,
onClose: () => {
if (!closeable.value || loading.value) {
message.error(closeMessage.value);
return false;
}
// 调用组件内注册的取消方法
cancelHandler.value?.();
// 调用外部传入的取消回调
onCancel?.(() => {});
unmount(); // 卸载
return true;
},
content: () => {
const Wrapper = defineComponent({
setup() {
// 提供Modal配置
provide(MODAL_OPTIONS_KEY, optionsRef);
// 提供关闭方法
provide(MODAL_CLOSE_KEY, close);
// 提供信息方法
provide(MODAL_MESSAGE_KEY, message);
// 模块-确认按钮
provide(
MODAL_CONFIRM_KEY,
(handler: (close: () => void) => Promise<void> | void) => {
confirmHandler.value = handler;
}
);
// 模块-取消按钮
provide(
MODAL_CANCEL_KEY,
(handler: (close: () => void) => Promise<void> | void) => {
cancelHandler.value = handler;
}
);
// 模块 - 可关闭状态
provide(MODAL_CLOSEABLE_KEY, (canClose: boolean) => {
closeable.value = canClose;
});
// 模块-过度
provide(
MODAL_LOADING_KEY,
(loadStatus: boolean, closeMsg?: string) => {
loading.value = loadStatus;
closeMessage.value = closeMsg || hookT("cannotClose");
}
);
// 暴露给父级使用
return {
confirmHandler,
cancelHandler,
render: () => h(componentNew as Component, { ...componentProps }),
};
},
render() {
return this.render();
},
});
const wrapper = instance
? h(Wrapper)
: h(customProvider, {}, () => h(Wrapper));
return h(wrapper, { ref: wrapperRef });
},
// onAfterLeave: () => {
// // 调用组件内注册的取消方法
// cancelHandler.value?.()
// // 调用外部传入的取消回调
// onCancel?.(() => {})
// },
};
const footerComp = computed(() => {
if (isBoolean(optionsRef.value.footer) && optionsRef.value.footer) {
// 确认事件
const confirmEvent = async () => {
await confirmHandler.value?.(close);
// 调用外部传入的确认回调
await onConfirm?.(close);
};
// 取消事件
const cancelEvent = async () => {
await cancelHandler.value?.(close);
// 调用外部传入的取消回调
await onCancel?.(close);
if (!cancelHandler.value && !onCancel) {
close();
}
};
return (
<div class="flex justify-end">
<NButton
disabled={loading.value}
{...cancelButtonProps}
style={{ marginRight: "8px" }}
onClick={cancelEvent}
>
{optionsRef.value.cancelText || hookT("cancel")}
</NButton>
<NButton
disabled={loading.value}
{...confirmButtonProps}
onClick={confirmEvent}
>
{optionsRef.value.confirmText || hookT("confirm")}
</NButton>
</div>
);
}
return null;
});
// 底部按钮配置
if (optionsRef.value.footer) config.footer = () => footerComp.value;
// 合并Modal配置
Object.assign(config, modelOptions);
if (instance) {
const currentNaiveModal = getNaiveModal();
if (currentNaiveModal) {
modalInstance.value = currentNaiveModal.create(config);
return modalInstance.value;
}
}
// 使用createDiscreteApi创建
const discreteModal = modal.create(config);
modalInstance.value = discreteModal;
options.onUpdateShow?.(true);
return discreteModal;
};
// 关闭Modal方法
const close = () => {
visible.value = false;
if (modalInstance.value) {
modalInstance.value.destroy();
}
options.onUpdateShow?.(false);
};
// 销毁所有Modal实例方法
const destroyAll = () => {
// 销毁当前实例
if (modalInstance.value) {
modalInstance.value.destroy();
modalInstance.value = null;
}
visible.value = false;
// 销毁所有实例
const currentNaiveModal = getNaiveModal();
if (currentNaiveModal) {
currentNaiveModal.destroyAll();
} else {
modal.destroyAll();
}
};
// 更新显示状态
const updateShow = (show: boolean) => {
visible.value = show;
};
return {
...create(options),
updateShow,
close,
destroyAll,
};
};
/**
* @description 重新设置Modal配置的钩子函数
* @returns {Object} Modal配置
*/
export const useModalOptions = (): Ref<CustomModalOptions> => {
return inject(MODAL_OPTIONS_KEY, ref({}));
};
/**
* @description 获取Modal关闭方法的钩子函数
*/
export const useModalClose = () =>
inject(MODAL_CLOSE_KEY, () => {
console.warn("useModalClose 必须在 Modal 组件内部使用");
});
/**
* @description 注册Modal确认按钮点击处理方法的钩子函数
* @param handler 确认按钮处理函数接收一个关闭Modal的函数作为参数
* @returns void
*/
export const useModalConfirm = (
handler: (close: () => void) => Promise<any> | void
) => {
const registerConfirm = inject(
MODAL_CONFIRM_KEY,
(fn: (close: () => void) => Promise<void> | void) => {
console.warn("useModalConfirm 必须在 Modal 组件内部使用");
return;
}
);
// 注册确认处理方法
registerConfirm(handler);
};
/**
* @description 注册Modal取消按钮点击处理方法的钩子函数
* @param handler 取消按钮处理函数接收一个关闭Modal的函数作为参数
* @returns void
*/
export const useModalCancel = (
handler: (close: () => void) => Promise<void> | void
) => {
const registerCancel = inject(
MODAL_CANCEL_KEY,
(fn: (close: () => void) => Promise<void> | void) => {
console.warn("useModalCancel 必须在 Modal 组件内部使用");
return;
}
);
// 注册取消处理方法
registerCancel(handler);
};
/**
* @description 控制Modal是否可关闭的钩子函数
* @returns {(canClose: boolean) => void} 设置Modal可关闭状态的函数
*/
export const useModalCloseable = () => {
const registerCloseable = inject(MODAL_CLOSEABLE_KEY, (canClose: boolean) => {
console.warn("useModalCloseable 必须在 Modal 组件内部使用");
return;
});
return registerCloseable;
};
/**
* @description 获取Modal消息提示实例的钩子函数
* @returns {Object} Message消息实例包含loading, success, error, warning, info等方法
*/
export const useModalMessage = () => {
const message = inject(MODAL_MESSAGE_KEY, {
loading: (str: string) => {},
success: (str: string) => {},
error: (str: string) => {},
warning: (str: string) => {},
info: (str: string) => {},
});
return message;
};
/**
* @description 控制Modal加载状态的钩子函数
* @returns {(loadStatus: boolean, closeMsg?: string) => void} 设置加载状态的函数,
* loadStatus为true时显示加载状态并禁止关闭closeMsg为自定义禁止关闭时的提示消息
*/
export const useModalLoading = () => {
const registerLoading = inject(
MODAL_LOADING_KEY,
(loadStatus: boolean, closeMsg?: string) => {
console.warn("useModalLoading 必须在 Modal 组件内部使用");
return;
}
);
return registerLoading;
};
/**
* @description 获取Modal所有钩子函数的集合
* @returns {Object} 包含所有Modal相关钩子函数的对象
*/
export const useModalHooks = () => ({
/**
* 设置Modal配置用于修改Modal的配置
*/
options: useModalOptions,
/**
* 关闭当前Modal的函数
*/
close: useModalClose,
/**
* 注册Modal确认按钮点击处理方法
*/
confirm: useModalConfirm,
/**
* 注册Modal取消按钮点击处理方法
*/
cancel: useModalCancel,
/**
* 设置Modal是否可关闭的状态控制函数
*/
closeable: useModalCloseable,
/**
* 获取Modal内部可用的消息提示实例
*/
message: useModalMessage,
/**
* 设置Modal加载状态的控制函数
*/
loading: useModalLoading,
});
// 设置资源
export const useModalUseDiscrete = ({ router, i18n, pinia }: any) => {
appsUseList["i18n"] = i18n || false;
appsUseList["router"] = router || false;
appsUseList["pinia"] = pinia || false;
};
export default useModal;

View File

@@ -0,0 +1,206 @@
import { ref, computed, watch, VNode, isRef } from 'vue'
import type { Ref } from 'vue'
import { NInput, NIcon } from 'naive-ui'
import { Search } from '@vicons/ionicons5'
import { useTimeoutFn, useDebounceFn } from '@vueuse/core'
/**
* 搜索回调函数类型
*/
export type SearchCallback = (value: string, isReset?: boolean) => void | Promise<void>
/**
* 搜索配置选项接口
*/
export interface UseSearchOptions {
/** 搜索回调函数 */
onSearch?: SearchCallback
/** 初始搜索值 */
value?: string | Ref<string>
/** 输入框占位符文本 */
placeholder?: string
/** 清空搜索时的延迟时间(毫秒) */
clearDelay?: number
/** 输入框尺寸 */
size?: 'small' | 'medium' | 'large'
/** 是否可清空 */
clearable?: boolean
/** 自定义输入框类名 */
className?: string
/** 是否禁用 */
disabled?: boolean
/** 是否自动去除首尾空格 */
trim?: boolean
/** 输入时是否实时搜索 */
immediate?: boolean
/** 实时搜索的防抖延迟(毫秒) */
debounceDelay?: number
}
/**
* useSearch 返回值接口
*/
export interface UseSearchReturn {
/** 搜索值的响应式引用 */
value: Ref<string>
/** 是否有搜索内容 */
hasSearchValue: Ref<boolean>
/** 处理键盘事件的函数 */
handleKeydown: (event: KeyboardEvent) => void
/** 处理清空事件的函数 */
handleClear: () => void
/** 处理搜索图标点击事件的函数 */
handleSearchClick: () => void
/** 手动触发搜索的函数 */
search: (isReset?: boolean) => void
/** 防抖搜索函数 */
debouncedSearch: () => void
/** 清空搜索内容的函数 */
clear: () => void
/** 设置搜索值的函数 */
setValue: (value: string) => void
/** 渲染搜索输入框组件的函数 */
SearchComponent: (customProps?: Record<string, any>) => VNode
}
/**
* 搜索功能的 hooks 函数
* @param options 搜索配置选项
* @returns 搜索相关的状态和方法
*/
export default function useSearch(options: UseSearchOptions = {}): UseSearchReturn {
const {
onSearch,
value = '',
placeholder = '请输入搜索内容',
clearDelay = 100,
size = 'large',
clearable = true,
className = 'min-w-[300px]',
disabled = false,
trim = true,
immediate = false,
debounceDelay = 300,
} = options
// 搜索值的响应式状态
const searchValue = isRef(value) ? value : ref<string>(value)
// 计算属性:是否有搜索内容
const hasSearchValue = computed(() => {
const value = trim ? searchValue.value.trim() : searchValue.value
return value.length > 0
})
/**
* 执行搜索操作
* @param isReset 是否为重置操作
*/
const search = (isReset: boolean = false): void => {
if (onSearch) {
const value = trim ? searchValue.value.trim() : searchValue.value
onSearch(value, isReset)
}
}
// 防抖搜索函数
const debouncedSearch = useDebounceFn(() => {
search()
}, debounceDelay)
// 实时搜索监听
if (immediate && onSearch) {
watch(searchValue, () => {
debouncedSearch()
})
}
/**
* 处理键盘按下事件
* @param event 键盘事件
*/
const handleKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Enter') search()
}
/**
* 处理清空事件
*/
const handleClear = (): void => {
searchValue.value = ''
// 使用延迟执行清空后的搜索
useTimeoutFn(() => {
search(true)
}, clearDelay)
}
/**
* 处理搜索图标点击事件
*/
const handleSearchClick = (): void => {
search(true)
}
/**
* 清空搜索内容
*/
const clear = (): void => {
searchValue.value = ''
}
/**
* 设置搜索值
* @param value 要设置的值
*/
const setValue = (value: string): void => {
searchValue.value = value
}
/**
* 渲染搜索输入框组件
* @param customProps 自定义属性
* @returns 搜索输入框的 VNode
*/
const renderSearchInput = (customProps: Record<string, any> = {}): VNode => {
const mergedProps = {
value: searchValue.value,
'onUpdate:value': (value: string) => {
searchValue.value = value
},
onKeydown: handleKeydown,
onClear: handleClear,
placeholder,
clearable,
size,
disabled,
class: className,
...customProps,
}
return (
<NInput
{...mergedProps}
v-slots={{
suffix: () => (
<div class="flex items-center cursor-pointer" onClick={handleSearchClick}>
<NIcon component={Search} class="text-[var(--text-color-3)] w-[1.6rem] font-bold" />
</div>
),
}}
/>
)
}
return {
value: searchValue,
hasSearchValue,
handleKeydown,
handleClear,
handleSearchClick,
search,
debouncedSearch,
clear,
setValue,
SearchComponent: renderSearchInput,
}
}

View File

@@ -0,0 +1,568 @@
import { ref, shallowRef, ShallowRef, Ref, effectScope, watch, onUnmounted, isRef, computed } from 'vue'
import {
type DataTableProps,
type DataTableSlots,
type PaginationProps,
type PaginationSlots,
NDataTable,
NPagination,
NButton,
NDropdown,
NCheckbox,
NIcon,
} from 'naive-ui'
import { translation, TranslationModule, type TranslationLocale } from '../locals/translation'
import { useMessage } from './useMessage'
import type { UseTableOptions, TableInstanceWithComponent, TableResponse, ColumnVisibility } from '../types/table'
// 获取当前语言
const currentLocale = localStorage.getItem('locale-active') || 'zhCN'
// 获取翻译文本
const hookT = (key: string, params?: string) => {
const locale = currentLocale.replace('-', '_').replace(/"/g, '') as TranslationLocale
const translationFn =
(translation[locale as TranslationLocale] as TranslationModule).useTable[
key as keyof TranslationModule['useTable']
] || translation.zhCN.useTable[key as keyof typeof translation.zhCN.useTable]
return typeof translationFn === 'function' ? translationFn(params || '') : translationFn
}
/**
* 从本地存储获取pageSize的纯函数
* @param storage 存储的key
* @param defaultSize 默认大小
* @param pageSizeOptions 可选的页面大小选项
* @returns 页面大小
*/
const getStoredPageSize = (
storage: string,
defaultSize: number = 10,
pageSizeOptions: number[] = [10, 20, 50, 100, 200],
): number => {
try {
if (!storage) return defaultSize
const stored = localStorage.getItem(storage)
if (stored) {
const parsedSize = parseInt(stored, 10)
// 验证存储的值是否在可选项中
if (pageSizeOptions.includes(parsedSize)) {
return parsedSize
}
}
} catch (error) {
console.warn('读取本地存储pageSize失败:', error)
}
return defaultSize
}
/**
* 保存pageSize到本地存储的纯函数
* @param storage 存储的key
* @param size 页面大小
*/
const savePageSizeToStorage = (storage: string, size: number): void => {
try {
if (size && storage) localStorage.setItem(storage, size.toString())
} catch (error) {
console.warn('保存pageSize到本地存储失败:', error)
}
}
/**
* 从本地存储获取列可见性配置的纯函数
* @param storage 存储的key
* @param columns 表格列配置
* @returns 列可见性配置
*/
const getStoredColumnVisibility = (storage: string, columns: any[]): ColumnVisibility => {
try {
if (!storage) return getDefaultColumnVisibility(columns)
const stored = localStorage.getItem(`table-column-settings-${storage}`)
if (stored) {
const parsedVisibility = JSON.parse(stored) as ColumnVisibility
// 验证存储的配置是否与当前列配置匹配
const defaultVisibility = getDefaultColumnVisibility(columns)
const mergedVisibility: ColumnVisibility = {}
// 合并默认配置和存储配置,确保新增的列默认显示
Object.keys(defaultVisibility).forEach((key) => {
mergedVisibility[key] = Object.prototype.hasOwnProperty.call(parsedVisibility, key)
? parsedVisibility[key]
: defaultVisibility[key]
})
return mergedVisibility
}
} catch (error) {
console.warn('读取本地存储列设置失败:', error)
}
return getDefaultColumnVisibility(columns)
}
/**
* 保存列可见性配置到本地存储的纯函数
* @param storage 存储的key
* @param visibility 列可见性配置
*/
const saveColumnVisibilityToStorage = (storage: string, visibility: ColumnVisibility): void => {
try {
if (storage) localStorage.setItem(`table-column-settings-${storage}`, JSON.stringify(visibility))
} catch (error) {
console.warn('保存列设置到本地存储失败:', error)
}
}
/**
* 获取默认列可见性配置的纯函数
* @param columns 表格列配置
* @returns 默认列可见性配置
*/
const getDefaultColumnVisibility = (columns: any[]): ColumnVisibility => {
const visibility: ColumnVisibility = {}
columns.forEach((column) => {
// 使用类型断言来访问 key 属性
const col = column as any
if (col.key) {
visibility[col.key] = true // 默认所有列都显示
}
})
return visibility
}
/**
* 表格钩子函数
* @param options 表格配置选项
* @returns 表格实例,包含表格状态和方法
*/
export default function useTable<T = Record<string, any>, Z extends Record<string, any> = Record<string, any>>({
config, // 表格列配置
request, // 数据请求函数
defaultValue = ref({}) as Ref<Z>, // 默认请求参数,支持响应式
watchValue = false, // 监听参数
alias = { page: 'page', pageSize: 'page_size' }, // 分页字段别名映射
storage = '', // 本地存储的key
}: UseTableOptions<T, Z> & {}) {
const scope = effectScope() // 创建一个作用域,用于管理副作用
return scope.run(() => {
// 表格状态
const columns = shallowRef(config) // 表格列配置
const loading = ref(false) // 加载状态
const data = ref({ list: [], total: 0 }) as Ref<{ list: T[]; total: number }> // 表格数据
const tableAlias = ref({ total: 'total', list: 'list' }) // 表格别名
const example = ref() // 表格引用
const param = (isRef(defaultValue) ? defaultValue : ref({ ...(defaultValue as Z) })) as Ref<Z> // 表格请求参数
const total = ref(0) // 分页参数
const props = shallowRef({}) as ShallowRef<DataTableProps> // 表格属性
const { error: errorMsg } = useMessage()
// 列设置状态
const columnVisibility = ref<ColumnVisibility>(getStoredColumnVisibility(storage, config))
// 计算过滤后的列配置
const filteredColumns = computed(() => {
return config.filter((column) => {
const col = column as any
if (!col.key) return true // 没有key的列始终显示
return columnVisibility.value[col.key] !== false
})
})
// 计算可见列的详细宽度信息
const visibleColumnsWidth = computed(() => {
let normalColumnsWidth = 0
let fixedColumnsWidth = 0
let totalWidth = 0
filteredColumns.value.forEach((column) => {
const col = column as any
if (col.width) {
// 处理数字和字符串类型的宽度
const width = typeof col.width === 'string' ? parseInt(col.width) : col.width
if (!isNaN(width)) {
totalWidth += width
if (col.fixed) {
fixedColumnsWidth += width
} else {
normalColumnsWidth += width
}
}
}
})
return {
totalWidth,
normalColumnsWidth,
fixedColumnsWidth,
}
})
// 精确计算动态 scroll-x 值
const dynamicScrollX = computed(() => {
const { totalWidth, normalColumnsWidth, fixedColumnsWidth } = visibleColumnsWidth.value
if (totalWidth <= 0) {
return undefined
}
// 精确的表格补偿计算
// 基于 Naive UI DataTable 的实际渲染需求
const TABLE_BORDER = 2 // 表格边框 (左右各1px)
const TABLE_PADDING = 16 // 表格内边距 (Naive UI 默认)
const SCROLL_COMPENSATION = 4 // 滚动区域补偿
// 总补偿宽度:保守且精确
const totalCompensation = TABLE_BORDER + TABLE_PADDING + SCROLL_COMPENSATION
// 最终宽度 = 实际列宽度 + 精确补偿
const preciseWidth = totalWidth + totalCompensation
return preciseWidth
})
// 分页相关状态
const { page, pageSize } = alias
const pageSizeOptionsRef = ref([10, 20, 50, 100, 200]) // 分页选项
// 防重复请求相关状态
const lastDirectRequestTime = ref(0) // 记录最后一次直接请求的时间
const REQUEST_DEBOUNCE_DELAY = 100 // 防抖延迟时间(毫秒)
// 初始化分页参数
if ((param.value as Record<string, unknown>)[page]) {
;(param.value as Record<string, unknown>)[page] = 1 // 当前页码
}
if ((param.value as Record<string, unknown>)[pageSize]) {
;(param.value as Record<string, unknown>)[pageSize] = getStoredPageSize(storage, 10, pageSizeOptionsRef.value) // 每页条数
}
/**
* 更新页码
* @param currentPage 当前页码
*/
const handlePageChange = (currentPage: number) => {
// 记录直接请求时间,防止 watch 重复触发
lastDirectRequestTime.value = Date.now()
;(param.value as Record<string, unknown>)[page] = currentPage
fetchData()
}
/**
* 更新每页条数
* @param size 每页条数
*/
const handlePageSizeChange = (size: number) => {
// 记录直接请求时间,防止 watch 重复触发
lastDirectRequestTime.value = Date.now()
// 保存到本地存储
savePageSizeToStorage(storage, size)
;(param.value as Record<string, unknown>)[page] = 1 // 重置页码为1
;(param.value as Record<string, unknown>)[pageSize] = size
fetchData()
}
/**
* 切换列可见性
* @param columnKey 列的key
*/
const toggleColumnVisibility = (columnKey: string) => {
columnVisibility.value = {
...columnVisibility.value,
[columnKey]: !columnVisibility.value[columnKey],
}
// 保存到本地存储
saveColumnVisibilityToStorage(storage, columnVisibility.value)
}
/**
* 重置列设置
*/
const resetColumnSettings = () => {
const defaultVisibility = getDefaultColumnVisibility(config)
columnVisibility.value = defaultVisibility
// 保存到本地存储
saveColumnVisibilityToStorage(storage, defaultVisibility)
}
/**
* 获取表格数据
*/
const fetchData = async <T,>(resetPage?: boolean) => {
try {
loading.value = true
const rdata: TableResponse<T> = await request(param.value)
total.value = rdata[tableAlias.value.total as keyof TableResponse<T>] as number
data.value = {
list: rdata[tableAlias.value.list as keyof TableResponse<T>] as [],
total: rdata[tableAlias.value.total as keyof TableResponse<T>] as number,
}
console.log(data.value)
// 如果需要重置页码,则重置页码
if (resetPage) (param.value as Record<string, unknown>)[page] = 1
return data.value
} catch (error: any) {
errorMsg(error.message)
console.error('请求数据失败:', error)
} finally {
loading.value = false
}
}
/**
* 重置表格状态和数据
*/
const reset = async <T,>() => {
param.value = defaultValue.value
return await fetchData<T>()
}
/**
* 渲染表格组件
*/
const component = (props: DataTableProps, context: { slots?: DataTableSlots }) => {
const { slots, ...attrs } = props as any
const s2 = context
// 合并动态 scroll-x 值
const mergedProps = {
...props,
...attrs,
}
// 精确的 scroll-x 处理:确保容器宽度与内容宽度完全匹配
if (dynamicScrollX.value) {
// 始终使用动态计算的精确宽度,确保无浏览器自动拉伸
mergedProps.scrollX = dynamicScrollX.value
}
watch(data.value, (newVal) => {
console.log(data.value)
})
return (
<NDataTable
remote
ref={example}
loading={loading.value}
data={data.value.list}
columns={filteredColumns.value}
scrollbar-props={{
xPlacement: 'top',
}}
{...mergedProps}
>
{{
empty: () => (slots?.empty || s2?.slots?.empty ? slots?.empty?.() || s2?.slots?.empty?.() : null),
loading: () => (slots?.loading || s2?.slots?.loading ? slots?.loading?.() || s2?.slots?.loading?.() : null),
}}
</NDataTable>
)
}
/**
* 渲染分页组件
*/
const paginationComponent = (paginationProps: PaginationProps = {}, context: { slots?: PaginationSlots } = {}) => {
const mergedSlots = {
...(context?.slots || {}),
}
return (
<NPagination
page={(param.value as Record<string, unknown>)[page] as number}
pageSize={(param.value as Record<string, unknown>)[pageSize] as number}
itemCount={total.value}
pageSizes={pageSizeOptionsRef.value}
showSizePicker={true}
onUpdatePage={handlePageChange}
onUpdatePageSize={handlePageSizeChange}
{...paginationProps}
v-slots={{
...mergedSlots,
prefix: () => <span>{hookT('total', `${total.value}`)}</span>,
}}
/>
)
}
/**
* 渲染列设置组件
*/
const columnSettingsComponent = () => {
// 生成下拉选项
const dropdownOptions = [
{
key: 'header',
type: 'render',
render: () => (
<div style="padding: 8px 12px; font-weight: 500; color: var(--n-text-color);">
{hookT('columnSettings')}
</div>
),
},
{
key: 'divider1',
type: 'divider',
},
...config
.filter((column) => (column as any).key)
.map((column) => {
const col = column as any
return {
key: col.key,
type: 'render',
render: () => (
<div
style="padding: 4px 12px; cursor: pointer; display: flex; align-items: center;"
onClick={(e: Event) => {
e.stopPropagation()
toggleColumnVisibility(col.key)
}}
>
<NCheckbox
checked={columnVisibility.value[col.key] !== false}
onUpdateChecked={() => toggleColumnVisibility(col.key)}
style="pointer-events: none;"
/>
<span style="margin-left: 8px; flex: 1;">{col.title || col.key}</span>
</div>
),
}
}),
{
key: 'divider2',
type: 'divider',
},
{
key: 'reset',
type: 'render',
render: () => (
<div
style="padding: 8px 12px; cursor: pointer; color: var(--n-color-target);"
onClick={() => resetColumnSettings()}
>
{hookT('resetColumns')}
</div>
),
},
]
return (
<NDropdown options={dropdownOptions} trigger="click" placement="bottom-end" showArrow={false}>
<NButton quaternary circle size="small" title={hookT('columnSettings')}>
<NIcon size={16}>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4H21V6H3V4ZM3 11H15V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor" />
<path d="M16 11H18V13H16V11ZM19 11H21V13H19V11Z" fill="currentColor" />
</svg>
</NIcon>
</NButton>
</NDropdown>
)
}
// 检测到参数变化时,重新请求数据
if (Array.isArray(watchValue)) {
// 只监听指定的字段
const source = computed(() => watchValue.map((key) => param.value[key]))
watch(
source,
() => {
// 检查是否刚刚有直接请求,如果是则跳过此次 watch 触发的请求
const timeSinceLastDirectRequest = Date.now() - lastDirectRequestTime.value
if (timeSinceLastDirectRequest < REQUEST_DEBOUNCE_DELAY) {
return
}
fetchData()
},
{ deep: true },
)
}
onUnmounted(() => {
scope.stop() // 停止作用域
}) // 清理副作用
// 返回表格实例
return {
loading,
example,
data,
tableAlias,
param,
total,
reset: reset<T>,
fetch: fetchData<T>,
TableComponent: component,
PageComponent: paginationComponent,
ColumnSettingsComponent: columnSettingsComponent,
config: columns,
props,
storage,
handlePageChange,
handlePageSizeChange,
pageSizeOptions: pageSizeOptionsRef,
columnVisibility,
toggleColumnVisibility,
resetColumnSettings,
filteredColumns,
visibleColumnsWidth,
dynamicScrollX,
}
}) as TableInstanceWithComponent<T, Z>
}
/**
* @description 表格列hook--操作列
*/
const useTableOperation = (
options: {
title: string
width?: number
onClick: (row: any) => void
isHide?: boolean | ((row: any) => boolean)
}[],
others?: any,
) => {
const width = options.reduce((accumulator, option) => accumulator + (option.width || 40), 0) + 20
return {
title: hookT('operation'),
key: 'CreatedAt',
width,
fixed: 'right' as const,
align: 'right' as const,
render: (row: any) => {
const buttons: JSX.Element[] = []
for (let index = 0; index < options.length; index++) {
const option = options[index]
const isHide =
typeof option.isHide === 'function'
? option.isHide(row)
: typeof option.isHide === 'boolean'
? option.isHide
: false
if (isHide) continue
buttons.push(
<NButton size="small" text type="primary" onClick={() => option.onClick(row)}>
{option.title}
</NButton>,
)
}
return (
<div class="flex justify-end">
{buttons.map((button, index) => (
<>
{button}
{index < buttons.length - 1 && <span class="mx-[.8rem] text-[#dcdfe6]">|</span>}
</>
))}
</div>
)
},
...others,
}
}
export { useTableOperation }

View File

@@ -0,0 +1,86 @@
import { ref, computed } from 'vue'
import { NTabs, NTabPane } from 'naive-ui'
import { useRoute, useRouter, type RouteRecordRaw } from 'vue-router'
/**
* 标签页配置项接口
*/
export interface UseTabsOptions {
/** 是否在初始化时自动选中第一个标签 */
defaultToFirst?: boolean
}
/**
* 标签页实例接口
*/
export interface TabsInstance {
/** 当前激活的标签值 */
activeKey: string
/** 子路由列表 */
childRoutes: RouteRecordRaw[]
/** 切换标签页方法 */
handleTabChange: (key: string) => void
/** 标签页渲染组件 */
TabsComponent: () => JSX.Element
}
/**
* 标签页钩子函数
* 用于处理二级路由的标签页导航
*/
export default function useTabs(options: UseTabsOptions = {}): TabsInstance {
const route = useRoute()
const router = useRouter()
const { defaultToFirst = true } = options
// 当前激活的标签值
const activeKey = ref(route.name as string)
// 获取当前路由的子路由配置
const childRoutes = computed(() => {
const parentRoute = router.getRoutes().find((r) => r.path === route.matched[0]?.path)
return parentRoute?.children || []
})
/**
* 处理标签切换
* @param key 目标路由名称
*/
const handleTabChange = (key: string) => {
const targetRoute = childRoutes.value.find((route) => route.name === key)
if (targetRoute) {
router.push({ name: key })
activeKey.value = key
}
}
/**
* 标签页组件
* 渲染标签页导航和对应的视图
*/
const TabsComponent = () => (
<div class="tabs-container">
<NTabs value={activeKey.value} onUpdateValue={handleTabChange} type="line" class="tabs-nav">
{childRoutes.value.map((route: RouteRecordRaw) => (
<NTabPane key={route.name as string} name={route.name as string} tab={route.meta?.title || route.name} />
))}
</NTabs>
<div class="tabs-content">
<router-view />
</div>
</div>
)
// 初始化时自动选中第一个标签
if (defaultToFirst && childRoutes.value.length > 0 && !route.name) {
const firstRoute = childRoutes.value[0]
handleTabChange(firstRoute.name as string)
}
return {
activeKey: activeKey.value,
childRoutes: childRoutes.value,
handleTabChange,
TabsComponent,
}
}

View File

@@ -0,0 +1,153 @@
import { type Ref, ref, watch, effectScope, onScopeDispose } from 'vue'
import type { NLocale, NDateLocale } from 'naive-ui'
import {
enUS,
dateEnUS,
zhCN,
dateZhCN,
zhTW,
dateZhTW,
jaJP,
dateJaJP,
ruRU,
dateRuRU,
koKR,
dateKoKR,
ptBR,
datePtBR,
frFR,
dateFrFR,
esAR,
dateEsAR,
arDZ,
dateArDZ,
} from 'naive-ui'
// 创建国际化列表
export const localeList: { type: string; name: string; locale: NLocale; dateLocale: NDateLocale }[] = [
{
type: 'zhCN',
name: '简体中文',
locale: zhCN,
dateLocale: dateZhCN,
},
{
type: 'zhTW',
name: '繁體中文 ',
locale: zhTW,
dateLocale: dateZhTW,
},
{
type: 'enUS',
name: 'English',
locale: enUS,
dateLocale: dateEnUS,
},
{
type: 'jaJP',
name: '日本語',
locale: jaJP,
dateLocale: dateJaJP,
},
{
type: 'ruRU',
name: 'Русский',
locale: ruRU,
dateLocale: dateRuRU,
},
{
type: 'koKR',
name: '한국어',
locale: koKR,
dateLocale: dateKoKR,
},
{
type: 'ptBR',
name: 'Português',
locale: ptBR,
dateLocale: datePtBR,
},
{
type: 'frFR',
name: 'Français',
locale: frFR,
dateLocale: dateFrFR,
},
{
type: 'esAR',
name: 'Español',
locale: esAR,
dateLocale: dateEsAR,
},
{
type: 'arDZ',
name: 'العربية',
locale: arDZ,
dateLocale: dateArDZ,
},
]
/**
* 将下划线格式的语言代码转换为驼峰格式
* @param locale - 语言代码,例如: 'zh_CN', 'en_US'
* @returns 转换后的语言代码,例如: 'zhCN', 'enUS'
*/
const transformLocale = (locale: string): string => {
return locale.replace(/_/g, '')
}
/**
* 动态加载 Naive UI 的语言包
* @param locale - 语言代码,例如: 'zh_CN', 'en_US'
* @returns Promise<{locale: NLocale, dateLocale: NDateLocale} | null>
*/
const loadNaiveLocale = async (locale: string) => {
try {
const localeConfig = localeList.find((item) => item.type === transformLocale(locale))
if (!localeConfig) {
throw new Error(`Locale ${locale} not found`)
}
return localeConfig
} catch (error) {
// 加载失败时输出警告信息并返回 null
console.warn(`Failed to load locale ${locale}:`, error)
return null
}
}
/**
* 同步 Vue I18n 和 Naive UI 的语言设置
* @returns {Object} 返回 Naive UI 的语言配置
* @property {Ref<NLocale|null>} naiveLocale - Naive UI 组件的语言配置
* @property {Ref<NDateLocale|null>} naiveDateLocale - Naive UI 日期组件的语言配置
*/
export function useNaiveI18nSync(locale: Ref<string>) {
// 创建响应式的 Naive UI 语言配置
const naiveLocale = ref<NLocale | null>(null)
const naiveDateLocale = ref<NDateLocale | null>(null)
const scope = effectScope()
scope.run(() => {
// 监听 Vue I18n 的语言变化
watch(
locale,
async (newLocale) => {
// 加载新语言的配置
const localeConfig = await loadNaiveLocale(newLocale)
// 如果加载成功,更新 Naive UI 的语言配置
if (localeConfig) {
naiveLocale.value = localeConfig.locale
naiveDateLocale.value = localeConfig.dateLocale
}
},
{ immediate: true }, // 立即执行一次,确保初始化时加载正确的语言
)
})
// 在组件卸载时停止所有响应式效果
onScopeDispose(() => {
scope.stop()
})
return {
naiveLocale,
naiveDateLocale,
}
}

View File

@@ -0,0 +1,4 @@
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export default [t('t_0_1743100809201')]

View File

@@ -0,0 +1,385 @@
// 定义完整的基准模板结构
type TranslationTemplate = {
useModal: {
cannotClose: string
cancel: string
confirm: string
}
useBatch: {
batchOperation: string
selectedItems: (count: number) => string
startBatch: string
placeholder: string
}
useForm: {
submit: string
reset: string
expand: string
collapse: string
moreConfig: string
help: string
required: string
placeholder: (label: string) => string
}
useFullScreen: {
exit: string
enter: string
}
useTable: {
operation: string
columnSettings: string
showColumn: string
hideColumn: string
resetColumns: string
allColumns: string
}
}
/**
* @description 格式化字符串,将传入的变量依次插入 `{}` 占位符
* @param {string} template 需要格式化的字符串,使用 `{}` 作为占位符
* @param {...any} values 需要插入的多个变量
* @returns {string} 格式化后的字符串
* @example
* formatString("你好,我是 {},今年 {} 岁", "小明", 25);
* // 返回:"你好,我是 小明,今年 25 岁"
*/
const formatString = (template: string, ...values: any[]) => {
let index = 0
return template.replace(/\{\}/g, () => (values[index] !== undefined ? values[index++] : ''))
}
// 创建语言翻译生成器函数
const createTranslation = <T = TranslationTemplate>(translation: T): T => translation
/**
* 国际化翻译
*/
export const translation = {
zhCN: createTranslation({
useModal: {
cannotClose: '当前状态无法关闭弹窗',
cancel: '取消',
confirm: '确认',
},
useBatch: {
batchOperation: '批量操作',
selectedItems: (count: number) => formatString('已选择 {} 项', count),
startBatch: '开始批量操作',
placeholder: '请选择操作',
},
useForm: {
submit: '提交',
reset: '重置',
expand: '展开',
collapse: '收起',
moreConfig: '更多配置',
help: '帮助文档',
required: '必填项',
placeholder: (label: string) => formatString('请输入{}', label),
},
useFullScreen: {
exit: '退出全屏',
enter: '进入全屏',
},
useTable: {
operation: '操作',
total: (total: number) => formatString('共 {} 条', total),
columnSettings: '列设置',
showColumn: '显示列',
hideColumn: '隐藏列',
resetColumns: '重置列设置',
allColumns: '全部列',
},
}),
zhTW: createTranslation({
useModal: {
cannotClose: '當前狀態無法關閉彈窗',
cancel: '取消',
confirm: '確認',
},
useBatch: {
batchOperation: '批量操作',
selectedItems: (count: number) => formatString('已選擇 {} 項', count),
startBatch: '開始批量操作',
placeholder: '請選擇操作',
},
useForm: {
submit: '提交',
reset: '重置',
expand: '展開',
collapse: '收起',
moreConfig: '更多配置',
help: '幫助文檔',
required: '必填項',
placeholder: (label: string) => formatString('請輸入{}', label),
},
useFullScreen: {
exit: '退出全屏',
enter: '進入全屏',
},
useTable: {
operation: '操作',
total: (total: number) => formatString('共 {} 條', total),
},
}),
enUS: createTranslation({
useModal: {
cannotClose: 'Cannot close the dialog in current state',
cancel: 'Cancel',
confirm: 'Confirm',
},
useBatch: {
batchOperation: 'Batch Operation',
selectedItems: (count: number) => formatString('{} items selected', count),
startBatch: 'Start Batch Operation',
placeholder: 'Select operation',
},
useForm: {
submit: 'Submit',
reset: 'Reset',
expand: 'Expand',
collapse: 'Collapse',
moreConfig: 'More Configuration',
help: 'Help Documentation',
required: 'Required',
placeholder: (label: string) => formatString('Please enter {}', label),
},
useFullScreen: {
exit: 'Exit Fullscreen',
enter: 'Enter Fullscreen',
},
useTable: {
operation: 'Operation',
total: (total: number) => formatString('Total {} items', total),
columnSettings: 'Column Settings',
showColumn: 'Show Column',
hideColumn: 'Hide Column',
resetColumns: 'Reset Columns',
allColumns: 'All Columns',
},
}),
jaJP: createTranslation({
useModal: {
cannotClose: '現在の状態ではダイアログを閉じることができません',
cancel: 'キャンセル',
confirm: '確認',
},
useBatch: {
batchOperation: 'バッチ操作',
selectedItems: (count: number) => formatString('{}項目が選択されました', count),
startBatch: 'バッチ操作を開始',
placeholder: '操作を選択',
},
useForm: {
submit: '提出する',
reset: 'リセット',
expand: '展開',
collapse: '折りたたみ',
moreConfig: '詳細設定',
help: 'ヘルプドキュメント',
required: '必須',
placeholder: (label: string) => formatString('{}を入力してください', label),
},
useFullScreen: {
exit: '全画面表示を終了',
enter: '全画面表示に入る',
},
useTable: {
operation: '操作',
total: (total: number) => formatString('合計 {} 件', total),
},
}),
ruRU: createTranslation({
useModal: {
cannotClose: 'Невозможно закрыть диалог в текущем состоянии',
cancel: 'Отмена',
confirm: 'Подтвердить',
},
useBatch: {
batchOperation: 'Пакетная операция',
selectedItems: (count: number) => formatString('Выбрано {} элементов', count),
startBatch: 'Начать пакетную операцию',
placeholder: 'Выберите операцию',
},
useForm: {
submit: 'Отправить',
reset: 'Сбросить',
expand: 'Развернуть',
collapse: 'Свернуть',
moreConfig: 'Дополнительная конфигурация',
help: 'Документация',
required: 'Обязательно',
placeholder: (label: string) => formatString('Пожалуйста, введите {}', label),
},
useFullScreen: {
exit: 'Выйти из полноэкранного режима',
enter: 'Войти в полноэкранный режим',
},
useTable: {
operation: 'Операция',
total: (total: number) => formatString('Всего {} элементов', total),
},
}),
koKR: createTranslation({
useModal: {
cannotClose: '현재 상태에서는 대화 상자를 닫을 수 없습니다',
cancel: '취소',
confirm: '확인',
},
useBatch: {
batchOperation: '일괄 작업',
selectedItems: (count: number) => formatString('{}개 항목 선택됨', count),
startBatch: '일괄 작업 시작',
placeholder: '작업 선택',
},
useForm: {
submit: '제출',
reset: '재설정',
expand: '확장',
collapse: '축소',
moreConfig: '추가 구성',
help: '도움말',
required: '필수 항목',
placeholder: (label: string) => formatString('{} 입력하세요', label),
},
useFullScreen: {
exit: '전체 화면 종료',
enter: '전체 화면 시작',
},
useTable: {
operation: '작업',
total: (total: number) => formatString('총 {} 페이지', total),
},
}),
ptBR: createTranslation({
useModal: {
cannotClose: 'Não é possível fechar o diálogo no estado atual',
cancel: 'Cancelar',
confirm: 'Confirmar',
},
useBatch: {
batchOperation: 'Operação em Lote',
selectedItems: (count: number) => formatString('{} itens selecionados', count),
startBatch: 'Iniciar Operação em Lote',
placeholder: 'Selecione a operação',
},
useForm: {
submit: 'Enviar',
reset: 'Redefinir',
expand: 'Expandir',
collapse: 'Recolher',
moreConfig: 'Mais Configurações',
help: 'Documentação de Ajuda',
required: 'Obrigatório',
placeholder: (label: string) => formatString('Por favor, insira {}', label),
},
useFullScreen: {
exit: 'Sair da Tela Cheia',
enter: 'Entrar em Tela Cheia',
},
useTable: {
operation: 'Operação',
total: (total: number) => formatString('Total {} páginas', total),
},
}),
frFR: createTranslation({
useModal: {
cannotClose: "Impossible de fermer la boîte de dialogue dans l'état actuel",
cancel: 'Annuler',
confirm: 'Confirmer',
},
useBatch: {
batchOperation: 'Opération par lot',
selectedItems: (count: number) => formatString('{} éléments sélectionnés', count),
startBatch: 'Démarrer une opération par lot',
placeholder: 'Sélectionnez une opération',
},
useForm: {
submit: 'Soumettre',
reset: 'Réinitialiser',
expand: 'Développer',
collapse: 'Réduire',
moreConfig: 'Plus de configuration',
help: "Documentation d'aide",
required: 'Obligatoire',
placeholder: (label: string) => formatString('Veuillez entrer {}', label),
},
useFullScreen: {
exit: 'Quitter le mode plein écran',
enter: 'Passer en mode plein écran',
},
useTable: {
operation: 'Opération',
total: (total: number) => formatString('Total {} pages', total),
},
}),
esAR: createTranslation({
useModal: {
cannotClose: 'No se puede cerrar el diálogo en el estado actual',
cancel: 'Cancelar',
confirm: 'Confirmar',
},
useBatch: {
batchOperation: 'Operación por lotes',
selectedItems: (count: number) => formatString('{} elementos seleccionados', count),
startBatch: 'Iniciar operación por lotes',
placeholder: 'Seleccionar operación',
},
useForm: {
submit: 'Enviar',
reset: 'Restablecer',
expand: 'Expandir',
collapse: 'Colapsar',
moreConfig: 'Más configuración',
help: 'Documentación de ayuda',
required: 'Obligatorio',
placeholder: (label: string) => formatString('Por favor ingrese {}', label),
},
useFullScreen: {
exit: 'Salir de pantalla completa',
enter: 'Entrar en pantalla completa',
},
useTable: {
operation: 'Operación',
total: (total: number) => formatString('Total {} páginas', total),
},
}),
arDZ: createTranslation({
useModal: {
cannotClose: 'لا يمكن إغلاق مربع الحوار في الحالة الحالية',
cancel: 'إلغاء',
confirm: 'تأكيد',
},
useBatch: {
batchOperation: 'عملية دفعية',
selectedItems: (count: number) => formatString('تم تحديد {} عنصر', count),
startBatch: 'بدء عملية دفعية',
placeholder: 'اختر العملية',
},
useForm: {
submit: 'إرسال',
reset: 'إعادة تعيين',
expand: 'توسيع',
collapse: 'طي',
moreConfig: 'مزيد من الإعدادات',
help: 'وثائق المساعدة',
required: 'إلزامي',
placeholder: (label: string) => formatString('الرجاء إدخال {}', label),
},
useFullScreen: {
exit: 'الخروج من وضع ملء الشاشة',
enter: 'الدخول إلى وضع ملء الشاشة',
},
useTable: {
operation: 'العملية',
total: (total: number) => formatString('إجمالي {} صفحات', total),
},
}),
}
// 类型导出
export type TranslationType = typeof translation
export type TranslationLocale = keyof TranslationType
export type TranslationModule = TranslationType[TranslationLocale]
export type TranslationModuleValue = keyof TranslationType[TranslationLocale][TranslationModule]

View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
// 导入全局样式
import './styles/index.css'
// 导入根组件
import App from './App.vue'
// 创建 Vue 应用实例
const app = createApp(App)
// 挂载应用到 DOM
app.mount('#app')

View File

@@ -0,0 +1,34 @@
/* 导入 Tailwind 的基础样式 */
@tailwind base;
/* 导入 Tailwind 的组件类 */
@tailwind components;
/* 导入 Tailwind 的工具类 */
@tailwind utilities;
/* 自定义组件样式 */
@layer components {
/* 表单卡片样式:用于可拖拽元素和表单项 */
.form-card {
@apply p-4 rounded-lg shadow-sm border border-gray-200 hover:border-gray-300 transition-colors;
}
/* 表单输入框样式 */
.form-input {
@apply w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
/* 表单标签样式 */
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
/* 表单按钮样式 */
.form-button {
@apply px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors;
}
/* 表单拖放区域样式 */
.form-area {
@apply min-h-[200px] p-6 border-2 border-dashed rounded-lg transition-colors;
}
}

View File

@@ -0,0 +1,71 @@
import type { ThemeTemplate, PresetConfig } from '../../types'
import './style.css'
// 预设参数配置,用于预设视图组件的相关参数
const presets: PresetConfig = {
Modal: {
preset: 'card',
},
}
// 宝塔亮色主题
const baotaLight: ThemeTemplate = {
name: 'baotaLight',
type: 'light',
title: '宝塔亮色主题',
themeOverrides: {
Layout: {
color: '#f9fafb',
},
common: {
primaryColor: '#20a53a',
fontSize: '12px',
fontWeight: '400',
},
Dialog: {
titleTextColor: '#333',
titleFontSize: '14px',
titleFontWeight: '400',
titlePadding: '12px 16px',
iconSize: '0px',
padding: '0px',
fontSize: '12px',
closeMargin: '10px 12px',
border: '1px solid #d9d9d9',
},
Card: {
titleFontSizeMedium: '14px',
titleFontWeightMedium: '400',
titlePaddingMedium: '12px 16px',
border: '1px solid #d9d9d9',
padding: '0px',
actionColor: '#20a53a',
},
Button: {
fontSizeSmall: '12px',
fontSizeMedium: '12px',
paddingMedium: '8px 16px',
heightMedium: '32px',
},
DataTable: {
thPaddingMedium: '8px',
fontSizeMedium: '12px',
thFontWeight: '400',
},
Table: {
thPaddingMedium: '8px',
fontSizeMedium: '12px',
thFontWeight: '400',
},
Tabs: {
navBgColor: '#fff',
navActiveBgColor: '#eaf8ed',
barColor: '#20a53a',
tabFontWeightActive: '400',
},
},
presetsOverrides: presets,
}
export { baotaLight }

View File

@@ -0,0 +1,51 @@
/* 变量配置 */
:root[class='baotaLight'] {
--n-dialog-title-padding: 0 10px 0 18px; /* 对话框标题内边距 */
--n-dialog-title-height: 40px; /* 对话框标题高度 */
--n-dialog-content-padding: 20px 12px; /* 对话框内容内边距 */
--n-dialog-action-bg: #f0f0f0; /* 对话框标题背景色 */
--n-dialog-action-padding: 8px 16px; /* 对话框操作按钮内边距 */
--bt-card-bg-color: #fff; /* tab卡片背景色 */
--bt-card-bg-color-active: #eaf8ed; /* tab卡片背景色-激活 */
}
:root[class='baotaDark'] {
--n-dialog-title-color: #eee; /* 对话框标题文字颜色 */
--n-dialog-action-bg: #333; /* 对话框标题背景色 */
--n-dialog-border: 1px solid #333; /* 对话框标题下边框 */
--bt-card-bg-color: #18181c; /* tab卡片背景色 */
--bt-card-bg-color-active: #1e2720; /* tab卡片背景色-激活 */
}
/* ------------------------------对话框--------------------------------- */
/* 对话框/模态框标题 */
.n-dialog .n-dialog__title,
.n-modal .n-card-header {
padding: var(--n-dialog-title-padding) !important;
background-color: var(--n-dialog-action-bg);
color: var(--n-dialog-title-color);
border-bottom: var(--n-dialog-border);
height: var(--n-dialog-title-height);
border-top-right-radius: var(--n-border-radius);
border-top-left-radius: var(--n-border-radius);
}
/* 对话框/模态框内容 */
.n-dialog .n-dialog__content,
.n-modal .n-card__content {
margin: 0;
padding: var(--n-dialog-content-padding);
}
/* 对话框操作按钮 */
.n-dialog .n-dialog__action,
.n-modal .n-card__footer {
border-top: var(--n-dialog-border);
padding: var(--n-dialog-action-padding);
background-color: var(--n-dialog-action-bg);
border-bottom-left-radius: var(--n-border-radius);
border-bottom-right-radius: var(--n-border-radius);
}
/* 对话框-END */

View File

@@ -0,0 +1,20 @@
import type { ThemeTemplate, PresetConfig } from '../../types'
import './style.css'
// 预设变量,用于继承预设主题
const presets: PresetConfig = {
Modal: {
preset: 'card',
},
}
// 暗色主题
const goldDark: ThemeTemplate = {
name: 'darkGold',
type: 'dark',
title: '暗金主题',
themeOverrides: {},
presetsOverrides: presets, // 预设变量
}
export { goldDark }

View File

@@ -0,0 +1,4 @@
/* Dark Theme */
:root[class='darkGold'] {
/* Empty light theme styles */
}

View File

@@ -0,0 +1,248 @@
import { computed, ref, effectScope, onScopeDispose, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { darkTheme, lightTheme, useThemeVars } from 'naive-ui'
import themes from './model'
import type { ThemeName, ThemeItemProps, ThemeTemplate } from './types'
// 驼峰命名转中划线命名的缓存
const camelToKebabCache = new Map<string, string>()
/**
* @description 驼峰命名转中划线命名
* @param {string} str 输入的驼峰字符串
* @returns {string} 转换后的中划线字符串
*/
const camelToKebabCase = (str: string): string => {
if (camelToKebabCache.has(str)) {
return camelToKebabCache.get(str)!
}
// 修改正则表达式,支持在字母与数字之间添加中划线
const result = str
.replace(/([a-z])([A-Z0-9])/g, '$1-$2')
.replace(/([0-9])([a-zA-Z])/g, '$1-$2')
.toLowerCase()
camelToKebabCache.set(str, result)
return result
}
/**
* @description 主题组合函数
* @param {ThemeName} name 初始主题名称
* @returns 主题状态和方法
*/
export const useTheme = (name?: ThemeName) => {
// 主题状态
const themeActive = useLocalStorage<ThemeName>('theme-active', name || 'defaultLight') // 主题名称
// 主题激活状态 Ref
const themeActiveOverrides = ref<ThemeTemplate | null>(null)
// 是否暗黑
// const isDark = useDark()
// 禁用自动切换暗色模式避免错误
// const isDark = ref(false)
// 是否暗黑 - 根据主题名称判断
const isDark = computed(() => {
// 根据主题名称判断是否为暗色主题
const currentTheme = themes[themeActive.value]
return currentTheme?.type === 'dark'
})
// 主题
const theme = computed(() => {
return isDark.value ? darkTheme : lightTheme
})
// 主题继承修改
const themeOverrides = computed(() => {
return themeActiveOverrides.value?.themeOverrides || {}
})
// 预设配置
const presetsOverrides = computed(() => {
// 如果没有激活的主题,则返回空对象
if (!themeActiveOverrides.value) return {}
return themeActiveOverrides.value || {}
})
/**
* @description 切换暗黑模式
* @param {boolean} hasAnimation 是否有动画
*/
const cutDarkMode = (hasAnimation: boolean = false, e?: MouseEvent) => {
// 检查当前主题是否存在暗黑模式
// isDark.value = !isDark.value
// 根据当前主题类型切换
const currentTheme = themes[themeActive.value]
const isCurrentlyDark = currentTheme?.type === 'dark'
if (hasAnimation) {
// 如果有动画,则执行切换暗黑模式动画
cutDarkModeAnimation(e ? { clientX: e.clientX, clientY: e.clientY } : undefined)
} else {
// 切换到相反的主题
themeActive.value = isCurrentlyDark ? 'defaultLight' : 'defaultDark'
}
// 更新主题名称
}
/**
* @description 切换暗色模式动画
*/
const cutDarkModeAnimation = (event?: { clientY: number; clientX: number }) => {
const root = document.documentElement
// 先移除现有动画类
root.classList.remove('animate-to-light', 'animate-to-dark')
// 根据当前主题类型判断
const currentTheme = themes[themeActive.value]
const isCurrentlyDark = currentTheme?.type === 'dark'
// 添加相应的动画类
root.classList.add(isCurrentlyDark ? 'animate-to-light' : 'animate-to-dark')
// 切换主题
themeActive.value = isCurrentlyDark ? 'defaultLight' : 'defaultDark'
setTimeout(() => {
// 先移除现有动画类
root.classList.remove('animate-to-light', 'animate-to-dark')
}, 500)
}
/**
* @description 动态加载CSS内容
* @param {string} cssContent CSS内容
* @param {string} id 样式标签ID
*/
const loadDynamicCss = (cssContent: string, id: string) => {
// 检查是否已存在相同ID的样式标签
let styleElement = document.getElementById(id) as HTMLStyleElement
if (!styleElement) {
// 如果不存在创建新的style标签
styleElement = document.createElement('style')
styleElement.id = id
document.head.appendChild(styleElement)
}
// 更新样式内容
styleElement.textContent = cssContent
}
/**
* @description 加载主题样式
* @param {string} themeName 主题名称
*/
const loadThemeStyles = async (themeName: string) => {
// 根据主题名称加载对应的样式文件
try {
// 从主题配置中获取样式路径
const themeItem = themes[themeName]
if (!themeItem) return
// 加载主题样式
const themeConfig = await themeItem.import()
const themeStyles = await themeItem.styleContent() // 获取主题样式内容
// 加载新样式
if (themeStyles || themeStyles) {
loadDynamicCss(themeStyles as string, 'theme-style')
}
// 更新激活的主题
themeActiveOverrides.value = themeConfig
} catch (error) {
console.error(`加载主题失败 ${themeName}:`, error)
}
}
/**
* @description 获取主题列表
* @returns {ThemeItemProps[]} 主题列表
*/
const getThemeList = () => {
const themeList: ThemeItemProps[] = []
for (const key in themes) {
themeList.push(themes[key])
}
return themeList
}
const scope = effectScope()
scope.run(() => {
watch(
themeActive,
(newVal, oldVal) => {
// 移除之前的主题类名
if (oldVal) {
document.documentElement.classList.remove(oldVal)
}
// 添加新的主题类名
document.documentElement.classList.add(newVal)
// 更新主题名称
// themeActive.value = newVal
// 加载主题样式
loadThemeStyles(newVal)
},
{ immediate: true },
)
onScopeDispose(() => {
scope.stop()
})
})
return {
// 状态
theme,
themeOverrides,
presetsOverrides,
isDark,
themeActive,
// 方法
getThemeList, // 获取主题列表
cutDarkModeAnimation, // 切换暗黑模式动画
cutDarkMode, // 切换暗黑模式
loadThemeStyles, // 加载主题样式
loadDynamicCss, // 动态加载CSS内容
}
}
/**
* @description 主题样式提取
* @param {string[]} options 主题变量
* @param options
*/
/**
* @description 主题样式提取
* @param {string[]} options 主题变量
* @returns {string} 生成的样式字符串
*/
export const useThemeCssVar = (options: string[]) => {
const vars = useThemeVars()
const stylesRef = ref('')
const scope = effectScope()
scope.run(() => {
watch(
vars,
(newVal) => {
// 使用数组收集样式,最后统一拼接
const styles: string[] = []
for (const key of options) {
if (key in newVal) {
const kebabKey = camelToKebabCase(key)
styles.push(`--n-${kebabKey}: ${newVal[key as keyof typeof vars.value]};`)
}
}
// 拼接样式字符串
stylesRef.value = styles.join('\n')
},
{ immediate: true },
)
onScopeDispose(() => {
scope.stop()
})
})
return stylesRef
}

View File

@@ -0,0 +1,60 @@
import type { ThemeTemplate, PresetConfig } from '../../types'
import './style.css'
// 预设变量,用于继承预设主题
const presets: PresetConfig = {
Modal: {
preset: 'card',
},
}
// 默认亮色主题
const defaultLight: ThemeTemplate = {
name: 'defaultLight', // 主题标识
type: 'light', // 主题类型,可选值为 light、dark用于继承预设主题
title: '默认亮色主题', // 主题名称
themeOverrides: {
common: {
// borderRadius: '0.6rem', // 圆角
// primaryColor: '#4caf50', // 主色
// primaryColorHover: '#20a53a', // 主色悬停
// primaryColorPressed: '#157f3a', // 主色按下
// primaryColorSuppl: '#4caf50', // 主色补充
},
}, // 主题变量
presetsOverrides: presets, // 预设变量
}
// 默认暗色主题
const defaultDark: ThemeTemplate = {
name: 'defaultDark',
type: 'dark',
title: '默认暗色主题',
themeOverrides: {
common: {
borderRadius: '0.6rem', // 圆角
// baseColor: '#F1F1F1', // 基础色
// primaryColor: '#4caf50', // 主色
// primaryColorHover: '#20a53a', // 主色悬停
// primaryColorPressed: '#157f3a', // 主色按下
// primaryColorSuppl: '#4caf50', // 主色补充
// borderRadius: '0.6rem', // 圆角
},
Popover: {
// color: '#ffffff', // 弹出层背景色
},
// Button: {
// textColorPrimary: '#ffffff', // 主按钮文本色
// textColorHoverPrimary: '#ffffff', // 主按钮文本色悬停
// textColorPressedPrimary: '#ffffff', // 主按钮文本色按下
// textColorFocusPrimary: '#ffffff', // 主按钮文本色聚焦
// },
// Radio: {
// buttonTextColorActive: '#ffffff', // 单选框文本色
// },
},
presetsOverrides: presets, // 预设变量
}
export { defaultLight, defaultDark }

View File

@@ -0,0 +1,53 @@
/* Light Theme */
:root[class='defaultLight'] {
/* Empty light theme styles */
--background-color: #121212;
--text-color: #f1f1f1;
--bt-popover-color: #ffffff;
}
/* Dark Theme */
:root[class='defaultDark'] {
/* Empty dark theme styles */
--bg-color: #121212;
--bt-popover-color: #48484e;
}
/* 创建动画 */
@keyframes fadeToLight {
from {
opacity: 0.8;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeToDark {
from {
opacity: 0.8;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 应用动画 */
:root {
--background-color: #ffffff;
--text-color: #333333;
}
:root.animate-to-light {
animation: fadeToLight 0.5s ease forwards;
overflow: hidden;
}
:root.animate-to-dark {
animation: fadeToDark 0.5s ease forwards;
overflow: hidden;
}

View File

@@ -0,0 +1,42 @@
// 导出主题表,需要自己定义
import type { ThemeJsonProps } from '../types'
const cssModules = import.meta.glob('../model/*/*.css', {
eager: false,
import: 'default',
// as: 'url', // 使用 url 加载器,将 CSS 文件作为独立的资源加载
})
const themes: ThemeJsonProps = {
defaultLight: {
name: 'defaultLight', // 主题标识
type: 'light', // 主题类型,可选值为 light、dark用于继承预设主题
title: '默认亮色主题', // 主题名称
import: async () => (await import('./default/index')).defaultLight, // 主题导入函数,用于动态导入主题文件
styleContent: async () => (await cssModules['./default/style.css']()) as string, // 主题样式导入函数,用于动态导入主题样式文件
},
defaultDark: {
name: 'defaultDark',
type: 'dark',
title: '默认暗色主题',
import: async () => (await import('./default/index')).defaultDark,
styleContent: async () => (await cssModules['./default/style.css']()) as string, // 主题样式导入函数,用于动态导入主题样式文件
},
// baotaLight: {
// name: 'baotaLight',
// type: 'light',
// title: '宝塔主题',
// import: async () => (await import('./baota/index')).baotaLight,
// styleContent: async () => (await cssModules['./baota/style.css']()) as string, // 主题样式导入函数,用于动态导入主题样式文件
// },
// darkGold: {
// name: 'darkGold',
// type: 'dark',
// title: '暗金主题',
// import: async () => (await import('./dark-gold/index')).goldDark,
// styleContent: async () => (await cssModules['./dark-gold/style.css']()) as string, // 主题样式导入函数,用于动态导入主题样式文件
// },
}
export default themes

View File

@@ -0,0 +1,52 @@
import type { ThemeTemplate, PresetConfig } from '../../types'
import './style.css'
// 预设变量,用于继承预设主题
const presets: PresetConfig = {
Modal: {
preset: 'card',
},
}
// 默认亮色主题
const blueLight: ThemeTemplate = {
name: 'blueLight', // 主题标识
type: 'light', // 主题类型,可选值为 light、dark用于继承预设主题
title: '蓝色主题', // 主题名称
themeOverrides: {
common: {},
}, // 主题变量
presetsOverrides: presets, // 预设变量
}
// 默认暗色主题
const blueDark: ThemeTemplate = {
name: 'blueDark',
type: 'dark',
title: '蓝色主题',
themeOverrides: {
common: {
// baseColor: '#F1F1F1', // 基础色
primaryColor: '#4caf50', // 主色
primaryColorHover: '#20a53a', // 主色悬停
primaryColorPressed: '#157f3a', // 主色按下
primaryColorSuppl: '#4caf50', // 主色补充
},
Popover: {
// color: '#ffffff', // 弹出层背景色
},
Button: {
textColorPrimary: '#ffffff', // 主按钮文本色
textColorHoverPrimary: '#ffffff', // 主按钮文本色悬停
textColorPressedPrimary: '#ffffff', // 主按钮文本色按下
textColorFocusPrimary: '#ffffff', // 主按钮文本色聚焦
},
Radio: {
buttonTextColorActive: '#ffffff', // 单选框文本色
},
},
presetsOverrides: presets, // 预设变量
}
export { blueLight, blueDark }

View File

@@ -0,0 +1,69 @@
/* Light Theme */
:root[class='defaultLight'] {
/* Empty light theme styles */
--background-color: #121212;
--text-color: #f1f1f1;
--bt-popover-color: #ffffff;
}
/* Dark Theme */
:root[class='defaultDark'] {
/* Empty dark theme styles */
--bg-color: #121212;
--bt-popover-color: #48484e;
}
/* 创建动画 */
@keyframes fadeToLight {
from {
opacity: 0.8;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeToDark {
from {
opacity: 0.8;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 应用动画 */
:root {
--background-color: #ffffff;
--text-color: #333333;
}
:root.animate-to-light {
animation: fadeToLight 0.5s ease forwards;
overflow: hidden;
}
:root.animate-to-dark {
animation: fadeToDark 0.5s ease forwards;
overflow: hidden;
}
.text-info {
color: #666;
}
.text-success {
color: #4caf50;
}
.text-warning {
color: #ff9800;
}
.text-error {
color: #f44336;
}

View File

@@ -0,0 +1,36 @@
import type { GlobalThemeOverrides, ModalProps, FormProps, FormItemProps, TableProps } from 'naive-ui'
export interface ThemeTemplate {
name: string
type: 'light' | 'dark'
title: string
themeOverrides: GlobalThemeOverrides
presetsOverrides: PresetConfig
}
export interface ThemeTemplateType {
[key: string]: ThemeTemplate
}
// 主题名称
export type ThemeName = string
// 预设配置
export interface PresetConfig {
Modal?: ModalProps
Form?: FormProps
FormItem?: FormItemProps
Table?: TableProps
}
export interface ThemeItemProps {
name: string // 主题标识
type: 'light' | 'dark' // 主题类型,可选值为 light、dark用于继承预设主题
title: string // 主题名称
import: () => Promise<ThemeTemplate> // 主题导入函数,用于动态导入主题文件
styleContent: () => Promise<string> // 主题样式内容,用于动态生成主题样式
}
export interface ThemeJsonProps {
[key: string]: ThemeItemProps // 主题表key为主题标识value为主题配置
}

View File

@@ -0,0 +1,17 @@
import type { DialogOptions } from 'naive-ui'
// 自定义Dialog配置类型
export interface CustomDialogOptions extends Omit<DialogOptions, 'type'> {
type?: 'info' | 'success' | 'warning' | 'error' // 类型
area?: string | [string, string]
title?: string | (() => VNodeChild) // 标题
content?: string | (() => VNodeChild) // 内容
confirmText?: string // 确认按钮文本
cancelText?: string // 取消按钮文本
confirmButtonProps?: ButtonProps // 确认按钮props
cancelButtonProps?: ButtonProps // 取消按钮props
onConfirm?: () => Promise<boolean | void> | void // 确认回调
onCancel?: () => Promise<boolean | void> | void // 取消回调
onClose?: () => void // 关闭回调
onMaskClick?: () => void // 遮罩点击回调
}

View File

@@ -0,0 +1,56 @@
declare module 'react-dnd-html5-backend' {
const HTML5Backend: any
export { HTML5Backend }
}
declare module 'vue3-dnd' {
import { DefineComponent } from 'vue'
export interface DragSourceMonitor<T = any> {
canDrag(): boolean
isDragging(): boolean
getItem(): T
getItemType(): string
getDropResult(): any
didDrop(): boolean
}
export interface DropTargetMonitor<T = any> {
canDrop(): boolean
isOver(options?: { shallow?: boolean }): boolean
getItem(): T
getItemType(): string
getDropResult(): any
didDrop(): boolean
}
export interface DndProviderProps {
backend: any
}
export interface CollectedProps {
[key: string]: any
}
export function useDrag<Item = unknown, DropResult = unknown, Collected extends CollectedProps = CollectedProps>(
spec: () => {
type: string
item: Item
collect?: (monitor: DragSourceMonitor<Item>) => Collected
},
): [{ value: Collected }, (el: any) => void]
export function useDrop<Item = unknown, DropResult = unknown, Collected extends CollectedProps = CollectedProps>(
spec: () => {
accept: string | string[]
drop?: (item: Item, monitor: DropTargetMonitor<Item>) => DropResult | undefined
collect?: (monitor: DropTargetMonitor<Item>) => Collected
},
): [{ value: Collected }, (el: any) => void]
export const DndProvider: DefineComponent<{
backend: any
}>
export const HTML5Backend: any
}

View File

@@ -0,0 +1,200 @@
import type {
FormRules,
FormProps,
FormItemProps,
GridProps,
InputProps,
InputNumberProps,
SelectProps,
RadioGroupProps,
RadioProps,
RadioButtonProps,
CheckboxGroupProps,
SwitchProps,
DatePickerProps,
TimePickerProps,
ColorPickerProps,
SliderProps,
RateProps,
TransferProps,
MentionProps,
DynamicInputProps,
AutoCompleteProps,
CascaderProps,
TreeSelectProps,
UploadProps,
InputGroupProps,
FormInst,
FormProps,
GridItemProps,
} from "naive-ui";
import type { Ref, ShallowRef, ComputedRef, ToRefs } from "vue";
/** 选项接口 */
export interface RadioOptionItem extends Partial<RadioProps> {
label: string;
value: string | number;
}
/** 复选框选项接口 */
export interface CheckboxOptionItem extends Partial<CheckboxProps> {
label: string;
value: string | number;
}
/** 表单元素类型定义 */
export type FormElementType =
| "input" // 输入框
| "inputNumber" // 数字输入框
| "inputGroup" // 输入框组
| "select" // 选择器
| "radio" // 单选框
| "radioButton" // 单选按钮
| "checkbox" // 复选框
| "switch" // 开关
| "datepicker" // 日期选择器
| "timepicker" // 时间选择器
| "colorPicker" // 颜色选择器
| "slider" // 滑块
| "rate" // 评分
| "transfer" // 穿梭框
| "mention" // 提及
| "dynamicInput" // 动态输入
| "dynamicTags" // 动态标签
| "autoComplete" // 自动完成
| "cascader" // 级联选择
| "treeSelect" // 树选择
| "upload" // 上传
| "uploadDragger" // 拖拽上传
| "formItem" // 表单项
| "formItemGi" // 表单项 - Grid
| "slot" // 插槽
| "render"; // 自定义渲染
/** Props 类型映射 */
type FormElementPropsMap = {
input: InputProps;
inputNumber: InputNumberProps;
inputGroup: InputGroupProps;
select: SelectProps;
radio: RadioProps & { options: RadioOptionItem[] };
radioButton: RadioButtonProps & { options: RadioOptionItem[] };
checkbox: CheckboxGroupProps & { options: CheckboxOptionItem[] };
switch: SwitchProps;
datepicker: DatePickerProps;
timepicker: TimePickerProps;
colorPicker: ColorPickerProps;
slider: SliderProps;
rate: RateProps;
transfer: TransferProps;
mention: MentionProps;
dynamicInput: DynamicInputProps;
dynamicTags: InputProps;
autoComplete: AutoCompleteProps;
cascader: CascaderProps;
treeSelect: TreeSelectProps;
upload: UploadProps;
uploadDragger: UploadProps;
formItem: FormItemProps;
formItemGi: FormItemProps & GridProps;
};
/** 基础表单元素接口 */
export type BaseFormElement<T extends FormElementType = FormElementType> = {
/** 元素类型 */
type: T;
/** 字段名称 */
field: string;
} & (T extends keyof FormElementPropsMap
? FormElementPropsMap[T]
: Record<string, any>);
/** 插槽表单元素接口 */
export interface SlotFormElement {
type: "slot";
/** 插槽名称 */
slot: string;
}
/** 自定义渲染表单元素接口 */
export interface RenderFormElement {
type: "custom";
/** 自定义渲染函数 */
render: (formData: any, formRef: any) => any;
}
/** 表单元素联合类型 */
export type FormElement = BaseFormElement | SlotFormElement | RenderFormElement;
/** 栅格项配置接口 */
export interface GridItemConfig extends Partial<GridProps> {
type: "grid";
/** 栅格子元素 */
children: FormItemGiConfig[];
}
/** 表单项 - Grid 配置接口 */
export interface FormItemGiConfig
extends Partial<FormItemProps & GridItemProps> {
type: "formItemGi";
/** 栅格子元素 */
children: FormElement[];
}
/** 表单项配置接口 */
export interface FormItemConfig extends Partial<FormItemProps> {
type: "formItem";
/** 子元素配置 */
children: FormElement[];
}
/** 表单项 - 自定义渲染配置接口 */
// export interface FormItemCustomConfig extends Partial<FormItemProps> {
// type: 'custom'
// /** 自定义渲染函数 */
// render: (formData: Ref<any>, formRef: Ref<FormInst | null>) => any
// }
/** 表单配置类型 */
export type FormBaseConfig = (
| FormItemConfi
| GridItemConfig
| FormItemCustomConfig
)[]; /** 表单配置类型-动态表单 */
export type FormConfig =
| Ref<FormBaseConfig>
| ComputedRef<FormBaseConfig>
| FormBaseConfig;
/** 表单 Hook 配置项接口 */
export interface UseFormOptions<T, Z = any> {
/** 表单配置 */
config: FormConfig;
/** 表单提交请求函数 */
request?: (data: T, formRef: Ref<FormInst>) => Promise<Z>;
/** 默认表单数据 */
defaultValue?: T | Ref<T>;
/** 表单验证规则 */
rules?: FormRules;
}
/**
* 扩展表单实例接口
* 在基础表单实例的基础上添加表单渲染组件方法
*/
export interface FormInstanceWithComponent<T> {
component: (
attrs: FormProps & ComponentProps,
context: unknown
) => JSX.Element; // 表单渲染组件
example: Ref<FormInst | null>; // 表单实例引用
data: Ref<T>; // 表单数据引用
loading: Ref<boolean>; // 加载状态
config: Ref<FormConfig>; // 表单配置引用
props: Ref<FormProps>; // 表单属性引用
rules: ShallowRef<FormRules>; // 表单验证规则引用
dataToRef: () => ToRefs<T>; // 响应式数据转ref
validate: () => Promise<boolean>; // 验证方法
fetch: () => Promise<T>; // 提交方法
reset: () => void; // 重置方法
}

View File

@@ -0,0 +1,47 @@
/**
* 加载遮罩层配置选项接口
*/
export interface LoadingMaskOptions {
/** 加载提示文本,显示在加载图标下方 */
text?: string
/** 加载描述文本,用于详细说明 */
description?: string
/** 加载图标颜色 */
color?: string
/** 加载图标大小 */
size?: 'small' | 'medium' | 'large'
/** 加载图标线条粗细 */
stroke?: string
/** 是否显示加载遮罩 */
show?: boolean
/** 是否全屏显示默认为true */
fullscreen?: boolean
/** 遮罩背景色支持rgba格式设置透明度 */
background?: string
/** 自定义样式对象 */
customStyle?: Record<string, unknown>
/** 自定义类名 */
customClass?: string
/** NSpin组件的props配置 */
spinProps?: SpinProps
/** 目标元素默认为body。可以是CSS选择器或HTML元素 */
target?: string | HTMLElement
/** 遮罩层的z-index值 */
zIndex?: number
/** 关闭回调函数 */
onClose?: () => void
}
/**
* 加载遮罩层实例接口
*/
export interface LoadingMaskInstance {
/** 打开加载遮罩 */
open: (options?: LoadingMaskOptions) => void
/** 关闭加载遮罩 */
close: () => void
/** 更新加载遮罩配置 */
update: (options: LoadingMaskOptions) => void
/** 销毁加载遮罩实例 */
destroy: () => void
}

View File

@@ -0,0 +1,12 @@
import type { MessageOptions, MessageReactive } from './message'
// 消息类型
export interface MessageApiExtended {
success: (content: string, options?: MessageOptions) => MessageReactive | undefined
warning: (content: string, options?: MessageOptions) => MessageReactive | undefined
error: (content: string, options?: MessageOptions) => MessageReactive | undefined
info: (content: string, options?: MessageOptions) => MessageReactive | undefined
loading: (content: string, options?: MessageOptions) => MessageReactive | undefined
request: (data: { status: boolean; message: string }, options?: MessageOptions) => MessageReactive | undefined
destroyAll: () => void
}

View File

@@ -0,0 +1,113 @@
import type { DataTableColumns, DataTableInst, DataTableProps } from 'naive-ui'
import type { Ref } from 'vue'
/** 列可见性配置接口 */
export interface ColumnVisibility {
/** 列的显示状态key为列的keyvalue为是否显示 */
[columnKey: string]: boolean
}
/** 列设置状态接口 */
export interface ColumnSettingsState {
/** 列可见性配置 */
visibility: ColumnVisibility
/** 表格唯一标识 */
tableId: string
}
/** 列设置组件属性接口 */
export interface ColumnSettingsProps {
/** 表格列配置 */
columns: DataTableColumns
/** 列可见性状态 */
visibility: ColumnVisibility
/** 可见性变更回调 */
onVisibilityChange: (visibility: ColumnVisibility) => void
}
/** 表格请求参数接口 */
export interface TableRequestParams {
/** 其他可能的查询参数 */
[key: string]: any
}
/** 表格响应数据接口 */
export interface TableResponse<T = Record<string, unknown>> {
/** 数据列表 */
list: T[]
/** 其他可能的响应数据 */
total: number
}
/** 表格 Hook 配置项接口 */
export interface UseTableOptions<T = Record<string, any>, Z extends Record<string, any>>
extends Partial<DataTableProps> {
/** 表格列配置 */
config: DataTableColumns<T>
/** 数据请求函数 */
request: <T>(params: Z) => Promise<TableResponse<T>>
/** 默认请求参数 */
defaultValue?: Ref<Z> | Z
/** 监听参数 */
watchValue?: string[] | boolean
/** 本地存储 */
storage?: string
/** 分页字段别名映射 */
alias?: { page: string; pageSize: string }
}
/**
* 表格实例接口
* 在基础表格实例的基础上添加表格渲染组件方法
*/
export interface TableInstanceWithComponent<T = Record<string, unknown>, Z = Record<string, unknown>> {
/** 表格渲染组件用于渲染整个表格的Vue组件 */
TableComponent: (props: Record<string, unknown>, context: Record<string, unknown>) => JSX.Element
/** 分页渲染组件用于渲染分页组件的Vue组件 */
PageComponent: (props: Record<string, unknown>, context: Record<string, unknown>) => JSX.Element
/** 列设置渲染组件用于渲染列设置下拉组件的Vue组件 */
ColumnSettingsComponent: () => JSX.Element
loading: Ref<boolean> // 加载状态
tableAlias: Ref<{ total: string; list: string }> // 表格别名
data: Ref<{ list: T[]; total: number }> // 表格数据引用
total: Ref<number> // 总条数
param: Ref<Z> // 表格请求参数引用
config: Ref<DataTableColumns<T>> // 表格列配置引
props: Ref<DataTableProps> // 表格属性引用
reset: () => Promise<void> // 重置方法
fetch: <T>(resetPage?: boolean) => Promise<T> // 触发方法
example: Ref<DataTableInst> // 表格实例引用
handlePageChange: (currentPage: number) => void // 分页改变
handlePageSizeChange: (size: number) => void // 分页大小改变
pageSizeOptions: Ref<number[]> // 分页大小选项
/** 列可见性状态 */
columnVisibility: Ref<ColumnVisibility>
/** 切换列可见性 */
toggleColumnVisibility: (columnKey: string) => void
/** 重置列设置 */
resetColumnSettings: () => void
}
/**
* 表格分页实例接口
* 在基础表格实例的基础上添加表格渲染组件方法
*/
export interface TablePageInstanceWithComponent {
component: (props: Record<string, unknown>, context: Record<string, unknown>) => JSX.Element
handlePageChange: (currentPage: number) => void
handlePageSizeChange: (size: number) => void
pageSizeOptions: Ref<number[]>
}
interface TablePageProps<T extends Record<string, any> = Record<string, any>> {
/** 当前页码 */
param: Ref<T>
/** 总条数 */
total: Ref<number>
/** 字段别名映射 */
alias?: { page?: string; pageSize?: string }
/** 分页组件属性 */
props?: PaginationProps
/** 分页组件插槽 */
slot?: PaginationSlots
}

View File

@@ -0,0 +1,58 @@
import { defineComponent, ref, computed } from 'vue'
import { NSpace, NTabs, NTabPane, NBackTop, NButton } from 'naive-ui'
import TableDemo from './tabs/TableDemo'
import FormDemo from './tabs/FormDemo'
import ColumnSettingsDemo from './tabs/ColumnSettingsDemo'
import { useModal } from '../hooks/useModal'
// import FormBuilder from '../components/FormBuilder'
export default defineComponent({
name: 'Demo',
setup() {
const tabName = ref('table')
const tabTitle = computed(() => {
if (tabName.value === 'table') {
return '动态表格'
} else if (tabName.value === 'form') {
return '动态表单'
} else if (tabName.value === 'column-settings') {
return '列设置功能'
} else if (tabName.value === 'builder') {
return '表单构建器'
}
})
const handleClick = () => {
useModal().imperative.open({
title: '测试标题',
content: '测试内容',
yes: () => {
console.log('确认')
},
})
}
return () => (
<NSpace vertical size="large">
<div class="p-0">
<NButton onClick={handleClick}></NButton>
<h1 class="text-[32px] font-bold mb-[24px]">{tabTitle.value}</h1>
<NTabs type="line" class=" rounded-lg " modelValue={tabName.value}>
<NTabPane name="table" tab="动态表格">
<TableDemo />
</NTabPane>
<NTabPane name="form" tab="动态表单">
<FormDemo />
</NTabPane>
<NTabPane name="column-settings" tab="列设置功能">
<ColumnSettingsDemo />
</NTabPane>
<NTabPane name="builder" tab="表单构建器">
{/* <FormBuilder /> */}
</NTabPane>
</NTabs>
</div>
</NSpace>
)
},
})

View File

@@ -0,0 +1,232 @@
import { defineComponent, ref } from 'vue'
import { NCard } from 'naive-ui'
import useForm from '@hooks/useForm'
import type { FormConfig, CheckboxOptionItem, RadioOptionItem, UseFormOptions } from '../../types/form'
const mockFormRequest = async (data: any) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('Form submitted:', data)
return { success: true }
}
export default defineComponent({
name: 'FormDemo',
setup() {
const dynamFormSelectList = ref([
{ label: '男', value: 'male' },
{ label: '女', value: 'female' },
{ label: '其他', value: 'other' },
])
const educationOptions = ref<RadioOptionItem[]>([
{ label: '高中', value: 'highschool' },
{ label: '大专', value: 'college' },
{ label: '本科', value: 'bachelor' },
{ label: '硕士', value: 'master' },
{ label: '博士', value: 'phd' },
])
const hobbyOptions = ref<CheckboxOptionItem[]>([
{ label: '阅读', value: 'reading' },
{ label: '运动', value: 'sports' },
{ label: '音乐', value: 'music' },
{ label: '旅行', value: 'travel' },
{ label: '摄影', value: 'photography' },
])
const departmentOptions = ref([
{ label: '技术部', value: 'tech' },
{ label: '产品部', value: 'product' },
{ label: '设计部', value: 'design' },
{ label: '运营部', value: 'operation' },
{ label: '市场部', value: 'marketing' },
])
const formConfig: FormConfig = [
{
type: 'grid',
cols: 24,
xGap: 24,
children: [
{
type: 'formItemGi',
label: '姓名',
span: 12,
required: true,
children: [
{
type: 'input',
field: 'name',
placeholder: '请输入姓名',
},
],
},
{
type: 'formItemGi',
label: '性别',
span: 12,
required: true,
children: [
{
type: 'select',
field: 'gender',
placeholder: '请选择性别',
options: dynamFormSelectList.value,
},
],
},
],
},
{
type: 'grid',
cols: 24,
xGap: 24,
children: [
{
type: 'formItemGi',
label: '出生日期',
span: 12,
required: true,
children: [
{
type: 'datepicker',
field: 'birthDate',
placeholder: '请选择出生日期',
},
],
},
{
type: 'formItemGi',
label: '部门',
span: 12,
required: true,
children: [
{
type: 'select',
field: 'department',
placeholder: '请选择部门',
options: departmentOptions.value,
},
],
},
],
},
{
type: 'grid',
cols: 24,
xGap: 24,
children: [
{
type: 'formItemGi',
label: '手机号码',
span: 12,
required: true,
children: [
{
type: 'input',
field: 'phone',
placeholder: '请输入手机号码',
},
],
},
{
type: 'formItemGi',
label: '邮箱',
span: 12,
required: true,
children: [
{
type: 'input',
field: 'email',
placeholder: '请输入邮箱地址',
},
],
},
],
},
{
type: 'formItem',
label: '教育程度',
required: true,
children: [
{
type: 'radio',
field: 'education',
options: educationOptions.value,
},
],
},
{
type: 'formItem',
label: '兴趣爱好',
children: [
{
type: 'checkbox',
field: 'hobbies',
options: hobbyOptions.value,
},
],
},
{
type: 'formItem',
label: '个人简介',
children: [
{
type: 'input',
field: 'introduction',
placeholder: '请输入个人简介',
inputProps: { type: 'textarea' },
rows: 3,
},
],
},
{
type: 'grid',
cols: 24,
xGap: 24,
children: [
{
type: 'formItemGi',
label: '薪资期望',
span: 12,
children: [
{
type: 'inputNumber',
field: 'expectedSalary',
placeholder: '请输入期望薪资',
},
],
},
{
type: 'formItemGi',
label: '工作年限',
span: 12,
children: [
{
type: 'inputNumber',
field: 'workYears',
placeholder: '请输入工作年限',
},
],
},
],
},
]
const { FormComponent, config, formData } = useForm<{ name: number }>({
config: formConfig,
requestFn: mockFormRequest,
defaultValues: {
name: 1,
},
})
return () => (
<NCard title="复杂表单示例" class="mt-[16px]">
<div class="p-[16px]">
<FormComponent />
</div>
</NCard>
)
},
})

View File

@@ -0,0 +1,87 @@
import { defineComponent } from 'vue'
import { NButton, NCard, NSwitch } from 'naive-ui'
import useTable from '@hooks/useTable'
// 定义表格数据接口
interface TableData {
id: number
cpu: number
memory: number
disk: number
netIn: string
netOut: string
status: boolean
updateTime: string
}
// 生成随机数据的辅助函数
const generateRandomData = (count: number): TableData[] => {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
cpu: Math.floor(Math.random() * 100),
memory: Math.floor(Math.random() * 100),
disk: Math.floor(Math.random() * 100),
netIn: `${(Math.random() * 100).toFixed(2)} MB/s`,
netOut: `${(Math.random() * 100).toFixed(2)} MB/s`,
status: Math.random() > 0.5,
updateTime: new Date().toLocaleString(),
}))
}
// 模拟API请求
const mockTableRequest = async (params: any) => {
const { page = 1, pageSize = 10 } = params
await new Promise((resolve) => setTimeout(resolve, 1000))
const total = 100
const list = generateRandomData(pageSize)
return {
list,
total,
}
}
export default defineComponent({
name: 'TableDemo',
setup() {
const { TableComponent } = useTable<TableData>({
columns: [
{ title: 'ID', key: 'id', width: 80 },
{ title: 'CPU使用率', key: 'cpu', width: 120 },
{ title: '内存使用率', key: 'memory', width: 120 },
{ title: '磁盘使用率', key: 'disk', width: 120 },
{ title: '网络流入', key: 'netIn', width: 120 },
{ title: '网络流出', key: 'netOut', width: 120 },
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
return <NSwitch size="small" value={row.status} />
},
},
{ title: '更新时间', key: 'updateTime', width: 160 },
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
align: 'right',
render: (row) => {
return (
<NButton type="text" size="small">
</NButton>
)
},
},
],
requestFn: mockTableRequest,
})
return () => (
<NCard title="表格示例" class="mt-[16px]">
<TableComponent />
</NCard>
)
},
})