初始提交:项目迁移,前端(管理端)

This commit is contained in:
jdc
2025-11-13 09:50:47 +08:00
commit 13f78a3086
695 changed files with 89296 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import { nextTick, ref } from "vue";
import { useCore } from "../../../hooks";
export function useData({ config, Table }: { config: ClTable.Config; Table: Vue.Ref<any> }) {
const { mitt, crud } = useCore();
// 列表数据
const data = ref<obj[]>([]);
// 设置数据
function setData(list: obj[]) {
data.value = list;
}
// 监听刷新
mitt.on("crud.refresh", ({ list }: ClCrud.Response["page"]) => {
data.value = list;
// 显示选中行
nextTick(() => {
crud.selection.forEach((e) => {
const d = list.find((a) => a[config.rowKey] == e[config.rowKey]);
if (d) {
Table.value.toggleRowSelection(d, true);
}
});
});
});
return {
data,
setData
};
}

View File

@@ -0,0 +1,91 @@
import { CloseBold, Search } from "@element-plus/icons-vue";
import { h } from "vue";
import { useCrud } from "../../../hooks";
import { renderNode } from "../../../utils/vnode";
export function renderHeader(item: ClTable.Column, { scope, slots }: any) {
const crud = useCrud();
const slot = slots[`header-${item.prop}`];
if (slot) {
return slot({
scope
});
}
if (!item.search || !item.search.component) {
return item.label;
}
// 显示输入框
function show(e: MouseEvent) {
item.search.isInput = true;
e.stopPropagation();
}
// 隐藏输入框
function hide() {
if (item.search.value !== undefined) {
item.search.value = undefined;
refresh();
}
item.search.isInput = false;
}
// 刷新
function refresh(params?: any) {
const { value } = item.search;
crud.value?.refresh({
page: 1,
[item.prop]: value === "" ? undefined : value,
...params
});
}
// 文字
const text = (
<div class="cl-table-header__search-label" onClick={show}>
<el-icon size={14}>{item.search.icon?.() ?? <Search />}</el-icon>
{item.renderLabel ? item.renderLabel(scope) : item.label}
</div>
);
// 输入框
const input = h(renderNode(item.search.component, { prop: item.prop }), {
clearable: true,
modelValue: item.search.value,
onVnodeMounted(vn) {
// 默认聚焦
vn.component?.exposed?.focus?.();
},
onInput(val: any) {
item.search.value = val;
},
onChange(val: any) {
item.search.value = val;
// 更改时刷新列表
if (item.search.refreshOnChange) {
refresh();
}
}
});
return (
<div class={["cl-table-header__search", { "is-input": item.search.isInput }]}>
<div class="cl-table-header__search-inner">{item.search.isInput ? input : text}</div>
{item.search.isInput && (
<div class="cl-table-header__search-close" onClick={hide}>
<el-icon>
<CloseBold />
</el-icon>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { debounce, last } from "lodash-es";
import { nextTick, onActivated, onMounted, ref } from "vue";
import { addClass, removeClass } from "../../../utils";
import { mitt } from "../../../utils/mitt";
// 表格高度
export function useHeight({ config, Table }: { Table: Vue.Ref<any>; config: ClTable.Config }) {
// 最大高度
const maxHeight = ref(0);
// 计算表格最大高度
const update = debounce(async () => {
await nextTick();
let vm = Table.value;
if (vm) {
while (!vm.$parent?.$el.className?.includes("cl-crud")) {
vm = vm.$parent;
}
if (vm) {
const p = vm.$parent.$el;
await nextTick();
// 高度
let h = 0;
// 表格下间距
if (vm.$el.className.includes("cl-row")) {
h += 10;
}
// 上高度
h += vm.$el.offsetTop;
// 获取下高度
let n = vm.$el.nextSibling;
// 集合
const arr = [vm.$el];
while (n) {
if (n.offsetHeight > 0) {
h += n.offsetHeight || 0;
arr.push(n);
if (n.className.includes("cl-row--last")) {
h += 10;
}
}
n = n.nextSibling;
}
// 移除 cl-row--last
arr.forEach((e) => {
removeClass(e, "cl-row--last");
});
// 最后一个可视元素
const z = last(arr);
// 去掉 cl-row 下间距高度
if (z?.className.includes("cl-row")) {
addClass(z, "cl-row--last");
h -= 10;
}
// 上间距
h += parseInt(window.getComputedStyle(p).paddingTop, 10);
// 设置最大高度
if (config.autoHeight) {
maxHeight.value = p.clientHeight - h;
}
}
}
}, 100);
// 窗口大小改变事件
mitt.on("resize", () => {
update();
});
onMounted(function () {
update();
});
onActivated(function () {
update();
});
return {
maxHeight,
calcMaxHeight: update
};
}

View File

@@ -0,0 +1,43 @@
import { inject, reactive, ref } from "vue";
import { useConfig } from "../../../hooks";
import { getValue, mergeConfig } from "../../../utils";
import type { TableInstance } from "element-plus";
export function useTable(props: any) {
const { style } = useConfig();
const Table = ref<TableInstance>();
// 配置
const config = reactive<ClTable.Config>(mergeConfig(props, inject("useTable__options") || {}));
// 列表项动态处理
config.columns = (config.columns || []).map((e) => getValue(e));
// 自动高度
config.autoHeight = config.autoHeight ?? style.table.autoHeight;
// 右键菜单
config.contextMenu = config.contextMenu ?? style.table.contextMenu;
// 事件
if (!config.on) {
config.on = {};
}
// 参数
if (!config.props) {
config.props = {};
}
return { Table, config };
}
export * from "./data";
export * from "./height";
export * from "./op";
export * from "./render";
export * from "./row";
export * from "./selection";
export * from "./sort";
export * from "./header";

View File

@@ -0,0 +1,69 @@
import { nextTick, ref } from "vue";
import { useCore } from "../../../hooks";
import { isArray, isBoolean } from "lodash-es";
export function useOp({ config }: { config: ClTable.Config }) {
const { mitt } = useCore();
// 是否可见,用于解决一些显示隐藏的副作用
const visible = ref(true);
// 重新构建
async function reBuild(cb?: fn) {
visible.value = false;
await nextTick();
if (cb) {
cb();
}
visible.value = true;
await nextTick();
mitt.emit("resize");
}
// 显示列
function showColumn(prop: string | string[], status?: boolean) {
const keys = isArray(prop) ? prop : [prop];
// 多级表头
function deep(list: ClTable.Column[]) {
list.forEach((e) => {
if (e.prop && keys.includes(e.prop)) {
e.hidden = isBoolean(status) ? !status : false;
}
if (e.children) {
deep(e.children);
}
});
}
deep(config.columns);
}
// 隐藏列
function hideColumn(prop: string | string[]) {
showColumn(prop, false);
}
// 设置列
function setColumns(list: ClTable.Column[]) {
if (list) {
reBuild(() => {
config.columns.splice(0, config.columns.length, ...list);
});
}
}
return {
visible,
reBuild,
showColumn,
hideColumn,
setColumns
};
}

View File

@@ -0,0 +1,22 @@
import { getCurrentInstance } from "vue";
import { useConfig } from "../../../hooks";
import { uniqueFns } from "../../../utils";
export function usePlugins() {
const that: any = getCurrentInstance();
const { style } = useConfig();
// 插件创建
function create(plugins: ClTable.Plugin[] = []) {
// 执行
uniqueFns([...(style.table.plugins || []), ...plugins]).forEach((p) => {
p({
exposed: that.exposed
});
});
}
return {
create
};
}

View File

@@ -0,0 +1,327 @@
import { h, useSlots } from "vue";
import { useCore, useBrowser, useConfig } from "../../../hooks";
import { assign, cloneDeep, isArray, isEmpty, isObject, isString, orderBy } from "lodash-es";
import { deepFind, getValue } from "../../../utils";
import { renderNode } from "../../../utils/vnode";
import { renderHeader } from "./header";
// 渲染
export function useRender() {
const browser = useBrowser();
const slots = useSlots();
const { crud } = useCore();
const { style } = useConfig();
// 渲染列
function renderColumn(columns: ClTable.Column[]) {
const arr = columns.map((e) => {
const d = getValue(e);
if (!d.orderNum) {
d.orderNum = 0;
}
return d;
});
return orderBy(arr, "orderNum", "asc")
.map((item, index) => {
if (item.hidden) {
return null;
}
const ElTableColumn = (
<el-table-column
key={`cl-table-column__${index}`}
align={style.table.column.align}
header-align={style.table.column.headerAlign}
minWidth={style.table.column.minWidth}
/>
);
// 操作按钮
if (item.type === "op") {
const props = assign(
{
label: crud.dict.label.op,
width: style.table.column.opWidth,
fixed: browser.isMini ? null : "right"
},
item
);
return h(ElTableColumn, props, {
default: (scope: any) => {
return (
<div class="cl-table__op">
{renderOpButtons(item.buttons, { scope })}
</div>
);
}
});
}
// 多选,序号
else if (["selection", "index"].includes(item.type)) {
return h(ElTableColumn, item);
}
// 默认
else {
function deep(item: ClTable.Column) {
if (item.hidden) {
return null;
}
const props: obj = cloneDeep(item);
// Cannot set property children of #<Element>
delete props.children;
return h(ElTableColumn, props, {
header(scope: any) {
return renderHeader(item, { scope, slots });
},
default(scope: any) {
if (item.children) {
return item.children.map(deep);
}
// 使用插槽
const slot = slots[`column-${item.prop}`];
if (slot) {
return slot({
scope,
item
});
} else {
// 绑定值
let value = scope.row[item.prop];
// 格式化
if (item.formatter) {
value = item.formatter(
scope.row,
scope.column,
value,
scope.$index
);
if (isObject(value)) {
return value;
}
}
// 自定义渲染
if (item.render) {
return item.render(
scope.row,
scope.column,
value,
scope.$index
);
}
// 自定义渲染2
else if (item.component) {
return renderNode(item.component, {
prop: item.prop,
scope: scope.row,
_data: {
column: scope.column,
index: scope.$index,
row: scope.row
}
});
}
// 字典状态
else if (item.dict) {
return renderDict(value, item);
}
// 空数据
else if (isEmpty(value)) {
return scope.emptyText;
} else {
return value;
}
}
}
});
}
return deep(item);
}
})
.filter(Boolean);
}
// 渲染操作按钮
function renderOpButtons(buttons: any, { scope }: any) {
const list = getValue(buttons || ["edit", "delete"], { scope }) as ClTable.OpButton;
return list.map((vnode) => {
if (vnode === "info") {
return (
<el-button
plain
size={style.size}
v-show={crud.getPermission("info")}
onClick={(e: MouseEvent) => {
crud.rowInfo(scope.row);
e.stopPropagation();
}}>
{crud.dict.label?.info}
</el-button>
);
} else if (vnode === "edit") {
return (
<el-button
text
type="primary"
size={style.size}
v-show={crud.getPermission("update")}
onClick={(e: MouseEvent) => {
crud.rowEdit(scope.row);
e.stopPropagation();
}}>
{crud.dict.label?.update}
</el-button>
);
} else if (vnode === "delete") {
return (
<el-button
text
type="danger"
size={style.size}
v-show={crud.getPermission("delete")}
onClick={(e: MouseEvent) => {
crud.rowDelete(scope.row);
e.stopPropagation();
}}>
{crud.dict.label?.delete}
</el-button>
);
} else {
if (typeof vnode === "object") {
if (vnode.hidden) {
return null;
}
}
return renderNode(vnode, {
scope,
slots,
custom(vnode) {
return (
<el-button
text
type={vnode.type}
{...vnode?.props}
onClick={(e: MouseEvent) => {
vnode.onClick({ scope });
e.stopPropagation();
}}>
{vnode.label}
</el-button>
);
}
});
}
});
}
// 渲染字典
function renderDict(value: any, item: ClTable.Column) {
// 选项列表
const list = cloneDeep(item.dict || []) as DictOptions;
// 字符串分隔符
const separator = item.dictSeparator === undefined ? "," : item.dictSeparator;
// 设置颜色
if (item.dictColor) {
list.forEach((e, i) => {
if (!e.color) {
e.color = style.colors[i];
}
});
}
// 绑定值
let values: any[] = [];
// 格式化值
if (isArray(value)) {
values = value;
} else if (isString(value)) {
if (separator) {
values = value.split(separator);
} else {
values = [value];
}
} else {
values = [value];
}
// 返回值
const result = values
.filter((e) => e !== undefined && e !== null && e !== "")
.map((v) => {
const d = deepFind(v, list, { allLevels: item.dictAllLevels }) || {
label: v,
value: v
};
return {
...d,
children: []
};
});
// 格式化返回
if (item.dictFormatter) {
return item.dictFormatter(result);
} else {
// tag 返回
return result.map((e) => {
return h(
<el-tag disable-transitions style="margin: 2px; border: 0" />,
{
type: e.type,
closable: e.closable,
hit: e.hit,
color: e.color,
size: e.size,
effect: e.effect || "dark",
round: e.round
},
{
default: () => <span>{e.label}</span>
}
);
});
}
}
// 插槽 empty
function renderEmpty(emptyText: string) {
return (
<div class="cl-table__empty">
{slots.empty ? (
slots.empty()
) : (
<el-empty image-size={100} description={emptyText}></el-empty>
)}
</div>
);
}
// 插槽 append
function renderAppend() {
return <div class="cl-table__append">{slots.append && slots.append()}</div>;
}
return {
renderColumn,
renderEmpty,
renderAppend
};
}

View File

@@ -0,0 +1,130 @@
import { isEmpty, isFunction } from "lodash-es";
import { useCore } from "../../../hooks";
import { ContextMenu } from "../../context-menu";
// 单元行事件
export function useRow({
Table,
config,
Sort
}: {
Table: Vue.Ref<any>;
config: ClTable.Config;
Sort: {
defaultSort: {
prop?: string;
order?: string;
};
changeSort(prop: string, order: string): void;
};
}) {
const { crud } = useCore();
// 右键菜单
function onRowContextMenu(row: obj, column: obj, event: PointerEvent) {
// 菜单按钮
const buttons = config.contextMenu;
// 是否开启
const enable = !isEmpty(buttons);
if (enable) {
// 高亮
Table.value.setCurrentRow(row);
// 解析按钮
const list = buttons
.map((e) => {
switch (e) {
case "refresh":
return {
label: crud.dict.label.refresh,
callback(done: fn) {
crud.refresh();
done();
}
};
case "edit":
case "update":
return {
label: crud.dict.label.update,
hidden: !crud.getPermission("update"),
callback(done: fn) {
crud.rowEdit(row);
done();
}
};
case "delete":
return {
label: crud.dict.label.delete,
hidden: !crud.getPermission("delete"),
callback(done: fn) {
crud.rowDelete(row);
done();
}
};
case "info":
return {
label: crud.dict.label.info,
hidden: !crud.getPermission("info"),
callback(done: fn) {
crud.rowInfo(row);
done();
}
};
case "check":
return {
label: crud.selection.find((e) => e.id == row.id)
? crud.dict.label.deselect
: crud.dict.label.select,
hidden: !config.columns.find((e) => e.type === "selection"),
callback(done: fn) {
Table.value.toggleRowSelection(row);
done();
}
};
case "order-desc":
return {
label: `${column.label} - ${crud.dict.label.desc}`,
hidden: !column.sortable,
callback(done: fn) {
Sort.changeSort(column.property, "desc");
done();
}
};
case "order-asc":
return {
label: `${column.label} - ${crud.dict.label.asc}`,
hidden: !column.sortable,
callback(done: fn) {
Sort.changeSort(column.property, "asc");
done();
}
};
default:
if (isFunction(e)) {
return e(row, column, event);
} else {
return e;
}
}
})
.filter((e) => Boolean(e) && !e.hidden);
// 打开菜单
if (!isEmpty(list)) {
ContextMenu.open(event, {
list
});
}
}
// 回调
if (config.onRowContextmenu) {
config.onRowContextmenu(row, column, event);
}
}
return {
onRowContextMenu
};
}

View File

@@ -0,0 +1,16 @@
import { useCore } from "../../../hooks";
export function useSelection({ emit }: { emit: Vue.Emit }) {
const { crud } = useCore();
// 选择项发生变化
function onSelectionChange(selection: any[]) {
crud.selection.splice(0, crud.selection.length, ...selection);
emit("selection-change", crud.selection);
}
return {
selection: crud.selection,
onSelectionChange
};
}

View File

@@ -0,0 +1,86 @@
import { useCore } from "../../../hooks";
// 排序
export function useSort({
config,
Table,
emit
}: {
config: ClTable.Config;
Table: Vue.Ref<any>;
emit: Vue.Emit;
}) {
const { crud } = useCore();
// 设置默认排序Ï
const defaultSort = (function () {
let { prop, order } = config.defaultSort || {};
const item = config.columns.find((e) =>
["desc", "asc", "descending", "ascending"].find((a) => a == e.sortable)
);
if (item) {
prop = item.prop;
order = ["descending", "desc"].find((a) => a == item.sortable)
? "descending"
: "ascending";
}
if (order && prop) {
crud.params.order = ["descending", "desc"].includes(order) ? "desc" : "asc";
crud.params.prop = prop;
return {
prop,
order
};
}
return {};
})();
// 排序监听
function onSortChange({ prop, order }: { prop: string | undefined; order: string }) {
if (config.sortRefresh) {
if (order === "descending") {
order = "desc";
}
if (order === "ascending") {
order = "asc";
}
if (!order) {
prop = undefined;
}
crud.refresh({
prop,
order,
page: 1
});
}
emit("sort-change", { prop, order });
}
// 改变排序
function changeSort(prop: string, order: string) {
if (order === "desc") {
order = "descending";
}
if (order === "asc") {
order = "ascending";
}
Table.value?.sort(prop, order);
}
return {
defaultSort,
onSortChange,
changeSort
};
}

View File

@@ -0,0 +1,165 @@
import { defineComponent, h } from "vue";
import {
useRow,
useHeight,
useRender,
useSort,
useData,
useSelection,
useOp,
useTable
} from "./helper";
import { useCore, useProxy, useElApi, useConfig } from "../../hooks";
import { usePlugins } from "./helper/plugins";
export default defineComponent({
name: "cl-table",
props: {
// 列配置
columns: {
type: Array,
default: () => []
},
// 是否自动计算高度
autoHeight: {
type: Boolean,
default: null
},
// 固定高度
height: null,
// 右键菜单
contextMenu: {
type: [Array, Boolean],
default: null
},
// 默认排序
defaultSort: Object,
// 排序后是否刷新
sortRefresh: {
type: Boolean,
default: true
},
// 空数据显示文案
emptyText: String,
// 当前行的 key
rowKey: {
type: String,
default: "id"
}
},
emits: ["selection-change", "sort-change"],
setup(props, { emit, expose }) {
const { crud } = useCore();
const { style } = useConfig();
const { Table, config } = useTable(props);
const plugin = usePlugins();
// 排序
const Sort = useSort({ config, emit, Table });
// 行
const Row = useRow({
config,
Table,
Sort
});
// 高度
const Height = useHeight({ config, Table });
// 数据
const Data = useData({ config, Table });
// 多选
const Selection = useSelection({ emit });
// 操作
const Op = useOp({ config });
// 方法
const ElTableApi = useElApi(
[
"clearSelection",
"getSelectionRows",
"toggleRowSelection",
"toggleAllSelection",
"toggleRowExpansion",
"setCurrentRow",
"clearSort",
"clearFilter",
"doLayout",
"sort",
"scrollTo",
"setScrollTop",
"setScrollLeft",
"updateKeyChildren"
],
Table
);
const ctx = {
Table,
config,
columns: config.columns,
...Selection,
...Data,
...Sort,
...Row,
...Height,
...Op,
...ElTableApi
};
useProxy(ctx);
expose(ctx);
plugin.create(config.plugins);
return () => {
const { renderColumn, renderAppend, renderEmpty } = useRender();
return (
ctx.visible.value &&
h(
<el-table class="cl-table" ref={Table} v-loading={crud.loading} />,
{
...config.on,
...config.props,
// config
maxHeight: config.autoHeight ? ctx.maxHeight.value : null,
height: config.autoHeight ? config.height : null,
rowKey: config.rowKey,
// ctx
defaultSort: ctx.defaultSort,
data: ctx.data.value,
onRowContextmenu: ctx.onRowContextMenu,
onSelectionChange: ctx.onSelectionChange,
onSortChange: ctx.onSortChange,
// style
size: style.size,
border: style.table.border,
highlightCurrentRow: style.table.highlightCurrentRow,
resizable: style.table.resizable,
stripe: style.table.stripe,
},
{
default() {
return renderColumn(ctx.columns);
},
empty() {
return renderEmpty(config.emptyText || crud.dict.label.empty);
},
append() {
return renderAppend();
}
}
)
);
};
}
});