chore: 初始开源发布

iotlcm-admin-ui:管理前端(基于 Vben Admin 5.5.9 二次开发)。

- 天地图 API Key → YOUR_TIANDITU_KEY
- OAuth 客户端 ID → your-client-id,store 加密密钥 → 提示自行替换
- Makefile 移除明文 registry 登录口令,镜像地址改
- appIcon 指向内网图床的链接 → /favicon.ico
- 真实手机号 → 合法 11 位编造号(保留号段前 3 位 + 后 4 位)
- 真实邮箱 → example.com(署名头内的上游作者邮箱保留)
- 移除 CI 触发器
- 补 MIT LICENSE(含 Vben Admin 上游声明)+ README
- 统一 LF 行尾并加 .gitattributes

上游 Vben Admin 的版权与许可声明完整保留(各源文件 @Author/@Copyright 头 + LICENSE)。
This commit is contained in:
MiFaZhan
2026-10-26 13:46:34 +08:00
commit 3106d96ac7
1607 changed files with 185856 additions and 0 deletions
@@ -0,0 +1,102 @@
import { defineAsyncComponent } from 'vue';
/**
* Ant Design Vue 基础组件统一在这里异步引入。
*
* adapter 层只关心组件映射关系,避免把具体 UI 库的 import 细节散落到注册文件中。
*/
export const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
);
export const Button = defineAsyncComponent(
() => import('ant-design-vue/es/button'),
);
export const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'),
);
export const CheckboxGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
);
export const DatePicker = defineAsyncComponent(
() => import('ant-design-vue/es/date-picker'),
);
export const Divider = defineAsyncComponent(
() => import('ant-design-vue/es/divider'),
);
export const Image = defineAsyncComponent(
() => import('ant-design-vue/es/image'),
);
export const Input = defineAsyncComponent(
() => import('ant-design-vue/es/input'),
);
export const InputNumber = defineAsyncComponent(
() => import('ant-design-vue/es/input-number'),
);
export const InputPassword = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.InputPassword),
);
export const Mentions = defineAsyncComponent(
() => import('ant-design-vue/es/mentions'),
);
export const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
export const Radio = defineAsyncComponent(
() => import('ant-design-vue/es/radio'),
);
export const RadioGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
);
export const RangePicker = defineAsyncComponent(() =>
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
);
export const Rate = defineAsyncComponent(
() => import('ant-design-vue/es/rate'),
);
export const Select = defineAsyncComponent(
() => import('ant-design-vue/es/select'),
);
export const Space = defineAsyncComponent(
() => import('ant-design-vue/es/space'),
);
export const Switch = defineAsyncComponent(
() => import('ant-design-vue/es/switch'),
);
export const Textarea = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.Textarea),
);
export const TimePicker = defineAsyncComponent(
() => import('ant-design-vue/es/time-picker'),
);
export const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
export const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
export const Upload = defineAsyncComponent(
() => import('ant-design-vue/es/upload'),
);
@@ -0,0 +1,20 @@
import { globalShareState } from '@vben/common-ui';
import { notification } from 'ant-design-vue';
/**
* 框架共享消息适配。
*
* common-ui 内部只声明消息场景,这里绑定到当前 UI 库的 notification 实现。
*/
export function defineComponentAdapterMessage() {
globalShareState.defineMessage({
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
@@ -0,0 +1,125 @@
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { Ref } from 'vue';
import { h, render, unref } from 'vue';
import { Image, PreviewGroup } from '../antd';
/**
* UploadFile 没有稳定的图片标识字段时,通过 url/name/type 组合判断是否走图片预览。
*/
function isImageFile(file: UploadFile): boolean {
const imageExtensions = new Set(['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']);
if (file.url) {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
}
/**
* 本地待上传图片没有 url 时,需要转成 base64 才能进入 ImagePreviewGroup。
*/
function getBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
}
/**
* 基于 Ant Design Vue ImagePreviewGroup 创建一次性预览容器。
*
* 预览关闭后会卸载并移除临时 DOM,避免多次预览后残留节点。
*/
export const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 非图片沿用文件打开行为,不进入图片预览组
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
console.warn('无法打开文件,没有可用的URL或预览地址');
}
return;
}
// Image 与 PreviewGroup 只有预览时才加载,保持基础表单渲染轻量
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 同一上传列表中的图片都加入预览组,支持左右切换
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 避免关闭动画期间重复触发卸载
let isUnmounted = false;
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
visible: visible.value,
current: imageFiles.findIndex((f) => f.uid === file.uid),
onVisibleChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 等待预览关闭动画结束后再卸载临时容器
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
};
@@ -0,0 +1,46 @@
import type { Component } from 'vue';
import type { Recordable } from '@vben/types';
import { defineComponent, h, ref } from 'vue';
import { $t } from '@vben/locales';
/**
* 为输入类/选择类组件补齐统一占位文案,并保留原组件暴露的方法。
*/
export const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
const innerRef = ref();
// 透传内部组件实例方法,避免包装后影响外部通过 ref 调用 focus 等能力
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
@@ -0,0 +1,114 @@
import type {
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import { defineComponent, h, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isEmpty } from '@vben/utils';
import { Button, Upload } from '../antd';
import { previewImage } from './upload-preview';
/**
* Upload 未提供自定义插槽时,补齐项目统一的默认上传按钮。
*/
function createDefaultSlotsWithUpload(listType: string, placeholder: string) {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
}
}
/**
* 适配 Ant Design Vue Upload 到 Vben Form:
* - 使用 modelValue 与表单值同步
* - 上传列表为空时回写 undefined
* - 内置图片预览,非图片文件直接打开地址
*/
export const withPreviewUpload = () => {
return defineComponent({
name: Upload.name,
emits: ['change', 'update:modelValue'],
setup: (
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const handleChange = async (event: UploadChangeParam) => {
fileList.value = event.fileList;
emit('change', event);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
const handlePreview = async (file: UploadFile) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 禁用状态下只展示文件列表,不再渲染上传入口
if (isDisabled) {
return null;
}
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
// 监听 formApi.setValues 等外部回填,保持 Upload 内部 fileList 同步
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
);
},
});
};
@@ -0,0 +1,136 @@
import type { Component } from 'vue';
import type { ComponentType } from './types';
import { h } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { HtcRegisterComponent } from '#/components/HtCustom/FormExpand/register';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { FileUpload, ImageUpload } from '#/components/upload';
import {
AutoComplete,
Button,
Cascader,
Checkbox,
CheckboxGroup,
DatePicker,
Divider,
Input,
InputNumber,
InputPassword,
Mentions,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TimePicker,
TreeSelect,
} from './antd';
import { defineComponentAdapterMessage } from './helpers/message';
import { withDefaultPlaceholder } from './helpers/with-default-placeholder';
import { withPreviewUpload } from './helpers/with-preview-upload';
/**
* 通用组件适配层。
*
* 这里只维护“组件名称 -> 真实组件”的映射关系,供 vben-form、vben-modal、
* vben-drawer 等框架组件通过 globalShareState 复用。
*/
function createComponentAdapterMap(): Partial<
Record<ComponentType, Component>
> {
return {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
}),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiSelect',
},
'select',
{
component: Select,
loadingSlot: 'suffixIcon',
visibleEvent: 'onDropdownVisibleChange',
modelPropName: 'value',
},
),
ApiTreeSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiTreeSelect',
},
'select',
{
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
),
AutoComplete,
Cascader,
Checkbox,
CheckboxGroup,
DatePicker,
// 弹窗、抽屉等框架组件会通过共享状态读取这两个按钮组件
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
FileUpload,
ImageUpload,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
RichTextarea,
Select: withDefaultPlaceholder(Select, 'select'),
// SelectDivision,
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload: withPreviewUpload(),
...HtcRegisterComponent,
};
}
async function initComponentAdapter() {
// 先注册组件,再初始化 form 适配,确保 setupVbenForm 能读到完整组件表
globalShareState.setComponents(createComponentAdapterMap());
defineComponentAdapterMessage();
}
export { initComponentAdapter };
export type { ComponentType };
@@ -0,0 +1,19 @@
import type { SchemaComponentType } from './types';
import type { HtcComponentPropsMap } from '#/components/HtCustom/FormExpand/register';
type ComponentPropsGetter<Props> = (
value: Partial<Record<string, any>>,
actions: any,
) => Props;
type ComponentPropsMap = HtcComponentPropsMap;
type UntypedComponentProps =
| ComponentPropsGetter<Record<string, any>>
| Record<string, any>;
export type ComponentProps<T extends SchemaComponentType> =
T extends keyof ComponentPropsMap
? ComponentPropsGetter<ComponentPropsMap[T]> | ComponentPropsMap[T]
: UntypedComponentProps;
@@ -0,0 +1,61 @@
import type { BaseFormComponentType } from '@vben/common-ui';
import type { HtcRegisterComponentName } from '#/components/HtCustom/FormExpand/register';
/**
* 表单 schema 中 component 字段允许使用的组件名称。
*
* 新增适配组件时,需要同时在这里补充名称,并在 index.ts 中注册真实组件。
*/
export type AdapterComponentType =
| 'ApiCascader'
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'FileUpload'
| 'HtcFormSwitch'
| 'IconPicker'
| 'ImageUpload'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'RichTextarea'
| 'Select'
| 'SelectDivision'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload';
export type BaseComponentType =
| 'DefaultButton'
| 'PrimaryButton'
| 'VbenCheckbox'
| 'VbenInput'
| 'VbenInputPassword'
| 'VbenPinInput'
| 'VbenSelect';
export type SchemaComponentType =
| AdapterComponentType
| BaseComponentType
| HtcRegisterComponentName;
export type ComponentType =
| AdapterComponentType
| BaseFormComponentType
| HtcRegisterComponentName;