@neatui/vue 4.8.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION-5.0.md +66 -0
- package/README.md +3 -0
- package/package.json +16 -8
- package/src/components/basic/IFollowView.vue +1 -1
- package/src/components/basic/LayerView/Layer.vue +0 -2
- package/src/components/display/Avatar.vue +124 -1
- package/src/components/display/Tree.vue +10 -4
- package/src/components/display/TreeView.vue +12 -5
- package/src/components/display/index.ts +2 -1
- package/src/components/form/Cascader.vue +1 -1
- package/src/components/form/DateRangeView.vue +6 -6
- package/src/components/form/Gallery.vue +50 -0
- package/src/components/form/Input.vue +5 -2
- package/src/components/form/PageSelect.vue +3 -2
- package/src/components/form/Radio.vue +27 -0
- package/src/components/form/RichTextInput.vue +99 -0
- package/src/components/form/SearchMoreSelect.vue +3 -2
- package/src/components/form/Select.vue +4 -4
- package/src/components/form/SelectPicker.vue +3 -2
- package/src/components/form/Textarea.vue +169 -57
- package/src/components/form/TimePicker.vue +43 -7
- package/src/components/form/Upload.vue +1 -1
- package/src/components/form/index.ts +7 -0
- package/src/components/loader/FormLoader/FormLoader.vue +908 -349
- package/src/components/loader/FormLoader/FormRender.vue +409 -0
- package/src/components/loader/TableLoader/TableLoader.vue +338 -32
- package/src/components/loader/TableLoader/virtual.ts +27 -0
- package/src/components/loader/index.ts +2 -0
- package/src/index.ts +2 -0
- package/.prettierrc +0 -12
- package/rollup.config.js +0 -75
- package/tsconfig.json +0 -23
package/MIGRATION-5.0.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# NeatUI Vue 5.0 迁移说明
|
|
2
|
+
|
|
3
|
+
5.0 重整 FormLoader 的配置组合方式。基础节点使用 `label / field / value / child / attrs / clear / enums / array / event`,控件和校验由 `model / rules` 表达;保留 `media / vertical / paths`。组标题、按钮、折叠和装饰均可由这些节点组合。核心实现为 `FormLoader.vue`、`FormRender.vue` 两个文件。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @neatui/vue@^5.0.0 @neatui/css@^5.0.0
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Vue 包依赖 CSS 5.0 新增的 `@neatui/css/query` Sass 入口。移除业务项目中指向旧版 CSS 的 `resolutions` / `overrides`,两个包应一起升级。包仍以 Vue SFC 和 TypeScript 源码交付,应用需要 Vue 编译插件和 Sass;没有改为预编译 JS 入口。
|
|
12
|
+
|
|
13
|
+
Vue 最低版本声明统一为 3.5.11,与已有 Pinia 3 的要求一致;同时需要 Pinia 3 和 Vue Router 4。
|
|
14
|
+
|
|
15
|
+
## 从已发布的 4.8.2 升级
|
|
16
|
+
|
|
17
|
+
| 旧配置 | 5.0 写法 |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| 字段的 `slot: 'custom'` | `model: 'Custom'` 配合 `#Custom`,或使用同名字段插槽 |
|
|
20
|
+
| 字段级 `colon` | 用 `label` 节点树精确输出文字;表单级 `colon` 继续支持 |
|
|
21
|
+
| 只读取 `col` 的自定义插槽 | `col` 继续提供;通过 `bind / update` 接入双向绑定、转换和只读状态 |
|
|
22
|
+
|
|
23
|
+
例如将 `{ field: 'name', slot: 'custom' }` 改为 `{ field: 'name', model: 'Custom' }`:
|
|
24
|
+
|
|
25
|
+
```vue
|
|
26
|
+
<FormLoader :form="form" :data="data">
|
|
27
|
+
<template #Custom="{ bind }">
|
|
28
|
+
<Input v-bind="bind" />
|
|
29
|
+
</template>
|
|
30
|
+
</FormLoader>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
旧联动签名 `(data, item, route, router)` 继续可用。第三参数的 `path` 始终是网址路径,`formPath` 是表单数据路径;它还提供 `root / data / index / array / route / router`。事件、值转换和插槽里的 `ctx.path` 仍是表单数据路径。
|
|
34
|
+
|
|
35
|
+
`model[1].rules` 继续覆盖节点 `rules`,空数组表示关闭该节点校验。`verify: true` 显示统一校验图标;不会再同时触发控件和 FormLoader 两套校验。
|
|
36
|
+
|
|
37
|
+
## 从桌面旧组件或中间扩展配置迁移
|
|
38
|
+
|
|
39
|
+
以下是能力迁移,并非所有旧配置都能原样复制:
|
|
40
|
+
|
|
41
|
+
| 旧写法 | 新写法 |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `required` | `rules: [{ required: true, msg: '必填' }]` |
|
|
44
|
+
| `default / transform` | `value: { default, get, set }` |
|
|
45
|
+
| `value(data, value, node)` 读写转换 | `value.get(value, ctx)` 与 `value.set(value, ctx)` 分别配置 |
|
|
46
|
+
| `on` | `event`;回调接收 `(ctx, ...事件参数)` |
|
|
47
|
+
| `labelClass / labelWidth` | `label: ['标题', { class, style: { width } }]` |
|
|
48
|
+
| `transverse` | `label` 属性中的 `style.width: 'auto'` |
|
|
49
|
+
| `before / prefix / suffix / extra / childAttrs` | 使用 `child` 内容节点与 `attrs` 容器 |
|
|
50
|
+
| 节点 `rows` | `attrs: { 'ui-row': '…' }` |
|
|
51
|
+
| `restore` | `clear: () => ({ value: 是否清空, restore: true })` |
|
|
52
|
+
| `array.media` | `array.attrs: { 'ui-col': '…' }` |
|
|
53
|
+
| `every-label` 插槽 | `label` 插槽,接收 `col / node / data / text` |
|
|
54
|
+
|
|
55
|
+
`TextArea / view` 兼容为 `Textarea / View`,`rules.message` 兼容为 `msg`(显式 `msg` 优先),`array.lists` 兼容为 `array.child`。
|
|
56
|
+
|
|
57
|
+
## 新增与行为修复
|
|
58
|
+
|
|
59
|
+
- 标题与内容均可递归组合;数组支持默认值、数量限制、增删排序和行上下文。
|
|
60
|
+
- `validate()` 等待异步联动并检查最新数据;`reset()` 恢复当前数据对象的初始状态;`clearValidate()` 取消未完成校验。
|
|
61
|
+
- 换一条记录时替换 `data` 对象,建立新的重置基线;对同一对象 `Object.assign` 视为编辑。
|
|
62
|
+
- `paths` 缺失时补全对象,已有非对象路径明确报错。
|
|
63
|
+
- 增加 Radio、Gallery(别名 SelectImg)、RichTextInput;TimePicker 使用浏览器原生时间输入并校验范围与步长。
|
|
64
|
+
- Gallery 提供通用图片选择与搜索事件,不包含旧业务素材库的接口、分页和后台管理。RichTextInput 默认支持文本格式、列表、链接和撤销重做,未配置图片上传或表格扩展。
|
|
65
|
+
|
|
66
|
+
示例与完整 API 见仓库的 `web/app/pages/components/formloader.vue`。
|
package/README.md
CHANGED
|
@@ -4,6 +4,9 @@ NeatUI Vue component package for Vue 3 and Nuxt projects.
|
|
|
4
4
|
|
|
5
5
|
Nuxt users should use `@neatui/vue` directly.
|
|
6
6
|
|
|
7
|
+
Upgrading from 4.x or the legacy FormLoader: see [5.0 migration notes](./MIGRATION-5.0.md).
|
|
8
|
+
This package ships Vue SFC / TypeScript source and requires a Vue build plugin and Sass.
|
|
9
|
+
|
|
7
10
|
## Installation
|
|
8
11
|
|
|
9
12
|
```bash
|
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "basic component ui",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "xiaojunbo",
|
|
8
8
|
"peerDependencies": {
|
|
9
9
|
"pinia": "^3.0.3",
|
|
10
|
-
"vue": "^3.
|
|
10
|
+
"vue": "^3.5.11",
|
|
11
11
|
"vue-router": "^4.0.0"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"build": "rollup -c rollup.config.js"
|
|
14
|
+
"build": "rollup -c rollup.config.js",
|
|
15
|
+
"type-check": "vue-tsc --noEmit -p tsconfig.release.json"
|
|
15
16
|
},
|
|
16
17
|
"devDependencies": {
|
|
17
18
|
"@rollup/plugin-typescript": "^11.1.6",
|
|
@@ -23,7 +24,8 @@
|
|
|
23
24
|
"sass": "^1.80.7",
|
|
24
25
|
"typescript": "^5.1.6",
|
|
25
26
|
"vue": "^3.5.18",
|
|
26
|
-
"vue-router": "^4.5.1"
|
|
27
|
+
"vue-router": "^4.5.1",
|
|
28
|
+
"vue-tsc": "^3.1.1"
|
|
27
29
|
},
|
|
28
30
|
"dependencies": {
|
|
29
31
|
"@fekit/floor": "^3.0.1",
|
|
@@ -34,9 +36,13 @@
|
|
|
34
36
|
"@fekit/scrollto": "^3.0.3",
|
|
35
37
|
"@fekit/toast": "^2.7.2",
|
|
36
38
|
"@fekit/utils": "^6.0.0",
|
|
37
|
-
"@neatui/css": "^
|
|
39
|
+
"@neatui/css": "^5.0.0",
|
|
38
40
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
39
41
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
42
|
+
"@tiptap/core": "^3.31.3",
|
|
43
|
+
"@tiptap/pm": "^3.31.3",
|
|
44
|
+
"@tiptap/starter-kit": "^3.31.3",
|
|
45
|
+
"@tiptap/vue-3": "^3.31.3",
|
|
40
46
|
"@vue/compiler-sfc": "^3.4.30",
|
|
41
47
|
"compressorjs": "^1.2.1",
|
|
42
48
|
"echarts": "^5.5.0",
|
|
@@ -47,7 +53,9 @@
|
|
|
47
53
|
"swiper": "^11.0.4",
|
|
48
54
|
"vue-cropper": "^0.6.4"
|
|
49
55
|
},
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
56
|
+
"files": [
|
|
57
|
+
"src",
|
|
58
|
+
"README.md",
|
|
59
|
+
"MIGRATION-5.0.md"
|
|
60
|
+
]
|
|
53
61
|
}
|
|
@@ -1 +1,124 @@
|
|
|
1
|
-
<template
|
|
1
|
+
<template>
|
|
2
|
+
<span :ui-avatar="attr" :style="style" :role="showImage ? undefined : 'img'" :aria-label="showImage ? undefined : label" :title="title">
|
|
3
|
+
<img
|
|
4
|
+
v-if="showImage"
|
|
5
|
+
:src="src"
|
|
6
|
+
:alt="label"
|
|
7
|
+
:loading="loading"
|
|
8
|
+
:referrerpolicy="referrerpolicy"
|
|
9
|
+
:crossorigin="crossorigin"
|
|
10
|
+
@error="imageFailed = true"
|
|
11
|
+
/>
|
|
12
|
+
<slot v-else-if="$slots.default"></slot>
|
|
13
|
+
<span v-else aria-hidden="true">{{ initials }}</span>
|
|
14
|
+
</span>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { computed, ref, watch, useSlots, PropType } from 'vue';
|
|
19
|
+
|
|
20
|
+
type AvatarSize = 'xs' | 's' | 'm' | 'l' | 'xl' | number | string;
|
|
21
|
+
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
theme: { type: String, default: '@a' },
|
|
24
|
+
// 图片地址,加载失败时回退到文字
|
|
25
|
+
src: { type: String, default: '' },
|
|
26
|
+
// 用户名,用于生成首字母、背景色与无障碍标签
|
|
27
|
+
name: { type: String, default: '' },
|
|
28
|
+
// 替代文本,默认取 name
|
|
29
|
+
alt: { type: String, default: '' },
|
|
30
|
+
// 自定义显示文字,覆盖自动生成的首字母
|
|
31
|
+
text: { type: String, default: '' },
|
|
32
|
+
// name 为空时显示的文字
|
|
33
|
+
fallback: { type: String, default: '' },
|
|
34
|
+
// 预设尺寸 xs/s/m/l/xl,或数字(px)/任意 CSS 长度
|
|
35
|
+
size: { type: [String, Number] as PropType<AvatarSize>, default: '' },
|
|
36
|
+
shape: { type: String as PropType<'circle' | 'square'>, default: 'circle' },
|
|
37
|
+
// 主题色名(main/well/warn/risk/info)或任意 CSS 颜色;留空则按 name 自动取色
|
|
38
|
+
color: { type: String, default: '' },
|
|
39
|
+
loading: { type: String as PropType<'lazy' | 'eager'>, default: 'lazy' },
|
|
40
|
+
referrerpolicy: { type: String as PropType<ReferrerPolicy>, default: undefined },
|
|
41
|
+
crossorigin: { type: String as PropType<'' | 'anonymous' | 'use-credentials'>, default: undefined },
|
|
42
|
+
title: { type: String, default: undefined },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const slots = useSlots();
|
|
46
|
+
const presetSizes = ['xs', 's', 'm', 'l', 'xl'];
|
|
47
|
+
const themeColors = ['main', 'well', 'warn', 'risk', 'info'];
|
|
48
|
+
// 白字对比度均 ≥ 4.5:1
|
|
49
|
+
const palette = [
|
|
50
|
+
'#D32F2F',
|
|
51
|
+
'#C2185B',
|
|
52
|
+
'#AD1457',
|
|
53
|
+
'#7B1FA2',
|
|
54
|
+
'#512DA8',
|
|
55
|
+
'#303F9F',
|
|
56
|
+
'#1976D2',
|
|
57
|
+
'#0277BD',
|
|
58
|
+
'#00838F',
|
|
59
|
+
'#00796B',
|
|
60
|
+
'#2E7D32',
|
|
61
|
+
'#BF360C',
|
|
62
|
+
'#5D4037',
|
|
63
|
+
'#455A64',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const imageFailed = ref(false);
|
|
67
|
+
watch(
|
|
68
|
+
() => props.src,
|
|
69
|
+
() => (imageFailed.value = false),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const name = computed(() => props.name.trim());
|
|
73
|
+
const showImage = computed(() => !!props.src && !imageFailed.value);
|
|
74
|
+
const label = computed(() => props.alt || name.value || props.fallback);
|
|
75
|
+
|
|
76
|
+
// 多个词取各词首字母;单个英文词取前两位;其它(如中文)取首字
|
|
77
|
+
const initials = computed(() => {
|
|
78
|
+
if (props.text) return props.text;
|
|
79
|
+
if (!name.value) return props.fallback;
|
|
80
|
+
const words = name.value.split(/[\s\-_]+/).filter(Boolean);
|
|
81
|
+
if (words.length > 1) {
|
|
82
|
+
return words
|
|
83
|
+
.slice(0, 2)
|
|
84
|
+
.map((w) => Array.from(w)[0])
|
|
85
|
+
.join('')
|
|
86
|
+
.toUpperCase();
|
|
87
|
+
}
|
|
88
|
+
const chars = Array.from(name.value);
|
|
89
|
+
return (/^[\x20-\x7E]+$/.test(name.value) ? chars.slice(0, 2).join('') : chars[0]).toUpperCase();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const hashColor = (s: string) => {
|
|
93
|
+
let hash = 0;
|
|
94
|
+
for (const ch of s) hash = ((hash << 5) - hash + (ch.codePointAt(0) || 0)) | 0;
|
|
95
|
+
return palette[Math.abs(hash) % palette.length];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// 仅文字头像需要着色;图片与插槽内容保持中性底色
|
|
99
|
+
const colored = computed(() => !showImage.value && !slots.default && !!initials.value);
|
|
100
|
+
|
|
101
|
+
const attr = computed(() => {
|
|
102
|
+
const list = [props.theme];
|
|
103
|
+
if (presetSizes.includes(String(props.size))) list.push(String(props.size));
|
|
104
|
+
if (props.shape === 'square') list.push(':square');
|
|
105
|
+
if (colored.value && themeColors.includes(props.color)) list.push(props.color);
|
|
106
|
+
return list.join(' ');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const style = computed(() => {
|
|
110
|
+
const out: Record<string, string> = {};
|
|
111
|
+
const { size, color } = props;
|
|
112
|
+
if (size !== '' && !presetSizes.includes(String(size))) {
|
|
113
|
+
out['--avatar-size'] = typeof size === 'number' || /^\d+(\.\d+)?$/.test(size) ? `${size}px` : size;
|
|
114
|
+
}
|
|
115
|
+
if (colored.value && !themeColors.includes(color)) {
|
|
116
|
+
const bg = color || (name.value ? hashColor(name.value) : '');
|
|
117
|
+
if (bg) {
|
|
118
|
+
out.color = '#fff';
|
|
119
|
+
out.background = bg;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
});
|
|
124
|
+
</script>
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
<slot name="tools" :data="node"></slot>
|
|
75
75
|
</div>
|
|
76
76
|
<template v-if="node[options.children || 'children']?.length > 0 && node._fold">
|
|
77
|
-
<TreeNode :key="node[options.id || 'id']" v-
|
|
78
|
-
<template #tools="{ data
|
|
77
|
+
<TreeNode :key="node[options.id || 'id']" v-bind="$props" v-model="state.selected" :treeData="node[options.children || 'children']" :root="false" @select="tranSelect">
|
|
78
|
+
<template #tools="{ data }: { data: any }">
|
|
79
79
|
<slot name="tools" :data="data"></slot>
|
|
80
80
|
</template>
|
|
81
|
-
<template #suffix="{ data
|
|
81
|
+
<template #suffix="{ data }: { data: any }">
|
|
82
82
|
<slot name="suffix" :data="data"></slot>
|
|
83
83
|
</template>
|
|
84
|
-
<template #prefix="{ data
|
|
84
|
+
<template #prefix="{ data }: { data: any }">
|
|
85
85
|
<slot name="prefix" :data="data"></slot>
|
|
86
86
|
</template>
|
|
87
87
|
</TreeNode>
|
|
@@ -96,6 +96,12 @@
|
|
|
96
96
|
import { isObject, isFunction, isArray, deepcopy } from '@fekit/utils';
|
|
97
97
|
import { useRoute, useRouter } from 'vue-router';
|
|
98
98
|
|
|
99
|
+
defineSlots<{
|
|
100
|
+
prefix?: (props: { data: any }) => any;
|
|
101
|
+
suffix?: (props: { data: any }) => any;
|
|
102
|
+
tools?: (props: { data: any }) => any;
|
|
103
|
+
}>();
|
|
104
|
+
|
|
99
105
|
/**
|
|
100
106
|
* 入参说明
|
|
101
107
|
* modelValue 值
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
</template>
|
|
15
15
|
<slot name="prefix" :data="item"></slot>
|
|
16
16
|
</div>
|
|
17
|
-
<slot name="label">
|
|
17
|
+
<slot name="label" :data="item">
|
|
18
18
|
<i v-if="item.icon" :class="`icon icon-${item.icon}`"></i> <span>{{ item.label }}{{ item.__folding ? ' ⋯' : '' }}</span>
|
|
19
19
|
</slot>
|
|
20
20
|
<div ui-form-suffix="">
|
|
@@ -43,16 +43,16 @@
|
|
|
43
43
|
@click="(data: any) => emits('click', data)"
|
|
44
44
|
@select="(data: any) => emits('select', data)"
|
|
45
45
|
>
|
|
46
|
-
<template #prefix="{ data
|
|
46
|
+
<template #prefix="{ data }: { data: any }">
|
|
47
47
|
<slot name="prefix" :data="data"></slot>
|
|
48
48
|
</template>
|
|
49
|
-
<template #label="{ data
|
|
49
|
+
<template v-if="$slots.label" #label="{ data }: { data: any }">
|
|
50
50
|
<slot name="label" :data="data"></slot>
|
|
51
51
|
</template>
|
|
52
|
-
<template #suffix="{ data
|
|
52
|
+
<template #suffix="{ data }: { data: any }">
|
|
53
53
|
<slot name="suffix" :data="data"></slot>
|
|
54
54
|
</template>
|
|
55
|
-
<template #tools="{ data
|
|
55
|
+
<template #tools="{ data }: { data: any }">
|
|
56
56
|
<slot name="tools" :data="data"></slot>
|
|
57
57
|
</template>
|
|
58
58
|
</TreeView>
|
|
@@ -66,6 +66,13 @@
|
|
|
66
66
|
import { isObject, isFunction } from '@fekit/utils';
|
|
67
67
|
import { useRoute, useRouter } from 'vue-router';
|
|
68
68
|
|
|
69
|
+
defineSlots<{
|
|
70
|
+
prefix?: (props: { data: any }) => any;
|
|
71
|
+
label?: (props: { data: any }) => any;
|
|
72
|
+
suffix?: (props: { data: any }) => any;
|
|
73
|
+
tools?: (props: { data: any }) => any;
|
|
74
|
+
}>();
|
|
75
|
+
|
|
69
76
|
const route = useRoute();
|
|
70
77
|
const router = useRouter();
|
|
71
78
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import Avatar from './Avatar.vue';
|
|
1
2
|
import Image from './Image.vue';
|
|
2
3
|
import Tree from './Tree.vue';
|
|
3
4
|
import Calendar from './Calendar.vue';
|
|
@@ -5,4 +6,4 @@ import PhotoEditor from './PhotoEditor.vue';
|
|
|
5
6
|
import TreeView from './TreeView.vue';
|
|
6
7
|
// import ChartView from './ChartView.vue';
|
|
7
8
|
|
|
8
|
-
export { Image, Tree, Calendar, PhotoEditor, TreeView };
|
|
9
|
+
export { Avatar, Image, Tree, Calendar, PhotoEditor, TreeView };
|
|
@@ -342,7 +342,7 @@
|
|
|
342
342
|
|
|
343
343
|
if (checked) {
|
|
344
344
|
const labelList = state.selectd?.label === '' ? [] : state.selectd?.label?.split(' | ');
|
|
345
|
-
const _label = [...new Set([...labelList, ...labels])]
|
|
345
|
+
const _label = [...new Set([...labelList, ...labels])];
|
|
346
346
|
state.selectd.label = _label?.length > 0 ? _label.join(' | ') : '';
|
|
347
347
|
state.selectd.value = [...new Set([...state.selectd.value, ...values])];
|
|
348
348
|
} else {
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
</div>
|
|
21
21
|
</div>
|
|
22
22
|
<div ui-flex="row rm">
|
|
23
|
-
<div v-if="idate(sta.panel).update({ m: 1 }).time < idate(end.panel).time" ui-btn="@a s none :square" @click="next(1, { m: 1 })">
|
|
23
|
+
<div v-if="(idate(sta.panel).update({ m: 1 }).time ?? NaN) < (idate(end.panel).time ?? NaN)" ui-btn="@a s none :square" @click="next(1, { m: 1 })">
|
|
24
24
|
<Icon name="arrow-right" />
|
|
25
25
|
</div>
|
|
26
26
|
<div v-else ui-btn="@a s none :square" class="o-sl">
|
|
27
27
|
<Icon name="arrow-right" />
|
|
28
28
|
</div>
|
|
29
|
-
<div v-if="idate(sta.panel).update({ y: 1 }).time < idate(end.panel).time" ui-btn="@a s none :square" @click="next(1, { y: 1 })">
|
|
29
|
+
<div v-if="(idate(sta.panel).update({ y: 1 }).time ?? NaN) < (idate(end.panel).time ?? NaN)" ui-btn="@a s none :square" @click="next(1, { y: 1 })">
|
|
30
30
|
<Icon name="double-arrow-right" />
|
|
31
31
|
</div>
|
|
32
32
|
<div v-else ui-btn="@a s none :square" class="o-sl">
|
|
@@ -78,13 +78,13 @@
|
|
|
78
78
|
<div class="w-sm*m flex-fixed" ui-flex="col xy">
|
|
79
79
|
<div ui-date-head="" class="nx-sl mt-ss ny-sm b-solid b-back" ui-flex="row xm">
|
|
80
80
|
<div ui-flex="row lm">
|
|
81
|
-
<div v-if="idate(sta.panel).time < idate(end.panel).update({ y: -1 }).time" ui-btn="@a s none :square" @click="prev(2, { y: -1 })">
|
|
81
|
+
<div v-if="(idate(sta.panel).time ?? NaN) < (idate(end.panel).update({ y: -1 }).time ?? NaN)" ui-btn="@a s none :square" @click="prev(2, { y: -1 })">
|
|
82
82
|
<Icon name="double-arrow-left" />
|
|
83
83
|
</div>
|
|
84
84
|
<div v-else ui-btn="@a s none :square" class="o-sl">
|
|
85
85
|
<Icon name="double-arrow-left" />
|
|
86
86
|
</div>
|
|
87
|
-
<div v-if="idate(sta.panel).time < idate(end.panel).update({ m: -1 }).time" ui-btn="@a s none :square" @click="prev(2, { m: -1 })">
|
|
87
|
+
<div v-if="(idate(sta.panel).time ?? NaN) < (idate(end.panel).update({ m: -1 }).time ?? NaN)" ui-btn="@a s none :square" @click="prev(2, { m: -1 })">
|
|
88
88
|
<Icon name="arrow-left" />
|
|
89
89
|
</div>
|
|
90
90
|
<div v-else ui-btn="@a s none :square" class="o-sl">
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
}
|
|
212
212
|
interface Props {
|
|
213
213
|
modelValue?: Array<string | number>;
|
|
214
|
-
frame
|
|
214
|
+
frame?: boolean;
|
|
215
215
|
tools?: boolean;
|
|
216
216
|
range?: boolean;
|
|
217
217
|
pickerRange?: PickerRange;
|
|
@@ -358,7 +358,7 @@
|
|
|
358
358
|
if (!sta.value) {
|
|
359
359
|
sta.value = idate(item.date).format(props.format);
|
|
360
360
|
} else {
|
|
361
|
-
if (idate(item.date).time < idate(sta.value).time) {
|
|
361
|
+
if ((idate(item.date).time ?? NaN) < (idate(sta.value).time ?? NaN)) {
|
|
362
362
|
end.value = idate(sta.value).format(props.format);
|
|
363
363
|
sta.value = idate(item.date).format(props.format);
|
|
364
364
|
} else {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="neat-gallery" :aria-busy="loading">
|
|
3
|
+
<input v-if="searchable && !readonly" v-model="query" type="search" aria-label="搜索图片" placeholder="搜索图片" :disabled="disabled" @input="emit('search', query)" />
|
|
4
|
+
<p v-if="loading" role="status">加载中…</p>
|
|
5
|
+
<div class="neat-gallery-grid">
|
|
6
|
+
<button v-for="item in filtered" :key="item.value" type="button" :aria-label="item.label || item.value" :aria-pressed="selected.includes(item.value)"
|
|
7
|
+
:disabled="disabled || readonly || item.disabled || (!selected.includes(item.value) && multiple && !!limit && selected.length >= limit)"
|
|
8
|
+
:class="{ 'b-main': selected.includes(item.value) }" @click="toggle(item.value)">
|
|
9
|
+
<img :src="item.src || item.value" :alt="item.label || ''" loading="lazy" />
|
|
10
|
+
<span>{{ item.label || item.value }}</span>
|
|
11
|
+
</button>
|
|
12
|
+
</div>
|
|
13
|
+
<slot v-if="!loading && !filtered.length" name="empty"><p>暂无图片</p></slot>
|
|
14
|
+
<div v-if="selected.length" class="mt-sm" ui-flex="row lm">
|
|
15
|
+
<span>已选 {{ selected.length }} 项</span>
|
|
16
|
+
<button v-if="!disabled && !readonly" type="button" ui-btn="@a s" @click="setValue([])">清空选择</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { computed, ref } from 'vue';
|
|
22
|
+
const props = withDefaults(defineProps<{
|
|
23
|
+
modelValue?: string | string[]; options?: Array<string | { value: string; src?: string; label?: string; disabled?: boolean }>;
|
|
24
|
+
multiple?: boolean; limit?: number; disabled?: boolean; readonly?: boolean; searchable?: boolean; loading?: boolean;
|
|
25
|
+
}>(), { modelValue: '', options: () => [], multiple: false, limit: 0, disabled: false, readonly: false, searchable: true, loading: false });
|
|
26
|
+
const emit = defineEmits(['update:modelValue', 'change', 'search']);
|
|
27
|
+
const query = ref('');
|
|
28
|
+
const selected = computed(() => Array.isArray(props.modelValue) ? props.modelValue : props.modelValue ? [props.modelValue] : []);
|
|
29
|
+
const filtered = computed(() => props.options.map(item => typeof item === 'string' ? { value: item, src: item, label: item, disabled: false } : item)
|
|
30
|
+
.filter(item => (!props.readonly || selected.value.includes(item.value)))
|
|
31
|
+
.filter(item => (item.label || item.value).toLowerCase().includes(props.readonly ? '' : query.value.toLowerCase())));
|
|
32
|
+
const setValue = (values: string[]) => {
|
|
33
|
+
if (props.disabled || props.readonly) return;
|
|
34
|
+
const value = props.multiple ? values : values[0] || '';
|
|
35
|
+
emit('update:modelValue', value);
|
|
36
|
+
emit('change', value);
|
|
37
|
+
};
|
|
38
|
+
const toggle = (value: string) => {
|
|
39
|
+
const found = selected.value.includes(value);
|
|
40
|
+
if (!found && props.multiple && props.limit && selected.value.length >= props.limit) return;
|
|
41
|
+
setValue(found ? selected.value.filter(item => item !== value) : props.multiple ? [...selected.value, value] : [value]);
|
|
42
|
+
};
|
|
43
|
+
</script>
|
|
44
|
+
<style scoped>
|
|
45
|
+
.neat-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); gap: .5rem; margin-top: .5rem; }
|
|
46
|
+
.neat-gallery-grid button { padding: .4rem; border: 1px solid currentColor; border-radius: .3rem; overflow: hidden; }
|
|
47
|
+
.neat-gallery-grid img { display: block; width: 100%; height: 5rem; object-fit: cover; }
|
|
48
|
+
.neat-gallery-grid span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
49
|
+
.neat-gallery-grid button:disabled { opacity: .5; }
|
|
50
|
+
</style>
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
* prefix 前缀内容
|
|
82
82
|
* suffix 后缀内容
|
|
83
83
|
* interacted 用户是否交互
|
|
84
|
+
* emptyValue 清空或删空内容时回传的值,默认 null;字符串 v-model 可传 ''
|
|
84
85
|
*/
|
|
85
86
|
|
|
86
87
|
// 类型
|
|
@@ -106,6 +107,7 @@
|
|
|
106
107
|
prefix?: Array<any>;
|
|
107
108
|
suffix?: Array<any>;
|
|
108
109
|
interacted?: boolean | number | null;
|
|
110
|
+
emptyValue?: null | string;
|
|
109
111
|
}
|
|
110
112
|
const props: any = withDefaults(defineProps<Props>(), {
|
|
111
113
|
modelValue: '',
|
|
@@ -129,6 +131,7 @@
|
|
|
129
131
|
prefix: () => [],
|
|
130
132
|
suffix: () => [],
|
|
131
133
|
interacted: null,
|
|
134
|
+
emptyValue: null,
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
const hasClearValue = computed(() => props.modelValue !== '' && props.modelValue !== null && props.modelValue !== undefined);
|
|
@@ -221,7 +224,7 @@
|
|
|
221
224
|
|
|
222
225
|
// 清空
|
|
223
226
|
function clear() {
|
|
224
|
-
emits('update:modelValue',
|
|
227
|
+
emits('update:modelValue', props.emptyValue);
|
|
225
228
|
emits('clear');
|
|
226
229
|
}
|
|
227
230
|
|
|
@@ -246,7 +249,7 @@
|
|
|
246
249
|
}
|
|
247
250
|
// 输入事件
|
|
248
251
|
emits('input', e, state);
|
|
249
|
-
emits('update:modelValue', value ||
|
|
252
|
+
emits('update:modelValue', value || props.emptyValue);
|
|
250
253
|
};
|
|
251
254
|
// 失焦
|
|
252
255
|
const blur = (e: any) => {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
</template>
|
|
42
42
|
|
|
43
43
|
<script setup lang="ts">
|
|
44
|
+
import type { PropType } from 'vue';
|
|
44
45
|
import { reactive, ref, computed, watch } from 'vue';
|
|
45
46
|
import Input from './Input.vue';
|
|
46
47
|
import { IFollowView } from '../basic';
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
default: false,
|
|
55
56
|
},
|
|
56
57
|
theme: { type: String, default: '@a' },
|
|
57
|
-
sz: { type: String
|
|
58
|
+
sz: { type: String as PropType<'' | 'xs' | 's' | 'm' | 'l' | 'xl'>, default: 'm' },
|
|
58
59
|
co: { type: String, default: '' },
|
|
59
60
|
block: { type: Boolean, default: true },
|
|
60
61
|
// 提示文案
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
},
|
|
81
82
|
// 校验规则
|
|
82
83
|
rules: {
|
|
83
|
-
type: Array
|
|
84
|
+
type: Array as PropType<object[]>,
|
|
84
85
|
default: () => [],
|
|
85
86
|
},
|
|
86
87
|
// 是否是示校验图标
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div role="radiogroup" :aria-label="label" :aria-disabled="disabled || undefined" class="m-sm-sub">
|
|
3
|
+
<label v-for="(item, i) in items" :key="i" :ui-form="`${theme} type:radio`">
|
|
4
|
+
<input type="radio" :name="groupName" :value="item.value" :checked="modelValue === item.value"
|
|
5
|
+
:disabled="disabled || readonly || item.disabled" :aria-readonly="readonly || undefined"
|
|
6
|
+
@click="readonly && $event.preventDefault()" @change="select(item.value)" />
|
|
7
|
+
<slot :item="item" :index="i"><span>{{ item.label }}</span></slot>
|
|
8
|
+
</label>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
import { computed, getCurrentInstance } from 'vue';
|
|
13
|
+
const props = withDefaults(defineProps<{
|
|
14
|
+
modelValue?: string | number | boolean;
|
|
15
|
+
options?: Array<string | number | boolean | { label: string; value: string | number | boolean; disabled?: boolean }>;
|
|
16
|
+
name?: string; label?: string; disabled?: boolean; readonly?: boolean; theme?: string;
|
|
17
|
+
}>(), { options: () => [], theme: '@a', disabled: false, readonly: false });
|
|
18
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
19
|
+
const uid = getCurrentInstance()!.uid;
|
|
20
|
+
const groupName = computed(() => props.name || `neat-radio-${uid}`);
|
|
21
|
+
const items = computed(() => props.options.map(item => typeof item === 'object' ? item : { label: String(item), value: item, disabled: false }));
|
|
22
|
+
const select = (value: string | number | boolean) => {
|
|
23
|
+
if (props.disabled || props.readonly || value === props.modelValue) return;
|
|
24
|
+
emit('update:modelValue', value);
|
|
25
|
+
emit('change', value);
|
|
26
|
+
};
|
|
27
|
+
</script>
|