feat(ui): 函数管理

This commit is contained in:
廖彦棋 2024-03-07 11:40:57 +08:00
parent a36f14eb94
commit 8e7413da97
17 changed files with 506 additions and 17 deletions

View File

@ -60,6 +60,9 @@ importers:
'@vitejs/plugin-vue-jsx': '@vitejs/plugin-vue-jsx':
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0(vite@5.1.5)(vue@3.4.21) version: 3.1.0(vite@5.1.5)(vue@3.4.21)
'@vue/eslint-config-prettier':
specifier: ^7.0.0
version: 7.1.0(eslint@8.57.0)(prettier@3.2.5)
'@vue/eslint-config-typescript': '@vue/eslint-config-typescript':
specifier: ^12.0.0 specifier: ^12.0.0
version: 12.0.0(eslint-plugin-vue@9.22.0)(eslint@8.57.0)(typescript@5.3.3) version: 12.0.0(eslint-plugin-vue@9.22.0)(eslint@8.57.0)(typescript@5.3.3)
@ -1245,6 +1248,18 @@ packages:
resolution: {integrity: sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA==} resolution: {integrity: sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA==}
dev: false dev: false
/@vue/eslint-config-prettier@7.1.0(eslint@8.57.0)(prettier@3.2.5):
resolution: {integrity: sha512-Pv/lVr0bAzSIHLd9iz0KnvAr4GKyCEl+h52bc4e5yWuDVtLgFwycF7nrbWTAQAS+FU6q1geVd07lc6EWfJiWKQ==}
peerDependencies:
eslint: '>= 7.28.0'
prettier: '>= 2.0.0'
dependencies:
eslint: 8.57.0
eslint-config-prettier: 8.10.0(eslint@8.57.0)
eslint-plugin-prettier: 4.2.1(eslint-config-prettier@8.10.0)(eslint@8.57.0)(prettier@3.2.5)
prettier: 3.2.5
dev: true
/@vue/eslint-config-typescript@12.0.0(eslint-plugin-vue@9.22.0)(eslint@8.57.0)(typescript@5.3.3): /@vue/eslint-config-typescript@12.0.0(eslint-plugin-vue@9.22.0)(eslint@8.57.0)(typescript@5.3.3):
resolution: {integrity: sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==} resolution: {integrity: sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@ -1670,6 +1685,32 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
dev: true dev: true
/eslint-config-prettier@8.10.0(eslint@8.57.0):
resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
dependencies:
eslint: 8.57.0
dev: true
/eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.10.0)(eslint@8.57.0)(prettier@3.2.5):
resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==}
engines: {node: '>=12.0.0'}
peerDependencies:
eslint: '>=7.28.0'
eslint-config-prettier: '*'
prettier: '>=2.0.0'
peerDependenciesMeta:
eslint-config-prettier:
optional: true
dependencies:
eslint: 8.57.0
eslint-config-prettier: 8.10.0(eslint@8.57.0)
prettier: 3.2.5
prettier-linter-helpers: 1.0.0
dev: true
/eslint-plugin-vue@9.22.0(eslint@8.57.0): /eslint-plugin-vue@9.22.0(eslint@8.57.0):
resolution: {integrity: sha512-7wCXv5zuVnBtZE/74z4yZ0CM8AjH6bk4MQGm7hZjUC2DBppKU5ioeOk5LGSg/s9a1ZJnIsdPLJpXnu1Rc+cVHg==} resolution: {integrity: sha512-7wCXv5zuVnBtZE/74z4yZ0CM8AjH6bk4MQGm7hZjUC2DBppKU5ioeOk5LGSg/s9a1ZJnIsdPLJpXnu1Rc+cVHg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@ -1788,6 +1829,10 @@ packages:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true dev: true
/fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: true
/fast-glob@3.3.2: /fast-glob@3.3.2:
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
@ -2451,6 +2496,19 @@ packages:
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
dev: true dev: true
/prettier-linter-helpers@1.0.0:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
dependencies:
fast-diff: 1.3.0
dev: true
/prettier@3.2.5:
resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==}
engines: {node: '>=14'}
hasBin: true
dev: true
/proxy-from-env@1.1.0: /proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
dev: false dev: false

View File

@ -1,14 +1,28 @@
/* eslint-env node */ /* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution') require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = { module.exports = {
root: true, root: true,
'extends': [ extends: [
'plugin:vue/vue3-essential', "plugin:vue/vue3-essential",
'eslint:recommended', "eslint:recommended",
'@vue/eslint-config-typescript' "@vue/eslint-config-typescript",
"@vue/eslint-config-prettier",
], ],
parserOptions: { parserOptions: {
ecmaVersion: 'latest' ecmaVersion: "latest",
} sourceType: "module",
} parser: "@typescript-eslint/parser",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["vue", "@typescript-eslint"],
rules: {
"prettier/prettier": "warn",
"@typescript-eslint/ban-ts-comment": "off",
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-explicit-any": "off",
"no-undef": "off",
},
};

View File

@ -0,0 +1,9 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"semi": true,
"trailingComma": "es5",
"bracketSpacing": true
}

View File

@ -24,6 +24,7 @@
"@types/node": "^20.11.10", "@types/node": "^20.11.10",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.5.1",
"eslint": "^8.49.0", "eslint": "^8.49.0",

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import { computed } from "vue";
import { Message, type SwitchInstance } from "@arco-design/web-vue";
import type { BaseResponse } from "@gpt-vue/packages/type";
type OriginProps = SwitchInstance["$props"];
interface Props extends /* @vue-ignore */ OriginProps {
modelValue: boolean | string | number;
api: (params?: any) => Promise<BaseResponse<any>>;
}
const props = defineProps<Props>();
const emits = defineEmits(["update:modelValue"]);
const _value = computed({
get: () => props.modelValue,
set: (v) => {
emits("update:modelValue", v);
},
});
const onBeforeChange = async (params) => {
try {
await props.api({ ...params, value: !_value.value });
Message.success("操作成功");
return true;
} catch (err) {
console.log(err);
return false;
}
};
</script>
<template>
<a-switch v-bind="{ ...props, ...$attrs }" v-model="_value" :before-change="onBeforeChange" />
</template>

View File

@ -5,9 +5,10 @@ import { useTableScroll } from "@/components/SearchTable/utils";
import { Message } from "@arco-design/web-vue"; import { Message } from "@arco-design/web-vue";
import type { TableRequest, TableOriginalProps } from "./useAsyncTable"; import type { TableRequest, TableOriginalProps } from "./useAsyncTable";
export interface SimpleTable extends /* @vue-ignore */ TableOriginalProps { interface SimpleTable extends /* @vue-ignore */ TableOriginalProps {
request: TableRequest<Record<string, unknown>>; request: TableRequest<Record<string, unknown>>;
params?: Record<string, unknown>; params?: Record<string, unknown>;
columns?: TableOriginalProps["columns"];
} }
const props = defineProps<SimpleTable>(); const props = defineProps<SimpleTable>();

View File

@ -0,0 +1,29 @@
import { ref, reactive, unref } from "vue";
import { Message } from "@arco-design/web-vue";
import type { BaseResponse } from "@gpt-vue/packages/type";
function useSubmit<T extends Record<string, unknown>, R = any>(defaultData: T) {
const formRef = ref();
const formData = reactive<T>({ ...defaultData });
const submitting = ref(false);
const handleSubmit = async (api: (params?: any) => Promise<BaseResponse<R>>, params) => {
submitting.value = true;
try {
const hasError = await formRef.value?.validate();
if (!hasError) {
const { data, message } = await api({ ...formData, ...unref(params) });
Message.success(message);
return Promise.resolve({ formData, data });
}
return Promise.reject(false);
} catch (err) {
return Promise.reject(err);
} finally {
submitting.value = false;
}
};
return { formRef, formData, handleSubmit, submitting };
}
export default useSubmit;

View File

@ -42,6 +42,15 @@ const menu = [
}, },
component: () => import('@/views/Reward/RewardContainer.vue') component: () => import('@/views/Reward/RewardContainer.vue')
}, },
{
path: '/functions',
name: 'Functions',
meta: {
title: "函数管理",
icon: IconCalendar,
},
component: () => import('@/views/Functions/FunctionsContainer.vue')
},
{ {
path: '/loginLog', path: '/loginLog',
name: 'LoginLog', name: 'LoginLog',

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import { Message, type TableColumnData } from "@arco-design/web-vue";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
import ConfirmSwitch from "@/components/ConfirmSwitch.vue";
import usePopup from "@/composables/usePopup";
import { getList, remove, setStatus, save } from "./api";
import FunctionsForm from "./FunctionsForm.vue";
const columns: TableColumnData[] = [
{
dataIndex: "name",
title: "函数名称",
},
{
dataIndex: "label",
title: "函数别名",
},
{
dataIndex: "description",
title: "功能描述",
},
{
dataIndex: "enabled",
title: "启用状态",
slotName: "switch",
},
{
title: "操作",
slotName: "actions",
fixed: "right",
},
];
const openFormModal = usePopup(FunctionsForm, {
nodeProps: ([_, record]) => ({ record }),
popupProps: ([reload, record], exposed) => ({
title: `${record.id ? "编辑" : "新增"}函数`,
width: "800px",
onBeforeOk: async (done) => {
await exposed()?.handleSubmit(save, {
id: record.id,
parameters: exposed()?.parameters(),
});
await reload();
done(true);
},
}),
});
const handleRemove = async (id, reload) => {
await remove({ id });
Message.success("删除成功");
await reload();
return true;
};
</script>
<template>
<a-button type="primary" @click="openFormModal">新增</a-button>
<SimpleTable :request="getList" :columns="columns" :pagination="false">
<template #switch="{ record, column }">
<ConfirmSwitch
v-model="record[column.dataIndex]"
:api="(p) => setStatus({ ...p, id: record.id, filed: 'enabled' })"
/>
</template>
<template #exchange="{ record }">
<a-tag v-if="record.exchange.calls > 0">聊天{{ record.exchange.calls }}</a-tag>
<a-tag v-else-if="record.exchange.img_calls > 0" color="green"
>绘图{{ record.exchange.img_calls }}</a-tag
>
</template>
<template #actions="{ record, reload }">
<a-link @click="openFormModal(reload, record)">编辑</a-link>
<a-popconfirm
content="是否删除?"
position="left"
type="warning"
:on-before-ok="() => handleRemove(record.id, reload)"
>
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
</SimpleTable>
</template>

View File

@ -0,0 +1,74 @@
<script lang="ts" setup>
import { ref, watchEffect } from "vue";
import useSubmit from "@/composables/useSubmit";
import FunctionsFormTable from "./FunctionsFormTable.vue";
import translateTableData from "./translateTableData";
const props = defineProps({
record: Object,
});
const tableData = ref([]);
const { formRef, formData, handleSubmit, submitting } = useSubmit({
name: "",
label: "",
description: "",
action: "",
token: "",
parameters: {},
enabled: false,
});
const rules = {
name: [{ required: true, message: "请输入函数名称" }],
label: [{ required: true, message: "请输入函数标签" }],
description: [{ required: true, message: "请输入函数功能描述" }],
};
watchEffect(() => {
Object.assign(formData, props.record ?? {});
tableData.value = translateTableData.get(formData.parameters);
});
defineExpose({
handleSubmit,
parameters: () => translateTableData.set(tableData.value),
});
</script>
<template>
<a-spin :loading="submitting" style="width: 100%">
<a-form ref="formRef" :model="formData" auto-label-width :rules="rules">
<a-form-item field="name" label="函数名称">
<a-input v-model="formData.name" placeholder="函数名称最好为英文" />
</a-form-item>
<a-form-item field="label" label="函数标签">
<a-input v-model="formData.label" placeholder="函数的中文名称" />
</a-form-item>
<a-form-item field="description" label="功能描述">
<a-input v-model="formData.description" placeholder="函数的中文名称" />
</a-form-item>
<a-form-item field="parameters" label="函数参数">
<FunctionsFormTable v-model="tableData" />
</a-form-item>
<a-form-item field="action" label="API 地址">
<a-input v-model="formData.action" placeholder="该函数实现的API地址可以是第三方服务API" />
</a-form-item>
<a-form-item field="token" label="API Token">
<a-input-search v-model="formData.token" placeholder="API授权Token">
<template #append>
<a-tooltip
content="只有本地服务才可以使用自动生成Token第三方服务请填写第三方服务API Token"
>
<a-button>生成Token</a-button>
</a-tooltip>
</template>
</a-input-search>
</a-form-item>
<a-form-item field="enabled" label="启用状态">
<a-switch v-model="formData.enabled" />
</a-form-item>
</a-form>
</a-spin>
</template>

View File

@ -0,0 +1,72 @@
<script lang="ts" setup>
import { IconDelete } from "@arco-design/web-vue/es/icon";
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
});
const emits = defineEmits(["update:modelValue"]);
const handleCreateRow = () => {
emits("update:modelValue", [
...(props.modelValue ?? []),
{ name: "", type: "", description: "", required: false },
]);
};
const handleRemoveRow = (index) => {
emits(
"update:modelValue",
props.modelValue.filter((_, i) => i !== index)
);
};
</script>
<template>
<a-space direction="vertical" style="width: 100%">
<a-table :data="modelValue" size="small" style="width: 100%" :pagination="false">
<template #columns>
<a-table-column title="参数名称" :width="120">
<template #cell="scope">
<a-input v-model="scope.record.name" />
</template>
</a-table-column>
<a-table-column title="参数类型" :width="120">
<template #cell="scope">
<a-select
v-model="scope.record.type"
placeholder="参数类型"
:options="['string', 'number']"
/>
</template>
</a-table-column>
<a-table-column title="参数描述">
<template #cell="scope">
<a-input v-model="scope.record.description" />
</template>
</a-table-column>
<a-table-column title="必填参数" :width="100">
<template #cell="scope">
<a-checkbox v-model="scope.record.required" />
</template>
</a-table-column>
<a-table-column title="操作" :width="80">
<template #cell="scope">
<a-button
status="danger"
:icon="IconDelete"
circle
@click="handleRemoveRow(scope.rowIndex)"
size="small"
/>
</template>
</a-table-column>
</template>
</a-table>
<a-button type="primary" long @click="handleCreateRow">新增参数</a-button>
</a-space>
</template>

View File

@ -0,0 +1,41 @@
import http from "@/http/config";
export const getList = (params) => {
return http({
url: "/api/admin/function/list",
method: "get",
params
})
}
export const save = (data) => {
return http({
url: "/api/admin/function/save",
method: "post",
data
})
}
export const remove = (params) => {
return http({
url: "/api/admin/function/remove",
method: "get",
params
})
}
export const setStatus = (data) => {
return http({
url: "/api/admin/function/set",
method: "post",
data
})
}
export const token = (data) => {
return http({
url: "/api/admin/function/token",
method: "post",
data
})
}

View File

@ -0,0 +1,33 @@
const get = (origin) => {
const { properties, required, type } = origin;
if (type === "object") {
const array = Object.keys(properties).reduce((prev, name) => {
return [
...prev,
{
name,
...properties[name],
required: required.includes(name),
},
];
}, []);
return array;
}
return [];
}
const set = (tableData) => {
const properties = tableData.reduce((prev, curr) => {
return {
...prev,
[curr.name]: {
description: curr.description,
type: curr.type,
},
};
}, {});
const required = tableData.filter((i) => i.required).map((i) => i.name);
return { properties, required, type: "object" }
}
export default { get, set }

View File

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { TableColumnData } from "@arco-design/web-vue"; import { Message, type TableColumnData } from "@arco-design/web-vue";
import { dateFormat } from "@gpt-vue/packages/utils"; import { dateFormat } from "@gpt-vue/packages/utils";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue"; import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
import { getList } from "./api"; import { getList, remove } from "./api";
const columns: TableColumnData[] = [ const columns: TableColumnData[] = [
{ {
@ -38,22 +38,38 @@ const columns: TableColumnData[] = [
title: "操作", title: "操作",
slotName: "actions", slotName: "actions",
fixed: "right", fixed: "right",
width: 80,
}, },
]; ];
const handleRemove = async (id, reload) => {
await remove({ id });
Message.success("删除成功");
await reload();
return true;
};
</script> </script>
<template> <template>
<SimpleTable :request="getList" :columns="columns"> <SimpleTable :request="getList" :columns="columns">
<template #updated_at="{ record }"> <template #updated_at="{ record }">
<span v-if="record.status">{{ dateFormat(record.updated_at) }}</span> <span v-if="record.status">{{ dateFormat(record.updated_at) }}</span>
<a-tag v-else>未核销</a-tag> <a-tag v-else color="blue">未核销</a-tag>
</template> </template>
<template #exchange="{ record }"> <template #exchange="{ record }">
<a-tag v-if="record.exchange.calls > 0" <a-tag v-if="record.exchange.calls > 0">聊天{{ record.exchange.calls }}</a-tag>
>聊天{{ record.exchange.calls }}</a-tag <a-tag v-else-if="record.exchange.img_calls > 0" color="green"
>
<a-tag v-else-if="record.exchange.img_calls > 0"
>绘图{{ record.exchange.img_calls }}</a-tag >绘图{{ record.exchange.img_calls }}</a-tag
> >
</template> </template>
<template #actions="{ record, reload }">
<a-popconfirm
content="是否删除?"
position="left"
type="warning"
:on-before-ok="() => handleRemove(record.id, reload)"
>
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
</SimpleTable> </SimpleTable>
</template> </template>

View File

@ -7,3 +7,11 @@ export const getList = (params?: Record<string, unknown>) => {
params params
}) })
} }
export const remove = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/reward/remove",
method: "get",
params
})
}

View File

@ -3,6 +3,8 @@
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"strict": false,
"noImplicitAny": false,
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",

View File

@ -8,6 +8,7 @@
"playwright.config.*" "playwright.config.*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"noEmit": true, "noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",