gengx
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=真羊 AI 客服 · 管理后台
|
||||
|
||||
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
|
||||
VITE_APP_NAMESPACE=zhenyang-admin
|
||||
|
||||
# 对 store 持久化到 localStorage 时加密用的密钥。
|
||||
# 模板里默认是 please-replace-me-with-your-own-key——那是公开值,等于没加密。
|
||||
VITE_APP_STORE_SECURE_KEY=DHIpryct4GsHrszDncvvxvzb8cp69mhEoXZvIU-ubik
|
||||
@@ -0,0 +1,7 @@
|
||||
# public path
|
||||
VITE_BASE=/
|
||||
|
||||
# Basic interface address SPA
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
VITE_VISUALIZER=true
|
||||
@@ -0,0 +1,16 @@
|
||||
# 端口号
|
||||
VITE_PORT=5666
|
||||
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||
VITE_NITRO_MOCK=false
|
||||
|
||||
# 是否打开 devtools,true 为打开,false 为关闭
|
||||
VITE_DEVTOOLS=false
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
@@ -0,0 +1,22 @@
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
# 生产走同源相对路径:前端是静态文件,由同一个 Nginx 把 /api 反代到
|
||||
# admin_api.py。模板默认值是 vben 的公共 mock 服务器 mock-napi.vben.pro——
|
||||
# 那意味着打包后所有管理请求(含登录凭据)都会发给第三方主机。
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||
VITE_COMPRESS=none
|
||||
|
||||
# 是否开启 PWA
|
||||
VITE_PWA=false
|
||||
|
||||
# vue-router 的模式
|
||||
VITE_ROUTER_HISTORY=hash
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta name="description" content="A Modern Back-end Management System" />
|
||||
<meta name="keywords" content="Vben Admin Vue3 Vite" />
|
||||
<meta name="author" content="Vben" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
||||
/>
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script>
|
||||
// 生产环境下注入百度统计
|
||||
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
var hm = document.createElement('script');
|
||||
hm.src =
|
||||
'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf';
|
||||
var s = document.getElementsByTagName('script')[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@vben/web-antd",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://vben.pro",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "apps/web-antd"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "vben",
|
||||
"email": "ann.vben@gmail.com",
|
||||
"url": "https://github.com/anncwb"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm vite build --mode production",
|
||||
"build:analyze": "pnpm vite build --mode analyze",
|
||||
"dev": "pnpm vite --mode development",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit --skipLibCheck"
|
||||
},
|
||||
"imports": {
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben/access": "workspace:*",
|
||||
"@vben/common-ui": "workspace:*",
|
||||
"@vben/constants": "workspace:*",
|
||||
"@vben/hooks": "workspace:*",
|
||||
"@vben/icons": "workspace:*",
|
||||
"@vben/layouts": "workspace:*",
|
||||
"@vben/locales": "workspace:*",
|
||||
"@vben/plugins": "workspace:*",
|
||||
"@vben/preferences": "workspace:*",
|
||||
"@vben/request": "workspace:*",
|
||||
"@vben/stores": "workspace:*",
|
||||
"@vben/styles": "workspace:*",
|
||||
"@vben/types": "workspace:*",
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
|
||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||
*/
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type {
|
||||
AutoCompleteProps,
|
||||
ButtonProps,
|
||||
CascaderProps,
|
||||
CheckboxGroupProps,
|
||||
CheckboxProps,
|
||||
DatePickerProps,
|
||||
DividerProps,
|
||||
InputNumberProps,
|
||||
InputProps,
|
||||
MentionsProps,
|
||||
RadioGroupProps,
|
||||
RadioProps,
|
||||
RateProps,
|
||||
SelectProps,
|
||||
SpaceProps,
|
||||
SwitchProps,
|
||||
TextAreaProps,
|
||||
TimePickerProps,
|
||||
TreeSelectProps,
|
||||
UploadChangeParam,
|
||||
UploadFile,
|
||||
UploadProps,
|
||||
} from 'ant-design-vue';
|
||||
import type { RangePickerProps } from 'ant-design-vue/es/date-picker';
|
||||
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
ApiComponentSharedProps,
|
||||
BaseFormComponentType,
|
||||
IconPickerProps,
|
||||
} from '@vben/common-ui';
|
||||
import type { Sortable } from '@vben/hooks';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
render,
|
||||
unref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
ApiComponent,
|
||||
globalShareState,
|
||||
IconPicker,
|
||||
VCropper,
|
||||
} from '@vben/common-ui';
|
||||
import { useSortable } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { message, Modal, notification } from 'ant-design-vue';
|
||||
|
||||
type AdapterUploadProps = UploadProps & {
|
||||
aspectRatio?: string;
|
||||
crop?: boolean;
|
||||
draggable?: boolean;
|
||||
handleChange?: (event: UploadChangeParam) => void;
|
||||
maxSize?: number;
|
||||
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||
onHandleChange?: (event: UploadChangeParam) => void;
|
||||
};
|
||||
|
||||
const AutoComplete = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/auto-complete'),
|
||||
);
|
||||
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
|
||||
const Checkbox = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/checkbox'),
|
||||
);
|
||||
const CheckboxGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
|
||||
);
|
||||
const DatePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/date-picker'),
|
||||
);
|
||||
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
|
||||
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
|
||||
const InputNumber = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/input-number'),
|
||||
);
|
||||
const InputPassword = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.InputPassword),
|
||||
);
|
||||
const Mentions = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/mentions'),
|
||||
);
|
||||
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
|
||||
const RadioGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
|
||||
);
|
||||
const RangePicker = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
|
||||
);
|
||||
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
|
||||
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
|
||||
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
|
||||
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
|
||||
const Textarea = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.Textarea),
|
||||
);
|
||||
const TimePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/time-picker'),
|
||||
);
|
||||
const TreeSelect = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/tree-select'),
|
||||
);
|
||||
const Cascader = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/cascader'),
|
||||
);
|
||||
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
|
||||
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
|
||||
const PreviewGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
||||
);
|
||||
|
||||
const withDefaultPlaceholder = (
|
||||
component: Component,
|
||||
type: 'input' | 'select',
|
||||
componentProps: Recordable<any> = {},
|
||||
) => {
|
||||
return defineComponent({
|
||||
name: component.name,
|
||||
inheritAttrs: false,
|
||||
setup: (props: any, { attrs, expose, slots }) => {
|
||||
const placeholder =
|
||||
props?.placeholder ||
|
||||
attrs?.placeholder ||
|
||||
$t(`ui.placeholder.${type}`);
|
||||
// 透传组件暴露的方法
|
||||
const innerRef = ref();
|
||||
expose(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, key) => innerRef.value?.[key],
|
||||
has: (_target, key) => key in (innerRef.value || {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
return () =>
|
||||
h(
|
||||
component,
|
||||
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'webp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 检查是否为图片文件
|
||||
*/
|
||||
function isImageFile(file: UploadFile): boolean {
|
||||
if (file.url) {
|
||||
try {
|
||||
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
} catch {
|
||||
const ext = file.url?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
}
|
||||
if (!file.type) {
|
||||
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的上传按钮插槽
|
||||
*/
|
||||
function createDefaultUploadSlots(listType: string, placeholder: string) {
|
||||
if (listType === 'picture-card') {
|
||||
return { default: () => placeholder };
|
||||
}
|
||||
return {
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
icon: h(IconifyIcon, {
|
||||
icon: 'ant-design:upload-outlined',
|
||||
class: 'mb-1 size-4',
|
||||
}),
|
||||
},
|
||||
() => placeholder,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Base64
|
||||
*/
|
||||
function getBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||
reader.addEventListener('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
*/
|
||||
async function previewImage(
|
||||
file: UploadFile,
|
||||
visible: Ref<boolean>,
|
||||
fileList: Ref<UploadProps['fileList']>,
|
||||
) {
|
||||
// 非图片文件直接打开链接
|
||||
if (!isImageFile(file)) {
|
||||
const url = file.url || file.preview;
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
message.error($t('ui.formRules.previewWarning'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
||||
Image,
|
||||
PreviewGroup,
|
||||
]);
|
||||
|
||||
// 过滤图片文件并生成预览
|
||||
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
|
||||
|
||||
for (const imgFile of imageFiles) {
|
||||
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
|
||||
imgFile.preview = await getBase64(imgFile.originFileObj);
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
let isUnmounted = false;
|
||||
|
||||
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
|
||||
|
||||
const PreviewWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
return h(
|
||||
PreviewGroupComponent,
|
||||
{
|
||||
class: 'hidden',
|
||||
preview: {
|
||||
visible: visible.value,
|
||||
current: currentIndex,
|
||||
onVisibleChange: (value: boolean) => {
|
||||
visible.value = value;
|
||||
if (!value) {
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
() =>
|
||||
imageFiles.map((imgFile) =>
|
||||
h(ImageComponent, {
|
||||
key: imgFile.uid,
|
||||
src: imgFile.url || imgFile.preview,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(PreviewWrapper), container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪操作
|
||||
*/
|
||||
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
let isUnmounted = false;
|
||||
let objectUrl: null | string = null;
|
||||
|
||||
const open = ref<boolean>(true);
|
||||
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
open.value = false;
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const CropperWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
if (!objectUrl) {
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
}
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: open.value,
|
||||
title: h('div', {}, [
|
||||
$t('ui.crop.title'),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||
},
|
||||
$t('ui.crop.titleTip', [aspectRatio]),
|
||||
),
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
cancelText: $t('common.cancel'),
|
||||
okText: $t('ui.crop.confirm'),
|
||||
destroyOnClose: true,
|
||||
onOk: async () => {
|
||||
const cropper = cropperRef.value;
|
||||
if (!cropper) {
|
||||
reject(new Error('Cropper not found'));
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await cropper.getCropImage();
|
||||
if (dataUrl) {
|
||||
resolve(dataUrl);
|
||||
} else {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
resolve('');
|
||||
closeModal();
|
||||
},
|
||||
},
|
||||
() =>
|
||||
h(VCropper, {
|
||||
ref: (ref: any) => (cropperRef.value = ref),
|
||||
img: objectUrl as string,
|
||||
aspectRatio,
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(CropperWrapper), container);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 带预览功能的上传组件
|
||||
*/
|
||||
const withPreviewUpload = () => {
|
||||
return defineComponent({
|
||||
name: Upload.name,
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: any,
|
||||
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||
) {
|
||||
const previewVisible = ref<boolean>(false);
|
||||
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
|
||||
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||
const fileList = ref<UploadProps['fileList']>(
|
||||
attrs?.fileList || attrs?.['file-list'] || [],
|
||||
);
|
||||
|
||||
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
|
||||
const aspectRatio = computed(
|
||||
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||
);
|
||||
|
||||
const handleBeforeUpload = async (
|
||||
file: UploadFile,
|
||||
originFileList: Array<File>,
|
||||
) => {
|
||||
// 文件大小限制
|
||||
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||
file.status = 'removed';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 图片裁剪处理
|
||||
if (
|
||||
attrs.crop &&
|
||||
!attrs.multiple &&
|
||||
originFileList[0] &&
|
||||
isImageFile(file)
|
||||
) {
|
||||
file.status = 'removed';
|
||||
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||
if (!blob) {
|
||||
throw new Error($t('ui.crop.errorTip'));
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
return attrs.beforeUpload?.(file) ?? true;
|
||||
};
|
||||
|
||||
const handleChange = (event: UploadChangeParam) => {
|
||||
try {
|
||||
attrs.handleChange?.(event);
|
||||
attrs.onHandleChange?.(event);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
fileList.value = event.fileList.filter(
|
||||
(file) => file.status !== 'removed',
|
||||
);
|
||||
emit(
|
||||
'update:modelValue',
|
||||
event.fileList?.length ? fileList.value : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
previewVisible.value = true;
|
||||
await previewImage(file, previewVisible, fileList);
|
||||
};
|
||||
|
||||
const renderUploadButton = () => {
|
||||
if (attrs.disabled) return null;
|
||||
return isEmpty(slots)
|
||||
? createDefaultUploadSlots(listType, placeholder)
|
||||
: slots;
|
||||
};
|
||||
|
||||
// 拖拽排序
|
||||
const draggable = computed(
|
||||
() => (attrs.draggable ?? false) && !attrs.disabled,
|
||||
);
|
||||
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const sortableInstance = ref<null | Sortable>(null);
|
||||
|
||||
const styleId = `upload-drag-style-${uploadId}`;
|
||||
|
||||
function injectDragStyle() {
|
||||
if (!document.querySelector(`[id="${styleId}"]`)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDragStyle() {
|
||||
document.querySelector(`[id="${styleId}"]`)?.remove();
|
||||
}
|
||||
|
||||
async function initSortable(retryCount = 0) {
|
||||
if (!draggable.value) return;
|
||||
|
||||
injectDragStyle();
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const container = document.querySelector(
|
||||
`[data-upload-id="${uploadId}"] .ant-upload-list`,
|
||||
) as HTMLElement;
|
||||
|
||||
if (!container) {
|
||||
if (retryCount < 5) {
|
||||
setTimeout(() => initSortable(retryCount + 1), 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { initializeSortable } = useSortable(container, {
|
||||
animation: 300,
|
||||
delay: 400,
|
||||
delayOnTouchOnly: true,
|
||||
filter:
|
||||
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (
|
||||
oldIndex === undefined ||
|
||||
newIndex === undefined ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = [...(fileList.value || [])];
|
||||
const [movedItem] = list.splice(oldIndex, 1);
|
||||
if (movedItem) {
|
||||
list.splice(newIndex, 0, movedItem);
|
||||
fileList.value = list;
|
||||
}
|
||||
|
||||
attrs.onDragSort?.(oldIndex, newIndex);
|
||||
emit('update:modelValue', fileList.value);
|
||||
},
|
||||
});
|
||||
|
||||
sortableInstance.value = await initializeSortable();
|
||||
}
|
||||
|
||||
// 监听表单值变化
|
||||
watch(
|
||||
() => attrs.modelValue,
|
||||
(res) => {
|
||||
fileList.value = res;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(initSortable);
|
||||
onUnmounted(() => {
|
||||
sortableInstance.value?.destroy();
|
||||
removeDragStyle();
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||
h(
|
||||
Upload,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
fileList: fileList.value,
|
||||
beforeUpload: handleBeforeUpload,
|
||||
onChange: handleChange,
|
||||
onPreview: handlePreview,
|
||||
},
|
||||
renderUploadButton() as any,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||
export type ComponentType =
|
||||
| 'ApiCascader'
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'AutoComplete'
|
||||
| 'Cascader'
|
||||
| 'Checkbox'
|
||||
| 'CheckboxGroup'
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
| 'InputPassword'
|
||||
| 'Mentions'
|
||||
| 'PrimaryButton'
|
||||
| 'Radio'
|
||||
| 'RadioGroup'
|
||||
| 'RangePicker'
|
||||
| 'Rate'
|
||||
| 'Select'
|
||||
| 'Space'
|
||||
| 'Switch'
|
||||
| 'Textarea'
|
||||
| 'TimePicker'
|
||||
| 'TreeSelect'
|
||||
| 'Upload'
|
||||
| BaseFormComponentType;
|
||||
|
||||
/**
|
||||
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
|
||||
*/
|
||||
export interface ComponentPropsMap {
|
||||
ApiCascader: ApiComponentSharedProps & CascaderProps;
|
||||
ApiSelect: ApiComponentSharedProps & SelectProps;
|
||||
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
|
||||
AutoComplete: AutoCompleteProps;
|
||||
Cascader: CascaderProps;
|
||||
Checkbox: CheckboxProps;
|
||||
CheckboxGroup: CheckboxGroupProps;
|
||||
DatePicker: DatePickerProps;
|
||||
DefaultButton: ButtonProps;
|
||||
Divider: DividerProps;
|
||||
IconPicker: IconPickerProps;
|
||||
Input: InputProps;
|
||||
InputNumber: InputNumberProps;
|
||||
InputPassword: InputProps;
|
||||
Mentions: MentionsProps;
|
||||
PrimaryButton: ButtonProps;
|
||||
Radio: RadioProps;
|
||||
RadioGroup: RadioGroupProps;
|
||||
RangePicker: RangePickerProps;
|
||||
Rate: RateProps;
|
||||
Select: SelectProps;
|
||||
Space: SpaceProps;
|
||||
Switch: SwitchProps;
|
||||
Textarea: TextAreaProps;
|
||||
TimePicker: TimePickerProps;
|
||||
TreeSelect: TreeSelectProps;
|
||||
Upload: AdapterUploadProps;
|
||||
}
|
||||
|
||||
async function initComponentAdapter() {
|
||||
const components: Partial<Record<ComponentType, Component>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
// Button: () =>
|
||||
// import('xxx').then((res) => res.Button),
|
||||
|
||||
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Cascader,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
AutoComplete,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
// 自定义默认按钮
|
||||
DefaultButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||
},
|
||||
Divider,
|
||||
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
}),
|
||||
Input: withDefaultPlaceholder(Input, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||
style: { width: '100%' },
|
||||
}),
|
||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||
// 自定义主要按钮
|
||||
PrimaryButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||
},
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RangePicker,
|
||||
Rate,
|
||||
Select: withDefaultPlaceholder(Select, 'select'),
|
||||
Space,
|
||||
Switch,
|
||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||
TimePicker,
|
||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||
Upload: withPreviewUpload(),
|
||||
};
|
||||
|
||||
// 将组件注册到全局共享状态中
|
||||
globalShareState.setComponents(components);
|
||||
|
||||
// 定义全局共享状态中的消息提示
|
||||
globalShareState.defineMessage({
|
||||
// 复制成功消息提示
|
||||
copyPreferencesSuccess: (title, content) => {
|
||||
notification.success({
|
||||
description: content,
|
||||
message: title,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { initComponentAdapter };
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
VbenFormProps as FormProps,
|
||||
VbenFormSchema as FormSchema,
|
||||
FormValues,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
async function initSetupVbenForm() {
|
||||
setupVbenForm<ComponentType>({
|
||||
config: {
|
||||
// ant design vue组件库默认都是 v-model:value
|
||||
baseModelPropName: 'value',
|
||||
|
||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||
modelPropNameMap: {
|
||||
Checkbox: 'checked',
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 输入项目必填国际化适配
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 选择项目必填国际化适配
|
||||
selectRequired: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null) {
|
||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useVbenForm<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
options: FormProps<
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>,
|
||||
) {
|
||||
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export { initSetupVbenForm, useVbenForm, z };
|
||||
|
||||
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||
FormSchema<ComponentType, ComponentPropsMap, TValues>;
|
||||
export type VbenFormProps<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { FormValues } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import {
|
||||
setupVbenVxeTable,
|
||||
useVbenVxeGrid as useGrid,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
|
||||
import { Button, Image } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from './form';
|
||||
|
||||
setupVbenVxeTable({
|
||||
configVxeTable: (vxeUI) => {
|
||||
vxeUI.setConfig({
|
||||
grid: {
|
||||
align: 'center',
|
||||
border: false,
|
||||
columnConfig: {
|
||||
resizable: true,
|
||||
},
|
||||
minHeight: 180,
|
||||
formConfig: {
|
||||
// 全局禁用vxe-table的表单配置,使用formOptions
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
autoLoad: true,
|
||||
response: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
list: 'items',
|
||||
},
|
||||
showActiveMsg: true,
|
||||
showResponseMsg: false,
|
||||
},
|
||||
round: true,
|
||||
showOverflow: true,
|
||||
size: 'small',
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
||||
vxeUI.renderer.add('CellImage', {
|
||||
renderTableDefault(renderOpts, params) {
|
||||
const { props } = renderOpts;
|
||||
const { column, row } = params;
|
||||
return h(Image, { src: row[column.field], ...props });
|
||||
},
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellLink' },
|
||||
vxeUI.renderer.add('CellLink', {
|
||||
renderTableDefault(renderOpts) {
|
||||
const { props } = renderOpts;
|
||||
return h(
|
||||
Button,
|
||||
{ size: 'small', type: 'link' },
|
||||
{ default: () => props?.text },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
||||
// vxeUI.formats.add
|
||||
},
|
||||
useVbenForm,
|
||||
});
|
||||
|
||||
export const useVbenVxeGrid = <
|
||||
T extends Record<string, any>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
...rest: Parameters<
|
||||
typeof useGrid<
|
||||
T,
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>
|
||||
>
|
||||
) =>
|
||||
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
|
||||
...rest,
|
||||
);
|
||||
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
@@ -0,0 +1,261 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface ArchiveStats {
|
||||
conversations: number;
|
||||
exports: number;
|
||||
imports: number;
|
||||
last_message_at: string;
|
||||
media: number;
|
||||
media_failed: number;
|
||||
media_ready: number;
|
||||
messages: number;
|
||||
people: number;
|
||||
}
|
||||
|
||||
export interface ArchiveConversation {
|
||||
conversation_type: string;
|
||||
external_id: string;
|
||||
id: string;
|
||||
last_content: string;
|
||||
last_message_at: string;
|
||||
message_count: number;
|
||||
name: string;
|
||||
source_account: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMessage {
|
||||
attachment_count: number;
|
||||
attachments: ArchiveAttachment[];
|
||||
content: string;
|
||||
direction: string;
|
||||
id: string;
|
||||
message_type: string;
|
||||
sender_name: string;
|
||||
sender_person_id: null | string;
|
||||
sent_at: string;
|
||||
sequence_no: null | number;
|
||||
source_message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveAttachment {
|
||||
attachment_index: number;
|
||||
attachment_role: string;
|
||||
checksum?: string;
|
||||
id: string;
|
||||
match_confidence: number;
|
||||
match_method: string;
|
||||
media_type: 'audio' | 'file' | 'image' | 'video';
|
||||
mime_type: string;
|
||||
original_filename: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMediaAccess {
|
||||
expires_in: number;
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ArchiveStorage {
|
||||
bucket: string;
|
||||
custom_domain: string;
|
||||
enabled: boolean;
|
||||
encryption_mode: string;
|
||||
export_prefix: string;
|
||||
media_prefix: string;
|
||||
region: string;
|
||||
secret_id_masked: string;
|
||||
secret_id_present: boolean;
|
||||
secret_key_masked: string;
|
||||
secret_key_present: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMedia {
|
||||
bucket: string;
|
||||
created_at: string;
|
||||
encryption_mode: string;
|
||||
id: string;
|
||||
last_error: string;
|
||||
media_type: string;
|
||||
mime_type: string;
|
||||
object_key: string;
|
||||
original_filename: string;
|
||||
region: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
status: string;
|
||||
verified_at: string;
|
||||
version_id: string;
|
||||
}
|
||||
|
||||
export interface ArchivePerson {
|
||||
conversation_count: number;
|
||||
created_at: string;
|
||||
display_name: string;
|
||||
id: string;
|
||||
identity_count: number;
|
||||
message_count: number;
|
||||
real_name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArchiveIdentity {
|
||||
created_at: string;
|
||||
external_id: string;
|
||||
id: string;
|
||||
identity_type: string;
|
||||
scope_id: string;
|
||||
source: string;
|
||||
updated_at: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ArchivePersonDetail {
|
||||
display_name: string;
|
||||
id: string;
|
||||
identities: ArchiveIdentity[];
|
||||
real_name: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportFile {
|
||||
file_name: string;
|
||||
format: string;
|
||||
id: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
storage_status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportJob {
|
||||
completed_at: string;
|
||||
created_at: string;
|
||||
cutoff_at: string;
|
||||
error_message: string;
|
||||
files: ArchiveExportFile[];
|
||||
filters: Record<string, unknown>;
|
||||
formats: string[];
|
||||
id: string;
|
||||
progress: number;
|
||||
started_at: string;
|
||||
status: 'completed' | 'failed' | 'queued' | 'running';
|
||||
total_rows: number;
|
||||
}
|
||||
|
||||
function query(values: Record<string, number | string>) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== '') params.set(key, String(value));
|
||||
});
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
export function fetchArchiveStats() {
|
||||
return requestClient.get<ArchiveStats>('/archive/stats');
|
||||
}
|
||||
|
||||
export function fetchArchiveConversations(limit = 50, cursor = '') {
|
||||
return requestClient.get<{
|
||||
has_more: boolean;
|
||||
items: ArchiveConversation[];
|
||||
next_cursor: string;
|
||||
}>(`/archive/conversations${query({ cursor, limit })}`);
|
||||
}
|
||||
|
||||
export function fetchArchiveMessages(
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
cursor = '',
|
||||
) {
|
||||
return requestClient.get<{
|
||||
has_more: boolean;
|
||||
items: ArchiveMessage[];
|
||||
next_cursor: string;
|
||||
}>(
|
||||
`/archive/conversations/${encodeURIComponent(conversationId)}/messages${query({ cursor, limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveMediaAccessUrls(mediaIds: string[], expires = 900) {
|
||||
return requestClient.post<{ items: ArchiveMediaAccess[] }>(
|
||||
'/archive/media/access-urls',
|
||||
{ expires, media_ids: mediaIds },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveStorage() {
|
||||
return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage');
|
||||
}
|
||||
|
||||
export function saveArchiveStorage(data: Partial<ArchiveStorage> & {
|
||||
secret_id?: string;
|
||||
secret_key?: string;
|
||||
}) {
|
||||
return requestClient.put<{ storage: ArchiveStorage }>(
|
||||
'/archive/storage',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function testArchiveStorage() {
|
||||
return requestClient.post<{
|
||||
result: { bucket: string; ok: boolean; region: string; request_id: string };
|
||||
}>('/archive/storage/test');
|
||||
}
|
||||
|
||||
export function fetchArchiveMedia(limit = 100) {
|
||||
return requestClient.get<{ items: ArchiveMedia[] }>(
|
||||
`/archive/media${query({ limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchivePeople(limit = 100, keyword = '') {
|
||||
return requestClient.get<{ items: ArchivePerson[] }>(
|
||||
`/archive/people${query({ keyword, limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchivePerson(personId: string) {
|
||||
return requestClient.get<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function bindArchiveIdentity(
|
||||
personId: string,
|
||||
data: {
|
||||
external_id: string;
|
||||
identity_type: string;
|
||||
scope_id: string;
|
||||
verified: boolean;
|
||||
},
|
||||
) {
|
||||
return requestClient.post<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}/identities`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function createArchiveExport(data: {
|
||||
filters: Record<string, unknown>;
|
||||
formats: string[];
|
||||
}) {
|
||||
return requestClient.post<{ job: ArchiveExportJob }>('/archive/exports', data);
|
||||
}
|
||||
|
||||
export function fetchArchiveExports(limit = 100) {
|
||||
return requestClient.get<{ jobs: ArchiveExportJob[] }>(
|
||||
`/archive/exports${query({ limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadArchiveExport(fileId: string) {
|
||||
return requestClient.download<Blob>(
|
||||
`/archive/export-files/${encodeURIComponent(fileId)}/download`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 业务接口:模型清单、角色权限、用户、调用统计。
|
||||
*
|
||||
* 全部对 `admin_api.py` 的 /api/v2/*。这一层只负责搬数据,权限判定在后端——
|
||||
* 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface ModelProvider {
|
||||
api_key?: string;
|
||||
api_key_masked?: string;
|
||||
base_url: string;
|
||||
capabilities: string;
|
||||
enabled: boolean;
|
||||
/** 后端算好的实际请求地址(只读) */
|
||||
endpoint?: string;
|
||||
/** auto = 按接口类型补全路径;exact = 地址原样使用 */
|
||||
endpoint_mode: string;
|
||||
health: string;
|
||||
id: string;
|
||||
kind: string;
|
||||
max_inflight: number;
|
||||
max_tokens: number;
|
||||
model: string;
|
||||
name: string;
|
||||
rpm_limit: number;
|
||||
temperature: number;
|
||||
timeout_ms: number;
|
||||
}
|
||||
|
||||
export interface ModelPlan {
|
||||
answer_ids: string;
|
||||
fallback_ids: string;
|
||||
judge_id: string;
|
||||
judge_mode: string;
|
||||
version: number;
|
||||
vision_id: string;
|
||||
}
|
||||
|
||||
export interface RoleItem {
|
||||
builtin: boolean;
|
||||
code: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface PermissionItem {
|
||||
code: string;
|
||||
group_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface CallStats {
|
||||
avg_ms: number;
|
||||
avg_score: number;
|
||||
chosen: { count: number; provider: string }[];
|
||||
judged: number;
|
||||
max_ms: number;
|
||||
risk: Record<string, number>;
|
||||
score_buckets: { count: number; range: string }[];
|
||||
since: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 单次调用的候选出口——`model_calls.candidates_json` 解出来的一项。 */
|
||||
export interface ModelCallCandidate {
|
||||
error?: string;
|
||||
latency_ms?: number;
|
||||
provider: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 单条调用留痕:客户说了什么、模型答了什么、有没有被拦下来。 */
|
||||
export interface ModelCallLogItem {
|
||||
candidates: ModelCallCandidate[];
|
||||
chosen: string;
|
||||
created_at: string;
|
||||
customer_text: string;
|
||||
device_id: string;
|
||||
id: number;
|
||||
judge_mode: string;
|
||||
judge_risk: string;
|
||||
judge_score: number;
|
||||
judge_winner: string;
|
||||
/** chat = 回客户的话;guard = 界面识别之类的内部判断 */
|
||||
purpose: string;
|
||||
reply_text: string;
|
||||
review_reason: string;
|
||||
roles_version: number;
|
||||
task_id: string;
|
||||
total_ms: number;
|
||||
}
|
||||
|
||||
// ── 模型 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchModels() {
|
||||
return requestClient.get<{
|
||||
judge_modes: string[];
|
||||
kinds: string[];
|
||||
models: ModelProvider[];
|
||||
roles: ModelPlan;
|
||||
}>('/models');
|
||||
}
|
||||
|
||||
export function saveModel(data: Partial<ModelProvider>) {
|
||||
return requestClient.post<{ models: ModelProvider[] }>('/models', data);
|
||||
}
|
||||
|
||||
export function deleteModel(id: string) {
|
||||
return requestClient.delete<{ models: ModelProvider[] }>(
|
||||
`/models/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveModelPlan(data: Partial<ModelPlan>) {
|
||||
return requestClient.post<{ roles: ModelPlan; version: number }>(
|
||||
'/models/plan',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 角色与权限 ───────────────────────────────────────────────────────────────
|
||||
export function fetchRoles() {
|
||||
return requestClient.get<{ roles: RoleItem[] }>('/roles');
|
||||
}
|
||||
|
||||
export function fetchPermissions() {
|
||||
return requestClient.get<{ permissions: PermissionItem[] }>('/permissions');
|
||||
}
|
||||
|
||||
export function saveRole(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
}) {
|
||||
return requestClient.post<{ roles: RoleItem[] }>('/roles', data);
|
||||
}
|
||||
|
||||
export function deleteRole(code: string) {
|
||||
return requestClient.delete<{ roles: RoleItem[] }>(
|
||||
`/roles/${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 用户 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchUsers() {
|
||||
return requestClient.get<{ users: UserItem[] }>('/users');
|
||||
}
|
||||
|
||||
export function createUser(data: {
|
||||
password: string;
|
||||
role: string;
|
||||
username: string;
|
||||
}) {
|
||||
return requestClient.post<{ users: UserItem[] }>('/users', data);
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id: number,
|
||||
data: { active: boolean; role: string },
|
||||
) {
|
||||
return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data);
|
||||
}
|
||||
|
||||
// ── 运营 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchCallStats(days = 7) {
|
||||
return requestClient.get<CallStats>(`/stats/model-calls?days=${days}`);
|
||||
}
|
||||
|
||||
export function fetchCallLog(params: {
|
||||
days?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** chat = 回客户的话(默认);guard = 界面识别;'' = 全都要 */
|
||||
purpose?: string;
|
||||
q?: string;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
query.set('days', String(params.days ?? 7));
|
||||
query.set('limit', String(params.limit ?? 50));
|
||||
query.set('offset', String(params.offset ?? 0));
|
||||
query.set('purpose', params.purpose ?? 'chat');
|
||||
if (params.q) query.set('q', params.q);
|
||||
return requestClient.get<{ items: ModelCallLogItem[]; total: number }>(
|
||||
`/stats/model-calls/log?${query.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAudit(limit = 200) {
|
||||
return requestClient.get<{
|
||||
entries: {
|
||||
action: string;
|
||||
created_at: string;
|
||||
detail: string;
|
||||
id: number;
|
||||
ip_address: string;
|
||||
username: null | string;
|
||||
}[];
|
||||
}>(`/audit?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function changeMyPassword(data: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}) {
|
||||
return requestClient.post('/me/password', data);
|
||||
}
|
||||
|
||||
// ── 桌面端配置 ───────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 下发给桌面客户端的配置。
|
||||
*
|
||||
* 字段名是大写下划线,和老网页后台的表单完全一致——两边共用后端同一个校验函数,
|
||||
* 不会出现"这边填得进、那边填不进"。
|
||||
*
|
||||
* 这里**没有模型连接参数**:服务类型、API 地址、密钥、模型名、温度、tokens、
|
||||
* 超时全部搬到了「AI 模型 → 模型清单 / 角色编排」。桌面端的模型调用走网关,
|
||||
* 密钥不出后端。
|
||||
*/
|
||||
export interface DesktopConfig {
|
||||
AI_AGENT_NAME: string;
|
||||
AI_CONTEXT_ENABLED: boolean;
|
||||
AI_CONTEXT_MAX_ROUNDS: number;
|
||||
AI_COUNTER_INSULT_ENABLED: boolean;
|
||||
AI_DEVELOPMENT_MODE: boolean;
|
||||
AI_ENABLED: boolean;
|
||||
AI_HOSPITAL_NAME: string;
|
||||
AI_MCP_ENABLED: boolean;
|
||||
AI_MCP_MAX_ROUNDS: number;
|
||||
AI_MCP_SERVERS: unknown[];
|
||||
AI_UI_GUARD_ENABLED: boolean;
|
||||
AI_USE_VISION: boolean;
|
||||
/** 留空 = 按后台自己的地址自动推算 */
|
||||
AI_GATEWAY_URL: string;
|
||||
AI_REVIEW_RULES: ReviewRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择性审核规则:命中任意一条,这一条回复才会停下来等人工确认,其余照常
|
||||
* 自动发送。`id` 可以留空——留空由后端根据 label 自动生成并去重。
|
||||
*/
|
||||
export interface ReviewRule {
|
||||
id?: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigEnvelope {
|
||||
bool_keys: string[];
|
||||
config: DesktopConfig;
|
||||
/** 留空时实际会下发给客户端的网关地址 */
|
||||
gateway_url_effective: string;
|
||||
/** 模型设置搬去哪儿了,用来在页面上给人指路 */
|
||||
model_settings_moved_to: string;
|
||||
retired_keys: string[];
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function fetchConfig() {
|
||||
return requestClient.get<ConfigEnvelope>('/config');
|
||||
}
|
||||
|
||||
export function saveConfig(data: DesktopConfig) {
|
||||
return requestClient.post<{ version: number }>('/config', data);
|
||||
}
|
||||
|
||||
// ── 桌面端版本升级 ───────────────────────────────────────────────────────────
|
||||
export interface ReleasePolicy {
|
||||
download_url: string;
|
||||
force_upgrade: boolean;
|
||||
latest_version: string;
|
||||
release_notes: string;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
export function fetchRelease() {
|
||||
return requestClient.get<ReleasePolicy>('/release');
|
||||
}
|
||||
|
||||
export function saveRelease(data: {
|
||||
download_url: string;
|
||||
force_upgrade: boolean;
|
||||
latest_version: string;
|
||||
release_notes: string;
|
||||
}) {
|
||||
return requestClient.post<ReleasePolicy>('/release', data);
|
||||
}
|
||||
|
||||
// ── 模型连通性测试 ───────────────────────────────────────────────────────────
|
||||
export interface ModelTestResult {
|
||||
endpoint: string;
|
||||
http_status: null | number;
|
||||
latency_ms: number;
|
||||
message: string;
|
||||
model: string;
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给了 provider_id 就测清单里那一条,密钥由后端从库里解密取用——
|
||||
* 前端从头到尾拿不到明文,也就不可能在网络上多走一趟。
|
||||
*/
|
||||
export function testModel(data: {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
endpoint_mode?: string;
|
||||
kind?: string;
|
||||
model?: string;
|
||||
provider_id?: string;
|
||||
timeout_seconds?: number;
|
||||
}) {
|
||||
return requestClient.post<{ label: string; result: ModelTestResult }>(
|
||||
'/models/test',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 认证接口。
|
||||
*
|
||||
* vben 默认对接的是它自带的 mock 后端(`/auth/login` 返回 `{accessToken}`、
|
||||
* `/auth/codes` 单独取权限码)。我们的后端是 `admin_api.py`,形状不一样,而且
|
||||
* **登录时就把权限码一起返回了**——少一次往返,也避免"已登录但权限还没到"
|
||||
* 这个中间态导致路由守卫误判。
|
||||
*
|
||||
* 适配放在这一层,不去改后端:接口契约不该被某一个客户端的习惯带偏。
|
||||
*/
|
||||
export namespace AuthApi {
|
||||
export interface LoginParams {
|
||||
password?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/** 后端 /api/v2/auth/login 的真实返回 */
|
||||
export interface BackendLoginResult {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
user: {
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
permissions: string[];
|
||||
role: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录后暂存的权限码。getAccessCodesApi 直接用它,省掉一次请求。 */
|
||||
let cachedCodes: string[] = [];
|
||||
|
||||
export function takeCachedAccessCodes(): string[] {
|
||||
return cachedCodes;
|
||||
}
|
||||
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
const resp = await requestClient.post<AuthApi.BackendLoginResult>(
|
||||
'/auth/login',
|
||||
{ username: data.username, password: data.password, device_name: 'web' },
|
||||
);
|
||||
cachedCodes = resp.user?.permissions ?? [];
|
||||
return { accessToken: resp.access_token } as AuthApi.LoginResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 accessToken。
|
||||
*
|
||||
* 后端目前发的是 30 天有效的长令牌,没有 refresh token 机制,所以这里必须明确
|
||||
* 失败——返回一个假 token 会让请求拦截器以为续期成功,接着用一个无效令牌无限
|
||||
* 重试,表现是页面卡死而不是跳登录页。
|
||||
*/
|
||||
export async function refreshTokenApi(): Promise<never> {
|
||||
throw new Error('后端未启用令牌续期,请重新登录');
|
||||
}
|
||||
|
||||
export async function logoutApi() {
|
||||
cachedCodes = [];
|
||||
return requestClient.post('/auth/logout', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前用户的权限码。
|
||||
*
|
||||
* 优先用登录时带回来的那份;刷新页面后缓存是空的,再去 /me 拿一次。
|
||||
*/
|
||||
export async function getAccessCodesApi(): Promise<string[]> {
|
||||
if (cachedCodes.length > 0) {
|
||||
return cachedCodes;
|
||||
}
|
||||
const me = await requestClient.get<{ permissions: string[] }>('/me');
|
||||
cachedCodes = me.permissions ?? [];
|
||||
return cachedCodes;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './user';
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordStringComponent } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取用户所有菜单
|
||||
*/
|
||||
export async function getAllMenusApi() {
|
||||
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { UserInfo } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
interface BackendMe {
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
permissions: string[];
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前登录用户。
|
||||
*
|
||||
* 后端的 `/api/v2/me` 是权限的唯一数据源——路由守卫和按钮显隐都以它为准。
|
||||
* 这里把它映射成 vben 的 UserInfo 形状。
|
||||
*
|
||||
* `roles` 塞的是**权限码**而不是角色名,这是有意的:vben 的 `v-access:role`
|
||||
* 指令按这个数组匹配,而我们全线只判权限码,不判角色名。判角色名等于把运营
|
||||
* 策略焊死在前端,加个角色就要重新发版。
|
||||
*/
|
||||
export async function getUserInfoApi(): Promise<UserInfo> {
|
||||
const me = await requestClient.get<BackendMe>('/me');
|
||||
return {
|
||||
avatar: '',
|
||||
realName: me.username,
|
||||
roles: me.permissions ?? [],
|
||||
userId: String(me.id),
|
||||
username: me.username,
|
||||
// 首登必须改密:把落地页锁死在改密页,用户改完才放行。后端不会拦改密接口
|
||||
// 本身(拦了就只能去老网页后台改),锁在前端这一层。
|
||||
homePath: me.must_change_password ? '/profile/password' : undefined,
|
||||
desc: me.role,
|
||||
token: '',
|
||||
} as UserInfo;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core';
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 该文件可自行根据业务逻辑进行调整
|
||||
*/
|
||||
import type { RequestClientOptions } from '@vben/request';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import {
|
||||
authenticateResponseInterceptor,
|
||||
defaultResponseInterceptor,
|
||||
errorMessageResponseInterceptor,
|
||||
RequestClient,
|
||||
} from '@vben/request';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
...options,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
/**
|
||||
* 重新认证逻辑。
|
||||
*
|
||||
* 这里必须防重入:登出本身也是一个带鉴权头的请求,令牌已经无效时它同样会
|
||||
* 拿到 401,于是又走进这个函数、又去登出……递归下去,表现是浏览器疯狂
|
||||
* 发 POST /auth/logout 直到页面卡死。authStore.logout() 里的 try/catch 挡不住,
|
||||
* 它catch 的是自己那一层,下一层照样新起一轮。
|
||||
*/
|
||||
let reAuthenticating = false;
|
||||
async function doReAuthenticate() {
|
||||
if (reAuthenticating) {
|
||||
return;
|
||||
}
|
||||
reAuthenticating = true;
|
||||
console.warn('Access token or refresh token is invalid or expired. ');
|
||||
const accessStore = useAccessStore();
|
||||
const authStore = useAuthStore();
|
||||
accessStore.setAccessToken(null);
|
||||
if (
|
||||
preferences.app.loginExpiredMode === 'modal' &&
|
||||
accessStore.isAccessChecked
|
||||
) {
|
||||
accessStore.setLoginExpired(true);
|
||||
} else {
|
||||
try {
|
||||
await authStore.logout();
|
||||
} finally {
|
||||
reAuthenticating = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
reAuthenticating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token 逻辑。
|
||||
*
|
||||
* 后端发的是 30 天有效的长令牌,没有 refresh token。配套地
|
||||
* `enableRefreshToken` 关掉了,拦截器不会走到这里;万一哪天有人打开开关,
|
||||
* 这里立刻抛错,比默默续期一个假令牌然后无限重试要好得多。
|
||||
*/
|
||||
async function doRefreshToken(): Promise<string> {
|
||||
await refreshTokenApi();
|
||||
throw new Error('后端未启用令牌续期');
|
||||
}
|
||||
|
||||
function formatToken(token: null | string) {
|
||||
return token ? `Bearer ${token}` : null;
|
||||
}
|
||||
|
||||
// 请求头处理
|
||||
client.addRequestInterceptor({
|
||||
fulfilled: async (config) => {
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
config.headers.Authorization = formatToken(accessStore.accessToken);
|
||||
config.headers['Accept-Language'] = preferences.app.locale;
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
// 处理返回的响应数据格式。
|
||||
//
|
||||
// 模板默认配的是 `{codeField:'code', dataField:'data', successCode:0}`,也就是
|
||||
// 假定后端返回 `{code:0, data:{...}}` 这种信封。admin_api.py 返回的是扁平
|
||||
// JSON(`{"access_token":...}`),没有 code 字段,按默认配置每一个 200 都会被
|
||||
// 判成失败并抛出——症状是点登录毫无反应,控制台还一句错都不报。
|
||||
//
|
||||
// 所以改成透传:成败由 HTTP 状态码表达(这本来就是它该干的事),
|
||||
// 整个响应体就是数据。这个拦截器不能直接删掉——删了之后没人拆包,
|
||||
// 拿到的是整个 axios response,`resp.access_token` 一样是 undefined。
|
||||
client.addResponseInterceptor(
|
||||
defaultResponseInterceptor({
|
||||
codeField: '',
|
||||
dataField: (response) => response,
|
||||
successCode: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
// token过期的处理
|
||||
client.addResponseInterceptor(
|
||||
authenticateResponseInterceptor({
|
||||
client,
|
||||
doReAuthenticate,
|
||||
doRefreshToken,
|
||||
enableRefreshToken: preferences.app.enableRefreshToken,
|
||||
formatToken,
|
||||
}),
|
||||
);
|
||||
|
||||
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
||||
client.addResponseInterceptor(
|
||||
errorMessageResponseInterceptor((msg: string, error) => {
|
||||
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
||||
// 当前mock接口返回的错误字段是 error 或者 message
|
||||
const responseData = error?.response?.data ?? {};
|
||||
// FastAPI 的错误信息在 detail 里(HTTPException(detail=...)),
|
||||
// 模板只认 error / message,不加这个后端说的话一句都显示不出来。
|
||||
const errorMessage =
|
||||
responseData?.detail ??
|
||||
responseData?.error ??
|
||||
responseData?.message ??
|
||||
'';
|
||||
// 如果没有错误信息,则会根据状态码进行提示
|
||||
message.error(errorMessage || msg);
|
||||
}),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export const requestClient = createRequestClient(apiURL, {
|
||||
responseReturn: 'data',
|
||||
});
|
||||
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||
<App>
|
||||
<RouterView />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createApp, watchEffect } from 'vue';
|
||||
|
||||
import { registerAccessDirective } from '@vben/access';
|
||||
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { initStores } from '@vben/stores';
|
||||
import '@vben/styles';
|
||||
import '@vben/styles/antd';
|
||||
|
||||
import { useTitle } from '@vueuse/core';
|
||||
|
||||
import { $t, setupI18n } from '#/locales';
|
||||
|
||||
import { initComponentAdapter } from './adapter/component';
|
||||
import { initSetupVbenForm } from './adapter/form';
|
||||
import App from './app.vue';
|
||||
import { router } from './router';
|
||||
|
||||
async function bootstrap(namespace: string) {
|
||||
// 初始化组件适配器
|
||||
await initComponentAdapter();
|
||||
|
||||
// 初始化表单组件
|
||||
await initSetupVbenForm();
|
||||
|
||||
// // 设置弹窗的默认配置
|
||||
// setDefaultModalProps({
|
||||
// fullscreenButton: false,
|
||||
// });
|
||||
// // 设置抽屉的默认配置
|
||||
// setDefaultDrawerProps({
|
||||
// zIndex: 1020,
|
||||
// });
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// 注册v-loading指令
|
||||
registerLoadingDirective(app, {
|
||||
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
|
||||
spinning: 'spinning',
|
||||
});
|
||||
|
||||
// 国际化 i18n 配置
|
||||
await setupI18n(app);
|
||||
|
||||
// 配置 pinia-tore
|
||||
await initStores(app, { namespace });
|
||||
|
||||
// 安装权限指令
|
||||
registerAccessDirective(app);
|
||||
|
||||
// 初始化 tippy
|
||||
const { initTippy } = await import('@vben/common-ui/es/tippy');
|
||||
initTippy(app);
|
||||
|
||||
// 配置路由及路由守卫
|
||||
app.use(router);
|
||||
|
||||
// 配置Motion插件
|
||||
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||
app.use(MotionPlugin);
|
||||
|
||||
// 动态更新标题
|
||||
watchEffect(() => {
|
||||
if (preferences.app.dynamicTitle) {
|
||||
const routeTitle = router.currentRoute.value.meta?.title;
|
||||
const pageTitle =
|
||||
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
|
||||
useTitle(pageTitle);
|
||||
}
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
export { bootstrap };
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthPageLayout } from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const appName = computed(() => preferences.app.name);
|
||||
const logo = computed(() => preferences.logo.source);
|
||||
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:logo-dark="logoDark"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
</AuthPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||
date: '3小时前',
|
||||
isRead: true,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '收到了 14 份新周报',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '刚刚',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '朱偏右 回复了你',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '2024-01-01',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '曲丽丽 评论了你',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '代办提醒',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转Workspace示例',
|
||||
link: '/workspace',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转外部链接示例',
|
||||
link: 'https://doc.vben.pro',
|
||||
},
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { isDark } = usePreferences();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
router.push({ name: 'Profile' });
|
||||
},
|
||||
icon: 'lucide:user',
|
||||
text: $t('page.auth.profile'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_DOC_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: BookOpenText,
|
||||
text: $t('ui.widgets.document'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_GITHUB_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: SvgGithubIcon,
|
||||
text: 'GitHub',
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(`${VBEN_GITHUB_URL}/issues`, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: CircleHelp,
|
||||
text: $t('ui.widgets.qa'),
|
||||
},
|
||||
]);
|
||||
|
||||
const avatar = computed(() => {
|
||||
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout(false);
|
||||
}
|
||||
|
||||
function handleNoticeClear() {
|
||||
notifications.value = [];
|
||||
}
|
||||
|
||||
function markRead(id: number | string) {
|
||||
const item = notifications.value.find((item) => item.id === id);
|
||||
if (item) {
|
||||
item.isRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: number | string) {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
}
|
||||
|
||||
const viewAll = () => {};
|
||||
|
||||
const handleClick = (item: NotificationItem) => {
|
||||
// 如果通知项有链接,点击时跳转
|
||||
if (item.link) {
|
||||
navigateTo(item.link, item.query, item.state);
|
||||
}
|
||||
};
|
||||
|
||||
function navigateTo(
|
||||
link: string,
|
||||
query?: Record<string, any>,
|
||||
state?: Record<string, any>,
|
||||
) {
|
||||
if (link.startsWith('http://') || link.startsWith('https://')) {
|
||||
// 外部链接,在新标签页打开
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
// 内部路由链接,支持 query 参数和 state
|
||||
router.push({
|
||||
path: link,
|
||||
query: query || {},
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
content: preferences.app.watermarkContent,
|
||||
isDark: isDark.value,
|
||||
}),
|
||||
async ({ enable, content, isDark: isDarkValue }) => {
|
||||
if (enable) {
|
||||
const watermarkColor = isDarkValue
|
||||
? 'rgba(255, 255, 255, 0.12)'
|
||||
: 'rgba(0, 0, 0, 0.12)';
|
||||
|
||||
await updateWatermark({
|
||||
advancedStyle: {
|
||||
colorStops: [
|
||||
{
|
||||
color: watermarkColor,
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
color: watermarkColor,
|
||||
offset: 1,
|
||||
},
|
||||
],
|
||||
type: 'linear',
|
||||
},
|
||||
content:
|
||||
content ||
|
||||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicLayout
|
||||
:avatar
|
||||
:text="userStore.userInfo?.realName"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
>
|
||||
<template #user-dropdown>
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:menus
|
||||
:text="userStore.userInfo?.realName"
|
||||
description="ann.vben@gmail.com"
|
||||
tag-text="Pro"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@read="(item) => item.id && markRead(item.id)"
|
||||
@remove="(item) => item.id && remove(item.id)"
|
||||
@make-all="handleMakeAll"
|
||||
@on-click="handleClick"
|
||||
@view-all="viewAll"
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
:avatar
|
||||
>
|
||||
<LoginForm />
|
||||
</AuthenticationLoginExpiredModal>
|
||||
</template>
|
||||
<template #lock-screen>
|
||||
<LockScreen :avatar @to-login="handleLogout" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
const BasicLayout = () => import('./basic.vue');
|
||||
const AuthPageLayout = () => import('./auth.vue');
|
||||
|
||||
const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
|
||||
|
||||
export { AuthPageLayout, BasicLayout, IFrameView };
|
||||
@@ -0,0 +1,3 @@
|
||||
# locale
|
||||
|
||||
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Locale } from 'ant-design-vue/es/locale';
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
$t,
|
||||
setupI18n as coreSetup,
|
||||
loadLocalesMapFromDir,
|
||||
} from '@vben/locales';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import antdEnLocale from 'ant-design-vue/es/locale/en_US';
|
||||
import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const antdLocale = ref<Locale>(antdDefaultLocale);
|
||||
|
||||
const modules = import.meta.glob('./langs/**/*.json');
|
||||
|
||||
const localesMap = loadLocalesMapFromDir(
|
||||
/\.\/langs\/([^/]+)\/(.*)\.json$/,
|
||||
modules,
|
||||
);
|
||||
/**
|
||||
* 加载应用特有的语言包
|
||||
* 这里也可以改造为从服务端获取翻译数据
|
||||
* @param lang
|
||||
*/
|
||||
async function loadMessages(lang: SupportedLanguagesType) {
|
||||
const [appLocaleMessages] = await Promise.all([
|
||||
localesMap[lang]?.(),
|
||||
loadThirdPartyMessage(lang),
|
||||
]);
|
||||
return appLocaleMessages?.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载第三方组件库的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
|
||||
await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载dayjs的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadDayjsLocale(lang: SupportedLanguagesType) {
|
||||
let locale;
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
locale = await import('dayjs/locale/en');
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
locale = await import('dayjs/locale/zh-cn');
|
||||
break;
|
||||
}
|
||||
// 默认使用英语
|
||||
default: {
|
||||
locale = await import('dayjs/locale/en');
|
||||
}
|
||||
}
|
||||
if (locale) {
|
||||
dayjs.locale(locale);
|
||||
} else {
|
||||
console.error(`Failed to load dayjs locale for ${lang}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载antd的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadAntdLocale(lang: SupportedLanguagesType) {
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
antdLocale.value = antdEnLocale;
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
antdLocale.value = antdDefaultLocale;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
|
||||
await coreSetup(app, {
|
||||
defaultLocale: preferences.app.locale,
|
||||
loadMessages,
|
||||
missingWarn: !import.meta.env.PROD,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export { $t, antdLocale, setupI18n };
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "Demos",
|
||||
"antd": "Ant Design Vue",
|
||||
"vben": {
|
||||
"title": "Project",
|
||||
"about": "About",
|
||||
"document": "Document",
|
||||
"antdv": "Ant Design Vue Version",
|
||||
"antdv-next": "Antdv Next Version",
|
||||
"naive-ui": "Naive UI Version",
|
||||
"element-plus": "Element Plus Version",
|
||||
"tdesign": "TDesign Vue Version"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"codeLogin": "Code Login",
|
||||
"qrcodeLogin": "Qr Code Login",
|
||||
"forgetPassword": "Forget Password",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "演示",
|
||||
"antd": "Ant Design Vue",
|
||||
"vben": {
|
||||
"title": "项目",
|
||||
"about": "关于",
|
||||
"document": "文档",
|
||||
"antdv": "Ant Design Vue 版本",
|
||||
"antdv-next": "Antdv Next 版本",
|
||||
"naive-ui": "Naive UI 版本",
|
||||
"element-plus": "Element Plus 版本",
|
||||
"tdesign": "TDesign Vue 版本"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"codeLogin": "验证码登录",
|
||||
"qrcodeLogin": "二维码登录",
|
||||
"forgetPassword": "忘记密码",
|
||||
"profile": "个人中心"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { initPreferences } from '@vben/preferences';
|
||||
import { unmountGlobalLoading } from '@vben/utils';
|
||||
|
||||
import { overridesPreferences, preferencesExtension } from './preferences';
|
||||
|
||||
/**
|
||||
* 应用初始化完成之后再进行页面加载渲染
|
||||
*/
|
||||
async function initApplication() {
|
||||
// name用于指定项目唯一标识
|
||||
// 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
|
||||
const env = import.meta.env.PROD ? 'prod' : 'dev';
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION;
|
||||
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
|
||||
|
||||
// app偏好设置初始化
|
||||
await initPreferences({
|
||||
extension: preferencesExtension,
|
||||
namespace,
|
||||
overrides: overridesPreferences,
|
||||
});
|
||||
|
||||
// 启动应用并挂载
|
||||
// vue应用主要逻辑及视图
|
||||
const { bootstrap } = await import('./bootstrap');
|
||||
await bootstrap(namespace);
|
||||
|
||||
// 移除并销毁loading
|
||||
unmountGlobalLoading();
|
||||
}
|
||||
|
||||
initApplication();
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
defineOverridesPreferences,
|
||||
definePreferencesExtension,
|
||||
} from '@vben/preferences';
|
||||
|
||||
interface WebAntdPreferencesExtension {
|
||||
defaultTableSize: number;
|
||||
enableFormFullscreen: boolean;
|
||||
reportTitle: string;
|
||||
tenantMode: 'multi' | 'single';
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 项目配置文件
|
||||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
|
||||
* !!! 更改配置后请清空缓存,否则可能不生效
|
||||
*/
|
||||
export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE,
|
||||
// 内部管理后台,不需要主题色/布局那一堆面向 C 端的偏好设置开关
|
||||
enablePreferences: false,
|
||||
// 模板默认首页是 /dashboard/analytics——那是一整页 Math.random() 生成的
|
||||
// 假图表。已经删掉了,首页改成调用统计:这套系统真正的总览就是它。
|
||||
defaultHomePath: '/model/stats',
|
||||
// 后端发的是长期令牌,没有 refresh token 机制。开着这个开关,401 之后
|
||||
// 拦截器会先去尝试续期、失败再登出,白白多一轮请求和一次报错。
|
||||
enableRefreshToken: false,
|
||||
},
|
||||
copyright: {
|
||||
companyName: '真羊',
|
||||
companySiteLink: '',
|
||||
date: '2026',
|
||||
enable: true,
|
||||
// 模板默认挂的是 vben 作者名下的真实备案号(闽ICP备19024351号)。
|
||||
// 那是别人的备案,挂在这里既不对也没意义。等这套后台真的对公网提供服务、
|
||||
// 拿到自己的备案号,再填进来;内网部署本来就不需要备案。
|
||||
icp: '',
|
||||
icpLink: '',
|
||||
},
|
||||
});
|
||||
|
||||
export const preferencesExtension =
|
||||
definePreferencesExtension<WebAntdPreferencesExtension>({
|
||||
tabLabel: 'preferences.antd.tabLabel',
|
||||
title: 'preferences.antd.title',
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableFormFullscreen',
|
||||
label: 'preferences.antd.fields.enableFormFullscreen.label',
|
||||
tip: 'preferences.antd.fields.enableFormFullscreen.tip',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: 'preferences.antd.fields.tenantMode.label',
|
||||
options: [
|
||||
{
|
||||
label: 'preferences.antd.fields.tenantMode.options.single.label',
|
||||
value: 'single',
|
||||
},
|
||||
{
|
||||
label: 'preferences.antd.fields.tenantMode.options.multi.label',
|
||||
value: 'multi',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 200,
|
||||
min: 10,
|
||||
step: 10,
|
||||
},
|
||||
defaultValue: 20,
|
||||
key: 'defaultTableSize',
|
||||
label: 'preferences.antd.fields.defaultTableSize.label',
|
||||
},
|
||||
{
|
||||
component: 'input',
|
||||
defaultValue: '',
|
||||
key: 'reportTitle',
|
||||
label: 'preferences.antd.fields.reportTitle.label',
|
||||
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
ComponentRecordType,
|
||||
GenerateMenuAndRoutesOptions,
|
||||
} from '@vben/types';
|
||||
|
||||
import { generateAccessible } from '@vben/access';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getAllMenusApi } from '#/api';
|
||||
import { BasicLayout, IFrameView } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
||||
|
||||
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
||||
|
||||
const layoutMap: ComponentRecordType = {
|
||||
BasicLayout,
|
||||
IFrameView,
|
||||
};
|
||||
|
||||
return await generateAccessible(preferences.app.accessMode, {
|
||||
...options,
|
||||
fetchMenuListAsync: async () => {
|
||||
message.loading({
|
||||
content: `${$t('common.loadingMenu')}...`,
|
||||
duration: 1.5,
|
||||
});
|
||||
return await getAllMenusApi();
|
||||
},
|
||||
// 可以指定没有权限跳转403页面
|
||||
forbiddenComponent,
|
||||
// 如果 route.meta.menuVisibleWithForbidden = true
|
||||
layoutMap,
|
||||
pageMap,
|
||||
});
|
||||
}
|
||||
|
||||
export { generateAccess };
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { startProgress, stopProgress } from '@vben/utils';
|
||||
|
||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { generateAccess } from './access';
|
||||
|
||||
/**
|
||||
* 通用守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function setupCommonGuard(router: Router) {
|
||||
// 记录已经加载的页面
|
||||
const loadedPaths = new Set<string>();
|
||||
|
||||
router.beforeEach((to) => {
|
||||
to.meta.loaded = loadedPaths.has(to.path);
|
||||
|
||||
// 页面加载进度条
|
||||
if (!to.meta.loaded && preferences.transition.progress) {
|
||||
startProgress();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
|
||||
|
||||
loadedPaths.add(to.path);
|
||||
|
||||
// 关闭页面加载进度条
|
||||
if (preferences.transition.progress) {
|
||||
stopProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限访问守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function setupAccessGuard(router: Router) {
|
||||
router.beforeEach(async (to, from) => {
|
||||
const accessStore = useAccessStore();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
if (coreRouteNames.includes(to.name as string)) {
|
||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||
return decodeURIComponent(
|
||||
(to.query?.redirect as string) ||
|
||||
userStore.userInfo?.homePath ||
|
||||
preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// accessToken 检查
|
||||
if (!accessStore.accessToken) {
|
||||
// 明确声明忽略权限访问权限,则可以访问
|
||||
if (to.meta.ignoreAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 没有访问权限,跳转登录页面
|
||||
if (to.fullPath !== LOGIN_PATH) {
|
||||
return {
|
||||
path: LOGIN_PATH,
|
||||
// 如不需要,直接删除 query
|
||||
query:
|
||||
to.fullPath === preferences.app.defaultHomePath
|
||||
? {}
|
||||
: { redirect: encodeURIComponent(to.fullPath) },
|
||||
// 携带当前跳转的页面,登录后重新跳转该页面
|
||||
replace: true,
|
||||
};
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 生成路由表
|
||||
// 当前登录用户拥有的角色标识列表
|
||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||
const userRoles = userInfo.roles ?? [];
|
||||
|
||||
// 生成菜单和路由
|
||||
const { accessibleMenus, accessibleRoutes } = await generateAccess({
|
||||
roles: userRoles,
|
||||
router,
|
||||
// 则会在菜单中显示,但是访问会被重定向到403
|
||||
routes: accessRoutes,
|
||||
});
|
||||
|
||||
// 保存菜单信息和路由信息
|
||||
accessStore.setAccessMenus(accessibleMenus);
|
||||
accessStore.setAccessRoutes(accessibleRoutes);
|
||||
accessStore.setIsAccessChecked(true);
|
||||
const redirectPath = (from.query.redirect ??
|
||||
(to.path === preferences.app.defaultHomePath
|
||||
? userInfo.homePath || preferences.app.defaultHomePath
|
||||
: to.fullPath)) as string;
|
||||
|
||||
return {
|
||||
...router.resolve(decodeURIComponent(redirectPath)),
|
||||
replace: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function createRouterGuard(router: Router) {
|
||||
/** 通用 */
|
||||
setupCommonGuard(router);
|
||||
/** 权限访问 */
|
||||
setupAccessGuard(router);
|
||||
}
|
||||
|
||||
export { createRouterGuard };
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createWebHistory,
|
||||
} from 'vue-router';
|
||||
|
||||
import { resetStaticRoutes } from '@vben/utils';
|
||||
|
||||
import { createRouterGuard } from './guard';
|
||||
import { routes } from './routes';
|
||||
|
||||
/**
|
||||
* @zh_CN 创建vue-router实例
|
||||
*/
|
||||
const router = createRouter({
|
||||
history:
|
||||
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
|
||||
? createWebHashHistory(import.meta.env.VITE_BASE)
|
||||
: createWebHistory(import.meta.env.VITE_BASE),
|
||||
// 应该添加到路由的初始路由列表。
|
||||
routes,
|
||||
scrollBehavior: (to, _from, savedPosition) => {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
|
||||
},
|
||||
// 是否应该禁止尾部斜杠。
|
||||
// strict: true,
|
||||
});
|
||||
|
||||
const resetRoutes = () => resetStaticRoutes(router, routes);
|
||||
|
||||
// 创建路由守卫
|
||||
createRouterGuard(router);
|
||||
|
||||
export { resetRoutes, router };
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const BasicLayout = () => import('#/layouts/basic.vue');
|
||||
const AuthPageLayout = () => import('#/layouts/auth.vue');
|
||||
/** 全局404页面 */
|
||||
const fallbackNotFoundRoute: RouteRecordRaw = {
|
||||
component: () => import('#/views/_core/fallback/not-found.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
title: '404',
|
||||
},
|
||||
name: 'FallbackNotFound',
|
||||
path: '/:path(.*)*',
|
||||
};
|
||||
|
||||
/** 基本路由,这些路由是必须存在的 */
|
||||
const coreRoutes: RouteRecordRaw[] = [
|
||||
/**
|
||||
* 根路由
|
||||
* 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
|
||||
* 此路由必须存在,且不应修改
|
||||
*/
|
||||
{
|
||||
component: BasicLayout,
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
title: 'Root',
|
||||
},
|
||||
name: 'Root',
|
||||
path: '/',
|
||||
redirect: preferences.app.defaultHomePath,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
component: AuthPageLayout,
|
||||
meta: {
|
||||
hideInTab: true,
|
||||
title: 'Authentication',
|
||||
},
|
||||
name: 'Authentication',
|
||||
path: '/auth',
|
||||
redirect: LOGIN_PATH,
|
||||
children: [
|
||||
// 只保留账号密码登录。短信登录、扫码登录、注册、找回密码这四个页面是模板
|
||||
// 自带的演示页,后端没有对应接口,路由留着就是一堆能点进去但走不通的死路。
|
||||
{
|
||||
name: 'Login',
|
||||
path: 'login',
|
||||
component: () => import('#/views/_core/authentication/login.vue'),
|
||||
meta: {
|
||||
title: $t('page.auth.login'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export { coreRoutes, fallbackNotFoundRoute };
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
|
||||
|
||||
import { coreRoutes, fallbackNotFoundRoute } from './core';
|
||||
|
||||
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
// 有需要可以自行打开注释,并创建文件夹
|
||||
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
|
||||
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
|
||||
|
||||
/** 动态路由 */
|
||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||
|
||||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
||||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
||||
const staticRoutes: RouteRecordRaw[] = [];
|
||||
const externalRoutes: RouteRecordRaw[] = [];
|
||||
|
||||
/** 路由列表,由基本路由、外部路由和404兜底路由组成
|
||||
* 无需走权限验证(会一直显示在菜单中) */
|
||||
const routes: RouteRecordRaw[] = [
|
||||
...coreRoutes,
|
||||
...externalRoutes,
|
||||
fallbackNotFoundRoute,
|
||||
];
|
||||
|
||||
/** 基本路由列表,这些路由不需要进入权限拦截 */
|
||||
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
|
||||
|
||||
/** 有权限校验的路由列表,包含动态路由和静态路由 */
|
||||
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
|
||||
export { accessRoutes, coreRouteNames, routes };
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 业务路由。
|
||||
*
|
||||
* `meta.authority` 里写的是**权限码**,不是角色名——和后端 `require("model:write")`
|
||||
* 判的是同一个东西。加一个角色只是在后台勾几个框,前端一行代码都不用改。
|
||||
*
|
||||
* 前端只负责"看不见",后端负责"进不去"。两者缺一不可:只做前端等于没做,
|
||||
* 任何人拿 curl 都能绕过去;只做后端则是用户点进去才报错,体验很差。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:bot',
|
||||
order: 0,
|
||||
title: 'AI 模型',
|
||||
},
|
||||
name: 'ModelCenter',
|
||||
path: '/model',
|
||||
children: [
|
||||
{
|
||||
name: 'ModelCatalog',
|
||||
path: 'catalog',
|
||||
component: () => import('#/views/console/model-catalog.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:layers',
|
||||
title: '模型清单',
|
||||
authority: ['model:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ModelPlan',
|
||||
path: 'plan',
|
||||
component: () => import('#/views/console/model-plan.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:git-branch',
|
||||
title: '角色编排',
|
||||
authority: ['model:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CallStats',
|
||||
path: 'stats',
|
||||
component: () => import('#/views/console/call-stats.vue'),
|
||||
meta: {
|
||||
// 首页,标签栏里固定住不让关
|
||||
affixTab: true,
|
||||
icon: 'lucide:bar-chart-3',
|
||||
title: '调用统计',
|
||||
authority: ['stats:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:monitor-smartphone',
|
||||
order: 1,
|
||||
title: '桌面端',
|
||||
},
|
||||
name: 'DesktopCenter',
|
||||
path: '/desktop',
|
||||
children: [
|
||||
{
|
||||
name: 'DesktopConfig',
|
||||
path: 'config',
|
||||
component: () => import('#/views/console/desktop-config.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:sliders-horizontal',
|
||||
title: '客户端配置',
|
||||
// 只读用户也该能看这份配置——排查问题时"线上到底配的什么"是第一个
|
||||
// 要回答的问题。改动才需要 config:write。
|
||||
authority: ['config:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AppRelease',
|
||||
path: 'release',
|
||||
component: () => import('#/views/console/release.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:package-check',
|
||||
title: '版本升级',
|
||||
authority: ['config:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:messages-square',
|
||||
order: 2,
|
||||
title: '聊天归档',
|
||||
},
|
||||
name: 'ArchiveCenter',
|
||||
path: '/archive',
|
||||
children: [
|
||||
{
|
||||
name: 'ArchiveOverview',
|
||||
path: 'overview',
|
||||
component: () => import('#/views/archive/overview.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:gauge',
|
||||
title: '归档概览',
|
||||
authority: ['im:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveConversations',
|
||||
path: 'conversations',
|
||||
component: () => import('#/views/archive/conversations.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:message-circle-more',
|
||||
title: '会话与消息',
|
||||
authority: ['im:content:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveExports',
|
||||
path: 'exports',
|
||||
component: () => import('#/views/archive/exports.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:file-down',
|
||||
title: '导出中心',
|
||||
authority: ['im:export'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchivePeople',
|
||||
path: 'people',
|
||||
component: () => import('#/views/archive/people.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:contact-round',
|
||||
title: '人员唯一标识',
|
||||
authority: ['im:identity:write'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveStorage',
|
||||
path: 'storage',
|
||||
component: () => import('#/views/archive/storage.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:cloud-cog',
|
||||
title: 'COS 存储',
|
||||
authority: ['im:storage:write'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:shield',
|
||||
order: 3,
|
||||
title: '系统管理',
|
||||
},
|
||||
name: 'SystemCenter',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
name: 'RoleManage',
|
||||
path: 'roles',
|
||||
component: () => import('#/views/console/roles.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:key-round',
|
||||
title: '角色权限',
|
||||
authority: ['user:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'UserManage',
|
||||
path: 'users',
|
||||
component: () => import('#/views/console/users.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:users',
|
||||
title: '用户管理',
|
||||
authority: ['user:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AuditLog',
|
||||
path: 'audit',
|
||||
component: () => import('#/views/console/audit.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:scroll-text',
|
||||
title: '审计日志',
|
||||
authority: ['audit:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: { hideInMenu: true, title: '个人设置' },
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
children: [
|
||||
{
|
||||
name: 'ChangePassword',
|
||||
path: 'password',
|
||||
component: () => import('#/views/console/change-password.vue'),
|
||||
meta: { hideInMenu: true, title: '修改密码' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Recordable, UserInfo } from '@vben/types';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
|
||||
|
||||
import { notification } from 'ant-design-vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessStore = useAccessStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const loginLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
* @param params 登录表单数据
|
||||
*/
|
||||
async function authLogin(
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { accessToken } = await loginApi(params);
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
accessStore.setAccessToken(accessToken);
|
||||
|
||||
// 获取用户信息并存储到 accessStore 中
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
getAccessCodesApi(),
|
||||
]);
|
||||
|
||||
userInfo = fetchUserInfoResult;
|
||||
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.realName) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
async function logout(redirect: boolean = true) {
|
||||
try {
|
||||
await logoutApi();
|
||||
} catch {
|
||||
// 不做任何处理
|
||||
}
|
||||
resetAllStores();
|
||||
accessStore.setLoginExpired(false);
|
||||
|
||||
// 回登录页带上当前路由地址
|
||||
await router.replace({
|
||||
path: LOGIN_PATH,
|
||||
query: redirect
|
||||
? {
|
||||
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
|
||||
}
|
||||
: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
const userInfo = await getUserInfoApi();
|
||||
userStore.setUserInfo(userInfo);
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
$reset,
|
||||
authLogin,
|
||||
fetchUserInfo,
|
||||
loginLoading,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { About } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'About' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<About />
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: 'example@example.com',
|
||||
},
|
||||
fieldName: 'email',
|
||||
label: $t('authentication.email'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.emailTip') })
|
||||
.email($t('authentication.emailValidErrorTip')),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationForgetPassword
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 模板自带的演示账号下拉、滑块验证码、手机号/扫码/第三方登录、注册和找回密码
|
||||
// 都删掉了——后端没有对应的接口,留着只会让人点进死路。滑块验证码尤其要注意:
|
||||
// 它是纯前端的,挡不住脚本,只挡真人。真要防爆破得在服务端做失败计数。
|
||||
const formSchema = computed((): VbenFormSchema[] => [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '用户名' },
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
rules: z.string().min(1, { message: '请输入用户名' }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: { placeholder: '密码' },
|
||||
fieldName: 'password',
|
||||
label: '密码',
|
||||
rules: z.string().min(1, { message: '请输入密码' }),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
:show-code-login="false"
|
||||
:show-forget-password="false"
|
||||
:show-qrcode-login="false"
|
||||
:show-register="false"
|
||||
:show-third-party-login="false"
|
||||
sub-title="请使用管理员分配的账号登录"
|
||||
title="真羊 AI 客服 · 管理后台"
|
||||
@submit="authStore.authLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
import { AuthenticationRegister, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'Register' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('authentication.passwordStrength'),
|
||||
};
|
||||
},
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string({ error: $t('authentication.passwordTip') })
|
||||
.min(1, { message: $t('authentication.passwordTip') })
|
||||
.refine((value) => value === password, {
|
||||
message: $t('authentication.confirmPasswordTip'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['password'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: $t('authentication.confirmPassword'),
|
||||
},
|
||||
{
|
||||
component: 'VbenCheckbox',
|
||||
fieldName: 'agreePolicy',
|
||||
renderComponentContent: () => ({
|
||||
default: () =>
|
||||
h('span', [
|
||||
$t('authentication.agree'),
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'vben-link ml-1 ',
|
||||
href: '',
|
||||
},
|
||||
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
|
||||
),
|
||||
]),
|
||||
}),
|
||||
rules: z.boolean().refine((value) => !!value, {
|
||||
message: $t('authentication.agreeTip'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationRegister
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Profile } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
import ProfileSecuritySetting from './security-setting.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '基本设置',
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
v-model:model-value="tabsValue"
|
||||
title="个人中心"
|
||||
:user-info="userStore.userInfo"
|
||||
:tabs="tabs"
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileNotificationSetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '其他用户的消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'systemMessage',
|
||||
label: '系统消息',
|
||||
description: '系统消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'todoTask',
|
||||
label: '待办任务',
|
||||
description: '待办任务将以站内信的形式通知',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileNotificationSetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请再次输入新密码',
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
});
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
message.success('密码修改成功');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ProfilePasswordSetting
|
||||
class="w-1/3"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileSecuritySetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '当前密码强度:强',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityPhone',
|
||||
label: '密保手机',
|
||||
description: '已绑定手机:138****8293',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityQuestion',
|
||||
label: '密保问题',
|
||||
description: '未设置密保问题,密保问题可有效保护账户安全',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityEmail',
|
||||
label: '备用邮箱',
|
||||
description: '已绑定邮箱:ant***sign.com',
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
fieldName: 'securityMfa',
|
||||
label: 'MFA 设备',
|
||||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileSecuritySetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveConversation, ArchiveMessage } from '#/api/archive';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Empty,
|
||||
Image as AImage,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
fetchArchiveConversations,
|
||||
fetchArchiveMediaAccessUrls,
|
||||
fetchArchiveMessages,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const messageLoading = ref(false);
|
||||
const conversations = ref<ArchiveConversation[]>([]);
|
||||
const nextCursor = ref('');
|
||||
const hasMore = ref(false);
|
||||
const selected = ref<ArchiveConversation | null>(null);
|
||||
const messages = ref<ArchiveMessage[]>([]);
|
||||
const messageCursor = ref('');
|
||||
const messageHasMore = ref(false);
|
||||
const mediaUrls = ref<Record<string, string>>({});
|
||||
|
||||
function formatSize(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '未知大小';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value || '-';
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
minute: '2-digit',
|
||||
month: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`;
|
||||
}
|
||||
|
||||
function isBrowserAudio(mimeType: string) {
|
||||
return !['audio/amr', 'audio/silk'].includes(String(mimeType).toLowerCase());
|
||||
}
|
||||
|
||||
function attachmentStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
source_not_cached: '源文件未缓存,请先在企业微信中下载',
|
||||
upload_failed: '上传失败,下次启动将自动重试',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
async function loadMediaUrls(items: ArchiveMessage[]) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
items.flatMap((item) =>
|
||||
(item.attachments || [])
|
||||
.filter((attachment) => attachment.status === 'ready')
|
||||
.map((attachment) => attachment.id),
|
||||
),
|
||||
),
|
||||
].filter((id) => !mediaUrls.value[id]);
|
||||
for (let index = 0; index < ids.length; index += 200) {
|
||||
try {
|
||||
const data = await fetchArchiveMediaAccessUrls(ids.slice(index, index + 200));
|
||||
const additions = Object.fromEntries(
|
||||
data.items.map((item) => [item.id, item.url]),
|
||||
);
|
||||
mediaUrls.value = { ...mediaUrls.value, ...additions };
|
||||
} catch {
|
||||
// 消息正文仍可正常查看;素材签名失败时保留状态提示,刷新后可重试。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchArchiveConversations(
|
||||
50,
|
||||
reset ? '' : nextCursor.value,
|
||||
);
|
||||
conversations.value = reset
|
||||
? data.items
|
||||
: [...conversations.value, ...data.items];
|
||||
nextCursor.value = data.next_cursor;
|
||||
hasMore.value = data.has_more;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(record: Record<string, any>) {
|
||||
selected.value = record as ArchiveConversation;
|
||||
messages.value = [];
|
||||
messageCursor.value = '';
|
||||
mediaUrls.value = {};
|
||||
await loadMessages(true);
|
||||
}
|
||||
|
||||
async function loadMessages(reset = false) {
|
||||
if (!selected.value) return;
|
||||
messageLoading.value = true;
|
||||
try {
|
||||
const data = await fetchArchiveMessages(
|
||||
selected.value.id,
|
||||
100,
|
||||
reset ? '' : messageCursor.value,
|
||||
);
|
||||
messages.value = reset ? data.items : [...messages.value, ...data.items];
|
||||
messageCursor.value = data.next_cursor;
|
||||
messageHasMore.value = data.has_more;
|
||||
await loadMediaUrls(data.items);
|
||||
} finally {
|
||||
messageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
application: '应用会话',
|
||||
direct_wechat: '微信单聊',
|
||||
direct_wecom: '企微单聊',
|
||||
group: '群聊',
|
||||
service: '客服会话',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
onMounted(() => load(true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="会话与消息">
|
||||
<template #extra><Button :loading="loading" @click="load(true)">刷新</Button></template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
使用时间 + 唯一 ID 游标翻页,数据增长到百万级时不会因深分页越来越慢。
|
||||
</p>
|
||||
<Table
|
||||
:data-source="conversations"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:columns="[
|
||||
{ title: '会话', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'conversation_type', key: 'conversation_type', width: 120 },
|
||||
{ title: '归档账号', dataIndex: 'source_account', key: 'source_account', width: 150 },
|
||||
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: '最后消息时间', dataIndex: 'last_message_at', key: 'last_message_at', width: 210 },
|
||||
{ title: '操作', key: 'action', width: 90 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="font-medium">{{ record.name }}</div>
|
||||
<div class="max-w-[420px] truncate text-xs text-gray-400">{{ record.last_content }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'conversation_type'">
|
||||
<Tag>{{ TYPE_LABEL[record.conversation_type] || record.conversation_type }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_message_at'">
|
||||
{{ formatDateTime(record.last_message_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="openConversation(record)">查看</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<div v-if="hasMore" class="mt-4 text-center">
|
||||
<Button :loading="loading" @click="load(false)">加载更多会话</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="!!selected"
|
||||
:title="selected?.name"
|
||||
width="860"
|
||||
@close="selected = null"
|
||||
>
|
||||
<Empty v-if="!messageLoading && !messages.length" description="暂无消息" />
|
||||
<div v-for="item in messages" :key="item.id" class="mb-3 border-b border-gray-100 pb-3">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
<strong class="mr-2 text-gray-600">{{ item.sender_name || '未知发送者' }}</strong>
|
||||
{{ item.message_type }}
|
||||
<Tag v-if="item.attachment_count" class="ml-2">{{ item.attachment_count }} 个素材</Tag>
|
||||
</span>
|
||||
<span>{{ formatDateTime(item.sent_at) }}</span>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap break-words text-sm">{{ item.content || '(非文本消息)' }}</div>
|
||||
<div v-if="item.attachments?.length" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="attachment in item.attachments"
|
||||
:key="attachment.id"
|
||||
class="rounded-md border border-gray-200 bg-gray-50 p-3"
|
||||
>
|
||||
<AImage
|
||||
v-if="attachment.media_type === 'image' && mediaUrls[attachment.id]"
|
||||
:src="mediaUrls[attachment.id]"
|
||||
:alt="attachment.original_filename"
|
||||
:preview="true"
|
||||
:width="260"
|
||||
/>
|
||||
<video
|
||||
v-else-if="attachment.media_type === 'video' && mediaUrls[attachment.id]"
|
||||
class="max-h-[420px] max-w-full rounded bg-black"
|
||||
controls
|
||||
preload="metadata"
|
||||
:src="mediaUrls[attachment.id]"
|
||||
></video>
|
||||
<audio
|
||||
v-else-if="attachment.media_type === 'audio' && isBrowserAudio(attachment.mime_type) && mediaUrls[attachment.id]"
|
||||
class="w-full"
|
||||
controls
|
||||
preload="metadata"
|
||||
:src="mediaUrls[attachment.id]"
|
||||
></audio>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<Tag>{{ attachment.media_type }}</Tag>
|
||||
<span class="max-w-[460px] truncate" :title="attachment.original_filename">
|
||||
{{ attachment.original_filename || '未命名素材' }}
|
||||
</span>
|
||||
<span>{{ formatSize(attachment.size_bytes) }}</span>
|
||||
<Tag v-if="attachment.status !== 'ready'" color="warning">
|
||||
{{ attachmentStatusLabel(attachment.status) }}
|
||||
</Tag>
|
||||
<a
|
||||
v-if="mediaUrls[attachment.id]"
|
||||
:href="mediaUrls[attachment.id]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ attachment.media_type === 'file' || !isBrowserAudio(attachment.mime_type) ? '下载附件' : '查看原文件' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="messageHasMore" class="mt-3 text-center">
|
||||
<Button :loading="messageLoading" @click="loadMessages(false)">加载更早消息</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveExportJob } from '#/api/archive';
|
||||
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
|
||||
import { downloadFileFromBlob } from '@vben/utils';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Progress,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createArchiveExport,
|
||||
downloadArchiveExport,
|
||||
fetchArchiveExports,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const downloading = ref('');
|
||||
const jobs = ref<ArchiveExportJob[]>([]);
|
||||
const form = reactive({ date_from: '', date_to: '', formats: ['sql', 'xlsx'] });
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function setDate(field: 'date_from' | 'date_to', value: unknown) {
|
||||
form[field] = typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
jobs.value = (await fetchArchiveExports()).jobs;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
if (!form.formats.length) {
|
||||
message.warning('至少选择一种导出格式');
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
await createArchiveExport({
|
||||
formats: form.formats,
|
||||
filters: {
|
||||
date_from: form.date_from || undefined,
|
||||
date_to: form.date_to || undefined,
|
||||
},
|
||||
});
|
||||
message.success('导出任务已创建');
|
||||
await load();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(file: { file_name: string; id: string }) {
|
||||
downloading.value = file.id;
|
||||
try {
|
||||
const blob = await downloadArchiveExport(file.id);
|
||||
downloadFileFromBlob({ fileName: file.file_name, source: blob });
|
||||
} finally {
|
||||
downloading.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
completed: 'green', failed: 'red', queued: 'default', running: 'blue',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
completed: '已完成', failed: '失败', queued: '等待中', running: '处理中',
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
timer = setInterval(() => {
|
||||
if (jobs.value.some((item) => ['queued', 'running'].includes(item.status))) load();
|
||||
}, 3000);
|
||||
});
|
||||
onUnmounted(() => timer && clearInterval(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="新建导出" class="mb-4">
|
||||
<Form layout="inline">
|
||||
<Form.Item label="格式">
|
||||
<Checkbox.Group v-model:value="form.formats" :options="[
|
||||
{ label: 'SQL', value: 'sql' },
|
||||
{ label: 'Excel', value: 'xlsx' },
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="开始日期">
|
||||
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_from', $event)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="结束日期">
|
||||
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_to', $event)" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" :loading="creating" @click="createJob">开始导出</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p class="mb-0 mt-3 text-xs text-gray-500">
|
||||
导出按创建时的截止水位生成;SQL/CSV 流式写出,Excel 超过 90 万行自动拆分工作表。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="导出任务">
|
||||
<template #extra><Button :loading="loading" @click="load">刷新</Button></template>
|
||||
<Table
|
||||
:data-source="jobs" row-key="id" size="small" :loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 205 },
|
||||
{ title: '格式', dataIndex: 'formats', key: 'formats', width: 160 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 110 },
|
||||
{ title: '进度/行数', key: 'progress', width: 180 },
|
||||
{ title: '文件', key: 'files' },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'formats'">
|
||||
<Tag v-for="format in record.formats" :key="format">{{ format.toUpperCase() }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="STATUS_COLOR[record.status]">{{ STATUS_LABEL[record.status] }}</Tag>
|
||||
<div v-if="record.error_message" class="mt-1 text-xs text-red-500">{{ record.error_message }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<Progress v-if="record.status === 'running'" :percent="record.progress" size="small" />
|
||||
<span v-else>{{ record.total_rows || 0 }} 行</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'files'">
|
||||
<Space wrap>
|
||||
<Button
|
||||
v-for="file in record.files" :key="file.id" size="small"
|
||||
:loading="downloading === file.id" @click="download(file)"
|
||||
>{{ file.file_name }}</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveStats } from '#/api/archive';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Alert, Button, Card, Col, Row, Statistic } from 'ant-design-vue';
|
||||
|
||||
import { fetchArchiveStats } from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const stats = ref<ArchiveStats | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await fetchArchiveStats();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold">聊天归档</h2>
|
||||
<p class="mb-0 mt-1 text-sm text-gray-500">
|
||||
企业微信消息、人员、会话与 COS 素材的统一数据视图
|
||||
</p>
|
||||
</div>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="素材本体不进入数据库"
|
||||
description="图片、语音、视频和文件保存在腾讯云 COS;数据库只保存对象地址、版本、SHA-256、CRC64 与校验状态。"
|
||||
/>
|
||||
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="归档消息" :value="stats?.messages ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="会话" :value="stats?.conversations ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="唯一人员" :value="stats?.people ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="COS 素材" :value="stats?.media ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Card title="素材完整性" size="small" :loading="loading">
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">已校验</span>
|
||||
<strong class="text-green-600">{{ stats?.media_ready ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">校验失败</span>
|
||||
<strong :class="(stats?.media_failed ?? 0) ? 'text-red-600' : ''">
|
||||
{{ stats?.media_failed ?? 0 }}
|
||||
</strong>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card title="处理任务" size="small" :loading="loading">
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">导入批次</span><strong>{{ stats?.imports ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">导出任务</span><strong>{{ stats?.exports ?? 0 }}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card title="最新水位" size="small" :loading="loading">
|
||||
<p class="mb-1 text-gray-500">最后一条消息时间(UTC)</p>
|
||||
<strong>{{ stats?.last_message_at || '尚未导入' }}</strong>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchivePerson, ArchivePersonDetail } from '#/api/archive';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
bindArchiveIdentity,
|
||||
fetchArchivePeople,
|
||||
fetchArchivePerson,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const binding = ref(false);
|
||||
const keyword = ref('');
|
||||
const people = ref<ArchivePerson[]>([]);
|
||||
const selected = ref<ArchivePersonDetail | null>(null);
|
||||
const identity = reactive({
|
||||
external_id: '',
|
||||
identity_type: 'wecom_userid',
|
||||
scope_id: '',
|
||||
verified: true,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
people.value = (await fetchArchivePeople(200, keyword.value)).items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPerson(record: Record<string, any>) {
|
||||
selected.value = (await fetchArchivePerson(record.id)).person;
|
||||
identity.external_id = '';
|
||||
}
|
||||
|
||||
async function bind() {
|
||||
if (!selected.value || !identity.external_id.trim()) {
|
||||
message.warning('请输入企业微信人员 ID');
|
||||
return;
|
||||
}
|
||||
binding.value = true;
|
||||
try {
|
||||
selected.value = (
|
||||
await bindArchiveIdentity(selected.value.id, identity)
|
||||
).person;
|
||||
identity.external_id = '';
|
||||
message.success('人员标识已绑定');
|
||||
await load();
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="人员唯一标识">
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Input.Search
|
||||
v-model:value="keyword" allow-clear placeholder="姓名"
|
||||
:loading="loading" @search="load"
|
||||
/>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
同一个人可绑定本地 UID、企业微信 userid、微信 external_userid 等多个身份;唯一键由“身份类型 + 企业范围 + 外部 ID”组成。
|
||||
</p>
|
||||
<Table
|
||||
:data-source="people" row-key="id" size="small" :loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '显示名称', dataIndex: 'display_name', key: 'display_name' },
|
||||
{ title: '真实姓名', dataIndex: 'real_name', key: 'real_name' },
|
||||
{ title: '已绑定身份', dataIndex: 'identity_count', key: 'identity_count', width: 110 },
|
||||
{ title: '会话数', dataIndex: 'conversation_count', key: 'conversation_count', width: 90 },
|
||||
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="openPerson(record)">管理标识</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="!!selected" :title="`人员标识 · ${selected?.display_name || ''}`"
|
||||
width="680" @close="selected = null"
|
||||
>
|
||||
<Table
|
||||
class="mb-5" :data-source="selected?.identities || []" row-key="id"
|
||||
size="small" :pagination="false"
|
||||
:columns="[
|
||||
{ title: '类型', dataIndex: 'identity_type', key: 'identity_type', width: 140 },
|
||||
{ title: '企业/范围', dataIndex: 'scope_id', key: 'scope_id', width: 140 },
|
||||
{ title: '外部 ID', dataIndex: 'external_id', key: 'external_id' },
|
||||
{ title: '状态', dataIndex: 'verified', key: 'verified', width: 80 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'verified'">
|
||||
<Tag :color="record.verified ? 'green' : 'default'">
|
||||
{{ record.verified ? '已确认' : '未确认' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Card title="绑定新标识" size="small">
|
||||
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 17 }">
|
||||
<Form.Item label="身份类型">
|
||||
<Select v-model:value="identity.identity_type" :options="[
|
||||
{ label: '企业微信 userid', value: 'wecom_userid' },
|
||||
{ label: '微信 external_userid', value: 'wecom_external_userid' },
|
||||
{ label: '本地数据库 UID', value: 'wecom_local_uid' },
|
||||
{ label: '企微 open_userid', value: 'wecom_open_userid' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="企业/范围 ID">
|
||||
<Input v-model:value="identity.scope_id" placeholder="建议填写 corp_id;本地 UID 可填账号 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item label="外部人员 ID" required>
|
||||
<Input v-model:value="identity.external_id" />
|
||||
</Form.Item>
|
||||
<Form.Item label="确认身份">
|
||||
<Switch v-model:checked="identity.verified" />
|
||||
</Form.Item>
|
||||
<Form.Item :wrapper-col="{ offset: 6, span: 17 }">
|
||||
<Button type="primary" :loading="binding" @click="bind">确认绑定</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveMedia } from '#/api/archive';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
fetchArchiveMedia,
|
||||
fetchArchiveStorage,
|
||||
saveArchiveStorage,
|
||||
testArchiveStorage,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
const mediaItems = ref<ArchiveMedia[]>([]);
|
||||
const form = reactive({
|
||||
bucket: '',
|
||||
custom_domain: '',
|
||||
enabled: false,
|
||||
encryption_mode: 'AES256',
|
||||
export_prefix: 'archive/exports',
|
||||
media_prefix: 'archive/media',
|
||||
region: '',
|
||||
secret_id: '',
|
||||
secret_id_masked: '',
|
||||
secret_key: '',
|
||||
secret_key_masked: '',
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [storage, media] = await Promise.all([
|
||||
fetchArchiveStorage(),
|
||||
fetchArchiveMedia(100),
|
||||
]);
|
||||
Object.assign(form, storage.storage, { secret_id: '', secret_key: '' });
|
||||
mediaItems.value = media.items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await saveArchiveStorage({
|
||||
bucket: form.bucket,
|
||||
custom_domain: form.custom_domain,
|
||||
enabled: form.enabled,
|
||||
encryption_mode: form.encryption_mode,
|
||||
export_prefix: form.export_prefix,
|
||||
media_prefix: form.media_prefix,
|
||||
region: form.region,
|
||||
secret_id: form.secret_id,
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
Object.assign(form, data.storage, { secret_id: '', secret_key: '' });
|
||||
message.success('COS 配置已保存');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = (await testArchiveStorage()).result;
|
||||
message.success(`连接成功:${result.bucket} / ${result.region}`);
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sizeText(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4" type="warning" show-icon
|
||||
message="请使用最小权限的独立 COS 子账号密钥"
|
||||
description="SecretId / SecretKey 只写入后台并加密保存,页面不会回显明文。留空表示保持原密钥;更换截图中曾暴露过的密钥后再启用。"
|
||||
/>
|
||||
|
||||
<Card title="腾讯云 COS" class="mb-4" :loading="loading">
|
||||
<Form :label-col="{ span: 5 }" :wrapper-col="{ span: 15 }">
|
||||
<Form.Item label="启用存储"><Switch v-model:checked="form.enabled" /></Form.Item>
|
||||
<Form.Item label="Bucket">
|
||||
<Input v-model:value="form.bucket" placeholder="bucket-appid" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Region">
|
||||
<Input v-model:value="form.region" placeholder="ap-guangzhou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretId">
|
||||
<Input.Password v-model:value="form.secret_id" :placeholder="form.secret_id_masked || '请输入 SecretId'" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretKey">
|
||||
<Input.Password v-model:value="form.secret_key" :placeholder="form.secret_key_masked || '请输入 SecretKey'" />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材路径前缀">
|
||||
<Input v-model:value="form.media_prefix" />
|
||||
</Form.Item>
|
||||
<Form.Item label="导出路径前缀">
|
||||
<Input v-model:value="form.export_prefix" />
|
||||
</Form.Item>
|
||||
<Form.Item label="服务端加密">
|
||||
<Select v-model:value="form.encryption_mode" :options="[
|
||||
{ label: 'SSE-COS(AES256)', value: 'AES256' },
|
||||
{ label: 'SSE-KMS', value: 'cos/kms' },
|
||||
{ label: '不指定', value: '' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义域名">
|
||||
<Input v-model:value="form.custom_domain" placeholder="可选,仅允许完整 HTTPS 域名" />
|
||||
</Form.Item>
|
||||
<Form.Item :wrapper-col="{ offset: 5, span: 15 }">
|
||||
<Space>
|
||||
<Button type="primary" :loading="saving" @click="save">保存配置</Button>
|
||||
<Button :loading="testing" :disabled="!form.enabled" @click="testConnection">测试连接</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="最近素材对象">
|
||||
<Table
|
||||
:data-source="mediaItems" row-key="id" size="small" :loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '文件', dataIndex: 'original_filename', key: 'original_filename' },
|
||||
{ title: '类型', dataIndex: 'media_type', key: 'media_type', width: 90 },
|
||||
{ title: '大小', dataIndex: 'size_bytes', key: 'size_bytes', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: 'COS ObjectKey', dataIndex: 'object_key', key: 'object_key' },
|
||||
{ title: '校验时间', dataIndex: 'verified_at', key: 'verified_at', width: 200 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'size_bytes'">{{ sizeText(record.size_bytes) }}</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="record.status === 'ready' ? 'green' : record.status === 'failed' ? 'red' : 'orange'">
|
||||
{{ record.status }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'object_key'">
|
||||
<span class="break-all font-mono text-xs">{{ record.object_key }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Card, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { fetchAudit } from '#/api/console';
|
||||
|
||||
const loading = ref(false);
|
||||
const entries = ref<any[]>([]);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
entries.value = (await fetchAudit(200)).entries;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
'login.failed': 'red',
|
||||
'role.delete': 'red',
|
||||
'model.provider.delete': 'red',
|
||||
'role.save': 'orange',
|
||||
'model.roles.save': 'orange',
|
||||
'model.provider.save': 'blue',
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="审计日志">
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
谁在什么时候改了什么——出事之后唯一能回溯的东西。只显示最近 200 条。
|
||||
</p>
|
||||
<Table
|
||||
:data-source="entries" :loading="loading" row-key="id"
|
||||
size="small" :pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '操作人', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', key: 'action', width: 200 },
|
||||
{ title: '详情', dataIndex: 'detail', key: 'detail' },
|
||||
{ title: '来源 IP', dataIndex: 'ip_address', key: 'ip_address', width: 140 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Tag :color="TONE[record.action] || 'default'">{{ record.action }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.username || '(未登录)' }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import type { CallStats, ModelCallLogItem } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Progress,
|
||||
Row,
|
||||
Segmented,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { fetchCallLog, fetchCallStats } from '#/api/console';
|
||||
|
||||
const days = ref(7);
|
||||
const loading = ref(false);
|
||||
const stats = ref<CallStats | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await fetchCallStats(days.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 调用记录:定位某一句具体是怎么被回复的 ──────────────────────────────
|
||||
const logKeyword = ref('');
|
||||
const logLoading = ref(false);
|
||||
const logItems = ref<ModelCallLogItem[]>([]);
|
||||
const logTotal = ref(0);
|
||||
const logPage = ref(1);
|
||||
const logPageSize = ref(20);
|
||||
const detailItem = ref<ModelCallLogItem | null>(null);
|
||||
/**
|
||||
* chat = 回客户的话;guard = 界面识别之类的内部判断;'' = 全都要。
|
||||
*
|
||||
* 默认只看 chat:界面守卫每轮轮询都要问一次模型,条数是真实对话的几十倍,
|
||||
* 混在一起这张表根本没法用。但内部调用同样在花钱,所以留了入口能翻出来看。
|
||||
*/
|
||||
const logPurpose = ref('chat');
|
||||
|
||||
async function loadLog() {
|
||||
logLoading.value = true;
|
||||
try {
|
||||
const resp = await fetchCallLog({
|
||||
days: days.value,
|
||||
limit: logPageSize.value,
|
||||
offset: (logPage.value - 1) * logPageSize.value,
|
||||
q: logKeyword.value.trim(),
|
||||
purpose: logPurpose.value,
|
||||
});
|
||||
logItems.value = resp.items;
|
||||
logTotal.value = resp.total;
|
||||
} finally {
|
||||
logLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function searchLog() {
|
||||
logPage.value = 1;
|
||||
loadLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间统一显示成「MM-DD HH:mm:ss」。
|
||||
*
|
||||
* 库里存过两种格式:网关早期写的是 `2026-08-24 14:40:44`,后台写的是带时区的
|
||||
* ISO。同一次调用在表格里长得不一样,看着像两件事。新数据已经统一,这里负责
|
||||
* 让历史数据也能正常显示,解析不了就原样输出,不能显示成 Invalid Date。
|
||||
*/
|
||||
function formatTime(raw: string): string {
|
||||
const value = String(raw || '').trim();
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
|
||||
}
|
||||
|
||||
watch(logPurpose, () => {
|
||||
logPage.value = 1;
|
||||
loadLog();
|
||||
});
|
||||
|
||||
function logTableChange(pagination: { current?: number; pageSize?: number }) {
|
||||
logPage.value = pagination.current ?? 1;
|
||||
logPageSize.value = pagination.pageSize ?? 20;
|
||||
loadLog();
|
||||
}
|
||||
|
||||
watch(days, () => {
|
||||
load();
|
||||
logPage.value = 1;
|
||||
loadLog();
|
||||
});
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadLog();
|
||||
});
|
||||
|
||||
/** 各出口被选中的次数占比——回答"第二个模型值不值那一倍成本"。 */
|
||||
const chosenRows = computed(() => {
|
||||
const total = stats.value?.chosen.reduce((sum, row) => sum + row.count, 0) ?? 0;
|
||||
return (stats.value?.chosen ?? []).map((row) => ({
|
||||
...row,
|
||||
percent: total ? Math.round((row.count / total) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const riskRows = computed(() => {
|
||||
const risk = stats.value?.risk ?? {};
|
||||
const total = Object.values(risk).reduce((sum, n) => sum + n, 0);
|
||||
return Object.entries(risk).map(([level, count]) => ({
|
||||
level,
|
||||
count,
|
||||
percent: total ? Math.round((count / total) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const maxBucket = computed(() =>
|
||||
Math.max(1, ...(stats.value?.score_buckets ?? []).map((b) => b.count)),
|
||||
);
|
||||
|
||||
const RISK_COLOR: Record<string, string> = {
|
||||
low: 'green', medium: 'orange', high: 'red', unknown: 'default',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="这张表是决定要不要上双模型的唯一依据"
|
||||
description="分数分布告诉你现有回复的真实水平——如果大部分本来就是高分,并发问两个模型就是纯浪费;如果低分集中在某一类消息上,针对性换个模型比全量双跑划算得多。"
|
||||
/>
|
||||
|
||||
<div class="mb-4">
|
||||
<Segmented
|
||||
v-model:value="days"
|
||||
:options="[{ value: 1, label: '今天' }, { value: 7, label: '近 7 天' }, { value: 30, label: '近 30 天' }]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<Card size="small"><Statistic title="总调用" :value="stats?.total ?? 0" /></Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small"><Statistic title="已评审" :value="stats?.judged ?? 0" /></Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small">
|
||||
<Statistic title="平均分" :value="stats?.avg_score ?? 0" :precision="3" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small">
|
||||
<Statistic title="平均耗时" :value="stats?.avg_ms ?? 0" suffix="ms" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Card title="裁判分数分布" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!stats?.score_buckets?.length" description="还没有评审数据" />
|
||||
<div v-for="bucket in stats?.score_buckets ?? []" :key="bucket.range" class="mb-2">
|
||||
<div class="mb-1 flex justify-between text-xs">
|
||||
<span>{{ bucket.range }}</span>
|
||||
<span>{{ bucket.count }} 条</span>
|
||||
</div>
|
||||
<Progress
|
||||
:percent="Math.round((bucket.count / maxBucket) * 100)"
|
||||
:show-info="false" size="small"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="12">
|
||||
<Card title="风险等级" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!riskRows.length" description="还没有评审数据" />
|
||||
<div v-for="row in riskRows" :key="row.level" class="mb-2 flex items-center gap-3">
|
||||
<Tag :color="RISK_COLOR[row.level]" class="w-16 text-center">{{ row.level }}</Tag>
|
||||
<Progress :percent="row.percent" size="small" class="flex-1" />
|
||||
<span class="w-16 text-right text-xs">{{ row.count }} 条</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="各模型被选中的比例" size="small" :loading="loading">
|
||||
<Table
|
||||
:data-source="chosenRows" row-key="provider" size="small" :pagination="false"
|
||||
:columns="[
|
||||
{ title: '模型', dataIndex: 'provider', key: 'provider' },
|
||||
{ title: '被选中', dataIndex: 'count', key: 'count', width: 100 },
|
||||
{ title: '占比', key: 'percent', width: 240 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'percent'">
|
||||
<Progress :percent="record.percent" size="small" />
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
统计区间自 {{ stats?.since || '—' }} 起。最慢一次 {{ stats?.max_ms ?? 0 }}ms。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="调用记录" size="small" class="mt-4">
|
||||
<template #extra>
|
||||
<Input.Search
|
||||
v-model:value="logKeyword"
|
||||
placeholder="搜客户消息或模型回复里的关键词"
|
||||
style="width: 280px"
|
||||
allow-clear
|
||||
@search="searchLog"
|
||||
/>
|
||||
</template>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
按时间倒序,一条一次调用:客户说了什么、模型候选都答了什么、最终选中的是哪个、有没有被审核规则拦下——出问题时按这张表往回查。
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<Segmented
|
||||
v-model:value="logPurpose"
|
||||
:options="[
|
||||
{ value: 'chat', label: '客服对话' },
|
||||
{ value: 'guard', label: '界面识别' },
|
||||
{ value: '', label: '全部' },
|
||||
]"
|
||||
/>
|
||||
<span class="ml-3 text-xs text-gray-500">
|
||||
「界面识别」是机器人自己看企业微信窗口用的,不是发给客户的话;它每轮轮询都要问一次模型,条数远多于真实对话,所以默认不混在一起。
|
||||
</span>
|
||||
</div>
|
||||
<Table
|
||||
:data-source="logItems"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:loading="logLoading"
|
||||
:pagination="{
|
||||
current: logPage,
|
||||
pageSize: logPageSize,
|
||||
total: logTotal,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
}"
|
||||
@change="logTableChange"
|
||||
:columns="[
|
||||
{ title: '时间', key: 'created_at', width: 130 },
|
||||
{ title: '客户消息', key: 'customer_text', width: 200, ellipsis: true },
|
||||
{ title: '模型回复', key: 'reply_text', width: 200, ellipsis: true },
|
||||
{ title: '选中', dataIndex: 'chosen', key: 'chosen', width: 110, ellipsis: true },
|
||||
{ title: '裁判分', dataIndex: 'judge_score', key: 'judge_score', width: 80 },
|
||||
{ title: '风险', dataIndex: 'judge_risk', key: 'judge_risk', width: 80 },
|
||||
{ title: '审核', key: 'review_reason', width: 90 },
|
||||
{ title: '耗时', dataIndex: 'total_ms', key: 'total_ms', width: 80 },
|
||||
{ title: '', key: 'action', width: 70 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
<span :title="record.created_at">{{ formatTime(record.created_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'customer_text'">
|
||||
<span :title="record.customer_text">{{ record.customer_text || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reply_text'">
|
||||
<span :title="record.reply_text">{{ record.reply_text || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'judge_risk'">
|
||||
<Tag v-if="record.judge_risk" :color="RISK_COLOR[record.judge_risk]">{{ record.judge_risk }}</Tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'review_reason'">
|
||||
<Tag v-if="record.review_reason" color="orange">已拦</Tag>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total_ms'">
|
||||
{{ record.total_ms }}ms
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a @click="detailItem = record as ModelCallLogItem">详情</a>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
:open="!!detailItem"
|
||||
title="调用详情"
|
||||
:footer="null"
|
||||
width="640px"
|
||||
@cancel="detailItem = null"
|
||||
>
|
||||
<template v-if="detailItem">
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">客户消息</div>
|
||||
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.customer_text || '(空)' }}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">模型回复(最终选中)</div>
|
||||
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.reply_text || '(空)' }}</div>
|
||||
</div>
|
||||
<div v-if="detailItem.review_reason" class="mb-3">
|
||||
<Alert type="warning" show-icon :message="`已停发送审核:${detailItem.review_reason}`" />
|
||||
</div>
|
||||
<div v-if="detailItem.candidates?.length" class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">全部候选</div>
|
||||
<div
|
||||
v-for="(candidate, index) in detailItem.candidates"
|
||||
:key="index"
|
||||
class="mb-2 rounded border border-gray-200 p-2 text-sm dark:border-gray-700"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<Tag :color="candidate.provider === detailItem.chosen ? 'green' : 'default'">
|
||||
{{ candidate.provider }}{{ candidate.provider === detailItem.chosen ? '(选中)' : '' }}
|
||||
</Tag>
|
||||
<span class="text-xs text-gray-400">{{ candidate.latency_ms ?? 0 }}ms</span>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap">{{ candidate.text || candidate.error || '(空)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
裁判 {{ detailItem.judge_winner || '—' }}/{{ detailItem.judge_score }}分,共 {{ detailItem.total_ms }}ms,{{ formatTime(detailItem.created_at) }}
|
||||
</p>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Alert, Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { changeMyPassword } from '#/api/console';
|
||||
|
||||
const router = useRouter();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({ current_password: '', new_password: '', confirm: '' });
|
||||
|
||||
async function submit() {
|
||||
if (form.new_password !== form.confirm) {
|
||||
message.warning('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await changeMyPassword({
|
||||
current_password: form.current_password,
|
||||
new_password: form.new_password,
|
||||
});
|
||||
message.success('密码已修改');
|
||||
await router.push('/');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-xl p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="首次登录必须修改初始密码"
|
||||
description="初始密码是公开的默认值,不改等于没有密码。改完才能进入其他页面。"
|
||||
/>
|
||||
<Card title="修改密码">
|
||||
<Form :model="form" layout="vertical">
|
||||
<FormItem label="当前密码">
|
||||
<Input.Password v-model:value="form.current_password" />
|
||||
</FormItem>
|
||||
<FormItem label="新密码(至少 10 位,需同时含字母和数字)">
|
||||
<Input.Password v-model:value="form.new_password" />
|
||||
</FormItem>
|
||||
<FormItem label="确认新密码">
|
||||
<Input.Password v-model:value="form.confirm" />
|
||||
</FormItem>
|
||||
<Button type="primary" :loading="submitting" @click="submit">提交</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import type { DesktopConfig } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
DescriptionsItem,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { fetchConfig, saveConfig } from '#/api/console';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['config:write']));
|
||||
const canSeeModels = computed(() => hasAccessByCodes(['model:read']));
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const version = ref(0);
|
||||
const updatedAt = ref('');
|
||||
const updatedBy = ref('');
|
||||
const movedTo = ref('');
|
||||
const gatewayEffective = ref('');
|
||||
|
||||
/** MCP 服务器在后端是数组,编辑起来最直白的还是 JSON 文本框。 */
|
||||
const mcpText = ref('[]');
|
||||
const mcpError = ref('');
|
||||
|
||||
const form = reactive<DesktopConfig>({
|
||||
AI_AGENT_NAME: '',
|
||||
AI_GATEWAY_URL: '',
|
||||
AI_CONTEXT_ENABLED: true,
|
||||
AI_CONTEXT_MAX_ROUNDS: 8,
|
||||
AI_COUNTER_INSULT_ENABLED: false,
|
||||
AI_DEVELOPMENT_MODE: false,
|
||||
AI_ENABLED: true,
|
||||
AI_HOSPITAL_NAME: '',
|
||||
AI_MCP_ENABLED: false,
|
||||
AI_MCP_MAX_ROUNDS: 5,
|
||||
AI_MCP_SERVERS: [],
|
||||
AI_REVIEW_RULES: [],
|
||||
AI_UI_GUARD_ENABLED: true,
|
||||
AI_USE_VISION: true,
|
||||
});
|
||||
|
||||
const SWITCHES: { hint: string; key: keyof DesktopConfig; label: string }[] = [
|
||||
{ key: 'AI_ENABLED', label: '启用 AI 自动回复', hint: '总开关。关掉之后客户端只监听不回复。' },
|
||||
{ key: 'AI_USE_VISION', label: '启用视觉识别', hint: '图片和表情包走视觉模型识别,OCR 认不出时兜底。用哪个模型看「角色编排」里的媒体消息专用模型。' },
|
||||
{ key: 'AI_UI_GUARD_ENABLED', label: '人工操作保护', hint: '检测到有人在操作鼠标键盘时暂停自动化,避免抢焦点。' },
|
||||
{ key: 'AI_CONTEXT_ENABLED', label: '携带上下文', hint: '把最近几轮对话一起发给模型。关掉会省 token,但答复会失忆。' },
|
||||
{ key: 'AI_COUNTER_INSULT_ENABLED', label: '应对辱骂', hint: '客户情绪激动时用专门的话术回应,而不是照常答业务问题。' },
|
||||
{ key: 'AI_MCP_ENABLED', label: '启用 MCP 工具', hint: '允许模型调用挂号登记、客户查询等外部工具。工具在客户端本机执行。' },
|
||||
{ key: 'AI_DEVELOPMENT_MODE', label: '开发模式', hint: '打印详细日志、跳过部分节流。生产环境请关闭。' },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchConfig();
|
||||
Object.assign(form, data.config);
|
||||
mcpText.value = JSON.stringify(data.config.AI_MCP_SERVERS ?? [], null, 2);
|
||||
version.value = data.version;
|
||||
updatedAt.value = data.updated_at;
|
||||
updatedBy.value = data.updated_by;
|
||||
movedTo.value = data.model_settings_moved_to;
|
||||
gatewayEffective.value = data.gateway_url_effective;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addReviewRule() {
|
||||
form.AI_REVIEW_RULES.push({ label: '', keywords: [], enabled: true });
|
||||
}
|
||||
|
||||
function removeReviewRule(index: number) {
|
||||
form.AI_REVIEW_RULES.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前先在前端挡一遍空名称/空关键词——后端 `validate_review_rules` 一样会拦,
|
||||
* 但那要等一次网络往返才看到;跟 MCP 那份 JSON 预检查一个道理,能在本地发现
|
||||
* 的错误就不要留给服务器去发现。
|
||||
*/
|
||||
function reviewRulesError(): string {
|
||||
for (const [index, rule] of form.AI_REVIEW_RULES.entries()) {
|
||||
if (!rule.label.trim()) {
|
||||
return `第 ${index + 1} 条规则没有填名称`;
|
||||
}
|
||||
if (rule.keywords.length === 0) {
|
||||
return `规则「${rule.label}」至少要有一个关键词,否则永远不会命中`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseMcp(): null | unknown[] {
|
||||
try {
|
||||
const parsed = JSON.parse(mcpText.value || '[]');
|
||||
if (!Array.isArray(parsed)) {
|
||||
mcpError.value = 'JSON 根节点必须是数组';
|
||||
return null;
|
||||
}
|
||||
mcpError.value = '';
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
mcpError.value = `不是有效的 JSON:${(error as Error).message}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const servers = parseMcp();
|
||||
if (servers === null) {
|
||||
message.warning('MCP 服务器配置不是有效 JSON,请先修正');
|
||||
return;
|
||||
}
|
||||
const ruleError = reviewRulesError();
|
||||
if (ruleError) {
|
||||
message.warning(ruleError);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await saveConfig({ ...form, AI_MCP_SERVERS: servers });
|
||||
message.success(`已发布为 v${data.version},桌面端下次同步时生效`);
|
||||
await load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="这份配置会下发到每一台桌面客户端"
|
||||
description="保存即产生新版本号,客户端下次同步就会拿到。改错一个字段是全线生效的,不是只影响你自己这台。"
|
||||
/>
|
||||
|
||||
<Alert class="mb-4" type="info" show-icon>
|
||||
<template #message>模型配置不在这里</template>
|
||||
<template #description>
|
||||
<p class="mb-2">
|
||||
服务类型、API 地址、API Key、模型名称、温度、最大 tokens、请求超时——这些以前在本页的设置,
|
||||
现在统一在 <b>{{ movedTo || 'AI 模型 → 模型清单 / 角色编排' }}</b>。
|
||||
</p>
|
||||
<p class="mb-2 text-xs">
|
||||
搬走有三个原因:同一件事两个地方能配迟早会不一致;密钥留在这份配置里就必须明文下发到每一台客户端,
|
||||
而模型清单里是加密存储、只返遮罩值;一个客户只能配一个模型的话,双模型并发和裁判根本无从谈起。
|
||||
</p>
|
||||
<div v-if="canSeeModels" class="mt-2">
|
||||
<Button size="small" @click="router.push('/model/catalog')">去模型清单</Button>
|
||||
<Button size="small" class="ml-2" @click="router.push('/model/plan')">去角色编排</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<Card title="当前生效的配置" class="mb-4" :loading="loading">
|
||||
<Descriptions :column="3" size="small" bordered>
|
||||
<DescriptionsItem label="版本">v{{ version }}</DescriptionsItem>
|
||||
<DescriptionsItem label="最后修改">{{ updatedBy || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="时间">{{ updatedAt || '—' }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Form :model="form" layout="vertical">
|
||||
<Card title="能力开关" class="mb-4" :loading="loading">
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-1 md:grid-cols-2">
|
||||
<div
|
||||
v-for="item in SWITCHES"
|
||||
:key="item.key"
|
||||
class="flex items-start justify-between gap-4 border-b border-dashed py-3 last:border-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ item.label }}</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500">{{ item.hint }}</div>
|
||||
</div>
|
||||
<Switch
|
||||
v-model:checked="form[item.key] as boolean"
|
||||
:disabled="!canWrite"
|
||||
class="mt-1 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="客服人格" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
这几项会拼进系统提示词,决定客户看到的是"谁"在说话。它们不是模型参数,所以留在这里。
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-3">
|
||||
<FormItem label="客服名称">
|
||||
<Input v-model:value="form.AI_AGENT_NAME" :disabled="!canWrite" placeholder="贴心管家" />
|
||||
</FormItem>
|
||||
<FormItem label="机构名称">
|
||||
<Input v-model:value="form.AI_HOSPITAL_NAME" :disabled="!canWrite" placeholder="甄养堂" />
|
||||
</FormItem>
|
||||
<FormItem label="上下文轮数(1–50)">
|
||||
<InputNumber
|
||||
v-model:value="form.AI_CONTEXT_MAX_ROUNDS"
|
||||
:min="1" :max="50" :disabled="!canWrite" class="w-full"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
记几轮对话。这是客户端行为,和"用哪个模型"无关。
|
||||
</p>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="模型网关地址" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
桌面客户端只配一个后台地址,模型请求发到哪儿由这里决定、随配置一起下发。
|
||||
换网关位置只需要改这一处,不用挨个动客户端。
|
||||
</p>
|
||||
<FormItem label="地址(留空 = 按后台自己的地址自动推算)">
|
||||
<Input
|
||||
v-model:value="form.AI_GATEWAY_URL"
|
||||
:disabled="!canWrite"
|
||||
placeholder="留空即可,绝大多数情况不用填"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
当前实际下发:<code>{{ gatewayEffective || '—' }}</code>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
只有网关在别的域名或别的机器上时才需要填。写完整地址(<code>https://…</code>)
|
||||
或以 <code>/</code> 开头的路径(挂在本后台的域名下)。
|
||||
</p>
|
||||
</FormItem>
|
||||
</Card>
|
||||
|
||||
<Card title="选择性审核规则" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
命中任意一条,那一条回复才会停下来贴进企业微信输入框等人工确认,不会自动按回车;
|
||||
没命中的照常自动发送。关键词不区分大小写,客户这句话和模型准备发的回复,
|
||||
沾上任意一边都算命中。
|
||||
</p>
|
||||
<Alert
|
||||
class="mb-3"
|
||||
type="info"
|
||||
show-icon
|
||||
message="还有一条内置规则不在这份清单里、关不掉"
|
||||
description="模型裁判把某条回复判成「高风险」时,无论有没有命中下面的关键词,同样会转人工。裁判只要在角色编排里配了就会一直打分,这个信号比关键词更贴近内容本身。"
|
||||
/>
|
||||
<div
|
||||
v-for="(rule, index) in form.AI_REVIEW_RULES"
|
||||
:key="index"
|
||||
class="mb-3 flex items-start gap-3 border-b border-dashed pb-3 last:border-0"
|
||||
>
|
||||
<!--
|
||||
宽度必须写在这两层普通 div 上,不能直接给 antd 组件加 w-40 / flex-1:
|
||||
antd 的样式是运行时注入的,排在 Tailwind 之后,`.ant-input{width:100%}`
|
||||
会盖掉同为单类选择器的 `.w-40`。结果就是名称框撑满整行、关键词框被挤成
|
||||
0 宽,开关和删除按钮被顶到卡片外面去。
|
||||
-->
|
||||
<div class="w-40 shrink-0">
|
||||
<Input
|
||||
v-model:value="rule.label"
|
||||
:disabled="!canWrite"
|
||||
placeholder="规则名称,例如「诊断」"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Select
|
||||
v-model:value="rule.keywords"
|
||||
mode="tags"
|
||||
:disabled="!canWrite"
|
||||
placeholder="输入关键词后按回车,可以加多个"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<Switch v-model:checked="rule.enabled" :disabled="!canWrite" class="mt-1 shrink-0" />
|
||||
<Button
|
||||
danger type="link" size="small" :disabled="!canWrite"
|
||||
class="shrink-0" @click="removeReviewRule(index)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-if="canWrite" size="small" @click="addReviewRule">+ 新增规则</Button>
|
||||
<p v-if="form.AI_REVIEW_RULES.length === 0" class="mt-2 text-xs text-gray-500">
|
||||
还没有配任何规则——除了上面那条内置的裁判高风险检查,其余全部自动发送。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="MCP 服务器" class="mb-4" :loading="loading">
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-4">
|
||||
<FormItem label="单次最多调用工具轮数(1–20)">
|
||||
<InputNumber
|
||||
v-model:value="form.AI_MCP_MAX_ROUNDS"
|
||||
:min="1" :max="20" :disabled="!canWrite" class="w-full"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="服务器清单(JSON 数组)">
|
||||
<Textarea
|
||||
v-model:value="mcpText"
|
||||
:rows="8"
|
||||
:disabled="!canWrite"
|
||||
:status="mcpError ? 'error' : undefined"
|
||||
spellcheck="false"
|
||||
class="font-mono text-xs"
|
||||
@blur="parseMcp"
|
||||
/>
|
||||
<p v-if="mcpError" class="mt-1 text-xs text-red-500">{{ mcpError }}</p>
|
||||
<p v-else class="mt-1 text-xs text-gray-500">
|
||||
工具跑在客户端本机(要连内网),所以配置在这里而不是模型清单里。
|
||||
格式错误会当场拦下,不会等到桌面端同步之后才发现。
|
||||
</p>
|
||||
</FormItem>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
v-if="canWrite"
|
||||
class="sticky bottom-0 -mx-5 border-t bg-white/90 px-5 py-3 backdrop-blur dark:bg-black/70"
|
||||
>
|
||||
<Button type="primary" :loading="saving" @click="submit">保存并发布新版本</Button>
|
||||
<Button class="ml-2" :disabled="saving" @click="load">放弃修改</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelProvider } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { Alert, Button, Card, Form, FormItem, Input, InputNumber, message, Modal, Popconfirm, Select, Switch, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { deleteModel, fetchModels, saveModel, testModel } from '#/api/console';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['model:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const models = ref<ModelProvider[]>([]);
|
||||
const kinds = ref<string[]>([]);
|
||||
const open = ref(false);
|
||||
const editingId = ref('');
|
||||
|
||||
const blank = (): Partial<ModelProvider> => ({
|
||||
id: '', name: '', kind: 'openai', base_url: '', endpoint_mode: 'auto',
|
||||
api_key: '', model: '',
|
||||
capabilities: 'text', max_tokens: 500, temperature: 0.35,
|
||||
timeout_ms: 30_000, max_inflight: 32, rpm_limit: 0, enabled: true,
|
||||
});
|
||||
const form = reactive<Partial<ModelProvider>>(blank());
|
||||
|
||||
// ComfyUI 是文生图工作流引擎,当不了对话候选也当不了裁判。后端会把它排除在
|
||||
// 答题池外,这里同步提示一句,免得配完了发现"怎么不生效"。
|
||||
const isImageOnly = computed(() => form.kind === 'comfyui');
|
||||
|
||||
/**
|
||||
* 实际会请求的地址。
|
||||
*
|
||||
* 后端按接口类型给地址补路径(填 `https://api.openai.com/v1` 会补成
|
||||
* `.../v1/chat/completions`)。这条规则不显示出来的话,"我填的"和"真正请求的"
|
||||
* 之间隔着一层看不见的拼接——配错了要等 404 才发现,而且多半会去怀疑密钥。
|
||||
*
|
||||
* 这里在前端复刻同一套规则做实时预览。真正的地址仍以后端为准,两边规则若漂移,
|
||||
* 预览会和 `record.endpoint` 对不上,测连通时立刻暴露。
|
||||
*/
|
||||
const resolvedEndpoint = computed(() => {
|
||||
const base = (form.base_url || '').replace(/\/+$/, '');
|
||||
if (!base) return '';
|
||||
if (form.endpoint_mode === 'exact') return base;
|
||||
let path = '';
|
||||
try {
|
||||
path = new URL(base).pathname.replace(/\/+$/, '').toLowerCase();
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
if (form.kind === 'claude') {
|
||||
if (path.endsWith('/messages')) return base;
|
||||
return path.endsWith('/v1') ? `${base}/messages` : `${base}/v1/messages`;
|
||||
}
|
||||
if (form.kind === 'dify') {
|
||||
if (path.endsWith('/chat-messages') || path.endsWith('/completion-messages')) return base;
|
||||
return path.endsWith('/v1') ? `${base}/chat-messages` : `${base}/v1/chat-messages`;
|
||||
}
|
||||
if (path.endsWith('/chat/completions')) return base;
|
||||
if (path.startsWith('/v1/') && path.length > 4) return base;
|
||||
return `${base}/chat/completions`;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchModels();
|
||||
models.value = data.models;
|
||||
kinds.value = data.kinds;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = '';
|
||||
Object.assign(form, blank());
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: ModelProvider) {
|
||||
editingId.value = row.id;
|
||||
Object.assign(form, { ...row, api_key: '' }); // 密钥永不回显
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.id || !form.base_url) {
|
||||
message.warning('模型 ID 和接口地址不能为空');
|
||||
return;
|
||||
}
|
||||
const data = await saveModel({ ...form });
|
||||
models.value = data.models;
|
||||
open.value = false;
|
||||
message.success(editingId.value ? '已更新' : '已新增');
|
||||
}
|
||||
|
||||
/**
|
||||
* 连通性测试。
|
||||
*
|
||||
* 密钥不从这里发过去——只送 provider_id,后端从库里解密取用。所以"测一下现有
|
||||
* 配置通不通"这个最常见的操作,全程没有明文密钥在网络上走动。
|
||||
*
|
||||
* 结果直接展示后端返回的原文(含 HTTP 状态码和上游的错误描述)。"连接失败"
|
||||
* 四个字对排查毫无帮助,"401 API Key 无效"才是能动手的信息。
|
||||
*/
|
||||
const testing = ref('');
|
||||
const testResult = ref<null | { label: string; ok: boolean; text: string }>(null);
|
||||
|
||||
async function runTest(row: ModelProvider) {
|
||||
testing.value = row.id;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const data = await testModel({ provider_id: row.id, timeout_seconds: 20 });
|
||||
const r = data.result;
|
||||
testResult.value = {
|
||||
label: data.label || row.id,
|
||||
ok: r.ok,
|
||||
text: `${r.message} · 端点 ${r.endpoint} · 耗时 ${r.latency_ms}ms${
|
||||
r.http_status ? ` · HTTP ${r.http_status}` : ''
|
||||
}`,
|
||||
};
|
||||
} finally {
|
||||
testing.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: ModelProvider) {
|
||||
const data = await deleteModel(row.id);
|
||||
models.value = data.models;
|
||||
message.success('已删除');
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '接口类型', dataIndex: 'kind', key: 'kind', width: 110 },
|
||||
{ title: '实际请求地址', dataIndex: 'endpoint', key: 'endpoint', ellipsis: true },
|
||||
{ title: '模型', dataIndex: 'model', key: 'model' },
|
||||
{ title: '密钥', dataIndex: 'api_key_masked', key: 'api_key_masked', width: 160 },
|
||||
{ title: '能力', dataIndex: 'capabilities', key: 'capabilities', width: 120 },
|
||||
{ title: '并发上限', dataIndex: 'max_inflight', key: 'max_inflight', width: 90 },
|
||||
{ title: '状态', dataIndex: 'enabled', key: 'enabled', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 200 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="模型清单">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">新增模型</Button>
|
||||
</template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
密钥在后端加密存储,接口只返回遮罩值——这里看到的星号不是显示效果,
|
||||
是后端真的不会把明文发出来。留空保存表示不改动原密钥。
|
||||
</p>
|
||||
<Alert
|
||||
v-if="testResult"
|
||||
class="mb-3"
|
||||
closable
|
||||
:type="testResult.ok ? 'success' : 'error'"
|
||||
show-icon
|
||||
:message="`${testResult.label}:${testResult.ok ? '连接成功' : '连接失败'}`"
|
||||
:description="testResult.text"
|
||||
@close="testResult = null"
|
||||
/>
|
||||
<Table
|
||||
:columns="columns" :data-source="models" :loading="loading"
|
||||
row-key="id" size="small" :pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'kind'">
|
||||
<Tag :color="record.kind === 'comfyui' ? 'default' : 'blue'">{{ record.kind }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'endpoint'">
|
||||
<code class="text-xs">{{ record.endpoint || record.base_url }}</code>
|
||||
<Tag v-if="record.endpoint_mode === 'exact'" class="ml-1" color="purple">原样</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'api_key_masked'">
|
||||
<code class="text-xs">{{ record.api_key_masked || '(未设置)' }}</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enabled'">
|
||||
<Tag :color="record.enabled ? 'green' : 'default'">
|
||||
{{ record.enabled ? '已启用' : '已停用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button
|
||||
type="link" size="small"
|
||||
:disabled="!canWrite" :loading="testing === record.id"
|
||||
@click="runTest(record as ModelProvider)"
|
||||
>
|
||||
测连通
|
||||
</Button>
|
||||
<Button type="link" size="small" :disabled="!canWrite" @click="openEdit(record as ModelProvider)">
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除这个模型?" @confirm="remove(record as ModelProvider)">
|
||||
<Button type="link" size="small" danger :disabled="!canWrite">删除</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" :title="editingId ? '编辑模型' : '新增模型'" @ok="submit">
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem label="模型 ID(唯一,编排里按它引用)">
|
||||
<Input v-model:value="form.id" :disabled="!!editingId" placeholder="dify-kefu" />
|
||||
</FormItem>
|
||||
<FormItem label="显示名称">
|
||||
<Input v-model:value="form.name" placeholder="甄养堂客服" />
|
||||
</FormItem>
|
||||
<FormItem label="接口类型">
|
||||
<Select v-model:value="form.kind" :options="kinds.map((k) => ({ value: k, label: k }))" />
|
||||
<p v-if="isImageOnly" class="mt-1 text-xs text-orange-500">
|
||||
ComfyUI 是文生图工作流引擎,不能当对话模型——后端会把它排除在答题池和裁判之外。
|
||||
</p>
|
||||
</FormItem>
|
||||
<FormItem label="接口地址">
|
||||
<Input
|
||||
v-model:value="form.base_url"
|
||||
placeholder="https://api.anthropic.com"
|
||||
autocomplete="off"
|
||||
name="model-base-url"
|
||||
/>
|
||||
<div class="mt-2 flex items-start gap-2">
|
||||
<Switch
|
||||
size="small"
|
||||
:checked="form.endpoint_mode === 'exact'"
|
||||
class="mt-0.5 shrink-0"
|
||||
@change="(v: any) => (form.endpoint_mode = v ? 'exact' : 'auto')"
|
||||
/>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span class="font-medium">这就是完整的请求地址,不要再补路径</span>
|
||||
<p class="mt-0.5">
|
||||
关着时按接口类型自动补全(<code>/v1</code> → <code>/v1/chat/completions</code>),
|
||||
大多数服务商都是这个形状。路径不按套路的自建服务打开它。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="resolvedEndpoint" class="mt-2 break-all rounded bg-gray-50 px-2 py-1 text-xs dark:bg-gray-800">
|
||||
实际请求:<code>{{ resolvedEndpoint }}</code>
|
||||
</p>
|
||||
</FormItem>
|
||||
<FormItem :label="editingId ? 'API 密钥(留空表示不改动)' : 'API 密钥'">
|
||||
<Input.Password v-model:value="form.api_key" placeholder="留空 = 保持原密钥" />
|
||||
</FormItem>
|
||||
<FormItem label="模型名称(Dify 由应用侧决定,可留空)">
|
||||
<Input v-model:value="form.model" placeholder="claude-sonnet-4-6" />
|
||||
</FormItem>
|
||||
<FormItem label="能力">
|
||||
<Select
|
||||
v-model:value="form.capabilities"
|
||||
:options="[
|
||||
{ value: 'text', label: '仅文本' },
|
||||
{ value: 'text,vision', label: '文本 + 视觉' },
|
||||
{ value: 'image_gen', label: '文生图' },
|
||||
]"
|
||||
/>
|
||||
</FormItem>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<FormItem label="并发上限">
|
||||
<InputNumber v-model:value="form.max_inflight" :min="1" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem label="超时(毫秒)">
|
||||
<InputNumber v-model:value="form.timeout_ms" :min="1000" :step="1000" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem label="最大回复 tokens">
|
||||
<InputNumber v-model:value="form.max_tokens" :min="1" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem label="温度">
|
||||
<InputNumber v-model:value="form.temperature" :min="0" :max="2" :step="0.05" class="w-full" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="启用">
|
||||
<Switch v-model:checked="form.enabled" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelPlan, ModelProvider } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { Alert, Button, Card, Descriptions, DescriptionsItem, Form, FormItem, message, Select, Tag } from 'ant-design-vue';
|
||||
|
||||
import { fetchModels, saveModelPlan } from '#/api/console';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['model:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const models = ref<ModelProvider[]>([]);
|
||||
const current = ref<ModelPlan | null>(null);
|
||||
const judgeModes = ref<string[]>([]);
|
||||
|
||||
const form = reactive({
|
||||
answer_ids: [] as string[],
|
||||
judge_id: '',
|
||||
vision_id: '',
|
||||
fallback_ids: [] as string[],
|
||||
judge_mode: 'shadow',
|
||||
});
|
||||
|
||||
/** ComfyUI 当不了对话候选,选项里直接不给——省得配完了发现不生效。 */
|
||||
const chatOptions = computed(() =>
|
||||
models.value
|
||||
.filter((item) => item.kind !== 'comfyui' && item.enabled)
|
||||
.map((item) => ({ value: item.id, label: `${item.name}(${item.kind})` })),
|
||||
);
|
||||
|
||||
const visionOptions = computed(() =>
|
||||
chatOptions.value.filter((option) =>
|
||||
models.value
|
||||
.find((item) => item.id === option.value)
|
||||
?.capabilities.includes('vision'),
|
||||
),
|
||||
);
|
||||
|
||||
const MODE_HELP: Record<string, string> = {
|
||||
shadow:
|
||||
'照常发主模型的回复,裁判只在后台打分、不改变任何行为。先量出现有回复的真实水平,再决定要不要花第二份钱。',
|
||||
score_only:
|
||||
'用裁判的绝对分和风险做路由:低分或高风险转人工审核,不做二选一。',
|
||||
arbitrate:
|
||||
'完整的 best-of-N:并发问多个模型,按裁判选出的赢家发送。成本约 2.3 倍。',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchModels();
|
||||
models.value = data.models;
|
||||
judgeModes.value = data.judge_modes;
|
||||
current.value = data.roles;
|
||||
const split = (value: string) =>
|
||||
(value || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
Object.assign(form, {
|
||||
answer_ids: split(data.roles.answer_ids),
|
||||
fallback_ids: split(data.roles.fallback_ids),
|
||||
judge_id: data.roles.judge_id || '',
|
||||
vision_id: data.roles.vision_id || '',
|
||||
judge_mode: data.roles.judge_mode || 'shadow',
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (form.answer_ids.length === 0) {
|
||||
message.warning('至少要指定一个答题模型');
|
||||
return;
|
||||
}
|
||||
const data = await saveModelPlan({
|
||||
answer_ids: form.answer_ids.join(','),
|
||||
judge_id: form.judge_id,
|
||||
vision_id: form.vision_id,
|
||||
fallback_ids: form.fallback_ids.join(','),
|
||||
judge_mode: form.judge_mode,
|
||||
});
|
||||
current.value = data.roles;
|
||||
message.success(`已保存为 v${data.version},网关 20 秒内自动生效`);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="编排是追加版本,不是原地修改"
|
||||
description="每次保存都产生一个新版本号,回滚就是指回旧版本。网关每 20 秒重读一次,不用重启。"
|
||||
/>
|
||||
|
||||
<Card title="当前生效的编排" class="mb-4" :loading="loading">
|
||||
<Descriptions v-if="current" :column="2" size="small" bordered>
|
||||
<DescriptionsItem label="版本">v{{ current.version }}</DescriptionsItem>
|
||||
<DescriptionsItem label="裁判模式">
|
||||
<Tag :color="current.judge_mode === 'arbitrate' ? 'red' : current.judge_mode === 'score_only' ? 'orange' : 'blue'">
|
||||
{{ current.judge_mode }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="答题模型">{{ current.answer_ids || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="裁判模型">{{ current.judge_id || '—' }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="修改编排">
|
||||
<Form :model="form" layout="vertical">
|
||||
<FormItem label="答题模型(多选 = 并发提问,按顺序第一个是主模型)">
|
||||
<Select
|
||||
v-model:value="form.answer_ids" mode="multiple"
|
||||
:options="chatOptions" :disabled="!canWrite"
|
||||
placeholder="至少选一个"
|
||||
/>
|
||||
<p v-if="form.answer_ids.length > 1" class="mt-1 text-xs text-orange-500">
|
||||
选了 {{ form.answer_ids.length }} 个 = 每条回复都要付 {{ form.answer_ids.length }} 份生成费用。
|
||||
建议先跑一段影子模式,用调用统计里的分数分布判断值不值。
|
||||
</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="裁判模型(给回复打绝对分和风险等级)">
|
||||
<Select
|
||||
v-model:value="form.judge_id" :options="chatOptions"
|
||||
:disabled="!canWrite" allow-clear placeholder="不选则不做评审"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="裁判模式">
|
||||
<Select
|
||||
v-model:value="form.judge_mode" :disabled="!canWrite"
|
||||
:options="judgeModes.map((m) => ({ value: m, label: m }))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ MODE_HELP[form.judge_mode] }}</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="媒体消息专用模型(需具备视觉能力,留空则从答题池里挑)">
|
||||
<Select
|
||||
v-model:value="form.vision_id" :options="visionOptions"
|
||||
:disabled="!canWrite" allow-clear
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="降级链(主链全挂时按顺序尝试)">
|
||||
<Select
|
||||
v-model:value="form.fallback_ids" mode="multiple"
|
||||
:options="chatOptions" :disabled="!canWrite"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<Button v-if="canWrite" type="primary" @click="submit">保存为新版本</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
DescriptionsItem,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
message,
|
||||
Switch,
|
||||
Tag,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { fetchRelease, saveRelease } from '#/api/console';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['release:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const current = reactive({
|
||||
latest_version: '',
|
||||
download_url: '',
|
||||
release_notes: '',
|
||||
force_upgrade: false,
|
||||
updated_at: '',
|
||||
updated_by: '',
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
latest_version: '',
|
||||
download_url: '',
|
||||
release_notes: '',
|
||||
force_upgrade: false,
|
||||
});
|
||||
|
||||
/** 后端要求版本号形如 1.0.0,可带 v 前缀。这里先在前端提示一遍,少一次往返。 */
|
||||
const versionValid = computed(() =>
|
||||
/^v?\d+\.\d+\.\d+$/.test(form.latest_version.trim()),
|
||||
);
|
||||
const urlValid = computed(() => {
|
||||
const value = form.download_url.trim();
|
||||
return !value || /^https?:\/\/.+/.test(value);
|
||||
});
|
||||
/** 强制升级会挡住客户端继续使用,没有下载地址就是把人堵死在原地。 */
|
||||
const forceWithoutUrl = computed(
|
||||
() => form.force_upgrade && !form.download_url.trim(),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchRelease();
|
||||
Object.assign(current, data);
|
||||
Object.assign(form, {
|
||||
latest_version: data.latest_version,
|
||||
download_url: data.download_url,
|
||||
release_notes: data.release_notes,
|
||||
force_upgrade: data.force_upgrade,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!versionValid.value) {
|
||||
message.warning('版本号格式应为 1.0.0');
|
||||
return;
|
||||
}
|
||||
if (!urlValid.value) {
|
||||
message.warning('下载地址必须是完整的 http 或 https 地址');
|
||||
return;
|
||||
}
|
||||
if (forceWithoutUrl.value) {
|
||||
message.warning('开启强制升级前必须填写下载地址');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await saveRelease({ ...form });
|
||||
message.success(`已发布 v${data.latest_version}`);
|
||||
await load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="这里只是告诉客户端「有新版本」,不负责分发安装包"
|
||||
description="客户端启动时对比本地版本号,发现更新就按这里填的地址去下载。安装包放在哪由你决定,后端只存地址。"
|
||||
/>
|
||||
|
||||
<Card title="当前的版本策略" class="mb-4" :loading="loading">
|
||||
<Descriptions :column="2" size="small" bordered>
|
||||
<DescriptionsItem label="最新版本">
|
||||
v{{ current.latest_version || '—' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="强制升级">
|
||||
<Tag :color="current.force_upgrade ? 'red' : 'default'">
|
||||
{{ current.force_upgrade ? '开启' : '关闭' }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="下载地址" :span="2">
|
||||
<a v-if="current.download_url" :href="current.download_url" target="_blank" rel="noreferrer">
|
||||
{{ current.download_url }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">未设置</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="最后修改">{{ current.updated_by || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="时间">{{ current.updated_at || '—' }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
<p v-if="current.release_notes" class="mt-3 whitespace-pre-wrap rounded bg-gray-50 p-3 text-xs dark:bg-gray-800">
|
||||
{{ current.release_notes }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="发布新版本">
|
||||
<Form :model="form" layout="vertical">
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||
<FormItem
|
||||
label="版本号"
|
||||
:validate-status="form.latest_version && !versionValid ? 'error' : undefined"
|
||||
:help="form.latest_version && !versionValid ? '格式应为 1.0.0,可带 v 前缀' : ''"
|
||||
>
|
||||
<Input v-model:value="form.latest_version" :disabled="!canWrite" placeholder="2.1.0" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label="下载地址"
|
||||
:validate-status="!urlValid ? 'error' : undefined"
|
||||
:help="!urlValid ? '必须是完整的 http 或 https 地址' : ''"
|
||||
>
|
||||
<Input
|
||||
v-model:value="form.download_url"
|
||||
:disabled="!canWrite"
|
||||
placeholder="https://dl.example.com/setup-2.1.0.exe"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<FormItem label="更新说明(不超过 4000 字)">
|
||||
<Textarea
|
||||
v-model:value="form.release_notes"
|
||||
:rows="6"
|
||||
:disabled="!canWrite"
|
||||
:maxlength="4000"
|
||||
show-count
|
||||
placeholder="这一版改了什么。客户端升级提示里会原样显示给使用的人看。"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<div class="flex items-start gap-3">
|
||||
<Switch v-model:checked="form.force_upgrade" :disabled="!canWrite" class="mt-1" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">强制升级</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500">
|
||||
开启后旧版客户端会被挡住,必须升级才能继续用。只在旧版有严重问题时才开——
|
||||
否则正在接待客户的人会被中途打断。
|
||||
</div>
|
||||
<div v-if="forceWithoutUrl" class="mt-1 text-xs text-red-500">
|
||||
还没填下载地址。这样开强制升级,客户端会被挡住又无处可下载。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<Button v-if="canWrite" type="primary" :loading="saving" @click="submit">
|
||||
发布
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import type { PermissionItem, RoleItem } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { Alert, Button, Card, Checkbox, CheckboxGroup, Form, FormItem, Input, message, Modal, Popconfirm, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { deleteRole, fetchPermissions, fetchRoles, saveRole } from '#/api/console';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['role:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const roles = ref<RoleItem[]>([]);
|
||||
const permissions = ref<PermissionItem[]>([]);
|
||||
const open = ref(false);
|
||||
const isNew = ref(false);
|
||||
|
||||
const form = reactive({ code: '', name: '', permissions: [] as string[] });
|
||||
|
||||
/** 权限按分组展示。一屏十几个码平铺出来没人看得懂哪个是哪个。 */
|
||||
const grouped = computed(() => {
|
||||
const map = new Map<string, PermissionItem[]>();
|
||||
for (const item of permissions.value) {
|
||||
const list = map.get(item.group_name) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.group_name, list);
|
||||
}
|
||||
return [...map.entries()].map(([group, items]) => ({ group, items }));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
roles.value = (await fetchRoles()).roles;
|
||||
if (canWrite.value) {
|
||||
permissions.value = (await fetchPermissions()).permissions;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isNew.value = true;
|
||||
Object.assign(form, { code: '', name: '', permissions: [] });
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: RoleItem) {
|
||||
isNew.value = false;
|
||||
Object.assign(form, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
permissions: [...row.permissions],
|
||||
});
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.code) {
|
||||
message.warning('角色编码不能为空');
|
||||
return;
|
||||
}
|
||||
roles.value = (await saveRole({ ...form })).roles;
|
||||
open.value = false;
|
||||
message.success('已保存,权限立即生效(无需重启,也无需重新登录)');
|
||||
}
|
||||
|
||||
async function remove(row: RoleItem) {
|
||||
roles.value = (await deleteRole(row.code)).roles;
|
||||
message.success('已删除');
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '编码', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 140 },
|
||||
{ title: '权限', key: 'permissions' },
|
||||
{ title: '在用人数', dataIndex: 'user_count', key: 'user_count', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="权限存在数据库里,不在代码里"
|
||||
description="改完立刻生效——不用重启服务,用户也不用重新登录。管理员角色不可削权(削了就没人能加回来),内置角色和仍有人在用的角色不可删除。"
|
||||
/>
|
||||
<Card title="角色与权限">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">新增角色</Button>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns" :data-source="roles" :loading="loading"
|
||||
row-key="code" size="small" :pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'code'">
|
||||
<code>{{ record.code }}</code>
|
||||
<Tag v-if="record.builtin" class="ml-2" color="blue">内置</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'permissions'">
|
||||
<Tag v-for="code in record.permissions" :key="code" class="mb-1">
|
||||
{{ code }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" :disabled="!canWrite" @click="openEdit(record as RoleItem)">
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除这个角色?" @confirm="remove(record as RoleItem)">
|
||||
<Button
|
||||
type="link" size="small" danger
|
||||
:disabled="!canWrite || record.builtin || record.user_count > 0"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" :title="isNew ? '新增角色' : '编辑角色'" width="640px" @ok="submit">
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem label="角色编码(字母数字,创建后不可改)">
|
||||
<Input v-model:value="form.code" :disabled="!isNew" placeholder="reviewer" />
|
||||
</FormItem>
|
||||
<FormItem label="角色名称">
|
||||
<Input v-model:value="form.name" placeholder="审核员" />
|
||||
</FormItem>
|
||||
<FormItem label="权限">
|
||||
<CheckboxGroup v-model:value="form.permissions" class="w-full">
|
||||
<div v-for="block in grouped" :key="block.group" class="mb-3">
|
||||
<div class="mb-1 text-sm font-medium text-gray-600">{{ block.group }}</div>
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<Checkbox v-for="item in block.items" :key="item.code" :value="item.code">
|
||||
{{ item.name }}
|
||||
<code class="ml-1 text-xs text-gray-400">{{ item.code }}</code>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</CheckboxGroup>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
提交的是最终状态:取消勾选就是收回该权限。
|
||||
</p>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { RoleItem, UserItem } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { Button, Card, Form, FormItem, Input, message, Modal, Select, Switch, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { createUser, fetchRoles, fetchUsers, updateUser } from '#/api/console';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['user:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const users = ref<UserItem[]>([]);
|
||||
const roles = ref<RoleItem[]>([]);
|
||||
const open = ref(false);
|
||||
|
||||
const form = reactive({ username: '', password: '', role: 'viewer' });
|
||||
|
||||
const roleOptions = computed(() =>
|
||||
roles.value.map((item) => ({ value: item.code, label: `${item.name}(${item.code})` })),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
users.value = (await fetchUsers()).users;
|
||||
roles.value = (await fetchRoles()).roles;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { username: '', password: '', role: 'viewer' });
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.username || !form.password) {
|
||||
message.warning('用户名和密码不能为空');
|
||||
return;
|
||||
}
|
||||
users.value = (await createUser({ ...form })).users;
|
||||
open.value = false;
|
||||
message.success('已创建,该用户首次登录必须先改密码');
|
||||
}
|
||||
|
||||
async function changeRole(row: UserItem, role: string) {
|
||||
users.value = (await updateUser(row.id, { role, active: row.active })).users;
|
||||
message.success('角色已更新,立即生效');
|
||||
}
|
||||
|
||||
async function toggleActive(row: UserItem, active: boolean) {
|
||||
users.value = (await updateUser(row.id, { role: row.role, active })).users;
|
||||
message.success(active ? '已启用' : '已停用');
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 220 },
|
||||
{ title: '状态', dataIndex: 'active', key: 'active', width: 120 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="用户管理">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">新增用户</Button>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns" :data-source="users" :loading="loading"
|
||||
row-key="id" size="small" :pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'username'">
|
||||
{{ record.username }}
|
||||
<Tag v-if="record.must_change_password" color="orange" class="ml-2">
|
||||
待改密
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<Select
|
||||
:value="record.role" :options="roleOptions" :disabled="!canWrite"
|
||||
size="small" class="w-full"
|
||||
@change="(value) => changeRole(record as UserItem, String(value))"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'active'">
|
||||
<Switch
|
||||
:checked="record.active" :disabled="!canWrite" size="small"
|
||||
@change="(value) => toggleActive(record as UserItem, Boolean(value))"
|
||||
/>
|
||||
<span class="ml-2 text-xs">{{ record.active ? '已启用' : '已停用' }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" title="新增用户" @ok="submit">
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem label="用户名">
|
||||
<Input v-model:value="form.username" />
|
||||
</FormItem>
|
||||
<FormItem label="初始密码(至少 10 位,需同时含字母和数字)">
|
||||
<Input.Password v-model:value="form.password" />
|
||||
</FormItem>
|
||||
<FormItem label="角色">
|
||||
<Select v-model:value="form.role" :options="roleOptions" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web-app.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "./tsconfig.node.json" }],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from '@vben/vite-config';
|
||||
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
application: {},
|
||||
vite: {
|
||||
server: {
|
||||
proxy: {
|
||||
// 前端统一请求 /api/*,代理到 admin_api.py 的 /api/v2/*。
|
||||
// 走代理而不是直连,是为了开发态和生产态同源、没有 CORS 差异——
|
||||
// 生产环境前端是静态文件,由同一个 Nginx 反代到后端。
|
||||
'/api': {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '/api/v2'),
|
||||
// 端口可用环境变量覆盖:本机同时跑多套后端时(比如拿一次性的库
|
||||
// 验证前端,不碰生产库)不用改代码。
|
||||
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8766',
|
||||
ws: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user