mirror of
https://gitee.com/mirrors/AllinSSL.git
synced 2026-03-23 14:14:32 +08:00
【调整】增加部署雨云
This commit is contained in:
14
frontend/packages/vue/naive-ui/src/App.vue
Normal file
14
frontend/packages/vue/naive-ui/src/App.vue
Normal 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>
|
||||
711
frontend/packages/vue/naive-ui/src/components/circleProgress.tsx
Normal file
711
frontend/packages/vue/naive-ui/src/components/circleProgress.tsx
Normal 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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
20
frontend/packages/vue/naive-ui/src/components/themeMode.tsx
Normal file
20
frontend/packages/vue/naive-ui/src/components/themeMode.tsx
Normal 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
|
||||
69
frontend/packages/vue/naive-ui/src/components/themeTips.tsx
Normal file
69
frontend/packages/vue/naive-ui/src/components/themeTips.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
124
frontend/packages/vue/naive-ui/src/examples/MessageExample.tsx
Normal file
124
frontend/packages/vue/naive-ui/src/examples/MessageExample.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
44
frontend/packages/vue/naive-ui/src/hooks/index.tsx
Normal file
44
frontend/packages/vue/naive-ui/src/hooks/index.tsx
Normal 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,
|
||||
}
|
||||
147
frontend/packages/vue/naive-ui/src/hooks/useBatch.tsx
Normal file
147
frontend/packages/vue/naive-ui/src/hooks/useBatch.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
164
frontend/packages/vue/naive-ui/src/hooks/useDialog.tsx
Normal file
164
frontend/packages/vue/naive-ui/src/hooks/useDialog.tsx
Normal 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)
|
||||
}
|
||||
846
frontend/packages/vue/naive-ui/src/hooks/useForm.tsx
Normal file
846
frontend/packages/vue/naive-ui/src/hooks/useForm.tsx
Normal 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,
|
||||
})
|
||||
203
frontend/packages/vue/naive-ui/src/hooks/useFullScreen.tsx
Normal file
203
frontend/packages/vue/naive-ui/src/hooks/useFullScreen.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
222
frontend/packages/vue/naive-ui/src/hooks/useLoadingMask.tsx
Normal file
222
frontend/packages/vue/naive-ui/src/hooks/useLoadingMask.tsx
Normal 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
|
||||
83
frontend/packages/vue/naive-ui/src/hooks/useMessage.tsx
Normal file
83
frontend/packages/vue/naive-ui/src/hooks/useMessage.tsx
Normal 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
|
||||
517
frontend/packages/vue/naive-ui/src/hooks/useModal.tsx
Normal file
517
frontend/packages/vue/naive-ui/src/hooks/useModal.tsx
Normal 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;
|
||||
206
frontend/packages/vue/naive-ui/src/hooks/useSearch.tsx
Normal file
206
frontend/packages/vue/naive-ui/src/hooks/useSearch.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
568
frontend/packages/vue/naive-ui/src/hooks/useTable.tsx
Normal file
568
frontend/packages/vue/naive-ui/src/hooks/useTable.tsx
Normal 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 }
|
||||
86
frontend/packages/vue/naive-ui/src/hooks/useTabs.tsx
Normal file
86
frontend/packages/vue/naive-ui/src/hooks/useTabs.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
153
frontend/packages/vue/naive-ui/src/i18n/index.tsx
Normal file
153
frontend/packages/vue/naive-ui/src/i18n/index.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
4
frontend/packages/vue/naive-ui/src/i18n/tools.tsx
Normal file
4
frontend/packages/vue/naive-ui/src/i18n/tools.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
export default [t('t_0_1743100809201')]
|
||||
385
frontend/packages/vue/naive-ui/src/locals/translation.ts
Normal file
385
frontend/packages/vue/naive-ui/src/locals/translation.ts
Normal 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]
|
||||
10
frontend/packages/vue/naive-ui/src/main.ts
Normal file
10
frontend/packages/vue/naive-ui/src/main.ts
Normal 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')
|
||||
34
frontend/packages/vue/naive-ui/src/styles/index.css
Normal file
34
frontend/packages/vue/naive-ui/src/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
71
frontend/packages/vue/naive-ui/src/theme/bak/baota/index.tsx
Normal file
71
frontend/packages/vue/naive-ui/src/theme/bak/baota/index.tsx
Normal 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 }
|
||||
51
frontend/packages/vue/naive-ui/src/theme/bak/baota/style.css
Normal file
51
frontend/packages/vue/naive-ui/src/theme/bak/baota/style.css
Normal 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 */
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Dark Theme */
|
||||
:root[class='darkGold'] {
|
||||
/* Empty light theme styles */
|
||||
}
|
||||
248
frontend/packages/vue/naive-ui/src/theme/index.tsx
Normal file
248
frontend/packages/vue/naive-ui/src/theme/index.tsx
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
42
frontend/packages/vue/naive-ui/src/theme/model/index.tsx
Normal file
42
frontend/packages/vue/naive-ui/src/theme/model/index.tsx
Normal 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
|
||||
52
frontend/packages/vue/naive-ui/src/theme/model/ssl/index.tsx
Normal file
52
frontend/packages/vue/naive-ui/src/theme/model/ssl/index.tsx
Normal 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 }
|
||||
69
frontend/packages/vue/naive-ui/src/theme/model/ssl/style.css
Normal file
69
frontend/packages/vue/naive-ui/src/theme/model/ssl/style.css
Normal 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;
|
||||
}
|
||||
36
frontend/packages/vue/naive-ui/src/theme/types.d.ts
vendored
Normal file
36
frontend/packages/vue/naive-ui/src/theme/types.d.ts
vendored
Normal 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为主题配置
|
||||
}
|
||||
17
frontend/packages/vue/naive-ui/src/types/dialog.d.ts
vendored
Normal file
17
frontend/packages/vue/naive-ui/src/types/dialog.d.ts
vendored
Normal 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 // 遮罩点击回调
|
||||
}
|
||||
56
frontend/packages/vue/naive-ui/src/types/dnd.d.ts
vendored
Normal file
56
frontend/packages/vue/naive-ui/src/types/dnd.d.ts
vendored
Normal 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
|
||||
}
|
||||
200
frontend/packages/vue/naive-ui/src/types/form.d.ts
vendored
Normal file
200
frontend/packages/vue/naive-ui/src/types/form.d.ts
vendored
Normal 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; // 重置方法
|
||||
}
|
||||
47
frontend/packages/vue/naive-ui/src/types/loadingMask.d.ts
vendored
Normal file
47
frontend/packages/vue/naive-ui/src/types/loadingMask.d.ts
vendored
Normal 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
|
||||
}
|
||||
12
frontend/packages/vue/naive-ui/src/types/message.d.ts
vendored
Normal file
12
frontend/packages/vue/naive-ui/src/types/message.d.ts
vendored
Normal 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
|
||||
}
|
||||
113
frontend/packages/vue/naive-ui/src/types/table.d.ts
vendored
Normal file
113
frontend/packages/vue/naive-ui/src/types/table.d.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { DataTableColumns, DataTableInst, DataTableProps } from 'naive-ui'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/** 列可见性配置接口 */
|
||||
export interface ColumnVisibility {
|
||||
/** 列的显示状态,key为列的key,value为是否显示 */
|
||||
[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
|
||||
}
|
||||
58
frontend/packages/vue/naive-ui/src/views/Demo.tsx
Normal file
58
frontend/packages/vue/naive-ui/src/views/Demo.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
232
frontend/packages/vue/naive-ui/src/views/tabs/FormDemo.tsx
Normal file
232
frontend/packages/vue/naive-ui/src/views/tabs/FormDemo.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
87
frontend/packages/vue/naive-ui/src/views/tabs/TableDemo.tsx
Normal file
87
frontend/packages/vue/naive-ui/src/views/tabs/TableDemo.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user