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;
+68
View File
@@ -0,0 +1,68 @@
import type {
VbenFormProps as BaseVbenFormProps,
VbenFormSchema as FormSchema,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentProps } from './component/props';
import type { SchemaComponentType } from './component/types';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
RichTextarea: 'modelValue',
Switch: 'checked',
Upload: 'fileList',
ImageUpload: 'value',
},
},
defineRules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
}
type TypedFormSchema<T extends SchemaComponentType> = Omit<
FormSchema<T>,
'component' | 'componentProps'
> & {
component: T;
componentProps?: ComponentProps<T>;
};
export type VbenFormSchema = {
[K in SchemaComponentType]: TypedFormSchema<K>;
}[SchemaComponentType];
export type VbenFormProps = Omit<BaseVbenFormProps<ComponentType>, 'schema'> & {
schema?: VbenFormSchema[];
};
const useVbenForm = (options: VbenFormProps) => {
return useForm<ComponentType>(options as BaseVbenFormProps<ComponentType>);
};
export { initSetupVbenForm, useVbenForm, z };
@@ -0,0 +1,124 @@
/* eslint-disable vue/no-side-effects-in-computed-properties */
import { computed, defineComponent, h, ref, watch } from 'vue';
import hljs from 'highlight.js/lib/core';
const component = defineComponent({
props: {
code: {
type: String,
required: true,
},
language: {
type: String,
default: '',
},
autodetect: {
type: Boolean,
default: true,
},
ignoreIllegals: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const language = ref(props.language);
const ignoreIllegals = ref(props.ignoreIllegals);
watch(
() => props.language,
(newLanguage) => {
language.value = newLanguage;
},
);
watch(
() => props.ignoreIllegals,
(newIgnoreIllegals) => {
ignoreIllegals.value = newIgnoreIllegals;
},
);
const autodetect = computed(() => props.autodetect || !language.value);
const cannotDetectLanguage = computed(
() => !autodetect.value && !hljs.getLanguage(language.value),
);
const className = computed((): string => {
return cannotDetectLanguage.value ? '' : `hljs ${language.value}`;
});
const highlightedCode = computed((): string => {
// No idea what language to use, return raw code
if (cannotDetectLanguage.value) {
console.warn(
`The language "${language.value}" you specified could not be found.`,
);
return props.code;
}
if (autodetect.value) {
const result = hljs.highlightAuto(props.code);
language.value = result.language ?? '';
return result.value;
} else {
const result = hljs.highlight(props.code, {
language: language.value,
ignoreIllegals: props.ignoreIllegals,
});
return result.value;
}
});
// const saveSelection = (e: any) => {
// const selection = window.getSelection();
// if (selection && selection.rangeCount && selection.rangeCount > 0) {
// const range = selection.getRangeAt(0);
// return range;
// }
// return null;
// };
// const restoreSelection = (range: any, el: any) => {
// if (range) {
// const selection = window.getSelection();
// if (selection) {
// selection.removeAllRanges();
// selection.addRange(range);
// console.log('selection range', range);
// }
// }
// };
const updateContent = (e: any) => {
// const range = saveSelection(e)
const result = hljs.highlight(e.target.innerText, {
language: language.value,
ignoreIllegals: props.ignoreIllegals,
});
e.target.innerHTML = result.value;
emit('update:code', e.target.innerText);
// setTimeout(() => {
// restoreSelection(range, e.target)
// }, 0)
};
return {
className,
highlightedCode,
updateContent,
emit,
};
},
render() {
const updateContent = this.updateContent;
return h('pre', {}, [
h('code', {
class: this.className,
innerHTML: this.highlightedCode,
contenteditable: true,
// onInput(e: any) {},
onBlur(e: any) {
updateContent(e);
},
}),
]);
},
});
export default component;
@@ -0,0 +1,70 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
minHeight: 180,
formConfig: {
// 全局禁用vxe-table的表单配置,使用formOptions
enabled: false,
},
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Image, { src: row[column.field], ...props });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';