feat: 移除旧的表格表单组件

master
luoer 2023-11-24 17:14:55 +08:00
parent d2b8bc2f8e
commit 9a15a88eb0
38 changed files with 402 additions and 2700 deletions

6
.env
View File

@ -2,13 +2,13 @@
# 应用配置
# =====================================================================================
# 网站标题
VITE_TITLE = Appnify
VITE_TITLE = 绝弹管理中心
# 网站副标题
VITE_SUBTITLE = 快速开发web应用的模板工具
VITE_SUBTITLE = 绝弹管理中心
# 部署路径: 当为 ./ 时路由模式需为 hash
VITE_BASE = /
# 接口前缀:参见 axios 的 baseURL
VITE_API = https://appnify.app.juetan.cn/
VITE_API = http://127.0.0.1:3030/
# 首页路径
VITE_HOME_PATH = /home
# 路由模式web(路径) hash(锚点)

View File

@ -7,14 +7,12 @@ import pkg from "../../package.json";
* logo
* @description APPTIFY
*/
const LOGO = `
________ ______ ______ _________ ________ ______ __ __
/_______/\\\\ /_____/\\\\ /_____/\\\\ /________/\\\\/_______/\\\\/_____/\\\\ /_/\\\\/_/\\\\
\\\\::: _ \\\\ \\\\\\\\:::_ \\\\ \\\\\\\\:::_ \\\\ \\\\\\\\__.::.__\\\\/\\\\__.::._\\\\/\\\\::::_\\\\/_\\\\ \\\\ \\\\ \\\\ \\\\
\\\\::(_) \\\\ \\\\\\\\:(_) \\\\ \\\\\\\\:(_) \\\\ \\\\ \\\\::\\\\ \\\\ \\\\::\\\\ \\\\ \\\\:\\\\/___/\\\\\\\\:\\\\_\\\\ \\\\ \\\\
\\\\:: __ \\\\ \\\\\\\\: ___\\\\/ \\\\: ___\\\\/ \\\\::\\\\ \\\\ _\\\\::\\\\ \\\\__\\\\:::._\\\\/ \\\\::::_\\\\/
\\\\:.\\\\ \\\\ \\\\ \\\\\\\\ \\\\ \\\\ \\\\ \\\\ \\\\ \\\\::\\\\ \\\\ /__\\\\::\\\\__/\\\\\\\\:\\\\ \\\\ \\\\::\\\\ \\\\
\\\\__\\\\/\\\\__\\\\/ \\\\_\\\\/ \\\\_\\\\/ \\\\__\\\\/ \\\\________\\\\/ \\\\_\\\\/ \\\\__\\\\/
const LOGO = ` _ _______ _______ ____ _____ _____ ________ ____ ____
/ \\\\ |_ __ \\\\|_ __ \\\\|_ \\\\|_ _||_ _||_ __ ||_ _||_ _|
/ _ \\\\ | |__) | | |__) | | \\\\ | | | | | |_ \\\\_| \\\\ \\\\ / /
/ ___ \\\\ | ___/ | ___/ | |\\\\ \\\\| | | | | _| \\\\ \\\\/ /
_/ / \\\\ \\\\_ _| |_ _| |_ _| |_\\\\ |_ _| |_ _| |_ _| |_
|____| |____||_____| |_____| |_____|\\\\____||_____||_____| |______|
`;
/**
@ -47,7 +45,7 @@ const getBuildInfo = async () => {
const latestTag = await exec("git describe --tags --abbrev=0");
const commits = await exec(`git rev-list --count ${latestTag}..HEAD`);
const version = commits ? `${latestTag}.${commits}` : `v${pkg.version}`;
const content = ` 欢迎访问!版本: ${version} 标识: ${hash} 构建时间: ${time}`;
const content = `欢迎访问!版本: ${version} 标识: ${hash} 构建: ${time}`;
const style = `"color: #09f; font-weight: 900;", "font-size: 12px; color: #09f; font-family: ''"`;
const script = `console.log(\`%c${LOGO} \n%c${content}\n\`, ${style});\n`;
return script;

View File

@ -40,7 +40,7 @@ export function addExceptionInterceptor(axios: AxiosInstance, exipreHandler?: (.
logoutTipShowing = true;
Notification.warning({
title: '登陆提示',
content: '当前登陆已过期,请重新登陆!',
content: '登陆已过期,请重新登陆!',
onClose: () => (logoutTipShowing = false),
});
exipreHandler?.(error);

View File

@ -67,7 +67,7 @@ export function useFormModal(options: FormModalUseOptions) {
<AnFormModal
ref={(el: any) => (modalRef.value = el)}
title={props.title}
trigger={props.title}
trigger={props.trigger}
modalProps={props.modalProps as any}
model={props.model}
items={props.items}

View File

@ -1,237 +0,0 @@
### 介绍
基于`Arco-Design`组件封装的表单,旨在通过较少的配置提升开发效率,将一些通过的状态管理内置,使得开发者只需关注核心内容即可快速开发通用型表单。
本表单适用于通用型表单,对于自定义要求较高的需求,可能不太适合。
### 功能
- 配置化编写代码保证UI一致性提供开发效率。
- 提供typesciprt类型提示
- 表单项和校验规则之间可联动、可动态显示/隐藏
- 内置常用校验规则,开箱即用
- 支持组件参数透传,让每个组件都能自定义。
### 基本功能
基本用法:
```tsx
<template>
<Form v-bind="form" />
</template>
<script setup lang="ts">
import { Form, useForm } from '@/components'
const form = useForm({
model: {
id: undefined
},
items: [
{
field: 'username',
label: '用户名称',
type: 'input'
}
],
submit: async ({ model, items }) => {
await new Promise(res => setTimeout(res, 2000));
return { message: '操作成功!' }
},
formProps: {},
})
</script>
```
以上, 只有四个参数,只需定义关注的内容,剩下的内容如内部状态等, 由表单管理。
| 参数 | 说明 | 类型 |
| :--- | :--- | :--- |
| model | 表单数据(可选),默认从`items`每一项的`field`和`initialValue`生成,如果存在同名属性,将与其合并。 | `Record<string, any>` |
| items | 表单项,具体用法看下文。| `FormItem[]` |
| submit | 提交表单的函数,可为同步/异步函数。当有返回值且返回值为包含`message`的对象时,将弹出成功提示。| `({ model, items }) => Promise<any>` |
| formProps | 传递给`AForm`组件的参数(可选),具体可参考`Arco-Design`的`Form`组件,部分参数不可用,如`model`等。 | `FormInstance['$props']` |
### 表单数据
`model`表示当前表单的数据,可为空。当使用`useForm`时,将从`items`中每一项的`field`和`initialValue`生成。如果`model`中的属性与`field`值同名,且`initialValue`值不为空,则原`model`中的同名属性值将被覆盖。
对于日期范围框、级联选择器等值为数组的组件,提供有一份便捷的语法,请看如下示例:
```typescript
const form = useForm({
items: [
{
field: `[startDate, endDate]`,
label: '日期范围',
type: 'dateRange',
},
{
field: '[provice: number, city: number, town: number]',
label: '省市区',
type: 'cascader',
options: []
}
]
})
```
以上,`field` 使用的是类似Typescript元组的写法类型目前支持 number 和 boolean在提交时将得到如下数据
```typescript
{
startDate: '2023',
endDate: '2024',
province: 1,
city: 2,
town: 3
}
```
### 表单项
用法示例:
```typescript
const form = useForm({
items: [
{
field: 'username',
initialValue: 'apptify',
label: '用户名称',
type: 'input',
itemProps: {},
nodeProps: {},
visible: ({ model, item, items }) => true,
disable: ({ model, item, items }) => true,
required: true,
rules: ['email'],
options: ({ model }) => api.xx(model.id)
component: ({ model, item, items }) => <div> </div>,
help: string |
}
]
})
```
用法说明:
| 参数 | 说明 | 类型 | 默认值 |
| :--- | :--- | :--- | :--- |
| field | 字段名,将合并合并到`model`中,默认值为`undefined`,可通过`initalValue`指定初始值 | string | - |
| initialValue | 初始值, 作为默认初始值以及通过`formRef.reset`重置表单数据时的值 | any | undefined |
| label | 标签名,可为字符串或函数, 作用同`AFormItem`的`label`参数 | string \| ({ model,item }) => JSX.Element | - |
| type | 输入控件的类型,具体可参考下文 | NodeType | 'input' |
| visible | 动态控制该表单项是否显示 | boolean \| ({ model,item }) => boolean | - |
| disable | 动态控制该表单项是否禁止,作用同`FormItem`的`disabled`属性 | boolean \| ({ model, item }) => boolean | - |
| required | 是否必填,作用同`AFormItem`的`required`属性 | boolean | - |
| rules | 校验规则,内置常用规则,并支持动态生效,详见下文 | RuleType[] | - |
| options | 作用域`select`等多选项组件,支持动态获取 | (Option[]) \| ({ model, item }) => Option[] | - |
| itemProps | 传递给`AFormItem`组件的参数,部分参数不可用,如上面的`field`等参数 | FormItemInstance['$props'] | - |
| nodeProps | 传递给`type`属性对应组件的参数,如当`type`为`input`时, `nodeProps`类型为`Input`组件的props。 | NodeProps | - |
### 控件类型
表单项的`type`指定表单控件的类型,当输入具体的值时,`nodeProps`会提供对应的参数类型提示。内置有常见的组件,且带有默认的参数,具体默认参数可在`src/components/form/form-node.tsx`中查看:
| 类型 | 说明 |
| :--- | :--- |
| input | 同 [Input](https://arco.design/vue/component/input) 组件
| number | 同 [InputNumber](https://arco.design/vue/component/input-number) 组件
| password | 同 [InputPassword](https://arco.design/vue/component/input#password) 组件
| select | 同 [Select](https://arco.design/vue/component/select) 组件
| time | 同 [TimePicker](https://arco.design/vue/component/time-picker) 组件
| date | 同 [DatePicker](https://arco.design/vue/component/date-picker) 组件
| dateRange | 同 [RangePicker](https://arco.design/vue/component/date-picker#range) 组件
| textarea | 同 [Textarea](https://arco.design/vue/component/textarea) 组件
| cascader | 同 [Cascader](https://arco.design/vue/component/cascader) 组件
| checkbox | 同 [Checkbox](https://arco.design/vue/component/checkbox) 组件
| radio | 同 [Radio](https://arco.design/vue/component/radio) 组件
| slider | 同 [Slider](https://arco.design/vue/component/slider) 组件
| submit | 提交表单按钮,应只有一个。
| custom | 自定义组件,通过表单项的`component`属性定义需返回一个JSX元素。
对于`select`、`checkbox`、`radio`和`cascader`类型,其`options`参数不通过`nodeProps`传递,而是写在表单项的`options`属性。该属性支持数组和函数类型,当为数组类型时将直接传递给控件,当为函数时可动态请求,返回值需为数组类型。
以上描述,示例如下:
```typescript
const form = useForm({
items: [
{
field: 'gender',
label: '性别',
type: 'select',
options: [
{
label: '男',
value: 1,
},
{
label: '女',
value: 2,
}
]
},
{
field: 'departmentId',
label: '部门',
type: 'cascader',
options: async ({ model, item }) => {
const res = await api.getDepartments(model.xx);
return res.data;
}
}
]
})
```
### 表单校验
跟表单校验相关的属性有2个`required`(必填)和`rules`属性,其中`rules`内置常见的校验规则,参考如下:
| 校验规则 | 说明 |
| :--- | :--- |
| string | 格式为字符串 |
| number | 格式为数字 |
| passwod | 格式为密码类型,即至少包含大写字母、小写字母、数字和特殊字符。|
| required | 该项必填 |
| email | 格式为邮箱类型,例如: xx@abc.com |
| url | 格式为URL类型, 例如: https://abc.com |
| ip | 格式为IP类型, 例如: 101.10.10.302 |
| phone | 格式为11位手机号例如: 15912345678 |
| idcard | 格式为18位身份证号例如: 12345619991205131x |
| alphabet | 格式为26字母例如apptify |
当以上规则不满足需求时,可通过对象自定义校验规则,具体语法可参考`AFormItem`的 [FieldRule](https://arco.design/vue/component/form#FieldRule) 文档。在其基础上,可添加一个`disable`函数,用于动态禁止/允许当前校验规则。
用法示例:
```typescript
const form = useForm({
items: [
{
required: true,
rules: [
'email',
{
match: /\d{2,3}/,
message: '请输入2~3位数字',
disable: ({ model, item, items }) => !model.username
}
],
}
]
})
```
### 提交表单
`submit`为提交表单的函数,通常返回一个`promise`,当该函数抛出异常,则默认为提交失败。该函数有一个可选的返回值,如果返回值为包含`message`的对象时,将弹出一个包含`message`值的成功提示。
示例如下:
```typescript
const form = useForm({
submit: async ({ model, items }) => {
const res = await api.xx(model);
return { message: res.msg }
}
})
```
### 常见问题
- Q为什么不是模板形式
- A状态驱动配置式更易于描述逻辑模板介入和引入的组件比较多且对于做typescript类型提示不是很方便。
- Q为什么不是JSON形式
- A对于自定义组件支持、联动等不是非常友好尽管可以通过解析字符串执行等方式实现对typescript提示不是很友好。
### 最后
尽管看起来是低代码,但其实我更倾向于是业务组件。

View File

@ -1,113 +0,0 @@
export const config = {
item: {
defaults: {
type: "input",
},
},
/**
* API
*/
getApiErrorMessage(error: any) {
return error?.response?.data?.message || error?.message || "Error";
},
/**
*
*/
setModel: function setModel(model: any, data: any) {
for (const key of Object.keys(model)) {
// 数组类型
if (/^\[.+\]$/.test(key)) {
const subkeysStr = key.replaceAll(/\s/g, "").match(/^\[(.+)\]$/)?.[1];
if (!subkeysStr) {
model[key] = data[key];
continue;
}
const subkeys = subkeysStr.split(",");
const value = new Array(subkeys.length);
subkeys.forEach((subkey, index) => {
if (/.+:number$/.test(subkey)) {
subkey = subkey.replace(/:number$/, "");
value[index] = Number(data[subkey]);
return;
}
if (/.+:boolean$/.test(subkey)) {
subkey = subkey.replace(/:boolean$/, "");
value[index] = Boolean(data[subkey]);
return;
}
value[index] = data[subkey];
});
model[key] = value;
continue;
}
// 默认类型
model[key] = data[key];
}
return model;
},
/**
*
*/
getModel: function getModel(model: Record<string, any>) {
const data: any = {};
for (const [key, val] of Object.entries(model)) {
// 数组类型
if (/^\[.+\]$/.test(key)) {
const subkeysStr = key.replaceAll(/\s/g, "").match(/^\[(.+)\]$/)?.[1];
if (!subkeysStr) {
data[key] = val;
continue;
}
const subkeys = subkeysStr.split(",");
subkeys.forEach((subkey, index) => {
if (/(.+)?:number$/.test(subkey)) {
subkey = subkey.replace(/:number$/, "");
data[subkey] = val?.[index] && Number(val[index]);
return;
}
if (/(.+)?:boolean$/.test(subkey)) {
subkey = subkey.replace(/:boolean$/, "");
data[subkey] = val?.[index] && Boolean(val[index]);
return;
}
data[subkey] = val?.[index];
});
continue;
}
// 默认类型
data[key] = val;
}
return data;
},
};
export function initOptions({ item, model }: any, key = "options") {
if (Array.isArray(item.options)) {
item.nodeProps[key] = item.options;
return;
}
if (item.options && typeof item.options === "object") {
const { value, source } = item.options;
item._updateOptions = async () => {};
return;
}
if (typeof item.options === "function") {
const loadData = item.options;
item.nodeProps[key] = reactive([]);
item._updateOptions = async () => {
let data = await loadData({ item, model });
if (Array.isArray(data?.data?.data)) {
data = data.data.data.map((i: any) => ({
...i,
label: i.name,
value: i.id,
}));
}
if (Array.isArray(data)) {
item.nodeProps[key].splice(0);
item.nodeProps[key].push(...data);
}
};
item._updateOptions();
}
}

View File

@ -1,180 +0,0 @@
import { FormItem as BaseFormItem, FieldRule, FormItemInstance, SelectOptionData } from "@arco-design/web-vue";
import { NodeType, NodeUnion, nodeMap } from "./form-node";
import { RuleMap } from "./form-rules";
export type FieldStringRule = keyof typeof RuleMap;
export type FieldObjectRule = FieldRule & {
disable?: (arg: { item: IFormItem; model: Record<string, any> }) => boolean;
};
export type FieldRuleType = FieldStringRule | FieldObjectRule;
/**
*
*/
export const FormItem = (props: any, { emit }: any) => {
const { item } = props;
const args = {
...props,
field: item.field,
};
const rules = computed(() => {
const result = [];
if (item.required) {
result.push(RuleMap.required);
}
item.rules?.forEach((rule: any) => {
if (typeof rule === "string") {
result.push(RuleMap[rule as FieldStringRule]);
return;
}
if (!rule.disable) {
result.push(rule);
return;
}
if (!rule.disable({ model: props.model, item, items: props.items })) {
result.push(rule);
}
});
return result;
});
const disabled = computed(() => {
if (item.disable === undefined) {
return false;
}
if (typeof item.disable === "function") {
return item.disable(args);
}
return item.disable;
});
if (item.visible && !item.visible(args)) {
return null;
}
return (
<BaseFormItem {...item.itemProps} rules={rules.value} disabled={disabled.value} field={item.field}>
{{
default: () => {
if (item.component) {
return <item.component {...item.nodeProps} model={props.model} item={props.item} />;
}
const comp = nodeMap[item.type as NodeType]?.component;
if (!comp) {
return null;
}
if (item.type === "submit") {
return <comp loading={props.loading} onSubmit={() => emit("submit")} onCancel={emit("cancel")} />;
}
return <comp v-model={props.model[item.field]} {...item.nodeProps} />;
},
label: item.label && (() => (typeof item.label === "string" ? item.label : item.label?.(args))),
help: item.help && (() => (typeof item.help === "string" ? item.help : item.help?.(args))),
extra: item.extra && (() => (typeof item.extra === "string" ? item.extra : item.extra?.(args))),
}}
</BaseFormItem>
);
};
type FormItemBase = {
/**
*
* @example
* ```typescript
* {
* field: '[v1,v2]',
* type: 'dateRange',
* }
* // 将得到
* {
* v1: '2021',
* v2: '2021',
* }
* ```
*/
field: string;
/**
*
* @description model
*/
initial?: any;
/**
*
* @description FormItemlabel
*/
label?: string | ((args: { item: IFormItem; model: Record<string, any> }) => any);
/**
* `FormItem`
* @description fieldlabelrequiredrulesdisabled
*/
itemProps?: Partial<Omit<FormItemInstance["$props"], "field" | "label" | "required" | "rules" | "disabled">>;
/**
*
* @description false
*/
required?: boolean;
/**
*
* @description ()
* @example
* ```typescript
* rules: [
* // 内置
* 'idcard',
* // 自定义
* {
* match: /\d+/,
* message: '请输入数字',
* },
* ]
*```
* @see https://arco.design/vue/component/form#FieldRule
*/
rules?: FieldRuleType[];
/**
*
* @description
*/
visible?: (arg: { item: IFormItem; model: Record<string, any> }) => boolean;
/**
*
* @description
*/
disable?: (arg: { item: IFormItem; model: Record<string, any> }) => boolean;
/**
*
* @description ,
*/
options?: SelectOptionData[] | ((arg: { item: IFormItem; model: Record<string, any> }) => Promise<any>);
/**
*
* @description
*/
component?: (args: { item: IFormItem; model: Record<string, any>; field: string }) => any;
/**
*
* @description FormItemhelp
* @see https://arco.design/vue/component/form#form-item%20Slots
*/
help?: string | ((args: { item: IFormItem; model: Record<string, any> }) => any);
/**
*
* @description FormItemextra
* @see https://arco.design/vue/component/form#form-item%20Slots
*/
extra?: string | ((args: { item: IFormItem; model: Record<string, any> }) => any);
};
export type IFormItem = FormItemBase & NodeUnion;

View File

@ -1,194 +0,0 @@
import { Button, ButtonInstance, FormInstance, Message, Modal } from "@arco-design/web-vue";
import { assign, cloneDeep, omit } from "lodash-es";
import { PropType, VNode, defineComponent } from "vue";
import { Form } from "./form";
import { config } from "./form-config";
import { IFormItem } from "./form-item";
/**
*
*/
export const FormModal = defineComponent({
name: "FormModal",
inheritAttrs: false,
props: {
/**
*
* @default '添加'
*/
title: {
type: [String, Function] as PropType<
string | ((args: { model: Record<string, any>; items: IFormItem[] }) => string)
>,
default: "添加",
},
/**
*
*/
trigger: {
type: [Boolean, Function, Object] as PropType<
| boolean
| ((props: { model: any; items: any[] }) => VNode)
| {
text?: string;
buttonProps?: ButtonInstance["$props"];
}
>,
default: true,
},
/**
* Modalprops
*/
modalProps: {
type: Object as PropType<Omit<InstanceType<typeof Modal>["$props"], "visible" | "title" | "onBeforeOk">>,
},
/**
*
*/
model: {
type: Object as PropType<Record<any, any>>,
required: true,
},
/**
*
*/
items: {
type: Array as PropType<IFormItem[]>,
required: true,
},
/**
*
* @description `{ message }`
*/
submit: {
type: Function as PropType<(args: { model: any; items: IFormItem[] }) => PromiseLike<any>>,
default: () => true,
},
/**
* Formprops
*/
formProps: {
type: Object as PropType<Omit<FormInstance["$props"], "model">>,
},
},
emits: ["close", "submited"],
setup(props, { slots, emit, attrs }) {
const origin = cloneDeep(props.model);
const formRef = ref<InstanceType<typeof Form>>();
const loading = ref(false);
const visible = ref(false);
const open = async (data: Record<string, any> = {}) => {
visible.value = true;
await nextTick();
config.setModel(props.model, data);
};
const onBeforeOk = async () => {
if (typeof attrs.onBeforeOk === "function") {
const isOk = await attrs.onBeforeOk();
if (!isOk) return false;
}
const errors = await formRef.value?.formRef?.validate();
if (errors) {
return false;
}
try {
const model = formRef.value?.getModel() || {};
const res = await props.submit?.({ items: props.items, model });
res?.data?.message && Message.success(`提示: ${res.data.message}`);
emit("submited", res);
} catch (error: any) {
const message = config.getApiErrorMessage(error);
if (message) {
Message.error(`提示: ${message}`);
}
return false;
}
return true;
};
const onClose = () => {
visible.value = false;
assign(props.model, origin);
emit("close");
};
const modalTitle = computed(() => {
if (typeof props.title === "string") {
return props.title;
}
if (typeof props.title === "function") {
return props.title({ model: props.model, items: props.items });
}
});
const modalTrigger = computed(() => {
if (!props.trigger) {
return null;
}
let content;
if (typeof props.trigger === "boolean" || typeof props.trigger === "string") {
content = (
<Button type="primary">
{{
default: () => (typeof props.trigger === "string" ? props.trigger : "新增"),
icon: () => <i class="icon-park-outline-add" />,
}}
</Button>
);
}
if (typeof props.trigger === "function") {
content = props.trigger({ model: props.model, items: props.items });
}
if (typeof props.trigger === "object") {
content = (
<Button type="primary" {...props.trigger.buttonProps}>
{props.trigger?.text || "新增"}
</Button>
);
}
if (slots.trigger) {
content = slots.trigger({ model: props.model, items: props.items });
}
return <span onClick={() => open()}>{content}</span>;
});
return {
origin,
formRef,
loading,
visible,
modalTitle,
modalTrigger,
open,
onClose,
onBeforeOk,
};
},
render() {
return (
<>
{this.modalTrigger}
<Modal
{...this.modalProps}
v-model:visible={this.visible}
onBeforeOk={this.onBeforeOk}
onClose={this.onClose}
title={this.modalTitle}
class="ani-form-modal"
>
{this.visible && (
<Form ref={(el: any) => (this.formRef = el)} {...this.formProps} model={this.model} items={this.items}>
{{ ...this.$slots }}
</Form>
)}
</Modal>
</>
);
},
});
export type FormModalInstance = InstanceType<typeof FormModal>;
export type FormModalProps = FormModalInstance["$props"];
export default FormModal;

View File

@ -1,233 +0,0 @@
import {
AutoComplete,
Button,
Cascader,
CheckboxGroup,
DatePicker,
Input,
InputNumber,
InputPassword,
InputSearch,
RadioGroup,
RangePicker,
Select,
Slider,
Textarea,
TimePicker,
TreeSelect,
} from "@arco-design/web-vue";
import { initOptions } from "./form-config";
/**
*
*/
export const nodeMap = {
/**
*
*/
input: {
component: Input,
nodeProps: {
placeholder: "请输入",
allowClear: true,
} as InstanceType<typeof Input>["$props"],
},
/**
*
*/
search: {
component: InputSearch,
nodeProps: {
placeholder: "请输入",
allowClear: true,
} as InstanceType<typeof Input>["$props"] & InstanceType<typeof InputSearch>["$props"],
},
/**
*
*/
textarea: {
component: Textarea,
nodeProps: {
placeholder: "请输入",
allowClear: true,
} as InstanceType<typeof Textarea>["$props"],
},
/**
*
*/
number: {
component: InputNumber,
nodeProps: {
placeholder: "请输入",
defaultValue: 0,
allowClear: true,
} as InstanceType<typeof InputNumber>["$props"],
},
/**
*
*/
password: {
component: InputPassword,
nodeProps: {
placeholder: "请输入",
} as InstanceType<typeof InputPassword>["$props"],
},
/**
*
*/
select: {
component: Select,
nodeProps: {
placeholder: "请选择",
allowClear: true,
allowSearch: true,
options: [{}],
} as InstanceType<typeof Select>["$props"],
init: initOptions,
},
/**
*
*/
treeSelect: {
component: TreeSelect,
nodeProps: {
placeholder: "请选择",
allowClear: true,
allowSearch: true,
options: [],
onChange(value) {
value;
},
} as InstanceType<typeof TreeSelect>["$props"],
init: (arg: any) => initOptions(arg, "data"),
},
/**
*
*/
cascader: {
component: Cascader,
init: initOptions,
nodeProps: {
placeholder: "请选择",
allowClear: true,
expandTrigger: "hover",
} as InstanceType<typeof Cascader>["$props"],
},
/**
*
*/
time: {
component: TimePicker,
nodeProps: {
allowClear: true,
} as InstanceType<typeof TimePicker>["$props"],
},
/**
*
*/
date: {
component: DatePicker,
nodeProps: {
allowClear: true,
} as InstanceType<typeof DatePicker>["$props"],
},
/**
*
*/
dateRange: {
component: RangePicker,
nodeProps: {
allowClear: true,
} as InstanceType<typeof RangePicker>["$props"],
},
/**
*
*/
checkbox: {
component: CheckboxGroup,
nodeProps: {
allowClear: true,
} as InstanceType<typeof CheckboxGroup>["$props"],
init: initOptions,
},
/**
*
*/
radio: {
component: RadioGroup,
nodeProps: {
allowClear: true,
} as InstanceType<typeof RadioGroup>["$props"],
init: initOptions,
},
/**
*
*/
slider: {
component: Slider,
nodeProps: {
allowClear: true,
} as InstanceType<typeof Slider>["$props"],
},
/**
*
*/
autoComplete: {
component: AutoComplete,
nodeProps: {
allowClear: true,
} as InstanceType<typeof AutoComplete>["$props"],
},
/**
*
*/
submit: {
component: (props: any, { emit }: any) => {
return (
<>
<Button type="primary" loading={props.loading} onClick={() => emit("submit")} class="mr-3">
</Button>
{/* <Button loading={props.loading} onClick={() => emit("cancel")}>
</Button> */}
</>
);
},
nodeProps: {},
},
/**
*
*/
custom: {
nodeProps: {},
component: () => null,
},
};
/**
*
*/
export type NodeMap = typeof nodeMap;
/**
*
*/
export type NodeType = keyof NodeMap;
/**
* `FormItem`
* @description typenodeProps
*/
export type NodeUnion = {
[key in NodeType]: {
/**
* `input`
*/
type: key;
/**
* `type`
*/
nodeProps?: NodeMap[key]["nodeProps"];
};
}[NodeType];

View File

@ -1,46 +0,0 @@
import { FieldRule } from "@arco-design/web-vue";
const defineRuleMap = <T extends Record<string, FieldRule>>(ruleMap: T) => ruleMap;
export const RuleMap = defineRuleMap({
required: {
required: true,
message: "该项不能为空",
},
string: {
type: "string",
message: "请输入字符串",
},
number: {
type: "number",
message: "请输入数字",
},
email: {
type: "email",
message: "邮箱格式错误,示例: xx@abc.com",
},
url: {
type: "url",
message: "URL格式错误, 示例: www.abc.com",
},
ip: {
type: "ip",
message: "IP格式错误, 示例: 101.10.10.30",
},
phone: {
match: /^(?:(?:\+|00)86)?1\d{10}$/,
message: "手机格式错误, 示例(11位): 15912345678",
},
idcard: {
match: /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/,
message: "身份证格式错误, 长度为15或18位",
},
alphabet: {
match: /^[a-zA-Z]\w{4,15}$/,
message: "请输入英文字母, 长度为4~15位",
},
password: {
match: /^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*? ])\S*$/,
message: "至少包含大写字母、小写字母、数字和特殊字符",
},
});

View File

@ -1,120 +0,0 @@
import { Form as BaseForm, FormInstance as BaseFormInstance, Message } from "@arco-design/web-vue";
import { assign, cloneDeep, defaultsDeep } from "lodash-es";
import { PropType } from "vue";
import { config } from "./form-config";
import { FormItem, IFormItem } from "./form-item";
import { NodeType, nodeMap } from "./form-node";
type SubmitFn = (arg: { model: Record<string, any>; items: IFormItem[] }) => Promise<any>;
/**
*
*/
export const Form = defineComponent({
name: "Form",
props: {
/**
*
*/
model: {
type: Object as PropType<Record<any, any>>,
default: () => reactive({}),
},
/**
*
*/
items: {
type: Array as PropType<IFormItem[]>,
default: () => [],
},
/**
*
*/
submit: {
type: Function as PropType<SubmitFn>,
},
/**
* Form
*/
formProps: {
type: Object as PropType<Omit<BaseFormInstance["$props"], "model">>,
},
},
setup(props) {
const model = cloneDeep(props.model);
const formRef = ref<InstanceType<typeof BaseForm>>();
const loading = ref(false);
props.items.forEach((item: any) => {
const node = nodeMap[item.type as NodeType];
defaultsDeep(item, { nodeProps: node?.nodeProps ?? {} });
(node as any)?.init?.({ item, model: props.model });
});
const getItem = (field: string) => {
return props.items.find((item) => item.field === field);
};
const getModel = () => {
return config.getModel(props.model);
};
const setModel = (data: Record<string, any>) => {
config.setModel(props.model, data);
};
const resetModel = () => {
assign(props.model, model);
};
const submitForm = async () => {
if (await formRef.value?.validate()) {
return;
}
const model: Record<string, any> = getModel();
try {
loading.value = true;
const res = await props.submit?.({ model, items: props.items });
res?.message && Message.success(`提示: ${res.message}`);
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
message && Message.error(`提示: ${message}`);
} finally {
loading.value = false;
}
};
return {
formRef,
loading,
getItem,
submitForm,
resetModel,
setModel,
getModel,
};
},
render() {
(this.items as any).instance = this;
const props = {
items: this.items,
model: this.model,
slots: this.$slots,
};
return (
<BaseForm ref="formRef" layout="vertical" model={this.model} {...this.$attrs} {...this.formProps}>
{this.items.map((item) => (
<FormItem loading={this.loading} onSubmit={this.submitForm} item={item} {...props}></FormItem>
))}
</BaseForm>
);
},
});
export type FormInstance = InstanceType<typeof Form>;
export type FormProps = FormInstance["$props"];
export type FormDefinedProps = Pick<FormProps, "model" | "items" | "submit" | "formProps">;

View File

@ -1,4 +0,0 @@
export * from "./form";
export * from "./use-form";
export * from "./form-modal";
export * from "./use-form-modal";

View File

@ -1,61 +0,0 @@
import { Modal } from "@arco-design/web-vue";
import { merge } from "lodash-es";
import { Component, Ref, reactive } from "vue";
import { useForm } from "./use-form";
import FormModal, { FormModalInstance, FormModalProps } from "./form-modal";
const defaults: Partial<InstanceType<typeof Modal>> = {
width: 1080,
titleAlign: "start",
closable: false,
maskClosable: false,
};
/**
* FormModal
* @see src/components/form/use-form-modal.tsx
*/
export const useFormModal = (options: Partial<FormModalProps>): FormModalProps => {
const { model = {}, items = [] } = options || {};
const form = useForm({ model, items });
return reactive(
merge(
{
modalProps: { ...defaults },
formProps: {
layout: "vertical",
},
},
{
...options,
...form,
}
)
);
};
interface Context {
props: any;
modalRef: Ref<FormModalInstance | null>;
open: (args?: Record<string, any>) => Promise<void> | undefined;
}
export const useAniFormModal = (options: Partial<FormModalProps>): [Component, Context] => {
const props = useFormModal(options);
const modalRef = ref<FormModalInstance | null>(null);
const onModalRef = (el: any) => (modalRef.value = el);
const component = defineComponent({
name: "AniFormModalWrapper",
render() {
return <FormModal {...this.$attrs} {...props} ref={onModalRef} />;
},
});
const component1 = (p: any) => <FormModal {...p} {...props} ref={onModalRef} />;
const context = {
props,
modalRef,
open: (args?: Record<string, any>) => modalRef.value?.open(args),
};
return [component1, context];
};

View File

@ -1,59 +0,0 @@
import { FormInstance } from "@arco-design/web-vue";
import { merge } from "lodash-es";
import { IFormItem } from "./form-item";
export type Options = {
/**
*
*/
model?: Record<string, any>;
/**
*
*/
items: IFormItem[];
/**
*
*/
submit?: (arg: { model: Record<string, any>; items: IFormItem[] }) => Promise<any>;
/**
*
*/
formProps?: Partial<FormInstance["$props"]>;
};
/**
*
* @see src/components/form/use-form.tsx
*/
export const useForm = (options: Options) => {
const { model: _model = {} } = options;
const model: Record<string, any> = { id: undefined, ..._model };
const items: IFormItem[] = [];
for (const item of options.items) {
if (!item.nodeProps) {
item.nodeProps = {} as any;
}
model[item.field] = model[item.field] ?? item.initial;
items.push(item);
}
if (options.submit) {
const submit = items.find((item) => item.type === "submit") || {};
items.push(
merge(
{},
{
field: "id",
type: "submit",
itemProps: {
hideLabel: true,
},
},
submit
) as any
);
}
return reactive({ ...options, model, items }) as any;
};

View File

@ -1,34 +0,0 @@
import { cloneDeep } from "lodash-es";
/**
*
*/
export function getModel(model: any) {
const data: Record<string, any> = {};
for (const key of Object.keys(model)) {
if (/[^:]+:[^:]+/.test(key)) {
const keys = key.split(":");
const vals = cloneDeep(model[key] || []);
for (const k of keys) {
data[k] = vals.shift();
}
} else {
data[key] = cloneDeep(model[key]);
}
}
return data;
}
/**
*
*/
export function setModel(model: any, data: Record<string, any>) {
for (const key of Object.keys(model)) {
if (/[^:]+:[^:]+/.test(key)) {
const subKeys = key.split(":");
model[key] = subKeys.map((k) => data[k]);
} else {
model[key] = data[key];
}
}
}

View File

@ -1,2 +0,0 @@
export * from './form';
export * from './table';

View File

@ -1,162 +0,0 @@
### 基本用法
```typescript
<template>
<Table v-bind="table" />
</template>
<script setup lang="ts">
import { Table, useTable } from '@/components'
const table = useTable({
data: (search, paging) => {
return {
data: [
{
username: '用户A'
}
],
total: 30
};
},
columns: [
{
title: "用户名称",
dataIndex: "username",
},
],
pagination: {
pageSize: 10,
showTotal: true
},
search: {
items: [
{
extend: "username",
},
],
},
create: {
title: "新建用户",
items: [
{
field: "username",
label: "用户名称",
type: "input",
},
],
submit: async ({ model }) => {
return api.xx(model);
},
},
modify: {
title: "修改用户",
extend: true,
submit: async ({ model }) => {
return api.xx(model);
},
},
});
</script>
```
以上,就是一个 CRUD 表格的简单用法。参数描述:
| 参数 | 说明 | 类型 |
| :--- | :--- | :--- |
| data | 表格数据,可为数组或函数(发起 HTTP 请求) | BaseData[] | ((search, paging) => Promise<any>) |
| columns | 表格列,参见 [TableColumnData](https://arco.design/vue/component/table#TableColumnData) 文档,增加和扩展部分属性,详见下文。 | TableColumnData[] |
| pagination | 分页参数,参见 [Pagination](https://arco.design/vue/component/pagination) 文档,默认 15/每页。| Pagination |
| search | 搜索表单的配置,参见 [Form]() 说明,其中 `submit` 参数不可用 | FormProps |
| common | 新增和修改表单弹窗的公用参数,参见 [FormModal]() 说明。 | FormModalProps |
| create | 新增表单弹窗的参数,参见 [FormModal]() 说明, 将与`common`参数合并。 | FormModalProps |
| modify | 修改表单弹窗的参数,参见 [FormModal]() 说明, 将与`common`参数合并。 | FormModalProps |
| tableProps | 传递给`Table`组件的参数,参见 [Table](https://arco.design/vue/component/table) 文档,其中`columns`参数不可用。| TableProps |
### 表格数据
`data`定义表格数据,可以是数组或函数。
- 当是数组时,直接用作数据源。
- 当是函数时,传入查询参数和分页参数,可返回数组或对象,返回数组作用同上,返回对象时需遵循`{ data: [], total: number }`格式,用于分页处理。
用法示例:
```typescript
const table = useTable({
data: async (search, paging) {
const res = await api.xx({ ...search, ...paging });
return {
data: res.data,
total: res.total
}
}
})
```
### 表格列
`columns`定义表格列,并在原本基础上增加默认值并扩展部分属性。增加和扩展的属性如下:
| 参数 | 说明 | 类型 |
| :------ | :--------------------------------------------------------------------------------------------------- | :------- | -------- |
| type | 特殊类型, 目前支持`index`(表示行数)、`button`(行操作按钮) | 'index' | 'button' |
| buttons | 当`type`为`button`时的按钮数组,如果子项是对象则为`Button`组件的参数,如果为函数则为自定义渲染函数。 | Button[] |
### 表格分页
`pagination`定义分页行为,具体参数可参考 [Pagination](https://arco.design/vue/component/pagination) 文档。当`data`为数组时,将作为数据源进行分页;当`data`为函数且返回值为对象时,则根据`total`值进行分页。
### 搜索表单
参阅
### 公共参数
参数为`FormModal`的参数,主要作为新增和修改的公共参数。在大多数情况,新增和修改的配置大多是相似的,没必要写两份,把相同的参数写在这里即可,不同的参数在`create`和`modify`中单独配置。
注意,这里的`items`也可以被搜索表单复用,搜索表单可通过`extends: <field>`继承`common.items`中对应的字段配置。使用示例如下:
```typescript
const table = useTable({
common: {
items: [
{
field: "username",
label: "用户名称",
type: "input",
required: true,
},
],
},
search: {
items: [
{
extend: "usernam",
required: false,
},
],
},
});
```
### 新增弹窗
`create`为新增表单弹窗的参数,即`useFormModal`对应的参数。参阅。当指定该参数时,会在表格左上添加新建按钮,如需自定义按钮样式或自定义渲染,可通过`create.trigger`参数配置。
### 修改弹窗
`modify`为新增表单弹窗的参数,即`useFormModal`对应的参数。参阅。当指定该参数时,会在表格行添加修改按钮。
### 表格参数
`tableProps`为传递给`Table`组件的额外参数,其中部分参数不可用,如`data`和`columns`等。此外,部分参数有默认值,具体参数可查看`src/components/table/table.config.ts`文件。
### 插槽
- `Table`组件的插槽可正常使用
- `action`插槽用作表格左上方的操作区。
## 问题
- 问题:日期范围框值为数组,处理不方便
- 解决:字段名使用`v1:v2`格式,提交时会生成`{ v1: '00:00:01', v2: '00:00:02' }`数据
- 问题:搜索表单、新增表单和修改表单通常用到同一表单项,如何避免重复定义
- 解决:表单项使用`{ extends: <field-name> }`会在`common.items`中寻找相同的项,并合并值。

View File

@ -1,38 +0,0 @@
import { dayjs } from "@/libs/dayjs";
import { TableColumn } from "./use-interface";
const defineColumn = <T extends TableColumn>(column: T) => {
return column;
};
export const updateColumn = defineColumn({
title: "更新用户",
dataIndex: "createdAt",
width: 190,
render({ record }) {
return (
<div class="flex flex-col overflow-hidden">
<span>{record.updatedBy ?? "无"}</span>
<span class="text-gray-400 text-xs truncate">
{dayjs(record.updatedAt).format()}
</span>
</div>
);
},
});
export const createColumn = defineColumn({
title: "创建用户",
dataIndex: "createdAt",
width: 190,
render({ record }) {
return (
<div class="flex flex-col overflow-hidden">
<span>{record.createdBy ?? "无"}</span>
<span class="text-gray-400 text-xs truncate">
{dayjs(record.createdAt).format()}
</span>
</div>
);
},
});

View File

@ -1,4 +0,0 @@
export * from "./colume";
export * from "./table";
export * from "./use-table";

View File

@ -1,78 +0,0 @@
import { Button } from "@arco-design/web-vue";
import { IconRefresh, IconSearch } from "@arco-design/web-vue/es/icon";
export const config = {
searchInlineCount: 3,
searchFormProps: {
labelAlign: "left",
autoLabelWidth: true,
model: {},
},
searchItemSubmit: {
field: "id",
type: "custom",
label: ' ',
itemProps: {
class: "table-search-item col-start-4 !mr-0 grid grid-cols-[0_1fr]",
// hideLabel: true,
},
component: () => {
const tableRef = inject<any>("ref:table");
return (
<div class="w-full flex gap-x-2 justify-end">
{(tableRef.search?.items?.length || 0) > config.searchInlineCount && (
<Button disabled={tableRef?.loading.value} onClick={() => tableRef?.reloadData()}>
{{ icon: () => <IconRefresh></IconRefresh>, default: () => "重置" }}
</Button>
)}
<Button type="primary" loading={tableRef?.loading.value} onClick={() => tableRef?.reloadData()}>
{{ icon: () => <IconSearch></IconSearch>, default: () => "查询" }}
</Button>
</div>
);
},
},
pagination: {
current: 1,
pageSize: 10,
total: 300,
showTotal: true,
},
columnBase: {
ellipsis: true,
tooltip: true,
render: ({ record, column }: any) => record[column.dataIndex] || "-",
},
columnIndex: {
title: "序号",
width: 60,
align: "center",
render: ({ rowIndex }: any) => {
const table = inject<any>("ref:table");
const page = table.pagination.current;
const size = table.pagination.pageSize;
return size * (page - 1) + rowIndex + 1;
},
},
columnButtonBase: {
buttonProps: {
// type: "text",
// size: "mini",
},
},
columnButtonDelete: {
title: "删除确认",
content: "确认删除当前数据吗?",
modalClass: "text-center",
hideCancel: false,
maskClosable: false,
},
columnDropdownModify: {
text: "修改",
icon: "icon-park-outline-edit",
},
getApiErrorMessage(error: any): string {
const message = error?.response?.data?.message || error?.message || "请求失败";
return '';
},
};

View File

@ -1,213 +0,0 @@
import AniEmpty from "@/components/AnEmpty/AnEmpty.vue";
import { TableColumnData as BaseColumn, TableData as BaseData, Table as BaseTable } from "@arco-design/web-vue";
import { merge } from "lodash-es";
import { PropType, computed, defineComponent, reactive, ref } from "vue";
import { Form, FormInstance, FormModal, FormModalInstance, FormModalProps, FormProps } from "../form";
import { config } from "./table.config";
type DataFn = (search: Record<string, any>, paging: { page: number; size: number }) => Promise<any>;
/**
*
* @see src/components/table/table.tsx
*/
export const Table = defineComponent({
name: "Table",
props: {
/**
*
* @description `{ data: BaseData[], total: number }`
*/
data: {
type: [Array, Function] as PropType<BaseData[] | DataFn>,
},
/**
*
*/
columns: {
type: Array as PropType<BaseColumn[]>,
default: () => [],
},
/**
*
*/
pagination: {
type: Object as PropType<any>,
default: () => reactive(config.pagination),
},
/**
*
*/
search: {
type: Object as PropType<FormProps>,
},
/**
*
*/
create: {
type: Object as PropType<FormModalProps>,
},
/**
*
*/
modify: {
type: Object as PropType<FormModalProps>,
},
/**
*
*/
detail: {
type: Object as PropType<any>,
},
/**
* Table
*/
tableProps: {
type: Object as PropType<InstanceType<typeof BaseTable>["$props"]>,
},
},
setup(props) {
const loading = ref(false);
const tableRef = ref<InstanceType<typeof BaseTable>>();
const searchRef = ref<FormInstance>();
const createRef = ref<FormModalInstance>();
const modifyRef = ref<FormModalInstance>();
const renderData = ref<BaseData[]>([]);
const inlined = computed(() => (props.search?.items?.length ?? 0) <= config.searchInlineCount);
const reloadData = () => loadData({ current: 1, pageSize: 10 });
const openModifyModal = (data: any) => modifyRef.value?.open(data);
/**
*
* @param pagination
*/
const loadData = async (pagination: Partial<any> = {}) => {
const merged = { ...props.pagination, ...pagination };
const paging = { page: merged.current, size: merged.pageSize };
const model = searchRef.value?.getModel() ?? {};
// 本地加载
if (Array.isArray(props.data)) {
const filters = Object.entries(model);
const data = props.data.filter((item) => {
return filters.every(([key, value]) => {
if (typeof value === "string") {
return item[key].includes(value);
}
return item[key] === value;
});
});
renderData.value = data;
props.pagination.total = renderData.value.length;
props.pagination.current = 1;
}
// 远程加载
if (typeof props.data === "function") {
try {
loading.value = true;
const resData = await props.data(model, paging);
const { data = [], total = 0 } = resData?.data || {};
renderData.value = data;
props.pagination.total = total;
props.pagination.current = paging.page;
} catch (e) {
// todo
} finally {
loading.value = false;
}
}
};
watchEffect(() => {
if (Array.isArray(props.data)) {
renderData.value = props.data;
props.pagination.total = props.data.length;
props.pagination.current = 1;
}
});
onMounted(() => {
loadData();
});
if (props.search) {
merge(props.search, { formProps: { layout: "inline" } });
}
const state = {
loading,
inlined,
tableRef,
searchRef,
createRef,
modifyRef,
renderData,
loadData,
reloadData,
openModifyModal,
};
provide("ref:table", { ...state, ...props });
return state;
},
render() {
(this.columns as any).instance = this;
return (
<div class="table w-full">
{!this.inlined && (
<div class="border-b pb-0 border-slate-200 mb-3">
<Form ref="searchRef" class="!grid grid-cols-4 gap-x-6" {...this.search}></Form>
</div>
)}
<div class={`mb-3 flex toolbar justify-between ${!this.inlined && "mt-2"}`}>
<div class={`${this.create || this.$slots.action ? null : "!hidden"} flex-1 flex gap-2 `}>
{this.create && (
<FormModal {...(this.create as any)} ref="createRef" onSubmited={this.reloadData}></FormModal>
)}
{this.modify && (
<FormModal
{...(this.modify as any)}
ref="modifyRef"
onSubmited={this.reloadData}
trigger={false}
></FormModal>
)}
{this.$slots.action?.()}
</div>
<div>{this.inlined && <Form ref="searchRef" {...this.search}></Form>}</div>
</div>
<BaseTable
ref="tableRef"
row-key="id"
bordered={false}
{...this.$attrs}
{...this.tableProps}
loading={this.loading}
pagination={this.pagination}
data={this.renderData}
columns={this.columns}
onPageChange={(current: number) => this.loadData({ current })}
>
{{
empty: () => <AniEmpty />,
...this.$slots,
}}
</BaseTable>
</div>
);
},
});
/**
*
*/
export type TableInstance = InstanceType<typeof Table>;
/**
*
*/
export type TableProps = TableInstance["$props"];

View File

@ -1,164 +0,0 @@
import { Doption, Link, TableColumnData, TableData } from "@arco-design/web-vue";
import { RenderFunction } from "vue";
import { FormModalProps, FormProps } from "../form";
import { IFormItem } from "../form/form-item";
import { TableProps } from "./table";
interface UseColumnRenderOptions {
/**
*
*/
record: TableData;
/**
*
*/
column: TableColumnData;
/**
*
*/
rowIndex: number;
}
export type ColumnRender = (options: UseColumnRenderOptions) => any;
export interface TableColumnButton {
/**
*
*/
text?: string;
/**
*
* @description `delete` `onClick``modify` `modify`
*/
type?: "delete" | "modify";
/**
*
*/
onClick?: (data: UseColumnRenderOptions) => any;
/**
*
*/
disabled?: (data: UseColumnRenderOptions) => boolean;
/**
*
*/
visible?: (data: UseColumnRenderOptions) => boolean;
/**
* props
*/
buttonProps?: Partial<Omit<InstanceType<typeof Link>["$props"], "onClick" | "disabled">>;
}
interface TableColumnDropdown {
/**
*
*/
type?: "modify" | "delete";
/**
*
*/
text?: string;
/**
*
*/
icon?: string | RenderFunction;
/**
*
*/
disabled?: (data: UseColumnRenderOptions) => boolean;
/**
*
*/
visibled?: (data: UseColumnRenderOptions) => boolean;
/**
*
*/
onClick?: (data: UseColumnRenderOptions) => any;
/**
*
*/
doptionProps?: Partial<InstanceType<typeof Doption> & Record<string, any>>;
}
export interface TableColumn extends TableColumnData {
/**
*
*/
type?: "index" | "button" | "dropdown";
/**
*
*/
buttons?: TableColumnButton[];
/**
*
*/
dropdowns?: TableColumnDropdown[];
}
type ExtendedFormItem = Partial<IFormItem> & {
/**
* `create.items` `field`
*/
extend?: string;
};
type SearchFormItem = ExtendedFormItem & {
enableLoad?: boolean;
searchable?: boolean;
enterable?: boolean;
};
type Search = Partial<
Omit<FormProps, "items"> & {
/**
*
*/
items?: SearchFormItem[];
/**
* /
*/
button?: boolean;
}
>;
type Modify = Partial<
Omit<FormModalProps, "items"> & {
/**
* `create`
*/
extend: boolean;
/**
*
*/
items?: ExtendedFormItem[];
}
>;
export interface UseTableOptions extends Omit<TableProps, "search" | "create" | "modify" | "columns"> {
/**
*
* @see https://arco.design/web-vue/components/table/#tablecolumn
*/
columns: TableColumn[];
/**
*
* @see FormProps
*/
search?: Search;
/**
*
*/
create?: Partial<FormModalProps>;
/**
*
*/
modify?: Modify;
/**
*
*/
detail?: any;
/**
*
*/
delete?: any;
}

View File

@ -1,287 +0,0 @@
import { delConfirm } from "@/utils";
import { Divider, Doption, Dropdown, Link, Message, TableColumnData } from "@arco-design/web-vue";
import { isArray, merge } from "lodash-es";
import { Component, Ref, reactive } from "vue";
import { useFormModal } from "../form";
import { Table, TableInstance, TableProps } from "./table";
import { config } from "./table.config";
import { UseTableOptions } from "./use-interface";
const onClick = async (item: any, columnData: any, getTable: any) => {
if (item.type === "modify") {
const data = (await item.onClick?.(columnData)) ?? columnData.record;
getTable()?.openModifyModal(data);
return;
}
if (item.type === "delete") {
await delConfirm();
try {
const resData: any = await item?.onClick?.(columnData);
const message = resData?.data?.message;
message && Message.success(`提示:${message}`);
getTable()?.loadData();
} catch (error: any) {
const message = error.response?.data?.message;
message && Message.warning(`提示:${message}`);
}
return;
}
item.onClick?.(columnData);
};
/**
* hook
* @see `src/components/table/use-table.tsx`
*/
export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions)): any => {
const options: UseTableOptions = typeof optionsOrFn === "function" ? optionsOrFn() : optionsOrFn;
const columns: TableColumnData[] = [];
const getTable = (): TableInstance => (columns as any).instance;
/**
*
*/
for (let column of options.columns) {
/**
*
*/
if (column.type === "index") {
column = merge({}, config.columnIndex, column);
}
/**
*
*/
if (column.type === "button" && isArray(column.buttons)) {
const buttons = column.buttons;
let hasModify = false;
let hasDelete = false;
for (let i = 0; i < buttons.length; i++) {
let btn = merge({}, config.columnButtonBase);
if (buttons[i].type === "modify") {
btn = merge(btn, buttons[i]);
hasModify = true;
}
if (buttons[i].type === "delete") {
btn = merge(btn, buttons[i]);
hasDelete = true;
}
buttons[i] = merge(btn, buttons[i]);
}
if (!hasModify) {
buttons.push(merge({}, config.columnButtonBase));
}
if (!hasDelete) {
buttons.push(merge({}, config.columnButtonBase));
}
column.render = (columnData) => {
return column.buttons?.map((btn, index) => {
if (btn.visible?.(columnData) === false) {
return null;
}
return (
<>
{index !== 0 ? <Divider direction="vertical" margin={2} class="!border-gray-300"></Divider> : null}
<Link
{...btn.buttonProps}
onClick={() => onClick(btn, columnData, getTable)}
disabled={btn.disabled?.(columnData)}
>
{btn.text}
</Link>
</>
);
});
};
}
/**
*
*/
if (column.type === "dropdown" && Array.isArray(column.dropdowns)) {
if (options.modify) {
const index = column.dropdowns?.findIndex((i) => i.type === "modify");
if (index !== undefined) {
column.dropdowns[index] = merge({}, config.columnDropdownModify, column.dropdowns[index]);
} else {
column.dropdowns?.unshift(merge({}, config.columnDropdownModify));
}
}
column.render = (columnData) => {
const content = column.dropdowns?.map((dropdown) => {
const { text, icon, disabled, visibled, doptionProps } = dropdown;
if (visibled?.(columnData) === false) {
return null;
}
return (
<Doption
{...doptionProps}
onClick={() => onClick(dropdown, columnData, getTable)}
disabled={disabled?.(columnData)}
>
{{
icon: typeof icon === "function" ? icon() : () => <i class={icon} />,
default: text,
}}
</Doption>
);
});
const trigger = () => (
<span class="px-2 py-[1px] h-6 vertical-b rounded cursor-pointer text-[rgb(var(--link-6))] hover:bg-[var(--color-fill-2)]">
<i class="icon-park-outline-more"></i>
</span>
);
return (
<>
<Link></Link>
<Link></Link>
<Dropdown position="br">
{{
default: trigger,
content: content,
}}
</Dropdown>
</>
);
};
}
columns.push({ ...config.columnBase, ...column });
}
/**
*
*/
if (options.create) {
options.create = useFormModal(options.create as any) as any;
}
/**
*
*/
if (options.search && options.search.items) {
const searchItems: any[] = [];
const createItems = options.create?.items ?? [];
for (const item of options.search.items) {
if (item.extend) {
const createItem = createItems.find((i) => i.field === item.extend);
if (createItem) {
searchItems.push(merge({}, createItem, item));
continue;
}
}
const onSearch = item.searchable ? () => getTable().reloadData() : undefined;
const onPressEnter = item.enterable ? () => getTable().reloadData() : undefined;
searchItems.push(merge({ nodeProps: { onSearch, onPressEnter } }, item));
}
if (options.search.button !== false) {
searchItems.push(config.searchItemSubmit);
}
options.search.items = searchItems;
}
/**
*
*/
if (options.modify) {
if (options.modify.extend && options.create) {
const createItems = options.create.items;
const modifyItems = options.modify.items;
if (modifyItems && createItems) {
for (let i = 0; i < modifyItems.length; i++) {
if (modifyItems[i].extend) {
modifyItems[i] = merge({}, createItems[i], modifyItems[i]);
}
}
}
const merged = merge(
{ modalProps: { titleAlign: "start", closable: false }, model: { id: undefined } },
options.create,
options.modify
);
options.modify = useFormModal(merged as any) as any;
} else {
options.modify = useFormModal(options.modify as any) as any;
}
}
return reactive({ ...options, columns });
};
/**
*
*/
interface TableContext {
/**
* ()
*/
props: TableProps;
/**
*
*/
tableRef: Ref<TableInstance | null>;
/**
*
*/
refresh: () => void;
/**
*
*/
reload?: () => void;
}
type TableReturnType = [
/**
*
*/
Component,
/**
*
*/
TableContext
];
export const useAniTable = (options: UseTableOptions): TableReturnType => {
const props = useTable(options);
const tableRef = ref<TableInstance | null>(null);
const context = {
props,
tableRef,
refresh: () => tableRef.value?.reloadData(),
getTableInstance() {
return tableRef.value?.tableRef;
},
getSearchInstance() {
return tableRef.value?.searchRef;
},
getCreateInstance() {
return tableRef.value?.createRef;
},
/**
*
*/
getCreateFormInstance() {
return this.getCreateInstance()?.formRef;
},
/**
*
*/
getModifyInstance() {
return tableRef.value?.modifyRef;
},
/**
*
*/
getModifyFormInstance() {
return this.getModifyInstance()?.formRef;
},
};
const aniTable = defineComponent({
name: "AniTableWrapper",
setup(p, { slots }) {
const onRef = (el: TableInstance) => (tableRef.value = el);
return () => <Table ref={onRef} {...props}>{slots}</Table>;
},
});
return [aniTable, context];
};

View File

@ -23,7 +23,7 @@
</div>
</a-doption>
<a-divider :margin="4"></a-divider>
<a-doption @click="password.open()">
<a-doption @click="open()">
<template #icon>
<i class="icon-park-outline-lock"></i>
</template>
@ -47,51 +47,52 @@
</template>
<script setup lang="ts">
import { useAniFormModal } from "@/components";
import { useUserStore } from "@/store";
import { delConfirm } from "@/utils";
import { Message } from "@arco-design/web-vue";
import { useFormModal } from '@/components/AnForm';
import { useUserStore } from '@/store';
import { delConfirm } from '@/utils';
import { Message } from '@arco-design/web-vue';
const userStore = useUserStore();
const route = useRoute();
const router = useRouter();
const logout = async () => {
await delConfirm('退出后将跳转到登录页面,确定退出吗?')
await delConfirm({
content: '退出后将跳转到登录页面,确定退出吗?',
okText: '确定退出',
});
userStore.clearUser();
Message.success("提示:已退出登陆!");
router.push({ path: "/login", query: { redirect: route.path } });
Message.success('提示:已退出登陆!');
router.push({ path: '/login', query: { redirect: route.path } });
};
const [PasswordModal, password] = useAniFormModal({
title: "修改密码",
const { component: PasswordModal, open } = useFormModal({
title: '修改密码',
trigger: false,
modalProps: {
width: 452,
},
width: 452,
items: [
{
field: "password",
label: "原密码",
type: "input",
nodeProps: {
placeholder: "请输入原密码",
field: 'password',
label: '原密码',
setter: 'input',
setterProps: {
placeholder: '请输入原密码',
},
},
{
field: "password1",
label: "新密码",
type: "input",
nodeProps: {
placeholder: "请输入新密码",
field: 'password1',
label: '新密码',
setter: 'input',
setterProps: {
placeholder: '请输入新密码',
},
},
{
field: "password2",
label: "确认密码",
type: "input",
nodeProps: {
placeholder: "请再次输入新密码",
field: 'password2',
label: '确认密码',
setter: 'input',
setterProps: {
placeholder: '请再次输入新密码',
},
},
],

View File

@ -6,14 +6,9 @@
<div class="h-13 flex items-center">
<router-link to="/" class="px-2 py-1 rounded flex items-center gap-2 text-slate-700">
<img src="/favicon.ico" alt="" width="22" height="22" class="" />
<h1 class="relative text-lg leading-[19px] dark:text-white m-0 p-0">
<h1 class="relative text-lg leading-[20px] dark:text-white m-0 p-0 font-semibold">
{{ appStore.title }}
<!-- <span
v-if="isDev"
class="absolute -right-14 -top-1 text-xs font-normal text-brand-500 bg-brand-50 px-1.5 rounded-full"
>
本地版
</span> -->
<span class="absolute -right-10 -top-1 font-normal text-xs text-gray-400"> v0.0.1 </span>
</h1>
</router-link>
</div>
@ -41,7 +36,7 @@
:collapsible="true"
:collapsed="isCollapsed"
:hide-trigger="false"
@collapse="(val) => (isCollapsed = val)"
@collapse="val => (isCollapsed = val)"
>
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-hidden pt-1">
<Menu />
@ -72,14 +67,14 @@
</template>
<script lang="ts" setup>
import { useAppStore } from "@/store";
import { useMenuStore } from "@/store/menu";
import { Message } from "@arco-design/web-vue";
import { IconSync } from "@arco-design/web-vue/es/icon";
import Menu from "./components/menu.vue";
import userDropdown from "./components/userDropdown.vue";
import { useAppStore } from '@/store';
import { useMenuStore } from '@/store/menu';
import { Message } from '@arco-design/web-vue';
import { IconSync } from '@arco-design/web-vue/es/icon';
import Menu from './components/menu.vue';
import userDropdown from './components/userDropdown.vue';
defineOptions({ name: "LayoutPage" });
defineOptions({ name: 'LayoutPage' });
const appStore = useAppStore();
const menuStore = useMenuStore();
@ -88,24 +83,24 @@ const themeConfig = ref({ visible: false });
const buttons = [
{
icon: "icon-park-outline-remind",
tooltip: "通知",
icon: 'icon-park-outline-remind',
tooltip: '通知',
onClick: () => {
Message.info("暂无通知");
Message.info('暂无通知');
},
},
{
icon: "icon-park-outline-config",
tooltip: "设置",
icon: 'icon-park-outline-config',
tooltip: '设置',
onClick: () => {
themeConfig.value.visible = true;
},
},
{
icon: "icon-park-outline-github",
tooltip: "仓库",
icon: 'icon-park-outline-github',
tooltip: '仓库',
onClick: () => {
window.open("https://github.com/appnify/starter-vue", "_blank");
window.open('https://github.com/appnify/starter-vue', '_blank');
},
},
];

View File

@ -105,8 +105,8 @@ const onSubmitForm = async () => {
const res = await api.auth.login(model);
userStore.setAccessToken(res.data.data);
Notification.success({
title: "登陆提示",
content: `欢迎,您已成功登陆系统!`,
title: "提示",
content: `${meridiem}好,您已成功登陆本系统!`,
});
router.push({ path: (route.query.redirect as string) || "/" });
} catch (error: any) {

View File

@ -1,77 +1,90 @@
<template>
<BreadPage>
<Table v-bind="table"></Table>
<CategoryTable />
</BreadPage>
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { Table, useTable } from "@/components";
import { dayjs } from "@/libs/dayjs";
import { Tag } from "@arco-design/web-vue";
import { api } from '@/api';
import { useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable';
const table = useTable({
data: async (model, paging) => {
return api.log.getLoginLogs({ ...model, ...paging });
},
const { component: CategoryTable } = useTable({
columns: [
{
title: "登陆账号",
dataIndex: "nickname",
width: 140,
title: '文章标题',
dataIndex: 'title',
render: ({ record }) => (
<div class="overflow-hidden">
<span>{record.title}</span>
<span class="text-gray-400 text-xs truncate">{record.description.substr(0, 80)}</span>
</div>
),
},
useCreateColumn(),
useUpdateColumn(),
{
title: "操作描述",
dataIndex: "description",
render: ({ record: { status, description } }) => {
return (
<span>
<Tag color={status === null || status ? "green" : "red"} class="mr-2">
{ status === null || status ? "成功" : "失败" }
</Tag>
{description}
</span>
);
},
},
{
title: "登陆地址",
dataIndex: "ip",
width: 200,
render: ({ record }) => `${record.addr || "未知"}(${record.ip})`,
},
{
title: "操作系统",
dataIndex: "os",
width: 160,
},
{
title: "浏览器",
dataIndex: "browser",
width: 160,
},
{
title: "登陆时间",
dataIndex: "createdAt",
type: 'button',
title: '操作',
width: 120,
render: ({ record }) => dayjs(record.createdAt).fromNow(),
buttons: [
{
type: 'modify',
text: '修改',
},
{
type: 'delete',
text: '删除',
onClick: props => api.post.delPost(props.record.id),
},
],
},
],
search: {
source: async model => api.post.getPosts(model),
search: [
{
field: 'nickname',
label: '文章标题',
setter: 'search',
enterable: true,
searchable: true,
},
],
create: {
title: '添加文章',
width: 1080,
items: [
{
field: "nickname",
label: "登陆账号",
type: "input",
field: 'title',
label: '标题',
setter: 'input',
required: true,
},
{
field: 'slug',
label: '别名',
setter: 'input',
required: true,
},
{
field: 'description',
label: '内容',
setter: 'textarea',
required: false,
nodeProps: {
placeholder: '请输入登陆账号',
setterProps: {
maxLength: 2000,
},
itemProps: {
hideLabel: true,
}
},
],
submit: model => {
return api.post.addPost(model as any);
},
},
modify: {
extend: true,
title: '修改文章',
submit: model => {
return api.post.updatePost(model.id, model);
},
},
});
</script>

View File

@ -2,12 +2,12 @@
<div class="w-[210px] h-full overflow-hidden grid grid-rows-[auto_1fr]">
<div class="flex gap-2">
<a-input-search allow-clear placeholder="分类名称" class="mb-2" @search="updateFileCategories"></a-input-search>
<a-button @click="formCtx.open">
<a-button @click="() => open()">
<template #icon>
<i class="icon-park-outline-add"></i>
</template>
</a-button>
<form-modal></form-modal>
<CategoryModal></CategoryModal>
</div>
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto">
<a-spin :loading="loading" class="w-full h-full">
@ -30,7 +30,7 @@
</template>
</a-button>
<template #content>
<a-doption @click="formCtx.open(item)">
<a-doption @click="open(item)">
<template #icon>
<i class="icon-park-outline-edit"></i>
</template>
@ -55,7 +55,7 @@
<script setup lang="ts">
import { FileCategory, api } from '@/api';
import { useAniFormModal } from '@/components';
import { useFormModal } from '@/components/AnForm';
import { delConfirm } from '@/utils';
import { Message } from '@arco-design/web-vue';
import { PropType } from 'vue';
@ -92,38 +92,33 @@ const onDeleteRow = async (row: FileCategory) => {
Message.success(res.data.message);
};
const [formModal, formCtx] = useAniFormModal({
title: ({ model }) => (!model.id ? '新建分类' : '修改分类'),
const { component: CategoryModal, open } = useFormModal({
title: model => (!model.id ? '新建分类' : '修改分类'),
trigger: false,
modalProps: {
width: 580,
},
model: {
id: undefined,
},
width: 580,
items: [
{
field: 'name',
label: '分类名称',
type: 'input',
label: '名称',
setter: 'input',
},
{
field: 'code',
label: '分类编码',
type: 'input',
label: '编码',
setter: 'input',
},
{
field: 'description',
label: '备注',
type: 'textarea',
setter: 'textarea',
},
],
submit: async ({ model }) => {
submit: async model => {
let res;
if (model.id) {
res = await api.fileCategory.setFileCategory(model.id, model);
} else {
res = await api.fileCategory.addFileCategory(model);
res = await api.fileCategory.addFileCategory(model as any);
}
updateFileCategories();
return res;

View File

@ -1,7 +1,7 @@
<template>
<a-button type="primary" @click="visible = true">
<template #icon>
<i class="icon-park-outline-upload"></i>
<i class="icon-park-outline-upload-one"></i>
</template>
上传
</a-button>

View File

@ -6,7 +6,7 @@
<div class="bg-white p-4">
<MaterialTable>
<template #action>
<AnUpload></AnUpload>
<AnUpload @success="() => tableRef?.refresh()"></AnUpload>
</template>
</MaterialTable>
<a-image-preview v-model:visible="visible" :src="image"></a-image-preview>
@ -61,39 +61,45 @@ const {
{
title: '文件名称',
dataIndex: 'name',
render: ({ record }) => (
<div class="group flex items-center gap-2">
<div class="w-8 flex justify-center">
{record.mimetype.startsWith('image') ? (
<a-avatar size={26} shape="square">
<img src={record.path}></img>
</a-avatar>
) : (
<i class={`${getIcon(record.mimetype)} text-4xl`}></i>
)}
</div>
<div class="flex flex-col overflow-hidden">
<span class="flex items-center gap-2">
<span
class="truncate hover:text-brand-500 hover:decoration-underline underline-offset-2 cursor-pointer"
onClick={() => preview(record)}
>
{record.name}
render: ({ record }) => {
return (
<div class="group flex items-center gap-2">
<div class="w-8 flex justify-center">
{record.mimetype.startsWith('image') ? (
<a-avatar size={26} shape="square">
<img src={record.path}></img>
</a-avatar>
) : (
<i class={`${getIcon(record.mimetype)} text-4xl`}></i>
)}
</div>
<div class="flex flex-col overflow-hidden">
<span class="flex items-center gap-2">
<span
class="truncate hover:text-brand-500 hover:decoration-underline underline-offset-2 cursor-pointer"
onClick={() => preview(record)}
>
{record.name}
</span>
<span
class="inline-block w-5 text-xs text-gray-400 ml-0"
title="复制地址"
onClick={() => copyLink(record)}
>
<i class="hidden! group-hover:inline-block! icon-park-outline-copy hover:text-gray-700 cursor-pointer"></i>
</span>
</span>
<span class="inline-block w-5 text-xs text-gray-400 ml-0" title="复制地址" onClick={() => copyLink(record)}>
<i class="hidden! group-hover:inline-block! icon-park-outline-copy hover:text-gray-700 cursor-pointer"></i>
</span>
</span>
<div class="h-5 inline-flex items-center text-xs text-gray-400 space-x-4">
<span>
<i class="icon-park-outline-folder-close mr-1"></i>
{record.category || '默认分类'}
</span>
<span>{numeral(record.size).format('0 b')}</span>
<div class="h-5 inline-flex items-center text-xs text-gray-400 space-x-2">
<span>
<i class="icon-park-outline-folder-close mr-1"></i>
{record.category?.name ?? '默认分类'}
</span>
<span>{numeral(record.size).format('0 b')}</span>
</div>
</div>
</div>
</div>
),
);
},
},
useCreateColumn(),
useUpdateColumn(),
@ -119,8 +125,8 @@ const {
return api.file.delFile(props.record.id);
},
buttonProps: {
status: 'danger'
}
status: 'danger',
},
},
],
},
@ -147,17 +153,18 @@ const {
title: '修改素材',
width: 580,
items: [
{
field: 'name',
label: '文件名',
setter: 'input',
required: true,
},
{
field: 'categoryId',
label: '分类',
setter: 'select',
options: () => api.fileCategory.getFileCategorys({ size: 0 }) as any,
},
{
field: 'name',
label: '名称',
setter: 'input',
},
{
field: 'description',
label: '描述',

View File

@ -7,25 +7,19 @@
<script setup lang="tsx">
import { api } from '@/api';
import { useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable';
import { listToTree } from '@/utils/listToTree';
const { component: CategoryTable } = useTable({
columns: [
{
title: '名称',
title: '文章标题',
dataIndex: 'title',
width: 240,
render: ({ record }) => (
<div class="flex flex-col overflow-hidden">
<div class="overflow-hidden">
<span>{record.title}</span>
<span class="text-gray-400 text-xs truncate">#{record.slug}</span>
<div class="text-gray-400 text-xs truncate">{record.description}</div>
</div>
),
},
{
title: '描述',
dataIndex: 'description',
},
useCreateColumn(),
useUpdateColumn(),
{
@ -40,59 +34,56 @@ const { component: CategoryTable } = useTable({
{
type: 'delete',
text: '删除',
onClick({ record }) {
return api.category.delCategory(record.id);
},
onClick: props => api.post.delPost(props.record.id),
},
],
},
],
source: async model => {
const res = await api.category.getCategories(model);
const data = listToTree(res.data.data ?? []);
return { data: { data, total: (res.data as any).total } };
},
source: async model => api.post.getPosts(model),
search: [
{
field: 'nickname',
label: '登陆账号',
label: '文章标题',
setter: 'search',
enterable: true,
searchable: true,
},
],
create: {
title: '添加分类',
width: 580,
title: '添加文章',
width: 1080,
items: [
{
field: 'title',
label: '分类名称',
label: '标题',
setter: 'input',
required: true,
},
{
field: 'slug',
label: '分类别名',
label: '别名',
setter: 'input',
required: true,
},
{
field: 'description',
label: '描述',
label: '内容',
setter: 'textarea',
required: false,
setterProps: {
maxLength: 2000,
},
},
],
submit: model => {
return api.category.addCategory(model as any);
return api.post.addPost(model as any);
},
},
modify: {
extend: true,
title: '修改分类',
title: '修改文章',
submit: model => {
return api.category.setCategory(model.id, model as any);
return api.post.updatePost(model.id, model);
},
},
});

View File

@ -73,12 +73,7 @@ const { component: UserTable } = useTable({
],
},
],
source: search => {
return api.user.getUsers(search);
},
paging: {
hide: false,
},
source: search => api.user.getUsers(search),
search: [
{
field: 'username',
@ -106,7 +101,7 @@ const { component: UserTable } = useTable({
setter: 'input',
},
],
submit: async model => {
submit: model => {
return 1;
},
},

View File

@ -1,35 +1,32 @@
<template>
<BreadPage>
<Table v-bind="table"></Table>
<OperationTable></OperationTable>
</BreadPage>
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { Table, useTable } from "@/components";
import { dayjs } from "@/libs/dayjs";
import { Tag } from "@arco-design/web-vue";
import { api } from '@/api';
import { useTable } from '@/components/AnTable';
import { dayjs } from '@/libs/dayjs';
import { Tag } from '@arco-design/web-vue';
defineOptions({ name: "SystemLogoPage" })
defineOptions({ name: 'SystemLogoPage' });
const table = useTable({
data: async (model, paging) => {
return api.log.getLoginLogs({ ...model, ...paging });
},
const { component: OperationTable } = useTable({
columns: [
{
title: "登陆账号",
dataIndex: "nickname",
title: '登陆账号',
dataIndex: 'nickname',
width: 140,
},
{
title: "操作描述",
dataIndex: "description",
title: '操作描述',
dataIndex: 'description',
render: ({ record: { status, description } }) => {
return (
<span>
<Tag color={status === null || status ? "green" : "red"} class="mr-2">
{ status === null || status ? "成功" : "失败" }
<Tag color={status === null || status ? 'green' : 'red'} class="mr-2">
{status === null || status ? '成功' : '失败'}
</Tag>
{description}
</span>
@ -37,44 +34,37 @@ const table = useTable({
},
},
{
title: "登陆地址",
dataIndex: "ip",
title: '登陆地址',
dataIndex: 'ip',
width: 200,
render: ({ record }) => `${record.addr || "未知"}(${record.ip})`,
render: ({ record }) => `${record.addr || '未知'}(${record.ip})`,
},
{
title: "操作系统",
dataIndex: "os",
title: '操作系统',
dataIndex: 'os',
width: 160,
},
{
title: "浏览器",
dataIndex: "browser",
title: '浏览器',
dataIndex: 'browser',
width: 160,
},
{
title: "登陆时间",
dataIndex: "createdAt",
title: '登陆时间',
dataIndex: 'createdAt',
width: 120,
render: ({ record }) => dayjs(record.createdAt).fromNow(),
},
],
search: {
items: [
{
field: "nickname",
label: "登陆账号",
type: "input",
required: false,
nodeProps: {
placeholder: '请输入登陆账号',
},
itemProps: {
hideLabel: true,
}
},
],
},
source: model => api.log.getLoginLogs(model),
search: [
{
field: 'nickname',
label: '登陆账号',
setter: 'input',
required: false,
},
],
});
</script>

View File

@ -5,41 +5,35 @@
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { createColumn, updateColumn, useAniTable } from "@/components";
import { MenuType, MenuTypes } from "@/constants/menu";
import { flatMenus } from "@/router";
import { listToTree } from "@/utils/listToTree";
import { api } from '@/api';
import { useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable';
import { MenuType, MenuTypes } from '@/constants/menu';
import { flatMenus } from '@/router';
import { listToTree } from '@/utils/listToTree';
defineOptions({ name: 'SystemMenuPage' })
defineOptions({ name: 'SystemMenuPage' });
const menuArr = flatMenus.map((i) => ({ label: i.title, value: i.id }));
const menuArr = flatMenus.map(i => ({ label: i.title, value: i.id }));
const expanded = ref(false);
const toggleExpand = () => {
expanded.value = !expanded.value;
menu.tableRef.value?.tableRef?.expandAll(expanded.value);
tableRef.value?.tableRef?.expandAll(expanded.value);
};
const [menuTable, menu] = useAniTable({
data: (search, paging) => {
return api.menu.getMenus({ ...search, ...paging, tree: true, size: 0 });
},
tableProps: {
defaultExpandAllRows: true,
},
const { component: MenuTable, tableRef } = useTable({
columns: [
{
title: () => (
<span>
菜单名称
<a-link class="ml-1 select-none" onClick={toggleExpand}>
{expanded.value ? "收起全部" : "展开全部"}
{expanded.value ? '收起全部' : '展开全部'}
</a-link>
</span>
),
dataIndex: "name",
dataIndex: 'name',
render({ record }) {
let id = "";
let id = '';
if (record.type === MenuType.PAGE) {
id = ` => ${record.path}`;
}
@ -48,7 +42,7 @@ const [menuTable, menu] = useAniTable({
}
return (
<div class="flex items-center gap-1">
<a-tag bordered color={MenuTypes.fmt(record.type, "color")}>
<a-tag bordered color={MenuTypes.fmt(record.type, 'color')}>
{{
default: () => MenuTypes.fmt(record.type),
}}
@ -56,7 +50,7 @@ const [menuTable, menu] = useAniTable({
<div class="flex-1 flex overflow-hidden ml-1">
<div class="flex-1">
<i class={`${record.icon} mr-1`}></i>
<span>{record.name ?? "无"}</span>
<span>{record.name ?? '无'}</span>
<span class="text-gray-400 text-xs truncate">{id}</span>
</div>
<a-switch checked-color="#3c9" size="small"></a-switch>
@ -65,27 +59,27 @@ const [menuTable, menu] = useAniTable({
);
},
},
createColumn,
updateColumn,
useCreateColumn(),
useUpdateColumn(),
{
title: "操作",
type: "button",
title: '操作',
type: 'button',
width: 200,
buttons: [
{
text: "新增子项",
disabled: ({ record }) => record.type === MenuType.BUTTON,
text: '新增子项',
disable: ({ record }) => record.type === MenuType.BUTTON,
onClick: ({ record }) => {
console.log(record);
},
},
{
type: "modify",
text: "修改",
type: 'modify',
text: '修改',
},
{
text: "删除",
type: "delete",
text: '删除',
type: 'delete',
onClick: ({ record }) => {
return api.menu.delMenu(record.id);
},
@ -93,36 +87,35 @@ const [menuTable, menu] = useAniTable({
],
},
],
search: {
items: [
{
extend: "name",
required: false,
nodeProps: {
placeholder: "菜单名称",
},
source: search => api.menu.getMenus({ ...search, tree: true, size: 0 }),
search: [
{
extend: 'name',
required: false,
setterProps: {
placeholder: '菜单名称',
},
],
},
},
],
create: {
title: "新建菜单",
modalProps: {
width: 732,
maskClosable: false,
},
formProps: {
layout: "vertical",
class: "!grid grid-cols-2 gap-x-4",
},
title: '新建菜单',
width: 980,
formClass: '!grid grid-cols-2 gap-x-4',
items: [
{
field: "parentId",
initial: 0,
label: "父级",
type: "treeSelect",
field: 'parentId',
value: 0,
label: '父级',
setter: 'treeSelect',
setterProps: {
fieldNames: {
key: 'id',
title: 'name',
},
},
async options() {
const res = await api.menu.getMenus({ size: 0 });
const data = res.data.data?.filter((i) => i.type !== MenuType.BUTTON) ?? [];
const data = res.data.data?.filter(i => i.type !== MenuType.BUTTON) ?? [];
for (const item of data) {
const type = MenuTypes.fmt(item.type);
// @ts-ignore
@ -132,92 +125,82 @@ const [menuTable, menu] = useAniTable({
return [
{
id: 0,
name: "主类目",
name: '主类目',
children: list,
},
];
},
nodeProps: {
fieldNames: {
key: "id",
title: "name",
},
},
},
{
field: "type",
initial: 1,
label: "类型",
type: "radio",
field: 'type',
value: 1,
label: '类型',
setter: 'input',
options: MenuTypes.raw,
nodeProps: {
type: "button",
class: "w-full",
},
},
{
field: "name",
label: "名称",
type: "input",
field: 'name',
label: '名称',
setter: 'input',
required: true,
},
{
field: "code",
label: "标识",
type: "input",
field: 'code',
label: '标识',
setter: 'input',
required: true,
},
{
field: "icon",
label: "图标",
type: "input",
field: 'icon',
label: '图标',
setter: 'input',
required: true,
visible: ({ model }) => model.type !== MenuType.BUTTON,
},
{
field: "path",
label: "路径",
type: "input",
field: 'path',
label: '路径',
setter: 'input',
required: true,
visible: ({ model }) => model.type !== MenuType.BUTTON,
nodeProps: {
placeholder: "内链请以 / 开头,外链请以 http 开头",
setterProps: {
placeholder: '内链请以 / 开头,外链请以 http 开头',
},
rules: [
{
match: /^(\/|http)/,
message: "请以 / 或 http 开头",
message: '请以 / 或 http 开头',
},
],
},
{
field: "component",
label: "关联组件",
type: "select",
field: 'component',
label: '关联组件',
setter: 'select',
required: true,
visible: ({ model }) => model.type === MenuType.PAGE,
options: menuArr,
nodeProps: {
placeholder: "当前页面对应的前端组件",
setterProps: {
placeholder: '当前页面对应的前端组件',
},
},
{
field: "description",
label: "菜单描述",
type: "textarea",
field: 'description',
label: '菜单描述',
setter: 'textarea',
itemProps: {
class: "col-span-2",
class: 'col-span-2',
},
},
],
submit: ({ model }) => {
return api.menu.addMenu(model);
submit: model => {
return api.menu.addMenu(model as any);
},
},
modify: {
extend: true,
title: "修改菜单",
submit: ({ model }) => {
title: '修改菜单',
submit: model => {
return api.menu.setMenu(model.id, model);
},
},

View File

@ -5,53 +5,49 @@
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { createColumn, updateColumn, useAniTable } from "@/components";
import { api } from '@/api';
import { useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable';
defineOptions({ name: 'SystemRolePage' })
defineOptions({ name: 'SystemRolePage' });
const [roleTable, roleCtx] = useAniTable({
data: async () => {
const { component: RoleTable } = useTable({
source: () => {
return api.role.getRoles();
},
columns: [
{
title: "角色名称",
dataIndex: "username",
width: 180,
render({ record }) {
return (
<div class="flex flex-col overflow-hidden">
<span>{record.name}</span>
<span class="text-gray-400 text-xs truncate">#{record.slug}</span>
</div>
);
},
title: '角色名称',
dataIndex: 'username',
render: ({ record }) => (
<div class="flex flex-col overflow-hidden">
<span>
{record.name}
<span class="text-gray-400 text-xs truncate ml-2">@{record.code}</span>
</span>
<div class="text-gray-400 text-xs truncate mt-0.5">{record.description}</div>
</div>
),
},
useCreateColumn(),
useUpdateColumn(),
{
title: "角色描述",
dataIndex: "description",
},
createColumn,
updateColumn,
{
title: "操作",
type: "button",
title: '操作',
type: 'button',
width: 200,
buttons: [
{
type: "modify",
text: "修改",
},
{
text: "分配权限",
text: '分配权限',
onClick: ({ record }) => {
console.log(record);
},
},
{
text: "删除",
type: "delete",
type: 'modify',
text: '修改',
},
{
text: '删除',
type: 'delete',
onClick: ({ record }) => {
return api.role.delRole(record.id);
},
@ -59,64 +55,43 @@ const [roleTable, roleCtx] = useAniTable({
],
},
],
search: {
items: [
{
field: "name",
type: "input",
nodeProps: {
placeholder: "角色名称",
},
itemProps: {
hideLabel: true,
},
},
],
},
search: [
{
field: 'name',
label: '角色名称',
setter: 'input',
},
],
create: {
title: "新建角色",
modalProps: {
width: 580,
maskClosable: false,
},
formProps: {
layout: "vertical",
},
title: '新建角色',
width: 580,
items: [
{
field: "name",
label: "角色名称",
type: "input",
field: 'name',
label: '名称',
setter: 'input',
required: true,
},
{
field: "code",
label: "角色标识",
type: "input",
field: 'code',
label: '标识',
setter: 'input',
required: true,
},
// {
// field: "menuIds",
// label: "",
// type: "select",
// options: () => api.menu.getMenus({ size: 0 }),
// nodeProps: {
// multiple: true,
// },
// },
{
field: "description",
label: "个人描述",
type: "textarea",
field: 'description',
label: '个人描述',
setter: 'textarea',
},
],
submit: ({ model }) => {
return api.role.addRole(model);
submit: model => {
return api.role.addRole(model as any);
},
},
modify: {
extend: true,
title: "修改角色",
submit: ({ model }) => {
title: '修改角色',
submit: model => {
return api.role.updateRole(model.id, model);
},
},
@ -131,7 +106,7 @@ const [roleTable, roleCtx] = useAniTable({
"name": "SystemRolePage",
"sort": 10302,
"title": "角色管理",
"icon": "icon-park-outline-key"
"icon": "icon-park-outline-shield"
}
}
</route>

View File

@ -1,26 +0,0 @@
import { api } from '@/api';
import { useAniFormModal } from '@/components';
export const usePassworModal = () => {
return useAniFormModal({
title: '重置密码',
trigger: false,
modalProps: {
width: 432,
},
model: {
id: undefined,
nickname: undefined,
},
items: [
{
field: 'password',
label: ({ model }) => `${model.nickname} 的新密码:`,
type: 'input',
},
],
submit: async ({ model }) => {
return api.user.setUser(model.id, model);
},
});
};

View File

@ -1,22 +1,36 @@
<template>
<BreadPage>
<UserTable />
<pass-modal></pass-modal>
<PasswordModal></PasswordModal>
</BreadPage>
</template>
<script setup lang="tsx">
import { api } from '@/api';
import { useCreateColumn, useTable, useUpdateColumn } from '@/components/AnTable';
import { usePassworModal } from './components/password';
import { useFormModal } from '@/components/AnForm';
defineOptions({ name: 'SystemUserPage' });
const [passModal, passCtx] = usePassworModal();
const { component: PasswordModal, open } = useFormModal({
title: '重置密码',
trigger: false,
width: 432,
model: {
id: undefined,
nickname: undefined,
},
items: [
{
field: 'password',
label: '新密码',
setter: 'input',
},
],
submit: model => api.user.setUser(model.id, model as any),
});
const { component: UserTable } = useTable({
source: async model => {
return api.user.getUsers(model);
},
columns: [
{
title: '用户昵称',
@ -54,9 +68,7 @@ const { component: UserTable } = useTable({
buttons: [
{
text: '重置密码',
onClick({ record }) {
passCtx.open(record);
},
onClick: ({ record }) => open(record),
},
{
type: 'modify',
@ -72,16 +84,14 @@ const { component: UserTable } = useTable({
],
},
],
search: {
hideSearch: true,
items: [
{
field: 'nickname',
label: '用户昵称',
setter: 'input',
},
],
},
source: model => api.user.getUsers(model),
search: [
{
field: 'nickname',
label: '用户昵称',
setter: 'input',
},
],
create: {
title: '新建用户',
width: 820,

View File

@ -5,6 +5,10 @@
body {
// --border-radius-small: 4px;
.arco-table .arco-table-element {
table-layout: fixed;
}
.arco-icon-hover::before {
width: 28px;
height: 28px;