fix conflicts

This commit is contained in:
RockYang
2024-03-12 18:03:24 +08:00
239 changed files with 602 additions and 452 deletions

View File

@@ -0,0 +1,3 @@
VITE_PROXY_BASE_URL="/api"
VITE_TARGET_URL="http://localhost:5678"
VITE_SOCKET_IO_URL="http://localhost:8899"

View File

@@ -0,0 +1,3 @@
VITE_PROXY_BASE_URL=""
VITE_TARGET_URL="/"
VITE_SOCKET_IO_URL="/"

View File

@@ -0,0 +1,28 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
"@vue/eslint-config-prettier",
],
parserOptions: {
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",
},
};

30
new-ui/projects/vue-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

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

View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint"
]
}

View File

@@ -0,0 +1,46 @@
# vue-admin
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```

9
new-ui/projects/vue-admin/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import { DefineComponent } from "vue";
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare const __AUTH_KEY: string;

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChatPlus-Ai</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{
"name": "@gpt-vue-projects/vue-admin",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@arco-design/web-vue": "^2.54.6",
"@gpt-vue/packages": "workspace:^1.0.0",
"echarts": "^5.5.0",
"md-editor-v3": "^2.2.1",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.11.10",
"@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"less": "^4.2.0",
"npm-run-all2": "^6.1.1",
"typescript": "~5.3.0",
"vite": "^5.0.11",
"vue-tsc": "^1.8.27"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,31 @@
<template>
<RouterView />
</template>
<style>
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
border-radius: 1px;
box-shadow: inset 0 0 5px #0000000d;
background: #d9d9d9;
}
::-webkit-scrollbar-track {
box-shadow: inset 0 0 5px #0000000d;
border-radius: 1px;
background: #fafafa;
}
.public-bg {
display: flex;
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
background: linear-gradient(133deg, #ffffff 0%, #dde8fe 100%);
overflow: hidden;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

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

@@ -0,0 +1,132 @@
<script lang="ts" setup>
import { IconDown, IconExport } from "@arco-design/web-vue/es/icon";
import { useAuthStore } from "@/stores/auth";
import useState from "@/composables/useState";
import Logo from "/images/logo.png";
import avatar from "/images/user-info.jpg";
import donateImg from "/images/wechat-pay.png";
import SystemMenu from "./SystemMenu.vue";
import PageWrapper from "./PageWrapper.vue";
const logoWidth = "200px";
const authStore = useAuthStore();
const [visible, setVisible] = useState(false);
</script>
<template>
<ALayout class="custom-layout">
<ALayoutHeader class="custom-layout-header">
<div class="logo">
<img :src="Logo" alt="logo" />
<span>ChatPlus 控制台</span>
</div>
<div class="action">
<ADropdown>
<ASpace align="center" :size="4">
<a-avatar class="user-avatar" :size="30">
<img :src="avatar" />
</a-avatar>
<IconDown />
</ASpace>
<template #content>
<a
class="dropdown-link"
href="https://github.com/yangjian102621/chatgpt-plus"
target="_blank"
>
<ADoption value="1">
<template #icon>
<icon-github />
</template>
<span>ChatPlus-AI 创作系统</span>
</ADoption>
</a>
<ADoption value="2" @click="setVisible(true)">
<template #icon>
<icon-wechatpay />
</template>
<span>打赏作者</span>
</ADoption>
</template>
<template #footer>
<APopconfirm content="确认退出?" position="br" @ok="authStore.logout">
<ASpace align="center" class="logout-area">
<IconExport size="16" />
<span>退出</span>
</ASpace>
</APopconfirm>
</template>
</ADropdown>
</div>
</ALayoutHeader>
<ALayout>
<SystemMenu :width="logoWidth" />
<ALayoutContent>
<PageWrapper>
<slot />
</PageWrapper>
</ALayoutContent>
</ALayout>
</ALayout>
<a-modal
v-model:visible="visible"
class="donate-dialog"
width="400px"
title="请作者喝杯咖啡"
:footer="false"
>
<a-alert :closable="false" :show-icon="false">
如果你觉得这个项目对你有帮助并且情况允许的话可以请作者喝杯咖啡非常感谢你的支持
</a-alert>
<p>
<a-image :src="donateImg" />
</p>
</a-modal>
</template>
<style lang="less" scoped>
.custom-layout {
width: 100vw;
height: 100vh;
overflow: hidden;
&-header {
display: flex;
width: 100%;
height: 60px;
align-items: center;
border-bottom: 1px solid var(--color-neutral-2);
.logo {
display: flex;
width: v-bind("logoWidth");
align-items: center;
justify-content: center;
gap: 12px;
img {
width: 30px;
height: 30px;
}
}
.action {
display: flex;
padding: 0 12px;
flex: 1;
justify-content: right;
align-items: center;
}
}
}
.dropdown-link {
text-decoration: none;
}
.donate-dialog {
p {
text-align: center;
}
}
.logout-area {
padding: 8px 0;
display: flex;
width: 80px;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div class="page-wrapper">
<div class="page-content">
<RouterView />
</div>
</div>
</template>
<style scoped>
.page-wrapper {
height: calc(100vh - 60px);
display: flex;
flex-direction: column;
background: #f7f8fa;
overflow: hidden;
}
.page-content {
margin: 12px;
padding: 12px;
flex: 1;
background: #fff;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import { computed, ref, type PropType } from "vue";
import { getDefaultFormData, useComponentConfig } from "./utils";
import { ValueType } from "./type.d";
import type { SearchTableColumns, SearchColumns } from "./type";
const props = defineProps({
modelValue: {
type: Object,
default: () => ({}),
},
columns: {
type: Array as PropType<SearchTableColumns[]>,
default: () => [],
},
submitting: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(["update:modelValue", "request"]);
const size = "small";
const collapsed = ref(false);
const formData = computed({
get: () => props.modelValue,
set(value) {
emits("update:modelValue", value);
},
});
const searchColumns = computed(() => {
return props.columns?.filter((item) => item.dataIndex && item.search) as (SearchColumns & {
dataIndex: string;
})[];
});
const optionsEvent = {
onReset: () => {
formData.value = getDefaultFormData(props.columns);
emits("request");
},
onSearch: () => emits("request"),
onCollapse: (value: boolean) => {
collapsed.value = value ?? !collapsed.value;
},
};
</script>
<template>
<AForm
v-if="searchColumns?.length"
class="search-form-conteiner"
:model="formData"
:size="size"
:label-col-props="{ span: 0 }"
:wrapper-col-props="{ span: 24 }"
@submit="optionsEvent.onSearch"
>
<AGrid
:cols="{ md: 1, lg: 3, xl: 4, xxl: 5 }"
:row-gap="12"
:col-gap="12"
:collapsed="collapsed"
>
<AGridItem
v-for="item in searchColumns"
:key="item.dataIndex"
style="transition: all 0.3s ease-in-out"
>
<AFormItem :field="item.dataIndex" :label="item.title as string">
<slot :name="item.search.slotsName">
<component
v-model="formData[item.dataIndex]"
:is="ValueType[item.search.valueType ?? 'input'] ?? item.search.render"
v-bind="useComponentConfig(size, item)"
/>
</slot>
</AFormItem>
</AGridItem>
<AGridItem>
<ASpace>
<slot name="search-options" :option="optionsEvent">
<AButton type="primary" html-type="submit" :size="size" :loading="submitting">
<icon-search />
<span>查询</span>
</AButton>
<AButton :size="size" @click="optionsEvent.onReset" :loading="submitting">
<icon-refresh />
<span>重置</span>
</AButton>
</slot>
</ASpace>
</AGridItem>
<AGridItem suffix>
<ASpace class="flex-end">
<slot name="search-extra" />
</ASpace>
</AGridItem>
</AGrid>
</AForm>
</template>
<style scoped>
.search-form-conteiner {
padding: 8px 0px 0px;
}
.flex-end {
display: flex;
justify-content: end;
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import { computed, ref, onActivated } from "vue";
import useAsyncTable from "./useAsyncTable";
import FormSection from "./FormSection.vue";
import type { SearchTableProps } from "./type";
import { useTableScroll, getDefaultFormData, useRequestParams } from "./utils";
import { Message } from "@arco-design/web-vue";
const props = defineProps<SearchTableProps>();
const formData = ref({ ...getDefaultFormData(props.columns) });
const tableContainerRef = ref<HTMLElement>();
// 表格请求参数
const requestParams = computed(() => ({
...useRequestParams(props.columns, formData.value),
...props.params,
}));
const [tableConfig, getList] = useAsyncTable(props.request, requestParams);
const _columns = computed(() => {
return props.columns
.filter((item) => !item.hideInTable)
.map((item) => ({
ellipsis: true,
tooltip: true,
...item,
}));
});
const handleSearch = async (tips?: boolean) => {
tips && Message.success("操作成功");
await getList();
};
onActivated(handleSearch);
</script>
<template>
<div class="search-table">
<div class="search-table-header">
<div>
<slot name="header-title">{{ props.headerTitle }}</slot>
</div>
<div class="header-option">
<slot name="header-option" :formData="formData" :reload="handleSearch" />
</div>
</div>
<FormSection
v-model="formData"
:columns="columns"
:submitting="tableConfig.loading as boolean"
@request="handleSearch"
>
<template v-for="slot in Object.keys($slots)" #[slot]="config">
<slot :name="slot" v-bind="{ ...config, reload: handleSearch }" />
</template>
</FormSection>
<div ref="tableContainerRef" class="search-table-container">
<ATable
v-bind="{
...$attrs,
...tableConfig,
...props,
scroll: useTableScroll(_columns, tableContainerRef as HTMLElement),
columns: _columns,
}"
>
<template v-for="slot in Object.keys($slots)" #[slot]="config">
<slot :name="slot" v-bind="{ ...config, reload: handleSearch }" />
</template>
</ATable>
</div>
</div>
</template>
<style scoped>
.search-table {
display: flex;
flex-direction: column;
height: 100%;
}
.search-table-container {
flex: 1;
}
.search-table-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,49 @@
import type { Component } from "vue";
import type { JsxElement } from "typescript";
import {
DatePicker,
Input,
InputNumber,
RadioGroup,
RangePicker,
Select,
Switch,
type TableColumnData,
} from "@arco-design/web-vue";
import type { TableOriginalProps, TableRequest } from "./useAsyncTable";
type Object = Record<string, unknown>;
export enum ValueType {
"input" = Input,
"select" = Select,
"number" = InputNumber,
"date" = DatePicker,
"range" = RangePicker,
"radio" = RadioGroup,
"switch" = Switch,
}
export type SearchConfig = {
valueType?: keyof typeof ValueType;
fieldProps?: Object;
render?: Component | JsxElement;
slotsName?: string;
defaultValue?: any;
transform?: (value) => Record<string, any>;
};
export interface SearchTableColumns extends TableColumnData {
search?: SearchConfig;
hideInTable?: boolean;
[key: string]: any;
}
export type SearchColumns = SearchTableColumns & { search: SearchConfig };
export interface SearchTableProps extends /* @vue-ignore */ TableOriginalProps {
request: TableRequest<Object>;
params?: Object;
columns: SearchTableColumns[];
headerTitle?: string;
}

View File

@@ -0,0 +1,63 @@
import { computed, onMounted, reactive, unref, type Ref } from "vue";
import type { TableInstance } from "@arco-design/web-vue";
import type { BaseResponse, ListResponse } from "@gpt-vue/packages/type";
export type TableOriginalProps = TableInstance["$props"];
export type TableRequest<T extends Record<string, unknown>> = (params?: any) => Promise<BaseResponse<ListResponse<T>>>
export type TableReturn = [TableOriginalProps, () => Promise<void>];
function useAsyncTable<T extends Record<string, unknown>>(
request: TableRequest<T>,
params?: Ref<Record<string, unknown>>
): TableReturn {
const paginationState = reactive({
current: 1,
pageSize: 10,
total: 0,
});
const tableState = reactive({
loading: false,
data: []
})
const tableConfig = computed<TableOriginalProps>(() => {
return {
...tableState,
rowKey: "id",
pagination: {
...paginationState,
showTotal: true,
showPageSize: true,
},
onPageChange: (page) => {
paginationState.current = page;
getTableData();
},
onPageSizeChange(pageSize) {
paginationState.pageSize = pageSize;
getTableData();
},
};
});
const getTableData = async () => {
tableState.loading = true
try {
const { data } = await request({
...unref(params ?? {}),
page: paginationState.current,
page_size: paginationState.pageSize,
});
tableState.data = (data as any)?.items;
paginationState.total = (data as any)?.total;
} finally {
tableState.loading = false
}
};
onMounted(getTableData);
return [tableConfig, getTableData] as TableReturn;
}
export default useAsyncTable;

View File

@@ -0,0 +1,53 @@
import type { TableColumnData } from "@arco-design/web-vue";
import type { SearchTableColumns, SearchColumns } from "./type";
export function useTableXScroll(columns: TableColumnData[]) {
return columns.reduce((prev, curr) => {
const width = curr.width ?? 150;
return prev + width;
}, 0);
}
export function useTableScroll(columns: SearchTableColumns[], container?: HTMLElement) {
const x = columns.reduce((prev, curr) => {
const width = curr.hideInTable ? 0 : curr.width ?? 150;
return prev + width;
}, 0);
const y = container?.clientHeight ?? undefined;
return { x, y };
}
export function getDefaultFormData(columns: SearchTableColumns[]) {
return columns?.reduce((field, curr) => {
if (curr.dataIndex && curr?.search?.defaultValue) {
field[curr.dataIndex] = curr.search.defaultValue;
}
return field;
}, {});
}
export function useRequestParams(
columns: SearchTableColumns[],
originFormData: Record<string, any>
) {
const filterFormData = columns?.reduce((prev, curr) => {
if (!curr.dataIndex || !curr.search) {
return prev;
}
if (curr?.search?.transform) {
const filters = curr.search.transform(originFormData[curr.dataIndex]);
return Object.assign(prev, filters);
}
return Object.assign(prev, { [curr.dataIndex]: originFormData[curr.dataIndex] });
}, {});
return filterFormData as Record<string, any>;
}
export function useComponentConfig(size: string, item: SearchColumns) {
return {
size,
placeholder: item.search.valueType === "range" ? ["开始时间", "结束时间"] : item.title,
allowClear: true,
...(item.search.fieldProps ?? {}),
};
}

View File

@@ -0,0 +1,80 @@
<script lang="ts" setup>
import { computed, ref, onActivated } from "vue";
import useAsyncTable from "./useAsyncTable";
import { useTableScroll } from "@/components/SearchTable/utils";
import { Message } from "@arco-design/web-vue";
import type { TableRequest, TableOriginalProps } from "./useAsyncTable";
interface SimpleTable extends /* @vue-ignore */ TableOriginalProps {
request: TableRequest<Record<string, unknown>>;
params?: Record<string, unknown>;
columns?: TableOriginalProps["columns"];
}
const props = defineProps<SimpleTable>();
const tableContainerRef = ref<HTMLElement>();
// 表格请求参数
const [tableConfig, getList] = useAsyncTable(props.request, props.params);
const _columns = computed(() => {
return props.columns?.map((item) => ({
ellipsis: true,
tooltip: true,
...item,
}));
});
const handleSearch = async (tips?: boolean) => {
tips && Message.success("操作成功");
await getList();
};
onActivated(handleSearch);
</script>
<template>
<div class="simple-table">
<div class="simple-header">
<a-space class="flex-end">
<slot name="header" v-bind="{ reload: handleSearch }" />
</a-space>
</div>
<div ref="tableContainerRef" class="simple-table-container">
<ATable
v-bind="{
...$attrs,
...tableConfig,
...props,
scroll: useTableScroll(_columns || [], tableContainerRef as HTMLElement),
columns: _columns,
}"
>
<template v-for="slot in Object.keys($slots)" #[slot]="config">
<slot :name="slot" v-bind="{ ...config, reload: handleSearch }" />
</template>
</ATable>
</div>
</div>
</template>
<style scoped>
.simple-table {
display: flex;
flex-direction: column;
height: 100%;
}
.simple-table-container {
flex: 1;
}
.simple-table-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.simple-header {
padding: 8px 0px 16px;
}
.flex-end {
display: flex;
justify-content: end;
}
</style>

View File

@@ -0,0 +1,43 @@
import { computed, onMounted, reactive, unref } from "vue";
import type { TableInstance } from "@arco-design/web-vue";
import type { BaseResponse } from "@gpt-vue/packages/type";
export type TableOriginalProps = TableInstance["$props"];
export type TableRequest<T extends Record<string, unknown>> = (
params?: any
) => Promise<BaseResponse<T[]>>;
export type TableReturn = [TableOriginalProps, () => Promise<void>];
function useAsyncTable<T extends Record<string, unknown>>(
request: TableRequest<T>,
params?: Record<string, unknown>
): TableReturn {
const tableState = reactive<{ loading: Boolean; data: T[] }>({
loading: false,
data: [],
});
const tableConfig = computed<TableOriginalProps>(() => {
return {
...tableState,
rowKey: "id",
};
});
const getTableData = async () => {
tableState.loading = true;
try {
const { data } = await request({
...unref(params ?? {}),
});
tableState.data = data as any;
} finally {
tableState.loading = false;
}
};
onMounted(getTableData);
return [tableConfig, getTableData] as TableReturn;
}
export default useAsyncTable;

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import router from "@/router";
import showMenu from "@/router/menu";
defineProps({
width: {
type: [Number, String],
default: 200,
},
});
const route = useRoute();
const goto = (name: string) => router.push({ name });
const selectedKeys = computed(() => [route.name]);
</script>
<template>
<ALayoutSider :style="{ width, height: '100%' }">
<AMenu :selected-keys="selectedKeys" @menu-item-click="goto">
<AMenuItem v-for="item in showMenu" :key="item.name">
<template #icon>
<component :is="item.meta?.icon" />
</template>
{{ item.meta.title }}
</AMenuItem>
</AMenu>
</ALayoutSider>
</template>

View File

@@ -0,0 +1,44 @@
import usePopup, { type Config } from "./usePopup";
import { Message } from "@arco-design/web-vue";
import type { Component } from "vue";
import type { BaseResponse } from "@gpt-vue/packages/type";
interface Arg {
reload?: () => void;
record?: Record<string, any>;
}
export default function (
node: Component,
api: (params?: any) => Promise<BaseResponse<any>>,
config?: Config
): (arg: Arg) => void {
const nodeProps = (arg: Arg[]) => {
return {
data: arg[0].record || {},
...config.nodeProps?.(arg),
};
};
const popupProps = (arg: Arg[], getExposed) => {
return {
width: 750,
maskClosable: false,
onBeforeOk: async () => {
const exposed = getExposed();
const validateRes = await exposed?.formRef.value.validate();
if (validateRes) {
return false;
}
const { code } = await api(exposed?.form.value);
if (code === 0) {
Message.success("操作成功");
}
arg[0]?.reload?.();
return code === 0;
},
...config.popupProps?.(arg, getExposed),
};
};
return usePopup(node, { nodeProps, popupProps });
}

View File

@@ -0,0 +1,37 @@
import { h } from "vue";
import type { Component, ComponentInternalInstance } from "vue";
import { Modal, Drawer } from "@arco-design/web-vue";
import type { ModalConfig, DrawerConfig } from "@arco-design/web-vue";
import app from "@/main";
export interface Config {
nodeProps?: (...arg: any) => Record<string, any>;
popupProps?: (
arg: any[],
exposed: () => ComponentInternalInstance["exposed"]
) => Omit<ModalConfig | DrawerConfig, "content"> & {
[key: string]: any;
};
type?: "drawer" | "modal";
}
const component = {
modal: Modal,
drawer: Drawer,
};
function usePopup(node: Component, config: Config) {
const { nodeProps, popupProps, type = "modal" } = config;
return (...arg: any[]) => {
const content = h(node, nodeProps ? nodeProps(arg) : {});
const popupNode = component[type];
// 获取全局组件的上下文
popupNode._context = app._context;
popupNode.open({
content: () => content,
...popupProps?.(arg, () => content?.component?.exposed as any),
});
};
}
export default usePopup;

View File

@@ -0,0 +1,26 @@
import { ref } from "vue";
import type { Ref } from "vue";
import type { BaseResponse } from "@gpt-vue/packages/type";
type Request<T> = (params?: any) => Promise<BaseResponse<T>>
function useRequest<T>(request: Request<T>) {
const result = ref<T>()
const loading = ref(false)
const requestData = async (params?: any) => {
loading.value = true
try {
const res = await request(params)
result.value = res.data
return Promise.resolve(res)
} catch (err) {
return Promise.reject(err)
} finally {
loading.value = false
}
}
return [requestData, result, loading] as [Request<T>, Ref<T>, Ref<boolean>]
}
export default useRequest

View File

@@ -0,0 +1,10 @@
import { ref, type Ref } from "vue";
function useState<T>(defaultValue?: T): [Ref<T>, (newValue: T) => void] {
const state = ref<T>(defaultValue) as Ref<T>;
const setState = (newValue: T) => {
state.value = newValue;
};
return [state, setState];
}
export default useState;

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, any> = Record<string, any>, R = any>(defaultData?: T) {
const formRef = ref();
const formData = reactive<T | Record<string, any>>({ ...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 && 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

@@ -0,0 +1,64 @@
import router from "@/router";
import { Notification } from "@arco-design/web-vue";
import createInstance from "@gpt-vue/packages/request"
import type { BaseResponse } from "@gpt-vue/packages/type";
export const uploadUrl = import.meta.env.VITE_PROXY_BASE_URL + "/api/admin/upload";
export const instance = createInstance()
instance.interceptors.request.use((config) => {
config.headers[__AUTH_KEY] = localStorage.getItem(__AUTH_KEY);
config.headers["Authorization"] = localStorage.getItem(__AUTH_KEY);
return config;
});
instance.interceptors.response.use(
(response) => {
const { data }: { data: BaseResponse<unknown> } = response
if (data && typeof data === "object" && data.code !== 0) {
if (data.code === 400) {
localStorage.removeItem(__AUTH_KEY);
router.push({ name: "Login" })
}
Notification.error(data.message ?? '未知错误')
}
return { data, response } as any;
},
(error) => {
const STATUS_CODE: any = {
401: {
msg: error.response.data || "没有操作权限!",
event: null,
},
500: {
msg: error.response.data || "系统正在部署升级中,请稍后再试!",
event: null,
},
};
const statusCodeEvent = STATUS_CODE?.[error.response.status];
if (statusCodeEvent) {
Notification.error(statusCodeEvent.msg);
statusCodeEvent.event?.();
}
if (error.message.indexOf("timeout") !== -1) {
Notification.error("连接超时");
}
return Promise.reject(error);
}
);
function http<T = any>(config: any): Promise<BaseResponse<T>> {
return instance(config).then((res) => {
return res.data;
}) as unknown as Promise<BaseResponse<T>>;
}
export function originHttp<T = any>(config: any) {
return instance<T>(config as any).then((res) => res);
}
export default http;

View File

@@ -0,0 +1,42 @@
import http from "@/http/config";
export const userLogin = (data: {
username: string;
password: string;
}) => {
return http({
url: "/api/admin/login",
method: "post",
data,
});
};
export const userLogout = () => {
return http({
url: "/api/admin/logout",
method: "get",
});
};
export const getSession = () => {
return http({
url: "/api/admin/session",
method: "get",
});
};
export const loginLog = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/user/loginLog",
method: "get",
params
})
}
export const captcha = () => {
return http({
url: "/api/admin/login/captcha",
method: "get",
});
};

View File

@@ -0,0 +1,26 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import ArcoVue from "@arco-design/web-vue";
import ArcoVueIcon from "@arco-design/web-vue/es/icon";
import "@arco-design/web-vue/dist/arco.css";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ArcoVue);
app.use(ArcoVueIcon);
app.mount("#app");
app.config.warnHandler = (msg, vm, trace) => {
if (msg.includes('Invalid prop name: "key" is a reserved property.')) {
// 如果警告信息包含我们要屏蔽的内容,则不执行任何操作
return;
}
console.warn(`[Vue warn]: ${msg}${trace}`);
};
export default app;

View File

@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from "@/stores/auth";
import CustomLayout from '@/components/CustomLayout.vue'
import menu from './menu'
const whiteListRoutes = [
{
path: "/login",
name: "Login",
component: () => import("@/views/LoginView.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "404",
component: () => import("@/views/NotFound.vue"),
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: CustomLayout,
redirect: () => menu[0].path,
children: menu
},
...whiteListRoutes
]
})
const whiteList = whiteListRoutes.map((i) => i.name);
router.beforeEach((to, _, next) => {
const authStore = useAuthStore();
authStore.init()
if (typeof to.name === "string" && whiteList.includes(to.name)) {
if (authStore.token && to.name === "Login") {
next({ path: menu[0].path });
return;
}
next();
return;
}
if (!authStore.token) {
next({ name: "Login" });
return;
}
next();
});
export default router

View File

@@ -0,0 +1,128 @@
import {
IconUser,
IconDashboard,
IconOrderedList,
IconCalendar,
IconHeartFill,
IconCodeSquare,
IconMessage,
IconSettings,
IconUserGroup,
IconLock,
IconCodepen,
IconWechatpay,
} from "@arco-design/web-vue/es/icon";
const menu = [
{
path: "/dashboard",
name: "Dashboard",
meta: {
title: "仪表盘",
icon: IconDashboard,
},
component: () => import("@/views/DashboardView.vue"),
},
{
path: "/user",
name: "User",
meta: {
title: "用户管理",
icon: IconUser,
},
component: () => import("@/views/User/UserContainer.vue"),
},
{
path: "/Role",
name: "Role",
meta: {
title: "角色管理",
icon: IconUserGroup,
},
component: () => import("@/views/Role/RoleContainer.vue"),
},
{
path: "/ChatModel",
name: "ChatModel",
meta: {
title: "语言模型",
icon: IconCodepen,
},
component: () => import("@/views/ChatModel/ChatModelContainer.vue"),
},
{
path: "/Product",
name: "Product",
meta: {
title: "充值产品",
icon: IconWechatpay,
},
component: () => import("@/views/Product/ProductContainer.vue"),
},
{
path: "/ApiKey",
name: "ApiKey",
meta: {
title: "APIKEY",
icon: IconLock,
},
component: () => import("@/views/ApiKey/ApiKeyContainer.vue"),
},
{
path: "/order",
name: "Order",
meta: {
title: "充值订单",
icon: IconOrderedList,
},
component: () => import("@/views/Order/OrderContainer.vue"),
},
{
path: "/reward",
name: "Reward",
meta: {
title: "众筹管理",
icon: IconHeartFill,
},
component: () => import("@/views/Reward/RewardContainer.vue"),
},
{
path: "/functions",
name: "Functions",
meta: {
title: "函数管理",
icon: IconCodeSquare,
},
component: () => import("@/views/Functions/FunctionsContainer.vue"),
},
{
path: "/chats",
name: "Chats",
meta: {
title: "对话管理",
icon: IconMessage,
},
component: () => import("@/views/Chats/ChatsContainer.vue"),
},
{
path: "/system",
name: "System",
meta: {
title: "系统设置",
icon: IconSettings,
},
component: () => import("@/views/System/SystemContainer.vue"),
},
{
path: "/loginLog",
name: "LoginLog",
meta: {
title: "登录日志",
icon: IconCalendar,
},
component: () => import("@/views/LoginLog.vue"),
},
];
export default menu;

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { Message } from '@arco-design/web-vue'
import { userLogin, userLogout } from '@/http/login'
import router from '@/router'
export const useAuthStore = defineStore({
id: __AUTH_KEY,
state: () => ({ token: null } as { token: string | null }),
actions: {
init() {
this.$state.token = localStorage.getItem(__AUTH_KEY);
},
async login(params: any) {
try {
const { data } = await userLogin(params)
if (data) {
this.$state.token = data;
localStorage.setItem(__AUTH_KEY, data)
Message.success('登录成功');
router.replace({ name: 'home' })
return Promise.resolve(data)
}
} catch (err) {
return Promise.reject(err)
}
},
async logout() {
try {
await userLogout()
if (this.$state.token) {
localStorage.removeItem(__AUTH_KEY)
this.$reset()
}
Message.success('退出成功');
router.push({ name: 'Login' })
return Promise.resolve(true)
} catch (err) {
return Promise.reject(err)
}
}
}
})

View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,130 @@
<script lang="ts" setup>
import { getList, save, deleting, setStatus } from "./api";
import { ref } from "vue";
import ApiKeyForm from "./ApiKeyForm.vue";
import useCustomFormPopup from "@/composables/useCustomFormPopup";
import { Message } from "@arco-design/web-vue";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
import { dateFormat } from "@gpt-vue/packages/utils";
// table 配置
const columns = [
{
title: "所属平台",
dataIndex: "platform",
},
{
title: "名称",
dataIndex: "name",
},
{
title: "key",
dataIndex: "value",
slotName: "value",
},
{
title: "用途",
dataIndex: "type",
},
{
title: "使用代理",
dataIndex: "use_proxy",
slotName: "proxy",
},
{
title: "最后使用时间",
dataIndex: "last_used_at",
render: ({ record }) => {
return dateFormat(record.last_used_at);
},
},
{
title: "启用状态",
dataIndex: "enabled",
slotName: "status",
},
{
title: "操作",
slotName: "action",
},
];
// 数据
const tableData = ref([]);
const getData = () => {
getList().then(({ code, data }) => {
if (code === 0) {
tableData.value = data;
}
});
};
getData();
// 新增编辑
const popup = useCustomFormPopup(ApiKeyForm, save, {
popupProps: (arg) => ({ title: arg[0].record ? "编辑ApiKey" : "新增ApiKey" }),
});
// 删除
const handleDelete = ({ id }, reload) => {
deleting(id).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
// 状态
const handleStatusChange = ({ filed, value, record, reload }) => {
setStatus({
id: record.id,
value,
filed,
}).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
</script>
<template>
<SimpleTable :columns="columns" :request="getList">
<template #action="{ record, reload }">
<a-link @click="popup({ record, reload })">编辑</a-link>
<a-popconfirm content="确定删除?" @ok="handleDelete(record, reload)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
<template #header="{ reload }">
<a-button @click="popup({ reload })" size="small" type="primary"
><template #icon> <icon-plus /> </template>新增
</a-button>
</template>
<template #value="{ record, column }">
<a-typography-text copyable ellipsis style="margin: 0">
{{ record[column.dataIndex] }}
</a-typography-text>
</template>
<template #status="{ record, reload }">
<a-switch
v-model="record.enabled"
@change="
(value) => {
handleStatusChange({ filed: 'enabled', value, record, reload });
}
"
/>
</template>
<template #proxy="{ record, reload }">
<a-switch
v-model="record.use_proxy"
@change="
(value) => {
handleStatusChange({ filed: 'use_proxy', value, record, reload });
}
"
/>
</template>
</SimpleTable>
</template>

View File

@@ -0,0 +1,119 @@
<template>
<a-alert type="warning">
<div class="warning">
{{
`注意如果是百度文心一言平台API-KEY 为 APIKey|SecretKey中间用竖线|)连接\n注意如果是讯飞星火大模型API-KEY 为 AppId|APIKey|APISecret中间用竖线|)连接`
}}
</div>
</a-alert>
<a-form
ref="formRef"
:model="form"
:style="{ width: '600px', 'margin-top': '10px' }"
@submit="handleSubmit"
>
<a-form-item
field="platform"
label="所属平台"
:rules="[{ required: true, message: '请输入所属平台' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.platform" placeholder="请输入所属平台" />
</a-form-item>
<a-form-item
field="name"
label="名称"
:rules="[{ required: true, message: '请输入名称' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input v-model="form.name" placeholder="请输入名称" />
</a-form-item>
<a-form-item
field="type"
label="用途"
:rules="[{ required: true, message: '请输入用途' }]"
:validate-trigger="['change', 'input']"
>
<a-select v-model="form.type" placeholder="请输入用途" :options="typeOPtions"> </a-select>
</a-form-item>
<a-form-item
field="value"
label="API KEY"
:rules="[{ required: true, message: '请输入API KEY' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.value" placeholder="请输入API KEY" />
</a-form-item>
<a-form-item
field="api_url"
label="API URL"
:rules="[{ required: true, message: '请输入API URL' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.api_url" placeholder="请输入API URL" />
</a-form-item>
<a-form-item field="use_proxy" label="使用代理">
<a-switch v-model="form.use_proxy" />
<a-tooltip
content="是否使用代理访问 API URLOpenAI 官方API需要开启代理访问"
position="right"
>
<icon-info-circle-fill />
</a-tooltip>
</a-form-item>
<a-form-item field="enable" label="启用状态">
<a-switch v-model="form.enable" />
</a-form-item>
</a-form>
</template>
<script setup>
import { ref, defineExpose, defineProps } from "vue";
const props = defineProps({
data: {},
});
const formRef = ref();
const form = ref({});
if (props.data?.id) {
form.value = Object.assign({}, props.data);
}
defineExpose({
formRef,
form,
});
const typeOPtions = [
{
label: "聊天",
value: "chart",
},
{
label: "绘图",
value: "img",
},
];
</script>
<style lang="less" scoped>
.content-title {
display: flex;
justify-content: space-between;
align-items: center;
width: 350px;
}
.content-cell {
display: flex;
align-items: center;
width: 350px;
svg {
margin-left: 10px;
}
}
.warning {
color: #e6a23c;
white-space: pre;
}
</style>

View File

@@ -0,0 +1,29 @@
import http from "@/http/config";
export const getList = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/apikey/list",
method: "get",
params,
});
};
export const save = (data?: Record<string, unknown>) => {
return http({
url: "/api/admin/apikey/save",
method: "post",
data,
});
};
export const deleting = (id: string | number) => {
return http({
url: `/api/admin/apikey/remove?id=${id}`,
method: "get",
});
};
export const setStatus = (data) => {
return http({
url: `/api/admin/apikey/set`,
method: "post",
data,
});
};

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import { getList, save, deleting, setStatus } from "./api";
import { ref } from "vue";
import ChatModelForm from "./ChatModelForm.vue";
import useCustomFormPopup from "@/composables/useCustomFormPopup";
import { Message } from "@arco-design/web-vue";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
import { dateFormat } from "@gpt-vue/packages/utils";
// table 配置
const columns = [
{
title: "所属平台",
dataIndex: "platform",
},
{
title: "模型名称",
dataIndex: "name",
},
{
title: "模型值",
dataIndex: "value",
},
{
title: "对话权重",
dataIndex: "weight",
},
{
title: "启用状态",
dataIndex: "enabled",
slotName: "status",
},
{
title: "开放状态",
dataIndex: "open",
slotName: "open",
},
{
title: "创建时间",
dataIndex: "created_at",
render: ({ record }) => {
return dateFormat(record.created_at);
},
},
{
title: "操作",
slotName: "action",
},
];
// 数据
const tableData = ref([]);
const getData = () => {
getList().then(({ code, data }) => {
if (code === 0) {
tableData.value = data;
}
});
};
getData();
// 新增编辑
const popup = useCustomFormPopup(ChatModelForm, save, {
popupProps: (arg) => ({ title: arg[0].record ? "编辑ApiKey" : "新增ApiKey" }),
});
// 删除
const handleDelete = ({ id }, reload) => {
deleting(id).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
// 状态
const handleStatusChange = ({ filed, value, record, reload }) => {
setStatus({
id: record.id,
value,
filed,
}).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
</script>
<template>
<SimpleTable :columns="columns" :request="getList">
<template #action="{ record, reload }">
<a-link @click="popup({ record, reload })">编辑</a-link>
<a-popconfirm content="确定删除?" @ok="handleDelete(record, reload)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
<template #header="{ reload }">
<a-button @click="popup({ reload })" size="small" type="primary"
><template #icon> <icon-plus /> </template>新增</a-button
>
</template>
<template #status="{ record, reload }">
<a-switch
v-model="record.enabled"
@change="
(value) => {
handleStatusChange({ filed: 'enabled', value, record, reload });
}
"
/>
</template>
<template #open="{ record, reload }">
<a-switch
v-model="record.open"
@change="
(value) => {
handleStatusChange({ filed: 'open', value, record, reload });
}
"
/>
</template>
</SimpleTable>
</template>

View File

@@ -0,0 +1,94 @@
<template>
<a-form ref="formRef" :model="form" :style="{ width: '600px' }" @submit="handleSubmit">
<a-form-item
field="platform"
label="所属平台"
:rules="[{ required: true, message: '请输入所属平台' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.platform" placeholder="请输入所属平台" />
</a-form-item>
<a-form-item
field="name"
label="名称"
:rules="[{ required: true, message: '请输入名称' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input v-model="form.name" placeholder="请输入名称" />
</a-form-item>
<a-form-item
field="value"
label="模型值"
:rules="[{ required: true, message: '请输入名称' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input v-model="form.value" placeholder="请输入名称" />
</a-form-item>
<a-form-item
field="weight"
label="对话权重"
:rules="[{ required: true, message: '请输入对话权重' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input-number v-model="form.weight" placeholder="请输入对话权重" />
<a-tooltip content="对话权重,每次对话扣减多少次对话额度" position="right">
<icon-info-circle-fill />
</a-tooltip>
</a-form-item>
<a-form-item field="open" label="开放状态代理">
<a-switch v-model="form.open" />
</a-form-item>
<a-form-item field="enabled" label="启用状态">
<a-switch v-model="form.enabled" />
</a-form-item>
</a-form>
</template>
<script setup>
import { ref, defineExpose, defineProps } from "vue";
const props = defineProps({
data: {},
});
const formRef = ref();
const form = ref({});
if (props.data?.id) {
form.value = Object.assign({}, props.data);
}
defineExpose({
formRef,
form,
});
const typeOPtions = [
{
label: "聊天",
value: "chart",
},
{
label: "绘图",
value: "img",
},
];
</script>
<style lang="less" scoped>
.content-title {
display: flex;
justify-content: space-between;
align-items: center;
width: 350px;
}
.content-cell {
display: flex;
align-items: center;
width: 350px;
svg {
margin-left: 10px;
}
}
</style>

View File

@@ -0,0 +1,29 @@
import http from "@/http/config";
export const getList = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/model/list",
method: "get",
params,
});
};
export const save = (data?: Record<string, unknown>) => {
return http({
url: "/api/admin/model/save",
method: "post",
data,
});
};
export const deleting = (id: string | number) => {
return http({
url: `/api/admin/model/remove?id=${id}`,
method: "get",
});
};
export const setStatus = (data) => {
return http({
url: `/api/admin/model/set`,
method: "post",
data,
});
};

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
import { ref, h } from "vue";
import { Message, Modal } from "@arco-design/web-vue";
import { dateFormat } from "@gpt-vue/packages/utils";
import SearchTable from "@/components/SearchTable/SearchTable.vue";
import type { SearchTableColumns } from "@/components/SearchTable/type";
import app from "@/main";
import { getList, message, remove } from "./api";
import ChatsLogs from "./ChatsLogs.vue";
const columns: SearchTableColumns[] = [
{
dataIndex: "user_id",
title: "账户ID",
search: {
valueType: "input",
},
},
{
dataIndex: "username",
title: "账户",
},
{
dataIndex: "title",
title: "标题",
search: {
valueType: "input",
},
},
{
dataIndex: "model",
title: "模型",
search: {
valueType: "input",
},
},
{
dataIndex: "msg_num",
title: "消息数量",
},
{
dataIndex: "token",
title: "消耗算力",
},
{
dataIndex: "username",
title: "账户",
},
{
dataIndex: "created_at",
title: "创建时间",
search: {
valueType: "range",
},
render: ({ record }) => dateFormat(record.created_at),
},
{
title: "操作",
fixed: "right",
slotName: "actions",
},
];
const tabsList = [
{ key: "1", title: "对话列表", api: getList, columns },
{ key: "2", title: "消息记录", api: message, columns },
];
const activeKey = ref(tabsList[0].key);
const handleRemove = async (chat_id, reload) => {
await remove({ chat_id });
Message.success("删除成功");
await reload();
return true;
};
const handleCheck = (record) => {
if (activeKey.value === "1") {
Modal._context = app._context;
Modal.info({
title: "对话详情",
width: 800,
content: () => h(ChatsLogs, { id: record.chat_id }),
});
return;
}
Modal.info({
title: "消息详情",
content: record.content,
});
};
</script>
<template>
<a-tabs v-model:active-key="activeKey" lazy-load justify>
<a-tab-pane v-for="item in tabsList" :key="item.key" :title="item.title">
<SearchTable :request="item.api" :columns="item.columns">
<template #actions="{ record, reload }">
<a-link @click="handleCheck(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>
</SearchTable>
</a-tab-pane>
</a-tabs>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { onMounted } from "vue";
import { Message } from "@arco-design/web-vue";
import { dateFormat } from "@gpt-vue/packages/utils";
import useRequest from "@/composables/useRequest";
import { history } from "./api";
const props = defineProps({
id: String,
});
const [getData, data, loading] = useRequest(history);
onMounted(async () => {
await getData({ chat_id: props.id });
});
</script>
<template>
<template v-if="loading">
<div class="custom-skeleton">
<a-skeleton-shape />
<div style="flex: 1">
<a-skeleton-line :rows="2" />
</div>
</div>
</template>
<template v-else>
<div v-for="item in data" :key="item.id">
<div class="item-container" :class="item.type">
<div class="left">
<a-avatar shape="square">
<img :src="item.icon" />
</a-avatar>
</div>
<a-space class="right" direction="vertical">
<div>{{ item.content }}</div>
<a-space>
<div class="code">
<icon-clock-circle />
{{ dateFormat(item.created_at) }}
</div>
<div class="code">算力消耗: {{ item.tokens }}</div>
<a-typography-text
v-if="item.type === 'reply'"
copyable
:copy-delay="1000"
:copy-text="item.content"
@copy="Message.success('复制成功')"
>
<template #copy-icon>
<a-button class="code" size="mini">
<icon-copy />
</a-button>
</template>
<template #copy-tooltip>复制回答</template>
</a-typography-text>
</a-space>
</a-space>
</div>
</div>
</template>
</template>
<style lang="less" scoped>
.item-container {
display: flex;
padding: 20px 10px;
width: 100%;
gap: 20px;
border-bottom: 1px solid #d9d9e3;
box-sizing: border-box;
align-items: flex-start;
&.reply {
background: #f7f7f8;
}
.left {
width: 40px;
}
.right {
flex: 1;
overflow: hidden;
}
.code {
background-color: #e7e7e8;
color: #888;
padding: 3px 5px;
margin-right: 10px;
border-radius: 5px;
}
}
.custom-skeleton {
display: flex;
width: 100%;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,34 @@
import http from "@/http/config";
export const getList = (data) => {
return http({
url: "/api/admin/chat/list",
method: "post",
data
})
}
export const message = (data) => {
return http({
url: "/api/admin/chat/message",
method: "post",
data
})
}
export const history = (params) => {
return http({
url: "/api/admin/chat/history",
method: "get",
params
})
}
export const remove = (params) => {
return http({
url: "/api/admin/chat/remove",
method: "get",
params
})
}

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import http from "@/http/config";
import { ref, nextTick } from "vue";
import * as echarts from "echarts/core";
import { GridComponent, TitleComponent } from "echarts/components";
import { LineChart } from "echarts/charts";
import { UniversalTransition } from "echarts/features";
import { CanvasRenderer } from "echarts/renderers";
const icons = {
users: "icon-user",
chats: "icon-wechat",
tokens: "icon-computer",
income: "icon-wechatpay",
};
const dataSet = {
users: "今日新增用户",
chats: "今日新增对话",
tokens: "今日消耗 Tokens",
income: "今日入账",
};
const getData = () => {
http({
url: "api/admin/dashboard/stats",
method: "get",
}).then((res) => {
data.value = res.data;
handeChartData(res.data.chart);
});
};
getData();
// 图表
const chartTitle = {
historyMessage: "对话",
orders: "订单",
users: "用户数",
};
echarts.use([GridComponent, LineChart, CanvasRenderer, UniversalTransition, TitleComponent]);
const chartDomRefs = [];
const chartData = ref({});
const data = ref<Record<string, number>>({});
const handeChartData = (data) => {
const _chartData = {};
for (let key in data) {
const type = data[key];
_chartData[key] = {
series: [],
xAxis: [],
};
for (let date in type) {
_chartData[key].series.push(type[date]);
_chartData[key].xAxis.push(date);
}
nextTick(() => {
const myChart = echarts.init(chartDomRefs.pop());
myChart.setOption(createOption(_chartData[key], key));
});
}
chartData.value = _chartData;
};
const createOption = (data, key) => {
const { xAxis, series } = data;
return {
title: {
left: "center",
text: chartTitle[key],
},
xAxis: {
type: "category",
data: xAxis,
},
yAxis: {
type: "value",
},
series: [
{
data: series,
type: "line",
},
],
};
};
</script>
<template>
<div class="dashboard">
<a-grid :cols="{ xs: 1, sm: 1, md: 2, lg: 3, xl: 4 }" :colGap="12" :rowGap="16" class="grid">
<a-grid-item v-for="(value, key) in dataSet" :key="key">
<div class="data-card">
<span :class="key" class="icon"><component :is="icons[key]" /> </span>
<span class="count"
><a-statistic :extra="value" :value="data[key]" :precision="0"
/></span>
</div>
</a-grid-item>
</a-grid>
</div>
<div class="chart">
<a-grid
:cols="{ xs: 1, sm: 1, md: 1, lg: 2, xl: 2, xxl: 3 }"
:colGap="12"
:rowGap="16"
class="grid"
>
<a-grid-item v-for="(value, key, index) in chartData" :key="key">
<div
:ref="
(el) => {
chartDomRefs[index] = el;
}
"
class="chartDom"
>
{{ key }}
</div>
</a-grid-item>
</a-grid>
</div>
</template>
<style lang="less" scoped>
.dashboard {
display: flex;
text-align: center;
.grid {
width: 100%;
}
.data-card {
width: 100%;
display: flex;
flex: 0 0 25%;
padding: 0 10px;
box-sizing: border-box;
.icon {
display: inline-block;
font-size: 50px;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
color: #fff;
}
.users {
background: #2d8cf0;
}
.chats {
background: #64d572;
}
.tokens {
background: #f25e43;
}
.income {
background: #f25e43;
}
.count {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f3f3f3;
}
}
}
.chart {
margin-top: 15px;
.chartDom {
width: 450px;
height: 500px;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<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>
<SimpleTable :request="getList" :columns="columns" :pagination="false">
<template #header="{ reload }">
<a-button type="primary" @click="openFormModal(reload, {})">
<template #icon> <icon-plus /> </template>
新增
</a-button>
</template>
<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,81 @@
<script lang="ts" setup>
import { ref, watchEffect } from "vue";
import { Message } from "@arco-design/web-vue";
import useSubmit from "@/composables/useSubmit";
import FunctionsFormTable from "./FunctionsFormTable.vue";
import { token } from "./api";
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: true,
});
const rules = {
name: [{ required: true, message: "请输入函数名称" }],
label: [{ required: true, message: "请输入函数标签" }],
description: [{ required: true, message: "请输入函数功能描述" }],
};
const generateToken = async () => {
const { data } = await token({});
Message.success("生成 Token 成功");
formData.token = data;
};
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-group style="width: 100%">
<a-input v-model="formData.token" placeholder="API授权Token" />
<a-tooltip
content="只有本地服务才可以使用自动生成Token第三方服务请填写第三方服务API Token"
>
<a-button type="primary" @click="generateToken">生成Token</a-button>
</a-tooltip>
</a-input-group>
</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,74 @@
<script lang="ts" setup>
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="150">
<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" align="center">
<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"
shape="circle"
@click="handleRemoveRow(scope.rowIndex)"
size="small"
>
<icon-delete />
</a-button>
</template>
</a-table-column>
</template>
</a-table>
<a-button type="primary" @click="handleCreateRow">
<template #icon><icon-plus /></template>
<span>新增参数</span>
</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 = (params) => {
return http({
url: "/api/admin/function/token",
method: "get",
params
})
}

View File

@@ -0,0 +1,36 @@
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) => {
if (curr.name) {
return {
...prev,
[curr.name]: {
description: curr.description,
type: curr.type,
},
};
}
return prev
}, {});
const required = tableData.filter((i) => i.required).map((i) => i.name);
return { properties, required, type: "object" }
}
export default { get, set }

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
import { dateFormat } from "@gpt-vue/packages/utils";
import SearchTable from "@/components/SearchTable/SearchTable.vue";
import type { SearchTableColumns } from "@/components/SearchTable/type";
import { loginLog } from "@/http/login";
const columns: SearchTableColumns[] = [
{
dataIndex: "username",
title: "用户名",
},
{
dataIndex: "login_ip",
title: "登录IP",
},
{
dataIndex: "login_address",
title: "登录地址",
},
{
dataIndex: "created_at",
title: "登陆时间",
render: ({ record }) => dateFormat(record.created_at),
},
];
</script>
<template>
<SearchTable :request="loginLog" :columns="columns" />
</template>

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { onMounted, reactive } from "vue";
import { useAuthStore } from "@/stores/auth";
import { captcha } from "@/http/login";
import useState from "@/composables/useState";
import useRequest from "@/composables/useRequest";
// 表单
function useFormData() {
const formData = reactive({
username: "",
password: "",
captcha: "",
});
const rules = {
username: [{ required: true, message: "请输入您的账号" }],
password: [{ required: true, message: "请输入您的密码" }],
captcha: [{ required: true, message: "请输入验证码" }],
};
const authStore = useAuthStore();
const [loginRequest, _, submitting] = useRequest(authStore.login);
return { formData, loginRequest, submitting, rules };
}
// 验证码
function useCaptcha() {
const captchaImage = reactive({
pic_path: "",
captcha_id: "",
});
const getCaptchaImage = async () => {
const { data } = await captcha();
Object.assign(captchaImage, data);
};
onMounted(getCaptchaImage);
return { captchaImage, getCaptchaImage };
}
// 记住密码
function useRemeberPWD(formData) {
const storageKey = "r-f";
const [isRemember, setIsRemember] = useState(false);
const onIsRememberChange = (v) => {
if (v) {
const value = {
username: formData.username,
password: formData.password,
};
localStorage.setItem(storageKey, JSON.stringify(value));
} else {
localStorage.removeItem(storageKey);
}
setIsRemember(v);
};
onMounted(() => {
const getter = localStorage.getItem(storageKey);
if (getter) {
setIsRemember(true);
Object.assign(formData, JSON.parse(getter));
}
});
return { isRemember, onIsRememberChange };
}
const { formData, loginRequest, submitting, rules } = useFormData();
const { captchaImage, getCaptchaImage } = useCaptcha();
const { isRemember, onIsRememberChange } = useRemeberPWD(formData);
// 表单提交
async function handleSubmit({ errors }: any) {
if (errors) return;
try {
await loginRequest({
...formData,
captcha_id: captchaImage.captcha_id,
});
} catch (err) {
getCaptchaImage();
}
}
</script>
<template>
<div class="bg">
<div class="content">
<!-- 左侧图片 -->
<span class="left">
<img src="/left-img.png" alt="" style="width: 468px" />
</span>
<!-- 表单 -->
<div class="right-content">
<div class="form-box">
<div class="title">ChatGPT Plus Admin</div>
<a-form
ref="formRef"
:model="formData"
class="form"
size="medium"
auto-label-width
:label-col-props="{ span: 0 }"
:wrapper-col-props="{ span: 24 }"
:rules="rules"
@submit="handleSubmit"
>
<a-space direction="vertical" style="width: 100%">
<a-form-item field="username" label="账号">
<a-input v-model="formData.username" placeholder="请输入您的账号" class="input" />
</a-form-item>
<a-form-item field="password" label="密码">
<a-input-password
v-model="formData.password"
placeholder="请输入您的密码"
class="input"
/>
</a-form-item>
<a-form-item field="captcha" label="验证码">
<a-input v-model="formData.captcha" placeholder="请输入验证码" class="input">
<template #append>
<img
class="captcha-image"
:src="captchaImage.pic_path"
alt="验证码"
title="点击刷新验证码"
@click="getCaptchaImage()"
/>
</template>
</a-input>
</a-form-item>
<a-form-item hide-label>
<a-checkbox :model-value="isRemember" @change="onIsRememberChange"
>记住密码</a-checkbox
>
</a-form-item>
</a-space>
<a-form-item hide-label>
<a-button
:loading="submitting"
html-type="submit"
long
type="primary"
class="sign-in-btn"
>
登录
</a-button>
</a-form-item>
</a-form>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.bg {
width: 100%;
height: 100vh;
background: linear-gradient(133deg, #ffffff 0%, #dde8fe 100%);
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
}
.content {
width: 1080px;
height: 557px;
display: flex;
}
.left {
width: 540px;
height: 557px;
background: #2670fe;
border-radius: 16px 0 0 16px;
display: flex;
justify-content: center;
align-items: center;
}
.right-content {
width: 540px;
height: 557px;
background: #ffffff;
border-radius: 0 16px 16px 0;
display: flex;
justify-content: center;
}
.title {
font-size: 24px;
font-weight: 800;
color: #333333;
line-height: 35px;
letter-spacing: 1px;
text-align: center;
margin-bottom: 40px;
}
.form-box {
display: flex;
flex-direction: column;
width: 438px;
padding: 58px 0;
height: 100%;
box-sizing: border-box;
}
.form {
flex: 1;
justify-content: space-between;
}
.input {
border-radius: 10px 10px 10px 10px;
height: 60px;
border: 2px solid #e5e6eb;
}
.captcha-image {
//width: 100%;
//height: 100%;
cursor: pointer;
}
.sign-in-btn {
height: 60px;
font-weight: 500;
line-height: 33px;
font-size: 28px;
border-radius: 10px 10px 10px 10px;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
const router = useRouter();
const goBack = () => {
if (window.history.length) {
router.back();
return;
}
router.replace("/");
};
</script>
<template>
<AResult status="404" subtitle="找不到页面">
<template #extra>
<AButton type="primary" @click="goBack">返回</AButton>
</template>
</AResult>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import SearchTable from "@/components/SearchTable/SearchTable.vue";
import type { SearchTableColumns } from "@/components/SearchTable/type";
import { dateFormat } from "@gpt-vue/packages/utils";
import { getList } from "./api";
const columns: SearchTableColumns[] = [
{
dataIndex: "order_no",
title: "订单号",
search: {
valueType: "input",
},
width: 280,
},
{
dataIndex: "username",
title: "下单用户",
},
{
dataIndex: "subject",
title: "产品名称",
},
{
dataIndex: "amount",
title: "订单金额",
},
{
dataIndex: "remark.calls",
title: "调用次数",
},
{
dataIndex: "created_at",
title: "下单时间",
render: ({ record }) => dateFormat(record.created_at),
width: 200,
},
{
dataIndex: "status",
title: "订单状态",
hideInTable: true,
search: {
valueType: "select",
defaultValue: -1,
fieldProps: {
options: [
{ label: "全部", value: -1 },
{ label: "未支付", value: 0 },
{ label: "已支付", value: 2 },
],
},
},
},
{
dataIndex: "pay_time",
title: "支付时间",
search: {
valueType: "range",
},
slotName: "pay_time",
width: 200,
},
{
dataIndex: "pay_way",
title: "支付方式",
},
{
title: "操作",
slotName: "actions",
fixed: "right",
width: 80,
},
];
</script>
<template>
<SearchTable :request="getList" :columns="columns">
<template #pay_time="{ record }">
<a-tag v-if="!record.pay_time" color="blue">未支付</a-tag>
<span v-else>{{ dateFormat(record.pay_time) }}</span>
</template>
<template #actions="{ record }">
<a-link :key="record.id" status="danger">删除</a-link>
</template>
</SearchTable>
</template>

View File

@@ -0,0 +1,9 @@
import http from "@/http/config";
export const getList = (data?: Record<string, unknown>) => {
return http({
url: "/api/admin/order/list",
method: "post",
data
})
}

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import { getList, save, deleting, setStatus } from "./api";
import { ref } from "vue";
import ProductForm from "./ProductForm.vue";
import useCustomFormPopup from "@/composables/useCustomFormPopup";
import { Message } from "@arco-design/web-vue";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
import { dateFormat } from "@gpt-vue/packages/utils";
// table 配置
const columns = [
{
title: "产品名称",
dataIndex: "name",
},
{
title: "产品价格",
dataIndex: "price",
},
{
title: "优惠金额",
dataIndex: "discount",
},
{
title: "有效期(天)",
dataIndex: "days",
},
{
title: "对话次数",
dataIndex: "calls",
},
{
title: "绘图次数",
dataIndex: "img_calls",
},
{
title: "销量",
dataIndex: "sales",
},
{
title: "启用状态",
dataIndex: "enabled",
slotName: "status",
},
{
title: "更新时间",
dataIndex: "updated_at",
render: ({ record }) => {
return dateFormat(record.updated_at);
},
},
{
title: "操作",
slotName: "action",
},
];
// 数据
const tableData = ref([]);
const getData = () => {
getList().then(({ code, data }) => {
if (code === 0) {
tableData.value = data;
}
});
};
getData();
// 新增编辑
const popup = useCustomFormPopup(ProductForm, save, {
popupProps: (arg) => ({ title: arg[0].record ? "编辑产品" : "新增产品" }),
});
// 删除
const handleDelete = ({ id }, reload) => {
deleting(id).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
// 状态
const handleStatusChange = ({ value, record, reload }) => {
setStatus({
id: record.id,
enabled: value,
}).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
</script>
<template>
<SimpleTable :columns="columns" :request="getList">
<template #action="{ record, reload }">
<a-link @click="popup({ record, reload })">编辑</a-link>
<a-popconfirm content="确定删除?" @ok="handleDelete(record, reload)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
<template #header="{ reload }">
<a-button @click="popup({ reload })" size="small" type="primary"
><template #icon> <icon-plus /> </template>新增
</a-button>
</template>
<template #status="{ record, reload }">
<a-switch
v-model="record.enabled"
@change="
(value) => {
handleStatusChange({ value, record, reload });
}
"
/>
</template>
</SimpleTable>
</template>

View File

@@ -0,0 +1,87 @@
<template>
<a-form ref="formRef" :model="form" :style="{ width: '600px' }" @submit="handleSubmit">
<a-form-item
field="name"
label="产品名称"
:rules="[{ required: true, message: '请输入产品名称' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.name" placeholder="请输入产品名称" />
</a-form-item>
<a-form-item
field="price"
label="产品价格"
:rules="[{ required: true, message: '请输入产品价格' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input-number v-model="form.price" placeholder="请输入产品价格" />
</a-form-item>
<a-form-item
field="discount"
label="优惠金额"
:rules="[{ required: true, message: '请输入优惠金额' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input-number v-model="form.discount" placeholder="请输入优惠金额" />
</a-form-item>
<a-form-item
field="days"
label="有效期(天)"
:rules="[{ required: true, message: '请输入有效期(天)' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input-number v-model="form.days" placeholder="请输入有效期(天)" />
</a-form-item>
<a-form-item field="calls" label="对话次数" :validate-trigger="['change', 'input']" showable>
<a-input-number v-model="form.calls" placeholder="请输入对话次数" />
</a-form-item>
<a-form-item
field="img_calls"
label="绘图次数"
:validate-trigger="['change', 'input']"
showable
>
<a-input-number v-model="form.img_calls" placeholder="请输入绘图次数" />
</a-form-item>
<a-form-item field="enabled" label="启用状态">
<a-switch v-model="form.enabled" />
</a-form-item>
</a-form>
</template>
<script setup>
import { ref, defineExpose, defineProps } from "vue";
const props = defineProps({
data: {},
});
const formRef = ref();
const form = ref({});
if (props.data?.id) {
form.value = Object.assign({}, props.data);
}
defineExpose({
formRef,
form,
});
</script>
<style lang="less" scoped>
.content-title {
display: flex;
justify-content: space-between;
align-items: center;
width: 350px;
}
.content-cell {
display: flex;
align-items: center;
width: 350px;
svg {
margin-left: 10px;
}
}
</style>

View File

@@ -0,0 +1,29 @@
import http from "@/http/config";
export const getList = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/product/list",
method: "get",
params,
});
};
export const save = (data?: Record<string, unknown>) => {
return http({
url: "/api/admin/product/save",
method: "post",
data,
});
};
export const deleting = (id: string | number) => {
return http({
url: `/api/admin/product/remove?id=${id}`,
method: "get",
});
};
export const setStatus = (data) => {
return http({
url: `/api/admin/product/enable`,
method: "post",
data,
});
};

View File

@@ -0,0 +1,75 @@
<script lang="ts" setup>
import { Message, type TableColumnData } from "@arco-design/web-vue";
import { dateFormat } from "@gpt-vue/packages/utils";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
import { getList, remove } from "./api";
const columns: TableColumnData[] = [
{
dataIndex: "username",
title: "用户",
},
{
dataIndex: "tx_id",
title: "转账单号",
},
{
dataIndex: "amount",
title: "转账金额",
},
{
dataIndex: "remark",
title: "备注",
},
{
dataIndex: "created_at",
title: "转账时间",
render: ({ record }) => dateFormat(record.created_at),
},
{
title: "核销时间",
slotName: "updated_at",
},
{
title: "兑换详情",
slotName: "exchange",
},
{
title: "操作",
slotName: "actions",
fixed: "right",
width: 80,
},
];
const handleRemove = async (id, reload) => {
await remove({ id });
Message.success("删除成功");
await reload();
return true;
};
</script>
<template>
<SimpleTable :request="getList" :columns="columns">
<template #updated_at="{ record }">
<span v-if="record.status">{{ dateFormat(record.updated_at) }}</span>
<a-tag v-else color="blue">未核销</a-tag>
</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-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,17 @@
import http from "@/http/config";
export const getList = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/reward/list",
method: "get",
params
})
}
export const remove = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/reward/remove",
method: "get",
params
})
}

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { getList, save, deleting, setStatus } from "./api";
import { reactive, ref } from "vue";
import RoleForm from "./RoleForm.vue";
import useCustomFormPopup from "@/composables/useCustomFormPopup";
import { Message } from "@arco-design/web-vue";
import SimpleTable from "@/components/SimpleTable/SimpleTable.vue";
// table 配置
const columns = [
{
title: "角色名称",
dataIndex: "name",
},
{
title: "角色标识",
dataIndex: "key",
},
{
title: "启用状态",
dataIndex: "enable",
slotName: "status",
},
{
title: "角色图标",
dataIndex: "icon",
slotName: "icon",
},
{
title: "打招呼信息",
dataIndex: "hello_msg",
},
{
title: "操作",
slotName: "action",
},
];
const expandable = reactive({
title: "",
width: 80,
});
// 数据
const tableData = ref([]);
const getData = () => {
getList().then(({ code, data }) => {
if (code === 0) {
tableData.value = data;
}
});
};
getData();
//展开行table
const expandColumns = [
{
dataIndex: "role",
title: "对话角色",
width: 120,
},
{
dataIndex: "content",
title: "对话内容",
},
];
// 新增编辑
const popup = useCustomFormPopup(RoleForm, save, {
popupProps: (arg) => ({ title: arg[0].record ? "编辑角色" : "新增角色" }),
});
// 删除
const handleDelete = ({ id }, reload) => {
deleting(id).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
// 状态
const handleStatusChange = ({ value, record, reload }) => {
setStatus({
id: record.id,
value,
filed: "enable",
}).then(({ code }) => {
if (code === 0) {
Message.success("操作成功");
reload();
}
});
};
</script>
<template>
<SimpleTable :columns="columns" :request="getList" :expandable="expandable">
<template #expand-row="{ record }">
<a-table :columns="expandColumns" :data="record.context || []" :pagination="false"></a-table>
</template>
<template #action="{ record, reload }">
<a-link @click="popup({ record, reload })">编辑</a-link>
<a-popconfirm content="确定删除?" @ok="handleDelete(record, reload)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
<template #header="{ reload }">
<a-button @click="popup({ reload })" size="small" type="primary"
><template #icon> <icon-plus /> </template>新增</a-button
>
</template>
<template #status="{ record, reload }">
<a-switch
v-model="record.enable"
@change="
(value) => {
handleStatusChange({ value, record, reload });
}
"
/>
</template>
<template #icon="{ record }">
<a-avatar>
<img alt="avatar" :src="record.icon" />
</a-avatar>
</template>
</SimpleTable>
</template>

View File

@@ -0,0 +1,116 @@
<template>
<a-form ref="formRef" :model="form" :style="{ width: '600px' }" @submit="handleSubmit">
<a-form-item
field="name"
label="角色名称"
:rules="[{ required: true, message: '请输入角色名称' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item
field="key"
label="角色标志"
:rules="[{ required: true, message: '请输入角色标志' }]"
:validate-trigger="['change', 'input']"
showable
>
<a-input v-model="form.key" placeholder="请输入角色标志" />
</a-form-item>
<a-form-item
field="icon"
label="角色图标"
:rules="[{ required: true, message: '请输入角色图标' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.icon" placeholder="请输入角色图标" />
</a-form-item>
<a-form-item
field="hello_msg"
label="打招呼信息"
:rules="[{ required: true, message: '请输入打招呼信息' }]"
:validate-trigger="['change', 'input']"
>
<a-input v-model="form.hello_msg" placeholder="请输入打招呼信息" />
</a-form-item>
<a-form-item field="username" label="上下文信息" :validate-trigger="['change', 'input']">
<a-table :data="form.context || []" :pagination="false">
<template #columns>
<a-table-column title="对话角色">
<template #cell="{ record }">
<a-input v-model="record.role" />
</template>
</a-table-column>
<a-table-column width="350">
<template #title>
<div class="content-title">
<span>对话内容</span>
<a-button @click="addContext" type="primary">增加一行</a-button>
</div>
</template>
<template #cell="{ record }">
<div class="content-cell">
<a-input v-model="record.content" /><icon-minus-circle
@click="removeContext(record)"
/>
</div>
</template>
</a-table-column>
</template>
</a-table>
</a-form-item>
<a-form-item field="enable" label="启用状态">
<a-switch v-model="form.enable" />
</a-form-item>
</a-form>
</template>
<script setup>
import { ref, defineExpose, defineProps } from "vue";
const props = defineProps({
data: {},
});
const formRef = ref();
const form = ref({});
if (props.data?.id) {
form.value = Object.assign({}, props.data);
}
const addContext = () => {
form.value.context = form.value.context || [];
form.value.context.push({
role: "",
content: "",
});
};
const removeContext = (record) => {
const index = form.value.context.findIndex((item) => {
return item === record;
});
if (index > -1) {
form.value.context.splice(index, 1);
}
};
defineExpose({
formRef,
form,
});
</script>
<style lang="less" scoped>
.content-title {
display: flex;
justify-content: space-between;
align-items: center;
width: 350px;
}
.content-cell {
display: flex;
align-items: center;
width: 350px;
svg {
margin-left: 10px;
}
}
</style>

View File

@@ -0,0 +1,29 @@
import http from "@/http/config";
export const getList = (params?: Record<string, unknown>) => {
return http({
url: "/api/admin/role/list",
method: "get",
params,
});
};
export const save = (data?: Record<string, unknown>) => {
return http({
url: "/api/admin/role/save",
method: "post",
data,
});
};
export const deleting = (id: string | number) => {
return http({
url: `/api/admin/role/remove?id=${id}`,
method: "get",
});
};
export const setStatus = (data) => {
return http({
url: `/api/admin/role/set`,
method: "post",
data,
});
};

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { onMounted } from "vue";
import { Message } from "@arco-design/web-vue";
import useSubmit from "@/composables/useSubmit";
import useRequest from "@/composables/useRequest";
import { getConfig, modelList, save } from "./api";
import SystemUploader from "./SystemUploader.vue";
const { formRef, formData: system, handleSubmit, submitting } = useSubmit({});
const [getModelOptions, modelOptions, modelOptionsLoading] = useRequest(modelList);
const rules = {
title: [{ required: true, message: "请输入网站标题" }],
admin_title: [{ required: true, message: "请输入控制台标题" }],
};
const handleSave = async () => {
await handleSubmit(
() =>
save({
key: "system",
config: system,
}),
{}
);
Message.success("保存成功");
};
const reload = async () => {
const { data } = await getConfig({ key: "system" });
data && Object.assign(system, data);
};
onMounted(async () => {
getModelOptions();
reload();
});
</script>
<template>
<a-card :bordered="false">
<a-form ref="formRef" :model="system" :rules="rules" auto-label-width :disabled="submitting">
<a-form-item label="网站标题" field="title">
<a-input v-model="system['title']" />
</a-form-item>
<a-form-item label="控制台标题" field="admin_title">
<a-input v-model="system['admin_title']" />
</a-form-item>
<a-form-item label="注册赠送对话次数" field="user_init_calls">
<a-input v-model.number="system['init_chat_calls']" placeholder="新用户注册赠送对话次数" />
</a-form-item>
<a-form-item label="注册赠送绘图次数" field="init_img_calls">
<a-input v-model.number="system['init_img_calls']" placeholder="新用户注册赠送绘图次数" />
</a-form-item>
<a-form-item label="邀请赠送对话次数" field="invite_chat_calls">
<a-input
v-model.number="system['invite_chat_calls']"
placeholder="邀请新用户注册赠送对话次数"
/>
</a-form-item>
<a-form-item label="邀请赠送绘图次数" field="invite_img_calls">
<a-input
v-model.number="system['invite_img_calls']"
placeholder="邀请新用户注册赠送绘图次数"
/>
</a-form-item>
<a-form-item label="VIP每月对话次数" field="vip_month_calls">
<a-input v-model.number="system['vip_month_calls']" placeholder="VIP用户每月赠送对话次数" />
</a-form-item>
<a-form-item label="VIP每月绘图次数" field="vip_month_img_calls">
<a-input
v-model.number="system['vip_month_img_calls']"
placeholder="VIP用户每月赠送绘图次数"
/>
</a-form-item>
<a-form-item label="开放注册" field="enabled_register">
<a-space>
<a-switch v-model="system['enabled_register']" />
<a-tooltip content="关闭注册之后只能通过管理后台添加用户" position="right">
<icon-info-circle-fill size="18" />
</a-tooltip>
</a-space>
</a-form-item>
<a-form-item label="注册方式" field="register_ways">
<a-checkbox-group v-model="system['register_ways']">
<a-checkbox value="mobile">手机注册</a-checkbox>
<a-checkbox value="email">邮箱注册</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="启用众筹功能" field="enabled_reward">
<a-space>
<a-switch v-model="system['enabled_reward']" />
<a-tooltip content="如果关闭次功能将不在用户菜单显示众筹二维码" position="right">
<icon-info-circle-fill size="18" />
</a-tooltip>
</a-space>
</a-form-item>
<template v-if="system['enabled_reward']">
<a-form-item label="单次对话价格" field="chat_call_price">
<a-input v-model="system['chat_call_price']" placeholder="众筹金额跟对话次数的兑换比例" />
</a-form-item>
<a-form-item label="单次绘图价格" field="img_call_price">
<a-input v-model="system['img_call_price']" placeholder="众筹金额跟绘图次数的兑换比例" />
</a-form-item>
<a-form-item label="收款二维码" field="reward_img">
<SystemUploader v-model="system['reward_img']" placeholder="众筹收款二维码地址" />
</a-form-item>
</template>
<a-form-item label="微信客服二维码" field="wechat_card_url">
<SystemUploader v-model="system['wechat_card_url']" placeholder="微信客服二维码" />
</a-form-item>
<a-form-item label="订单超时时间" field="order_pay_timeout">
<a-space style="width: 100%">
<a-input
v-model.number="system['order_pay_timeout']"
placeholder="单位:秒"
style="width: 100%"
/>
<a-tooltip position="right">
<icon-info-circle-fill size="18" />
<template #content> 系统会定期清理超时未支付的订单<br />默认值900 </template>
</a-tooltip>
</a-space>
</a-form-item>
<a-form-item label="会员充值说明" field="order_pay_info_text">
<a-textarea
v-model="system['order_pay_info_text']"
:autosize="{ minRows: 3, maxRows: 10 }"
placeholder="请输入会员充值说明文字,比如介绍会员计划"
/>
</a-form-item>
<a-form-item label="默认AI模型" field="default_models">
<a-space style="width: 100%">
<a-select
v-model="system['default_models']"
multiple
:filterable="true"
placeholder="选择AI模型多选"
:options="modelOptions"
:loading="modelOptionsLoading"
:field-names="{ value: 'value', label: 'name' }"
style="width: 100%"
>
</a-select>
<a-tooltip content="新用户注册默认开通的 AI 模型" position="right">
<icon-info-circle-fill size="18" />
</a-tooltip>
</a-space>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="submitting" @click="handleSave">保存</a-button>
</a-form-item>
</a-form>
</a-card>
</template>

View File

@@ -0,0 +1,148 @@
<script lang="ts" setup>
import { onMounted } from "vue";
import { Message } from "@arco-design/web-vue";
import useSubmit from "@/composables/useSubmit";
import { getConfig, save } from "./api";
const {
formRef,
formData: chat,
handleSubmit,
submitting,
} = useSubmit({
open_ai: { temperature: 1, max_tokens: 1024 },
azure: { temperature: 1, max_tokens: 1024 },
chat_gml: { temperature: 0.95, max_tokens: 1024 },
baidu: { temperature: 0.95, max_tokens: 1024 },
xun_fei: { temperature: 0.5, max_tokens: 1024 },
context_deep: 0,
enable_context: true,
enable_history: true,
dall_api_url: "",
});
const rules = {
init_chat_calls: [{ required: true, message: "请输入赠送对话次数" }],
user_img_calls: [{ required: true, message: "请输入赠送绘图次数" }],
};
const handleSave = async () => {
await handleSubmit(
() =>
save({
key: "chat",
config: chat,
}),
{}
);
Message.success("保存成功");
};
const reload = async () => {
const { data } = await getConfig({ key: "chat" });
data && Object.assign(chat, data);
};
onMounted(reload);
</script>
<template>
<a-card :bordered="false">
<a-form ref="formRef" :model="chat" :rules="rules" auto-label-width :disabled="submitting">
<a-form-item label="开启聊天上下文">
<a-switch v-model="chat['enable_context']" />
</a-form-item>
<a-form-item label="保存聊天记录">
<a-switch v-model="chat['enable_history']" />
</a-form-item>
<a-form-item
label="会话上下文深度"
extra="会话上下文深度:在老会话中继续会话,默认加载多少条聊天记录作为上下文。如果设置为 0
则不加载聊天记录,仅仅使用当前角色的上下文。该配置参数最好设置需要为偶数,否则将无法兼容百度的
API。"
>
<a-input-number v-model="chat['context_deep']" :min="0" :max="10" />
</a-form-item>
<a-divider content-position="center">OpenAI</a-divider>
<a-form-item
label="模型创意度"
extra="值越大 AI 回答越发散,值越小回答越保守,建议保持默认值"
>
<a-slider v-model="chat['open_ai']['temperature']" :max="2" :step="0.1" />
</a-form-item>
<a-form-item label="最大响应长度">
<a-input
v-model.number="chat['open_ai']['max_tokens']"
placeholder="回复的最大字数最大4096"
/>
</a-form-item>
<a-divider content-position="center">Azure</a-divider>
<a-form-item
label="模型创意度"
extra="值越大 AI 回答越发散,值越小回答越保守,建议保持默认值"
>
<a-slider v-model="chat['azure']['temperature']" :max="2" :step="0.1" />
</a-form-item>
<a-form-item label="最大响应长度">
<a-input
v-model.number="chat['azure']['max_tokens']"
placeholder="回复的最大字数最大4096"
/>
</a-form-item>
<a-divider content-position="center">ChatGLM</a-divider>
<a-form-item
label="模型创意度"
extra="值越大 AI 回答越发散,值越小回答越保守,建议保持默认值"
>
<a-slider v-model="chat['chat_gml']['temperature']" :max="1" :step="0.01" />
</a-form-item>
<a-form-item label="最大响应长度">
<a-input
v-model.number="chat['chat_gml']['max_tokens']"
placeholder="回复的最大字数最大4096"
/>
</a-form-item>
<a-divider content-position="center">文心一言</a-divider>
<a-form-item
label="模型创意度"
extra="值越大 AI 回答越发散,值越小回答越保守,建议保持默认值"
>
<a-slider v-model="chat['baidu']['temperature']" :max="1" :step="0.01" />
</a-form-item>
<a-form-item label="最大响应长度">
<a-input
v-model.number="chat['baidu']['max_tokens']"
placeholder="回复的最大字数最大4096"
/>
</a-form-item>
<a-divider content-position="center">讯飞星火</a-divider>
<a-form-item
label="模型创意度"
extra="值越大 AI 回答越发散,值越小回答越保守,建议保持默认值"
>
<a-slider v-model="chat['xun_fei']['temperature']" :max="1" :step="0.1" />
</a-form-item>
<a-form-item label="最大响应长度">
<a-input
v-model.number="chat['xun_fei']['max_tokens']"
placeholder="回复的最大字数最大4096"
/>
</a-form-item>
<a-divider content-position="center">AI绘图</a-divider>
<a-form-item label="DALL-E3出图数量">
<a-input
v-model.number="chat['dall_img_num']"
placeholder="调用 DALL E3 API 传入的出图数量"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="submitting" @click="handleSave">保存</a-button>
</a-form-item>
</a-form>
</a-card>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { ref } from "vue";
import SystemBaseConfig from "./SystemBaseConfig.vue";
import SystemChatConfig from "./SystemChatConfig.vue";
import SystemNoticeConfig from "./SystemNoticeConfig.vue";
const tabsList = [
{ key: "1", title: "基本设置", components: SystemBaseConfig },
{ key: "2", title: "模型设置", components: SystemChatConfig },
{ key: "3", title: "公告设置", components: SystemNoticeConfig },
];
const activeKey = ref(tabsList[0].key);
</script>
<template>
<a-tabs v-model:active-key="activeKey" lazy-load justify>
<a-tab-pane v-for="item in tabsList" :key="item.key" :title="item.title">
<div class="system-config-wrapper">
<component :is="item.components" />
</div>
</a-tab-pane>
</a-tabs>
</template>
<style scoped>
.system-config-wrapper {
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
import { onMounted } from "vue";
import { Message } from "@arco-design/web-vue";
import MdEditor from "md-editor-v3";
import "md-editor-v3/lib/style.css";
import http from "@/http/config";
import useSubmit from "@/composables/useSubmit";
import { getConfig, save } from "./api";
const { formRef, formData, handleSubmit, submitting } = useSubmit({
content: "",
updated: true,
});
const handleSave = async () => {
await handleSubmit(
() =>
save({
key: "notice",
config: formData,
}),
{}
);
Message.success("保存成功");
};
const reload = async () => {
const { data } = await getConfig({ key: "notice" });
data && Object.assign(formData, data);
};
const onUploadImg = (files, callback) => {
Promise.all(
files.map((file) => {
return new Promise((rev, rej) => {
const formData = new FormData();
formData.append("file", file, file.name);
http({
url: `/api/upload`,
data: formData,
})
.then((res) => rev(res))
.catch((e) => rej(e));
});
})
)
.then((res) => {
Message.success({ content: "上传成功", duration: 500 });
callback(res.map((item) => item.data.url));
})
.catch((e) => {
Message.error("图片上传失败:" + e.message);
});
};
onMounted(reload);
</script>
<template>
<a-form ref="formRef" :model="formData" auto-label-width :disabled="submitting">
<a-form-item>
<md-editor v-model="formData.content" @on-upload-img="onUploadImg" />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="submitting" @click="handleSave">保存</a-button>
</a-form-item>
</a-form>
</template>

Some files were not shown because too many files have changed in this diff Show More