Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,403 @@
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElNotification } from 'element-plus';
const ElButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/button/index'),
import('element-plus/es/components/button/style/css'),
]).then(([res]) => res.ElButton),
);
const ElCheckbox = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox/style/css'),
]).then(([res]) => res.ElCheckbox),
);
const ElCheckboxButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-button/style/css'),
]).then(([res]) => res.ElCheckboxButton),
);
const ElCheckboxGroup = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-group/style/css'),
]).then(([res]) => res.ElCheckboxGroup),
);
const ElCascader = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/cascader/index'),
import('element-plus/es/components/cascader/style/css'),
]).then(([res]) => res.ElCascader),
);
const ElDatePicker = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/date-picker/index'),
import('element-plus/es/components/date-picker/style/css'),
]).then(([res]) => res.ElDatePicker),
);
const DeptSelector = defineAsyncComponent(() =>
import('#/components/zq-form/dept-selector/dept-selector.vue').then(
(res) => res.default,
),
);
const PostSelector = defineAsyncComponent(() =>
import('#/components/zq-form/post-selector/post-selector.vue').then(
(res) => res.default,
),
);
const RoleSelector = defineAsyncComponent(() =>
import('#/components/zq-form/role-selector/role-selector.vue').then(
(res) => res.default,
),
);
const FileSelector = defineAsyncComponent(() =>
import('#/components/zq-form/file-selector/file-selector.vue').then(
(res) => res.default,
),
);
const ImageSelector = defineAsyncComponent(() =>
import('#/components/zq-form/image-selector/image-selector.vue').then(
(res) => res.default,
),
);
const UserSelector = defineAsyncComponent(() =>
import('#/components/zq-form/user-selector/user-selector.vue').then(
(res) => res.default,
),
);
const JsonEditor = defineAsyncComponent(() =>
import('#/components/json-editor/json-editor.vue').then((res) => res.default),
);
const ZqIconPicker = defineAsyncComponent(() =>
import('#/components/zq-form/zq-icon-picker/zq-icon-picker.vue').then(
(res) => res.default,
),
);
const CronSelector = defineAsyncComponent(() =>
import('#/components/zq-form/cron-selector/cron-selector.vue').then(
(res) => res.default,
),
);
const CodeEditor = defineAsyncComponent(() =>
import('#/components/zq-form/code-editor/code-editor.vue').then(
(res) => res.default,
),
);
const ElDivider = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/divider/index'),
import('element-plus/es/components/divider/style/css'),
]).then(([res]) => res.ElDivider),
);
const ElInput = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/input/index'),
import('element-plus/es/components/input/style/css'),
]).then(([res]) => res.ElInput),
);
const ElInputNumber = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/input-number/index'),
import('element-plus/es/components/input-number/style/css'),
]).then(([res]) => res.ElInputNumber),
);
const ElRadio = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio/style/css'),
]).then(([res]) => res.ElRadio),
);
const ElRadioButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio-button/style/css'),
]).then(([res]) => res.ElRadioButton),
);
const ElRadioGroup = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio-group/style/css'),
]).then(([res]) => res.ElRadioGroup),
);
const ElSelectV2 = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/select-v2/index'),
import('element-plus/es/components/select-v2/style/css'),
]).then(([res]) => res.ElSelectV2),
);
const ElSpace = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/space/index'),
import('element-plus/es/components/space/style/css'),
]).then(([res]) => res.ElSpace),
);
const ElSwitch = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/switch/index'),
import('element-plus/es/components/switch/style/css'),
]).then(([res]) => res.ElSwitch),
);
const ElTimePicker = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/time-picker/index'),
import('element-plus/es/components/time-picker/style/css'),
]).then(([res]) => res.ElTimePicker),
);
const ElTreeSelect = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/tree-select/index'),
import('element-plus/es/components/tree-select/style/css'),
]).then(([res]) => res.ElTreeSelect),
);
const ElUpload = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/upload/index'),
import('element-plus/es/components/upload/style/css'),
]).then(([res]) => res.ElUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
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,
);
},
});
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'CodeEditor'
| 'CronSelector'
| 'DatePicker'
| 'DeptSelector'
| 'Divider'
| 'FileSelector'
| 'IconPicker'
| 'ImageSelector'
| 'Input'
| 'InputNumber'
| 'JsonEditor'
| 'PostSelector'
| 'RadioGroup'
| 'RoleSelector'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| 'UserSelector'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiSelect',
},
'select',
{
component: ElSelectV2,
loadingSlot: 'loading',
visibleEvent: 'onVisibleChange',
},
),
ApiTreeSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiTreeSelect',
},
'select',
{
component: ElTreeSelect,
props: { label: 'label', children: 'children' },
nodeKey: 'value',
loadingSlot: 'loading',
optionsPropName: 'data',
visibleEvent: 'onVisibleChange',
},
),
Cascader: withDefaultPlaceholder(ElCascader, 'select'),
Checkbox: ElCheckbox,
CheckboxGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options, isButton } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(isButton ? ElCheckboxButton : ElCheckbox, option),
);
}
}
return h(
ElCheckboxGroup,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs }, slots);
},
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
},
DeptSelector: withDefaultPlaceholder(DeptSelector, 'select'),
PostSelector: withDefaultPlaceholder(PostSelector, 'select'),
RoleSelector: withDefaultPlaceholder(RoleSelector, 'select'),
Divider: ElDivider,
FileSelector: withDefaultPlaceholder(FileSelector, 'select'),
IconPicker: withDefaultPlaceholder(ZqIconPicker, 'select'),
ImageSelector: withDefaultPlaceholder(ImageSelector, 'select'),
Input: withDefaultPlaceholder(ElInput, 'input'),
Textarea: withDefaultPlaceholder(ElInput, 'input', { type: 'textarea' }),
JsonEditor,
CodeEditor,
CronSelector,
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(attrs.isButton ? ElRadioButton : ElRadio, option),
);
}
}
return h(
ElRadioGroup,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
Select: (props, { attrs, slots }) => {
return h(ElSelectV2, { ...props, attrs }, slots);
},
Space: ElSpace,
Switch: ElSwitch,
TimePicker: (props, { attrs, slots }) => {
const { name, id, isRange } = props;
const extraProps: Recordable<any> = {};
if (isRange) {
if (name && !Array.isArray(name)) {
extraProps.name = [name, `${name}_end`];
}
if (id && !Array.isArray(id)) {
extraProps.id = [id, `${id}_end`];
}
}
return h(
ElTimePicker,
{
...props,
...attrs,
...extraProps,
},
slots,
);
},
DatePicker: (props, { attrs, slots }) => {
const { name, id, type } = props;
const extraProps: Recordable<any> = {};
if (type && type.includes('range')) {
if (name && !Array.isArray(name)) {
extraProps.name = [name, `${name}_end`];
}
if (id && !Array.isArray(id)) {
extraProps.id = [id, `${id}_end`];
}
}
return h(
ElDatePicker,
{
...props,
...attrs,
...extraProps,
},
slots,
);
},
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
Upload: ElUpload,
UserSelector: withDefaultPlaceholder(UserSelector, 'select'),
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
ElNotification({
title,
message: content,
position: 'bottom-right',
duration: 0,
type: 'success',
});
},
});
}
export { initComponentAdapter };
+41
View File
@@ -0,0 +1,41 @@
import type {
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
modelPropNameMap: {
Upload: 'fileList',
CheckboxGroup: 'model-value',
},
},
defineRules: {
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;
},
},
});
}
const useVbenForm = useForm<ComponentType>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
+290
View File
@@ -0,0 +1,290 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t, $te } from '@vben/locales';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { get, isFunction, isString } from '@vben/utils';
import {
ElButton,
ElImage,
ElPopconfirm,
ElTag,
ElTooltip,
} from 'element-plus';
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: '',
},
showActionMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'medium',
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
const src = row[column.field];
return h(ElImage, { src, previewSrcList: [src] });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
ElButton,
{ size: 'small', link: true },
{ default: () => props?.text },
);
},
});
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ type: 'success', label: $t('common.enabled'), value: true },
{ type: 'danger', label: $t('common.disabled'), value: false },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
ElTag,
{
type: tagItem?.type ?? 'info',
...props,
},
{ default: () => tagItem?.label ?? value },
);
},
});
/**
* 注册表格的操作按钮渲染器
*/
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { size: 'small', link: true, ...props };
let align = 'end';
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
type: 'danger',
text: $t('common.delete'),
icon: 'ep:delete',
},
edit: {
text: $t('common.edit'),
icon: 'ep:edit',
type: 'primary',
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
type: 'primary', // 自定义按钮默认使用主题色
...defaultProps,
};
} else {
// 对象配置的按钮,如果没有 type,也默认为 primary
const buttonConfig = {
...defaultProps,
...presets[opt.code],
...opt,
};
if (!buttonConfig.type && !presets[opt.code]) {
buttonConfig.type = 'primary';
}
return buttonConfig;
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
const { icon, text, code, ...btnProps } = opt;
const buttonType =
btnProps.type === 'danger'
? 'danger'
: (btnProps.type === 'primary'
? 'primary'
: 'default');
const button = h(
ElButton,
{
...btnProps,
type: buttonType,
size: 'small',
link: true,
circle: !!icon,
onClick: listen
? () =>
attrs?.onClick?.({
code,
row,
})
: undefined,
},
{
default: () => {
if (icon) {
return h(IconifyIcon, { class: 'size-4', icon });
}
return text;
},
},
);
// 如果有图标,用 Tooltip 包装;否则直接返回按钮
if (icon) {
return h(
ElTooltip,
{
content: text,
placement: 'top',
},
{
default: () => button,
},
);
}
return button;
}
function renderConfirm(opt: Recordable<any>) {
const { icon, text } = opt;
const button = h(
ElButton,
{
type: 'danger',
size: 'small',
link: true,
circle: !!icon,
title: icon ? text : undefined,
},
{
default: () => {
if (icon) {
return h(IconifyIcon, { class: 'size-4', icon });
}
return text;
},
},
);
return h(
ElPopconfirm,
{
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
confirmButtonType: 'danger',
onConfirm: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
},
{
reference: () => button,
default: () =>
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
// 自定义类型定义
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
/**
* 公告管理 API
*/
import { requestClient } from '#/api/request';
const BASE_URL = '/api/core/announcement';
// ============ 类型定义 ============
export interface Announcement {
id: string;
title: string;
content: string;
summary: string;
status: 'draft' | 'expired' | 'published';
priority: 0 | 1 | 2;
is_top: boolean;
target_type: 'all' | 'dept' | 'role' | 'user';
target_ids: string[];
publish_time?: string;
expire_time?: string;
publisher_id?: string;
publisher_name: string;
read_count: number;
created_at: string;
}
export interface AnnouncementListItem {
id: string;
title: string;
summary: string;
status: string;
priority: number;
is_top: boolean;
target_type: string;
publisher_name: string;
read_count: number;
publish_time?: string;
created_at: string;
}
export interface UserAnnouncement {
id: string;
title: string;
summary: string;
content: string;
priority: number;
is_top: boolean;
is_read: boolean;
publisher_name: string;
publish_time?: string;
}
export interface AnnouncementCreate {
title: string;
content: string;
summary?: string;
status?: string;
priority?: number;
is_top?: boolean;
target_type?: string;
target_ids?: string[];
publish_time?: string;
expire_time?: string;
}
export interface AnnouncementUpdate {
title?: string;
content?: string;
summary?: string;
status?: string;
priority?: number;
is_top?: boolean;
target_type?: string;
target_ids?: string[];
publish_time?: string;
expire_time?: string;
}
export interface ReadStats {
total_read: number;
readers: Array<{
read_at: string;
user_id: string;
user_name: string;
}>;
}
// ============ 管理端 API ============
/**
* 获取公告列表(管理端)
*/
export async function getAnnouncementListApi(params?: {
keyword?: string;
page?: number;
pageSize?: number;
status?: string;
}) {
return requestClient.get<{
items: AnnouncementListItem[];
total: number;
}>(`${BASE_URL}/admin/list`, { params });
}
/**
* 获取公告详情(管理端)
*/
export async function getAnnouncementDetailApi(id: string) {
return requestClient.get<Announcement>(`${BASE_URL}/admin/${id}`);
}
/**
* 创建公告
*/
export async function createAnnouncementApi(data: AnnouncementCreate) {
return requestClient.post<Announcement>(`${BASE_URL}/admin`, data);
}
/**
* 更新公告
*/
export async function updateAnnouncementApi(
id: string,
data: AnnouncementUpdate,
) {
return requestClient.put<Announcement>(`${BASE_URL}/admin/${id}`, data);
}
/**
* 删除公告
*/
export async function deleteAnnouncementApi(id: string) {
return requestClient.delete(`${BASE_URL}/admin/${id}`);
}
/**
* 发布公告
*/
export async function publishAnnouncementApi(id: string) {
return requestClient.post<Announcement>(`${BASE_URL}/admin/${id}/publish`);
}
/**
* 获取阅读统计
*/
export async function getReadStatsApi(id: string) {
return requestClient.get<ReadStats>(`${BASE_URL}/admin/${id}/stats`);
}
// ============ 用户端 API ============
/**
* 获取我的公告列表
*/
export async function getUserAnnouncementListApi(params?: {
page?: number;
pageSize?: number;
unread_only?: boolean;
}) {
return requestClient.get<{
items: UserAnnouncement[];
total: number;
}>(`${BASE_URL}/user/list`, { params });
}
/**
* 获取未读公告数量
*/
export async function getUnreadAnnouncementCountApi() {
return requestClient.get<{ count: number }>(`${BASE_URL}/user/unread-count`);
}
/**
* 获取公告详情(用户端,会自动标记已读)
*/
export async function getUserAnnouncementDetailApi(id: string) {
return requestClient.get<UserAnnouncement>(`${BASE_URL}/user/${id}`);
}
/**
* 标记公告已读
*/
export async function markAnnouncementReadApi(id: string) {
return requestClient.post(`${BASE_URL}/user/${id}/read`);
}
@@ -0,0 +1,48 @@
/**
* API Token (Personal Access Token) 管理
*/
import { requestClient } from '#/api/request';
export namespace ApiTokenApi {
export interface TokenItem {
id: string;
name: string;
token_prefix: string;
expires_at?: null | string;
last_used_at?: null | string;
description?: null | string;
is_active: boolean;
sys_create_datetime?: null | string;
}
export interface CreateTokenRequest {
name: string;
expires_at?: null | string;
description?: string;
}
export interface CreateTokenResponse {
id: string;
name: string;
token: string;
token_prefix: string;
expires_at?: null | string;
description?: null | string;
sys_create_datetime?: null | string;
}
}
export async function getApiTokenListApi() {
return requestClient.get<ApiTokenApi.TokenItem[]>('/api/core/api-tokens');
}
export async function createApiTokenApi(data: ApiTokenApi.CreateTokenRequest) {
return requestClient.post<ApiTokenApi.CreateTokenResponse>(
'/api/core/api-tokens',
data,
);
}
export async function revokeApiTokenApi(tokenId: string) {
return requestClient.delete(`/api/core/api-tokens/${tokenId}`);
}
@@ -0,0 +1,204 @@
import { requestClient } from '#/api/request';
/**
* 应用管理 API
* 低代码平台的顶层容器,管理表单、页面、工作流等资源
*/
// ============ 类型定义 ============
/** 应用类型 */
export type AppType =
| 'ai'
| 'dashboard'
| 'form'
| 'mixed'
| 'screen'
| 'workflow';
/** 应用状态 */
export type AppStatus = 'disabled' | 'draft' | 'published';
/** 应用信息 */
export interface Application {
id: string;
name: string;
code: string;
description: string;
icon: string;
cover: string;
app_type: AppType;
status: AppStatus;
home_path: null | string;
version: number;
config: Record<string, any>;
owner_id: null | string;
team_ids: string[];
system_menu_ids: string[];
sort: number;
is_deleted: boolean;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 应用列表项 */
export interface ApplicationListItem {
id: string;
name: string;
code: string;
description: string;
icon: string;
cover: string;
app_type: AppType;
status: AppStatus;
home_path: null | string;
version: number;
owner_id: null | string;
system_menu_ids: string[];
sys_create_datetime: string;
}
/** 创建应用请求 */
export interface ApplicationCreateInput {
name: string;
code: string;
description?: string;
icon?: string;
cover?: string;
app_type?: AppType;
home_path?: string;
config?: Record<string, any>;
system_menu_ids?: string[];
}
/** 更新应用请求 */
export interface ApplicationUpdateInput {
name?: string;
code?: string;
description?: string;
icon?: string;
cover?: string;
app_type?: AppType;
home_path?: string;
status?: AppStatus;
config?: Record<string, any>;
team_ids?: string[];
system_menu_ids?: string[];
}
/** 列表查询参数 */
export interface ApplicationListParams {
page?: number;
pageSize?: number;
keyword?: string;
appType?: AppType;
status?: AppStatus;
}
/** 分页响应 */
interface PaginatedResponse<T> {
items: T[];
total: number;
}
/** 统计信息 */
export interface ApplicationStats {
total: number;
by_status: Record<string, number>;
by_type: Record<string, number>;
}
// ============ 应用管理 API ============
/**
* 获取应用列表(分页)
*/
export async function getApplicationListApi(params?: ApplicationListParams) {
return requestClient.get<PaginatedResponse<ApplicationListItem>>(
'/api/core/applications/',
{ params },
);
}
/**
* 获取应用统计信息
*/
export async function getApplicationStatsApi() {
return requestClient.get<{ data: ApplicationStats }>(
'/api/core/applications/stats',
);
}
/**
* 检查字段唯一性
*/
export async function checkApplicationUniqueApi(
field: 'code' | 'name',
value: string,
excludeId?: string,
) {
return requestClient.get<{ data: { unique: boolean } }>(
'/api/core/applications/check/unique',
{
params: { field, value, excludeId },
},
);
}
/**
* 根据编码获取应用
*/
export async function getApplicationByCodeApi(code: string) {
return requestClient.get<Application>(`/api/core/applications/code/${code}`);
}
/**
* 获取应用详情
*/
export async function getApplicationDetailApi(id: string) {
return requestClient.get<Application>(`/api/core/applications/${id}`);
}
/**
* 创建应用
*/
export async function createApplicationApi(data: ApplicationCreateInput) {
return requestClient.post<Application>('/api/core/applications/', data);
}
/**
* 更新应用
*/
export async function updateApplicationApi(
id: string,
data: ApplicationUpdateInput,
) {
return requestClient.put<Application>(`/api/core/applications/${id}`, data);
}
/**
* 发布应用
*/
export async function publishApplicationApi(id: string) {
return requestClient.post<Application>(
`/api/core/applications/${id}/publish`,
);
}
/**
* 停用应用
*/
export async function disableApplicationApi(id: string) {
return requestClient.post<Application>(
`/api/core/applications/${id}/disable`,
);
}
/**
* 删除应用
*/
export async function deleteApplicationApi(id: string, hard = false) {
return requestClient.delete(`/api/core/applications/${id}`, {
params: { hard },
});
}
+53
View File
@@ -0,0 +1,53 @@
import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
password?: string;
username?: string;
}
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
refreshToken: string;
}
export interface RefreshTokenResult {
data: LoginResult;
status: number;
}
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/api/core/login', data);
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi(refreshToken: string) {
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/api/core/refresh_token',
{
refresh_token: refreshToken,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return requestClient.get('/api/core/logout');
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/api/core/userinfo');
}
+211
View File
@@ -0,0 +1,211 @@
import { requestClient } from '#/api/request';
const BASE_URL = '/api/core/chat';
// ============ 类型定义 ============
export interface Conversation {
id: string;
type: 'group' | 'private';
name?: string;
avatar?: string;
owner_id?: string;
last_message_time?: string;
last_message_preview?: string;
member_count: number;
sys_create_datetime?: string;
unread_count: number;
is_muted: boolean;
is_pinned: boolean;
peer_user_id?: string;
peer_user_name?: string;
peer_user_avatar?: string;
}
export interface ConversationMember {
id: string;
user_id: string;
role: string;
nickname?: string;
is_muted: boolean;
is_pinned: boolean;
unread_count: number;
joined_at?: string;
user_name?: string;
user_avatar?: string;
}
export interface ChatMessage {
id: string;
conversation_id: string;
sender_id: string;
msg_type: string;
content?: string;
file_id?: string;
reply_to_id?: string;
is_recalled: boolean;
recalled_at?: string;
extra?: Record<string, any>;
sys_create_datetime?: string;
sender_name?: string;
sender_avatar?: string;
reply_to_preview?: string;
reply_to_sender_name?: string;
file_name?: string;
file_url?: string;
file_size?: number;
file_ext?: string;
_sending?: boolean;
_tempId?: string;
_localUrl?: string;
}
// ============ 会话 API ============
/** 获取会话列表 */
export async function getConversationsApi() {
return requestClient.get<{ items: Conversation[]; total: number }>(
`${BASE_URL}/conversations`,
);
}
/** 创建/获取单聊 */
export async function createPrivateConversationApi(userId: string) {
return requestClient.post<Conversation>(`${BASE_URL}/conversations/private`, {
user_id: userId,
});
}
/** 创建群聊 */
export async function createGroupConversationApi(data: {
avatar?: string;
member_ids: string[];
name: string;
}) {
return requestClient.post<Conversation>(
`${BASE_URL}/conversations/group`,
data,
);
}
/** 获取会话详情 */
export async function getConversationApi(conversationId: string) {
return requestClient.get<Conversation>(
`${BASE_URL}/conversations/${conversationId}`,
);
}
/** 更新群聊信息 */
export async function updateConversationApi(
conversationId: string,
data: { avatar?: string; name?: string },
) {
return requestClient.put<Conversation>(
`${BASE_URL}/conversations/${conversationId}`,
data,
);
}
/** 解散群聊 */
export async function deleteConversationApi(conversationId: string) {
return requestClient.delete(`${BASE_URL}/conversations/${conversationId}`);
}
// ============ 成员 API ============
/** 获取成员列表 */
export async function getMembersApi(conversationId: string) {
return requestClient.get<ConversationMember[]>(
`${BASE_URL}/conversations/${conversationId}/members`,
);
}
/** 添加成员 */
export async function addMembersApi(conversationId: string, userIds: string[]) {
return requestClient.post(
`${BASE_URL}/conversations/${conversationId}/members`,
{ user_ids: userIds },
);
}
/** 移除成员 */
export async function removeMemberApi(conversationId: string, userId: string) {
return requestClient.delete(
`${BASE_URL}/conversations/${conversationId}/members/${userId}`,
);
}
// ============ 会话设置 API ============
/** 置顶/取消置顶 */
export async function togglePinApi(conversationId: string, value: boolean) {
return requestClient.put(`${BASE_URL}/conversations/${conversationId}/pin`, {
value,
});
}
/** 免打扰设置 */
export async function toggleMuteApi(conversationId: string, value: boolean) {
return requestClient.put(`${BASE_URL}/conversations/${conversationId}/mute`, {
value,
});
}
// ============ 消息 API ============
/** 获取消息列表(游标分页) */
export async function getMessagesApi(
conversationId: string,
params?: { beforeId?: string; limit?: number },
) {
return requestClient.get<{ has_more: boolean; items: ChatMessage[] }>(
`${BASE_URL}/conversations/${conversationId}/messages`,
{ params },
);
}
/** 发送消息(REST备用) */
export async function sendChatMessageApi(
conversationId: string,
data: {
content?: string;
extra?: Record<string, any>;
file_id?: string;
msg_type?: string;
reply_to_id?: string;
},
) {
return requestClient.post<ChatMessage>(
`${BASE_URL}/conversations/${conversationId}/messages`,
data,
);
}
/** 撤回消息 */
export async function recallMessageApi(messageId: string) {
return requestClient.post(`${BASE_URL}/messages/${messageId}/recall`);
}
/** 获取所有未读聊天消息 */
export async function getUnreadChatMessagesApi(limit = 50) {
return requestClient.get<{ items: ChatMessage[]; total: number }>(
`${BASE_URL}/messages/unread`,
{ params: { limit } },
);
}
/** 获取在线用户ID列表 */
export async function getOnlineUsersApi() {
return requestClient.get<{ user_ids: string[] }>(`${BASE_URL}/users/online`);
}
/** 标记已读 */
export async function markConversationReadApi(
conversationId: string,
messageId: string,
) {
return requestClient.post(
`${BASE_URL}/conversations/${conversationId}/read`,
{ message_id: messageId },
);
}
@@ -0,0 +1,526 @@
import { requestClient } from '#/api/request';
/**
* 数据源类型定义
*/
export type DataSourceType = 'api' | 'sql' | 'static';
export type ResultType =
| 'chart-axis'
| 'chart-gauge'
| 'chart-heatmap'
| 'chart-pie'
| 'chart-radar'
| 'chart-scatter'
| 'list'
| 'object'
| 'tree'
| 'value';
export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
export type ApiAuthType = 'api_key' | 'basic_auth' | 'bearer_token' | 'none';
export type ApiBodyType = 'form-data' | 'json' | 'none' | 'raw' | 'x-www-form-urlencoded';
/**
* Query参数定义
*/
export interface QueryParamItem {
key: string;
value: string;
description?: string;
enabled?: boolean;
}
/**
* 认证配置
*/
export interface AuthConfig {
// bearer_token
token?: string;
// basic_auth
username?: string;
password?: string;
// api_key
key_name?: string;
key_value?: string;
key_position?: 'header' | 'query';
}
/**
* 成功条件配置
*/
export interface SuccessCondition {
status_codes?: number[];
field_path?: string;
field_value?: string;
}
/**
* 参数定义
*/
export interface ParamDefinition {
name: string;
label?: string;
type?: 'boolean' | 'date' | 'datetime' | 'float' | 'integer' | 'string';
required?: boolean;
default?: any;
}
/**
* 树形配置
*/
export interface TreeConfig {
id_field?: string;
parent_field?: string;
children_field?: string;
root_value?: any;
}
/**
* 图表配置
*/
export interface ChartConfig {
// chart-axis(轴向图表)配置
x_field?: string; // X轴字段
series_fields?: string[]; // 系列字段(多个)
series_names?: string[]; // 系列名称(可选)
// chart-pie(饼图)配置
name_field?: string; // 名称字段
value_field?: string; // 数值字段
// chart-gauge(仪表盘)配置
max_field?: string; // 最大值字段
// chart-radar(雷达图)配置
indicator_field?: string; // 指标名称字段
value_fields?: string[]; // 数值字段(多个系列)
// chart-scatter(散点图)配置
y_field?: string; // Y坐标字段
size_field?: string; // 大小字段(气泡图)
}
/**
* 数据源完整类型
*/
export interface DataSource {
id: string;
application_id?: string;
application_name?: string;
name: string;
code: string;
source_type: DataSourceType;
description?: string;
status: boolean;
sort?: number;
// API 配置
api_url?: string;
api_method?: HttpMethod;
api_headers?: Record<string, string>;
api_query_params?: QueryParamItem[];
api_body_type?: ApiBodyType;
api_body?: Record<string, any>;
api_content_type?: string;
api_timeout?: number;
api_data_path?: string;
// API 认证
api_auth_type?: ApiAuthType;
api_auth_config?: AuthConfig;
// API 高级
api_retry_count?: number;
api_retry_interval?: number;
api_success_condition?: SuccessCondition;
api_proxy?: string;
api_follow_redirects?: boolean;
api_verify_ssl?: boolean;
// SQL 配置
sql_content?: string;
db_connection?: string;
// 静态数据
static_data?: any[];
// 参数定义
params?: ParamDefinition[];
// 结果处理
result_type?: ResultType;
tree_config?: TreeConfig;
field_mapping?: Record<string, string>;
chart_config?: ChartConfig;
// 缓存配置
cache_enabled?: boolean;
cache_ttl?: number;
// 系统字段
sys_create_datetime?: string;
sys_update_datetime?: string;
}
/**
* 数据源简单类型(用于下拉选择)
*/
export interface DataSourceSimple {
id: string;
application_id?: string;
name: string;
code: string;
source_type: DataSourceType;
result_type?: ResultType;
description?: string;
}
/**
* 创建/更新数据源输入
*/
export interface DataSourceInput {
application_id?: string;
name: string;
code: string;
source_type: DataSourceType;
description?: string;
status?: boolean;
sort?: number;
// API 配置
api_url?: string;
api_method?: HttpMethod;
api_headers?: Record<string, string>;
api_query_params?: QueryParamItem[];
api_body_type?: ApiBodyType;
api_body?: Record<string, any>;
api_content_type?: string;
api_timeout?: number;
api_data_path?: string;
// API 认证
api_auth_type?: ApiAuthType;
api_auth_config?: AuthConfig;
// API 高级
api_retry_count?: number;
api_retry_interval?: number;
api_success_condition?: SuccessCondition;
api_proxy?: string;
api_follow_redirects?: boolean;
api_verify_ssl?: boolean;
// SQL 配置
sql_content?: string;
db_connection?: string;
// 静态数据
static_data?: any[];
// 参数定义
params?: ParamDefinition[];
// 结果处理
result_type?: ResultType;
tree_config?: TreeConfig;
field_mapping?: Record<string, string>;
chart_config?: ChartConfig;
// 缓存配置
cache_enabled?: boolean;
cache_ttl?: number;
}
/**
* 列表查询参数
*/
export interface DataSourceListParams {
page?: number;
pageSize?: number;
applicationId?: string;
name?: string;
code?: string;
source_type?: DataSourceType;
status?: boolean;
}
/**
* 分页响应
*/
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/**
* 预览请求
*/
export interface PreviewRequest {
params?: Record<string, any>;
limit?: number;
}
/**
* 测试请求
*/
export interface TestRequest {
source_type: DataSourceType;
// API 配置
api_url?: string;
api_method?: HttpMethod;
api_headers?: Record<string, string>;
api_query_params?: QueryParamItem[];
api_body_type?: ApiBodyType;
api_body?: Record<string, any>;
api_content_type?: string;
api_timeout?: number;
api_data_path?: string;
// API 认证
api_auth_type?: ApiAuthType;
api_auth_config?: AuthConfig;
// API 高级
api_retry_count?: number;
api_retry_interval?: number;
api_success_condition?: SuccessCondition;
api_proxy?: string;
api_follow_redirects?: boolean;
api_verify_ssl?: boolean;
// SQL 配置
sql_content?: string;
db_connection?: string;
// 静态数据
static_data?: any[];
// 参数
params_def?: ParamDefinition[];
params?: Record<string, any>;
// 结果处理
result_type?: ResultType;
tree_config?: TreeConfig;
field_mapping?: Record<string, string>;
chart_config?: ChartConfig;
}
/**
* 复制请求
*/
export interface CopyRequest {
new_code: string;
new_name?: string;
}
/** 数据源导入请求 */
export type DataSourceImportInput = DataSourceInput;
/** 数据源导入预检查请求 */
export interface DataSourceImportCheckInput {
code: string;
}
/** 数据源导入预检查结果 */
export interface DataSourceImportCheckResult {
code_exists: boolean;
can_import: boolean;
}
// ============ CRUD API ============
/**
* 创建数据源
*/
export async function createDataSourceApi(data: DataSourceInput) {
return requestClient.post<DataSource>('/api/core/data-source', data);
}
/**
* 获取数据源列表(分页)
*/
export async function getDataSourceListApi(params?: DataSourceListParams) {
return requestClient.get<PaginatedResponse<DataSource>>(
'/api/core/data-source',
{
params,
},
);
}
/**
* 获取所有数据源(不分页)
*/
export async function getAllDataSourceApi(applicationId?: string) {
return requestClient.get<DataSourceSimple[]>(
'/api/core/data-source/get/all',
{
params: { applicationId },
},
);
}
/**
* 获取数据源详情
*/
export async function getDataSourceDetailApi(id: string) {
return requestClient.get<DataSource>(`/api/core/data-source/${id}`);
}
/**
* 根据编码获取数据源详情(包含参数定义)
*/
export async function getDataSourceByCodeApi(code: string) {
return requestClient.get<DataSource>(`/api/core/data-source/code/${code}`);
}
/**
* 更新数据源
*/
export async function updateDataSourceApi(id: string, data: DataSourceInput) {
return requestClient.put<DataSource>(`/api/core/data-source/${id}`, data);
}
/**
* 删除数据源
*/
export async function deleteDataSourceApi(id: string) {
return requestClient.delete<DataSource>(`/api/core/data-source/${id}`);
}
// ============ 执行 API ============
/**
* 执行数据源(GET 方式)
*/
export async function executeDataSourceGetApi(
code: string,
params?: Record<string, any>,
) {
return requestClient.get<any>(`/api/core/data-source/execute/${code}`, {
params,
});
}
/**
* 执行数据源(POST 方式)
*/
export async function executeDataSourcePostApi(
code: string,
params?: Record<string, any>,
) {
return requestClient.post<any>(`/api/core/data-source/execute/${code}`, {
params: params || {},
});
}
/**
* 预览数据源数据
*/
export async function previewDataSourceApi(id: string, data?: PreviewRequest) {
return requestClient.post<{ data: any[]; limited: number; total: number }>(
`/api/core/data-source/${id}/preview`,
data || { params: {}, limit: 100 },
);
}
/**
* 测试数据源配置
*/
export async function testDataSourceApi(data: TestRequest) {
return requestClient.post<{
data: any[];
limited: number;
success: boolean;
total: number;
}>('/api/core/data-source/test', data);
}
// ============ 其他 API ============
/**
* 复制数据源
*/
export async function copyDataSourceApi(id: string, data: CopyRequest) {
return requestClient.post<DataSource>(
`/api/core/data-source/${id}/copy`,
data,
);
}
/**
* 导出数据源配置(返回 JSON 文件)
*/
export async function exportDataSourceConfigApi(id: string) {
return requestClient.get<Blob>(`/api/core/data-source/${id}/export`, {
responseType: 'blob',
});
}
/**
* 导入预检查
*/
export async function checkImportDataSourceApi(data: DataSourceImportCheckInput) {
return requestClient.post<DataSourceImportCheckResult>(
'/api/core/data-source/import/check',
data,
);
}
/**
* 导入数据源配置
*/
export async function importDataSourceConfigApi(data: DataSourceImportInput) {
return requestClient.post<DataSource>('/api/core/data-source/import', data);
}
/**
* 清除数据源缓存
*/
export async function clearDataSourceCacheApi(id: string) {
return requestClient.post<{ msg: string }>(
`/api/core/data-source/${id}/clear-cache`,
);
}
/**
* 检查编码是否可用
*/
export async function checkCodeAvailableApi(code: string) {
return requestClient.get<{ available: boolean }>(
`/api/core/data-source/check-code/${code}`,
);
}
// ============ AI SQL 生成 API ============
/**
* 表关系定义
*/
export interface TableRelation {
id: string;
sourceTable: string;
sourceField: string;
targetTable: string;
targetField: string;
relationType: 'many-to-many' | 'many-to-one' | 'one-to-many' | 'one-to-one';
}
/**
* 表字段信息
*/
export interface TableFieldInfo {
name: string;
type: string;
comment?: string;
isPrimaryKey?: boolean;
}
/**
* AI 生成 SQL 请求
*/
export interface AIGenerateSqlRequest {
user_question: string;
db_connection: string;
database?: string;
schema_name?: string;
selected_tables?: string[];
table_fields?: Record<string, TableFieldInfo[]>;
table_relations?: TableRelation[];
include_table_relations?: boolean;
model_id: string;
}
/**
* AI 生成 SQL 响应
*/
export interface AIGenerateSqlResponse {
sql: string;
thought: string;
params: ParamDefinition[];
}
/**
* AI 生成 SQL
*/
export async function aiGenerateSqlApi(data: AIGenerateSqlRequest) {
return requestClient.post<AIGenerateSqlResponse>(
'/api/core/data-source/ai/generate-sql',
data,
);
}
@@ -0,0 +1,176 @@
import { requestClient } from '#/api/request';
export type DatabaseConnectionType =
| 'mysql'
| 'oracle'
| 'postgresql'
| 'sqlserver';
export interface DatabaseConnection {
id: string;
code: string;
name: string;
db_type: DatabaseConnectionType;
host: string;
port: number;
user: string;
default_database: string;
description: string;
status: boolean;
is_system: boolean;
has_password: boolean;
application_id?: string;
application_name?: string;
extra_options?: Record<string, unknown>;
sort?: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface DatabaseConnectionInput {
code: string;
name: string;
dbType: DatabaseConnectionType;
host: string;
port: number;
user?: string;
password?: string;
defaultDatabase?: string;
description?: string;
status?: boolean;
applicationId?: string;
}
export interface DatabaseConnectionUpdateInput {
name?: string;
dbType?: DatabaseConnectionType;
host?: string;
port?: number;
user?: string;
password?: string;
defaultDatabase?: string;
description?: string;
status?: boolean;
}
export interface DatabaseConnectionTestInput {
dbType: DatabaseConnectionType;
host: string;
port: number;
user?: string;
password?: string;
defaultDatabase?: string;
}
export interface DatabaseConnectionTestResult {
success: boolean;
message: string;
db_name?: string;
db_type?: string;
}
export interface DatabaseConnectionSimple {
code: string;
name: string;
db_type: string;
host: string;
port: number;
database: string;
is_system: boolean;
}
export async function getDatabaseConnectionListApi(params?: {
page?: number;
pageSize?: number;
applicationId?: string;
name?: string;
code?: string;
status?: boolean;
}) {
return requestClient.get<{ items: DatabaseConnection[]; total: number }>(
'/api/core/database-connection',
{ params },
);
}
export async function getAllDatabaseConnectionApi(applicationId?: string) {
return requestClient.get<DatabaseConnectionSimple[]>(
'/api/core/database-connection/get/all',
{ params: { applicationId } },
);
}
export async function getDatabaseConnectionDetailApi(id: string) {
return requestClient.get<DatabaseConnection>(
`/api/core/database-connection/${id}`,
);
}
export async function createDatabaseConnectionApi(data: DatabaseConnectionInput) {
return requestClient.post<DatabaseConnection>(
'/api/core/database-connection',
{
code: data.code,
name: data.name,
db_type: data.dbType,
host: data.host,
port: data.port,
user: data.user ?? '',
password: data.password ?? '',
default_database: data.defaultDatabase ?? '',
description: data.description ?? '',
status: data.status ?? true,
application_id: data.applicationId,
},
);
}
export async function updateDatabaseConnectionApi(
id: string,
data: DatabaseConnectionUpdateInput,
) {
return requestClient.put<DatabaseConnection>(
`/api/core/database-connection/${id}`,
{
name: data.name,
db_type: data.dbType,
host: data.host,
port: data.port,
user: data.user,
password: data.password,
default_database: data.defaultDatabase,
description: data.description,
status: data.status,
},
);
}
export async function deleteDatabaseConnectionApi(id: string) {
return requestClient.delete(`/api/core/database-connection/${id}`);
}
export async function testDatabaseConnectionApi(data: DatabaseConnectionTestInput) {
return requestClient.post<DatabaseConnectionTestResult>(
'/api/core/database-connection/test',
{
db_type: data.dbType,
host: data.host,
port: data.port,
user: data.user ?? '',
password: data.password ?? '',
default_database: data.defaultDatabase ?? '',
},
);
}
export async function testSavedDatabaseConnectionApi(id: string) {
return requestClient.post<DatabaseConnectionTestResult>(
`/api/core/database-connection/${id}/test`,
);
}
export async function checkDatabaseConnectionCodeApi(code: string) {
return requestClient.get<{ available: boolean; message?: string }>(
`/api/core/database-connection/check-code/${code}`,
);
}
@@ -0,0 +1,515 @@
/**
* 数据库管理API
*/
import { requestClient } from '#/api/request';
// ==================== 类型定义 ====================
/**
* 数据库配置
*/
export interface DatabaseConfig {
db_name: string;
name: string;
db_type: string;
host: string;
port: number;
database: string;
user: string;
has_password: boolean;
is_system?: boolean;
display_name?: string;
}
/**
* 数据库信息
*/
export interface DatabaseInfo {
name: string;
owner?: string;
encoding?: string;
collation?: string;
size?: string;
size_bytes?: number;
tables_count?: number;
description?: string;
}
/**
* Schema信息
*/
export interface SchemaInfo {
name: string;
owner?: string;
tables_count?: number;
}
/**
* 表信息
*/
export interface TableInfo {
schema_name?: string;
table_name: string;
table_type?: string;
row_count?: number;
total_size?: string;
total_size_bytes?: number;
description?: string;
}
/**
* 字段信息
*/
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable: boolean;
column_default?: string;
character_maximum_length?: number;
numeric_precision?: number;
numeric_scale?: number;
ordinal_position?: number;
is_primary_key: boolean;
is_unique: boolean;
description?: string;
}
/**
* 索引信息
*/
export interface IndexInfo {
index_name: string;
index_type?: string;
columns: string;
is_unique: boolean;
is_primary: boolean;
definition?: string;
}
/**
* 约束信息
*/
export interface ConstraintInfo {
constraint_name: string;
constraint_type: string;
columns?: string;
definition?: string;
referenced_table?: string;
referenced_columns?: string;
}
/**
* 表结构
*/
export interface TableStructure {
table_info: TableInfo;
columns: ColumnInfo[];
indexes: IndexInfo[];
constraints: ConstraintInfo[];
}
/**
* 查询数据参数
*/
export interface QueryDataParams {
table_name: string;
schema_name?: string;
database?: string;
page: number;
page_size: number;
where?: string;
order_by?: string;
}
/**
* 查询数据响应
*/
export interface QueryDataResponse {
columns: string[];
rows: Record<string, any>[];
total: number;
page: number;
page_size: number;
}
/**
* SQL执行参数
*/
export interface ExecuteSQLParams {
sql: string;
is_query: boolean;
}
/**
* SQL执行响应
*/
export interface ExecuteSQLResponse {
success: boolean;
message: string;
columns?: string[];
rows?: Record<string, any>[];
affected_rows?: number;
execution_time: number;
}
/**
* 数据库操作响应
*/
export interface DatabaseOperationResponse {
message: string;
success: boolean;
}
/**
* Schema 操作响应
*/
export interface SchemaOperationResponse {
message: string;
success: boolean;
}
/**
* 创建数据库参数
*/
export interface CreateDatabaseParams {
charset?: string;
collation?: string;
encoding?: string;
name: string;
owner?: string;
template?: string;
}
/**
* 创建 Schema 参数
*/
export interface CreateSchemaParams {
database?: string;
name: string;
owner?: string;
}
/**
* 重命名 Schema 参数
*/
export interface RenameDatabaseParams {
new_name: string;
}
export interface RenameSchemaParams {
database?: string;
new_name: string;
}
// ==================== API接口 ====================
/**
* 获取数据库配置列表
*/
export async function getDatabaseConfigsApi() {
return requestClient.get<DatabaseConfig[]>(
'/api/core/database_manager/configs',
);
}
/**
* 测试数据库连接
*/
export async function testDatabaseConnectionApi(dbName: string) {
return requestClient.post<ConnectionTestResponse>(
`/api/core/database_manager/${dbName}/test`,
);
}
/**
* 获取数据库列表
*/
export async function getDatabasesApi(dbName: string) {
return requestClient.get<DatabaseInfo[]>(
`/api/core/database_manager/${dbName}/databases`,
);
}
/**
* 创建数据库
*/
export async function createDatabaseApi(
dbName: string,
data: CreateDatabaseParams,
) {
return requestClient.post<DatabaseOperationResponse>(
`/api/core/database_manager/${dbName}/databases`,
data,
);
}
/**
* 删除数据库
*/
export async function dropDatabaseApi(dbName: string, databaseName: string) {
return requestClient.delete<DatabaseOperationResponse>(
`/api/core/database_manager/${dbName}/databases/${encodeURIComponent(databaseName)}`,
);
}
/**
* 重命名数据库(PostgreSQL
*/
export async function renameDatabaseApi(
dbName: string,
databaseName: string,
data: RenameDatabaseParams,
) {
return requestClient.patch<DatabaseOperationResponse>(
`/api/core/database_manager/${dbName}/databases/${encodeURIComponent(databaseName)}`,
data,
);
}
/**
* 获取Schema列表(PostgreSQL
*/
export async function getSchemasApi(dbName: string, database?: string) {
return requestClient.get<SchemaInfo[]>(
`/api/core/database_manager/${dbName}/schemas`,
{ params: { database } },
);
}
/**
* 创建 SchemaPostgreSQL
*/
export async function createSchemaApi(
dbName: string,
data: CreateSchemaParams,
) {
return requestClient.post<SchemaOperationResponse>(
`/api/core/database_manager/${dbName}/schemas`,
data,
);
}
/**
* 删除 SchemaPostgreSQL
*/
export async function dropSchemaApi(
dbName: string,
schemaName: string,
database?: string,
cascade = true,
) {
return requestClient.delete<SchemaOperationResponse>(
`/api/core/database_manager/${dbName}/schemas/${encodeURIComponent(schemaName)}`,
{ params: { cascade, database } },
);
}
/**
* 重命名 SchemaPostgreSQL
*/
export async function renameSchemaApi(
dbName: string,
schemaName: string,
data: RenameSchemaParams,
) {
return requestClient.patch<SchemaOperationResponse>(
`/api/core/database_manager/${dbName}/schemas/${encodeURIComponent(schemaName)}`,
data,
);
}
/**
* 获取表列表
*/
export async function getTablesApi(
dbName: string,
database?: string,
schemaName?: string,
) {
return requestClient.get<TableInfo[]>(
`/api/core/database_manager/${dbName}/tables`,
{ params: { database, schema_name: schemaName } },
);
}
/**
* 获取表结构
*/
export async function getTableStructureApi(
dbName: string,
tableName: string,
database?: string,
schemaName?: string,
) {
return requestClient.get<TableStructure>(
`/api/core/database_manager/${dbName}/tables/${tableName}/structure`,
{ params: { database, schema_name: schemaName } },
);
}
/**
* 获取表字段
*/
export async function getTableColumnsApi(
dbName: string,
tableName: string,
database?: string,
schemaName?: string,
) {
return requestClient.get<ColumnInfo[]>(
`/api/core/database_manager/${dbName}/tables/${tableName}/columns`,
{ params: { database, schema_name: schemaName } },
);
}
/**
* 获取表索引
*/
export async function getTableIndexesApi(
dbName: string,
tableName: string,
database?: string,
schemaName?: string,
) {
return requestClient.get<IndexInfo[]>(
`/api/core/database_manager/${dbName}/tables/${tableName}/indexes`,
{ params: { database, schema_name: schemaName } },
);
}
/**
* 获取表约束
*/
export async function getTableConstraintsApi(
dbName: string,
tableName: string,
database?: string,
schemaName?: string,
) {
return requestClient.get<ConstraintInfo[]>(
`/api/core/database_manager/${dbName}/tables/${tableName}/constraints`,
{ params: { database, schema_name: schemaName } },
);
}
/**
* 查询表数据
*/
export async function queryTableDataApi(
dbName: string,
params: QueryDataParams,
) {
return requestClient.post<QueryDataResponse>(
`/api/core/database_manager/${dbName}/query`,
params,
);
}
/**
* 执行SQL
*/
export async function executeSQLApi(
dbName: string,
data: ExecuteSQLParams,
): Promise<ExecuteSQLResponse> {
return requestClient.post<ExecuteSQLResponse>(
`/api/core/database_manager/${dbName}/execute`,
data,
);
}
/**
* 获取表DDL语句
*/
export async function getTableDDLApi(
dbName: string,
tableName: string,
database?: string,
schemaName?: string,
): Promise<{ ddl: string }> {
return requestClient.get(
`/api/core/database_manager/${dbName}/tables/${tableName}/ddl`,
{ params: { database, schema_name: schemaName } },
);
}
// ============ 视图管理 ============
/**
* 获取视图列表
*/
export async function getViewsApi(
dbName: string,
database?: string,
schemaName?: string,
): Promise<any[]> {
return requestClient.get(`/api/core/database_manager/${dbName}/views`, {
params: { database, schema_name: schemaName },
});
}
/**
* 获取视图结构
*/
export async function getViewStructureApi(
dbName: string,
viewName: string,
schemaName?: string,
): Promise<any> {
return requestClient.get(
`/api/core/database_manager/${dbName}/views/${viewName}/structure`,
{
params: { schema_name: schemaName },
},
);
}
/**
* 获取视图定义SQL
*/
export async function getViewDefinitionApi(
dbName: string,
viewName: string,
schemaName?: string,
): Promise<{ definition: string }> {
return requestClient.get(
`/api/core/database_manager/${dbName}/views/${viewName}/definition`,
{
params: { schema_name: schemaName },
},
);
}
/**
* 获取视图依赖的表
*/
export async function getViewDependenciesApi(
dbName: string,
viewName: string,
schemaName?: string,
): Promise<string[]> {
return requestClient.get(
`/api/core/database_manager/${dbName}/views/${viewName}/dependencies`,
{
params: { schema_name: schemaName },
},
);
}
/**
* 执行DDL语句
*/
export async function executeDDLApi(
dbName: string,
data: {
database?: string;
schema_name?: string;
sql: string;
},
) {
return requestClient.post<{
affected_rows: number;
message: string;
success: boolean;
}>(`/api/core/database_manager/${dbName}/execute/ddl`, data);
}
+240
View File
@@ -0,0 +1,240 @@
import { requestClient } from '#/api/request';
/**
* 部门相关类型定义
*/
export interface Dept {
id: string;
parent_id?: string;
name: string;
dept_type: string; // 'company' | 'department' | 'team' | 'other'
code?: string;
phone?: string;
email?: string;
status: boolean;
description?: string;
lead_id?: string;
level: number;
path: string;
dept_type_display?: string;
lead_name?: string;
user_count?: number;
child_count?: number;
can_delete?: boolean;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface DeptTreeNode extends Dept {
children?: DeptTreeNode[];
}
export interface DeptCreateInput {
parent_id?: string;
name: string;
dept_type?: string; // 'company' | 'department' | 'team' | 'other'
code?: string;
phone?: string;
email?: string;
status?: boolean;
description?: string;
lead_id?: string;
}
export interface DeptUpdateInput extends Partial<DeptCreateInput> {}
export interface DeptBatchDeleteInput {
ids: string[];
}
export interface DeptMoveInput {
target_parent_id?: string;
position?: number;
}
export interface DeptListParams {
page?: number;
pageSize?: number;
name?: string;
dept_type?: string;
status?: boolean;
parent_id?: string;
}
export interface DeptUser {
id: string;
username: string;
name?: string;
email?: string;
mobile?: string;
user_status?: number;
}
export interface DeptStats {
total_count: number;
active_count: number;
inactive_count: number;
type_counts: Record<string, number>;
max_level: number;
total_users: number;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/**
* 创建部门
*/
export async function createDeptApi(data: DeptCreateInput) {
return requestClient.post<Dept>('/api/core/dept', data);
}
/**
* 获取部门列表(分页)
*/
export async function getDeptListApi(params?: DeptListParams) {
return requestClient.get<Dept[]>('/api/core/dept', { params });
}
/**
* 获取部门树形结构
*/
export async function getDeptTreeApi() {
return requestClient.get<DeptTreeNode[]>('/api/core/dept/tree');
}
/**
* 获取部门详情
*/
export async function getDeptDetailApi(deptId: string) {
return requestClient.get<Dept>(`/api/core/dept/${deptId}`);
}
/**
* 更新部门
*/
export async function updateDeptApi(deptId: string, data: DeptUpdateInput) {
return requestClient.put<Dept>(`/api/core/dept/${deptId}`, data);
}
/**
* 删除部门
*/
export async function deleteDeptApi(deptId: string) {
return requestClient.delete<Dept>(`/api/core/dept/${deptId}`);
}
/**
* 批量删除部门
*/
export async function batchDeleteDeptApi(data: DeptBatchDeleteInput) {
return requestClient.post<{ count: number }>(
'/api/core/dept/batch_delete',
data,
);
}
/**
* 根据父部门ID获取子部门
*/
export async function getDeptByParentApi(parentId?: string) {
const url = parentId
? `/api/core/dept/by/parent/${parentId}`
: '/api/core/dept/by/parent/null';
return requestClient.get<Dept[]>(url);
}
/**
* 搜索部门
*/
export async function searchDeptApi(keyword: string) {
return requestClient.get<Dept[]>('/api/core/dept/search', {
params: { keyword },
});
}
/**
* 移动部门
*/
export async function moveDeptApi(deptId: string, data: DeptMoveInput) {
return requestClient.post<Dept>(`/api/core/dept/${deptId}/move`, data);
}
/**
* 获取部门路径
*/
export async function getDeptPathApi(deptId: string) {
return requestClient.get<Dept[]>(`/api/core/dept/${deptId}/path`);
}
/**
* 获取部门用户列表
*/
export async function getDeptUsersApi(
deptId: string,
params?: {
include_children?: boolean;
page?: number;
pageSize?: number;
username?: string;
},
) {
return requestClient.get<PaginatedResponse<DeptUser>>(
`/api/core/dept/users/${deptId}`,
{ params },
);
}
/**
* 为部门添加用户
*/
export async function addDeptUsersApi(
deptId: string,
data: { user_ids: string[] },
) {
return requestClient.post<{ count: number }>(
`/api/core/dept/users/${deptId}`,
data,
);
}
/**
* 从部门移除用户
*/
export async function removeDeptUsersApi(
deptId: string,
data: { user_ids: string[] },
) {
return requestClient.delete<{ count: number }>(
`/api/core/dept/users/${deptId}`,
{ data },
);
}
/**
* 获取部门统计信息
*/
export async function getDeptStatsApi() {
return requestClient.get<DeptStats>('/api/core/dept/stats');
}
/**
* 获取简单部门列表(用于选择器)
*/
export async function getSimpleDeptListApi() {
return requestClient.get<Dept[]>('/api/core/dept/simple');
}
/**
* 根据ID列表获取部门
*/
export async function getDeptsByIds(ids: string[]) {
if (!ids || ids.length === 0) return [];
return requestClient.get<DeptTreeNode[]>('/api/core/dept/by/ids', {
params: { ids: ids.join(',') },
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 设备管理 API
*/
import { requestClient } from '#/api/request';
export namespace DeviceApi {
/** 设备信息 */
export interface DeviceInfo {
device_id: string;
device_name?: string;
device_type?: string;
browser_type?: string;
os_type?: string;
ip_address?: string;
last_active_time?: string;
is_current: boolean;
is_online: boolean;
}
/** 设备列表响应 */
export interface DeviceListResponse {
current_device?: DeviceInfo;
online_devices: DeviceInfo[];
total_count: number;
}
/** 设备重命名请求 */
export interface DeviceRenameRequest {
device_name: string;
}
/** 统计信息响应 */
export interface StatisticsResponse {
online_count: number;
total_count: number;
}
}
/**
* 获取设备列表
*/
export async function getDeviceListApi() {
return requestClient.get<DeviceApi.DeviceListResponse>('/api/core/devices');
}
/**
* 强制登出指定设备
*/
export async function logoutDeviceApi(deviceId: string) {
return requestClient.delete(`/api/core/devices/${deviceId}`);
}
/**
* 登出其他所有设备
*/
export async function logoutOtherDevicesApi() {
return requestClient.delete('/api/core/devices/logout-others');
}
/**
* 重命名设备
*/
export async function renameDeviceApi(
deviceId: string,
data: DeviceApi.DeviceRenameRequest,
) {
return requestClient.post(`/api/core/devices/${deviceId}/rename`, data);
}
/**
* 获取设备统计信息
*/
export async function getDeviceStatisticsApi() {
return requestClient.get<DeviceApi.StatisticsResponse>(
'/api/core/devices/statistics',
);
}
+187
View File
@@ -0,0 +1,187 @@
import { requestClient } from '#/api/request';
/**
* 字典相关类型定义
*/
export interface Dict {
id: string;
application_id?: string;
application_name?: string;
is_global?: boolean;
name: string;
code: string;
status: boolean;
remark?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface DictCreateInput {
application_id?: string;
is_global?: boolean;
name: string;
code: string;
status?: boolean;
remark?: string;
}
export interface DictUpdateInput extends Partial<DictCreateInput> {}
export interface DictListParams {
page?: number;
pageSize?: number;
applicationId?: string;
name?: string;
code?: string;
status?: boolean;
}
/**
* 字典项相关类型定义
*/
export interface DictItem {
id: string;
dict_id: string;
label?: string;
value?: string;
icon?: string;
sort?: number;
status: boolean;
remark?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface DictItemCreateInput {
dict_id: string;
label?: string;
value?: string;
icon?: string;
sort?: number;
status?: boolean;
remark?: string;
}
export interface DictItemUpdateInput extends Partial<DictItemCreateInput> {}
export interface DictItemListParams {
page?: number;
pageSize?: number;
dict_id?: string;
label?: string;
value?: string;
status?: boolean;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// ============ 字典API ============
/**
* 创建字典
*/
export async function createDictApi(data: DictCreateInput) {
return requestClient.post<Dict>('/api/core/dict', data);
}
/**
* 获取字典列表(分页)
*/
export async function getDictListApi(params?: DictListParams) {
return requestClient.get<PaginatedResponse<Dict>>('/api/core/dict', {
params,
});
}
/**
* 获取所有字典(不分页)
*/
export async function getAllDictApi(applicationId?: string) {
return requestClient.get<Dict[]>('/api/core/dict/all', {
params: { applicationId },
});
}
/**
* 获取字典详情
*/
export async function getDictDetailApi(dictId: string) {
return requestClient.get<Dict>(`/api/core/dict/${dictId}`);
}
/**
* 更新字典
*/
export async function updateDictApi(dictId: string, data: DictUpdateInput) {
return requestClient.put<Dict>(`/api/core/dict/${dictId}`, data);
}
/**
* 删除字典
*/
export async function deleteDictApi(dictId: string) {
return requestClient.delete<Dict>(`/api/core/dict/${dictId}`);
}
// ============ 字典项API ============
/**
* 创建字典项
*/
export async function createDictItemApi(data: DictItemCreateInput) {
return requestClient.post<DictItem>('/api/core/dict_item', data);
}
/**
* 获取字典项列表(分页)
*/
export async function getDictItemListApi(params?: DictItemListParams) {
return requestClient.get<PaginatedResponse<DictItem>>('/api/core/dict_item', {
params,
});
}
/**
* 获取所有字典项(不分页)
*/
export async function getAllDictItemApi() {
return requestClient.get<DictItem[]>('/api/core/dict_item/get/all');
}
/**
* 根据字典编码获取字典项
*/
export async function getDictItemByCodeApi(code: string) {
return requestClient.get<DictItem[]>(
`/api/core/dict_item/by/dict_code/${code}`,
);
}
/**
* 获取字典项详情
*/
export async function getDictItemDetailApi(dictItemId: string) {
return requestClient.get<DictItem>(`/api/core/dict_item/${dictItemId}`);
}
/**
* 更新字典项
*/
export async function updateDictItemApi(
dictItemId: string,
data: DictItemUpdateInput,
) {
return requestClient.put<DictItem>(`/api/core/dict_item/${dictItemId}`, data);
}
/**
* 删除字典项
*/
export async function deleteDictItemApi(dictItemId: string) {
return requestClient.delete<DictItem>(`/api/core/dict_item/${dictItemId}`);
}
@@ -0,0 +1,118 @@
import { requestClient } from '#/api/request';
export interface FieldPermission {
field_name: string;
permission_type: 'hidden' | 'masked' | 'read' | 'write';
mask_rule?: 'default' | 'email' | 'id_card' | 'name' | 'phone';
}
export interface FieldPermissionConfig extends FieldPermission {
id: string;
role_id: string;
resource_type: string;
sort: number;
is_deleted: boolean;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface FieldPermissionBatchUpdate {
role_id: string;
resource_type: string;
configs: FieldPermission[];
}
export interface ResourceFieldMetadata {
field_name: string;
label: string;
field_type: string;
required?: boolean;
sensitive: boolean;
maskable: boolean;
default_permission: string;
}
export interface ResourceFieldsMetadata {
resource_type: string;
display_name: string;
fields: ResourceFieldMetadata[];
}
/**
* 获取所有资源的字段元数据
* @param applicationId 应用ID,子应用访问时只显示该应用的资源
*/
export async function getAllResourceFieldsApi(applicationId?: string) {
return requestClient.get<ResourceFieldsMetadata[]>(
'/api/core/field-permissions/resource-fields',
{
params: applicationId ? { applicationId } : undefined,
},
);
}
/**
* 获取指定资源的字段元数据
*/
export async function getResourceFieldsApi(resourceType: string) {
return requestClient.get<ResourceFieldsMetadata>(
`/api/core/field-permissions/resource-fields/${resourceType}`,
);
}
/**
* 获取角色的字段权限配置
*/
export async function getRoleFieldPermissionsApi(
roleId: string,
resourceType: string,
) {
return requestClient.get<FieldPermissionConfig[]>(
`/api/core/field-permissions/${roleId}`,
{
params: { resource_type: resourceType },
},
);
}
/**
* 批量更新字段权限配置
*/
export async function batchUpdateFieldPermissionsApi(
data: FieldPermissionBatchUpdate,
) {
return requestClient.post('/api/core/field-permissions/batch', data);
}
/**
* 删除角色的字段权限配置
*/
export async function deleteRoleFieldPermissionsApi(
roleId: string,
resourceType: string,
) {
return requestClient.delete(`/api/core/field-permissions/${roleId}`, {
params: { resource_type: resourceType },
});
}
/**
* 权限类型选项
*/
export const PERMISSION_TYPE_OPTIONS = [
{ label: '可读', value: 'read' },
{ label: '可写', value: 'write' },
{ label: '隐藏', value: 'hidden' },
{ label: '脱敏', value: 'masked' },
];
/**
* 脱敏规则选项
*/
export const MASK_RULE_OPTIONS = [
{ label: '手机号', value: 'phone' },
{ label: '邮箱', value: 'email' },
{ label: '身份证', value: 'id_card' },
{ label: '姓名', value: 'name' },
{ label: '默认', value: 'default' },
];
+507
View File
@@ -0,0 +1,507 @@
/**
* -*- coding: utf-8 -*-
* time: 2024/12/19
* author: 臧成龙
* QQ: 939589097
*/
import { requestClient } from '#/api/request';
export namespace SystemFileManagerApi {
export interface FileItem {
[key: string]: any;
id?: string;
name: string;
path: string;
file_type: 'file' | 'folder'; // 后端返回 file_type
file_size?: number; // 后端返回 file_size
file_ext?: string;
parent_id?: string;
updated_time?: string; // 后端返回 updated_time
has_children?: boolean;
}
export interface FolderTree {
id: string;
name: string;
path: string;
parent_id?: string;
children?: FolderTree[];
}
export interface FileListParams {
path?: string;
parent_id?: null | string;
type?: 'file' | 'folder';
page?: number;
pageSize?: number;
}
export interface FileListResult {
items: FileItem[];
total: number;
}
export interface StorageConfig {
[key: string]: any;
storage_type?: string;
max_file_size?: number;
allowed_extensions?: string[];
}
export interface MoveParams {
source_ids: string[];
target_parent_id: string;
}
export interface RenameParams {
name: string;
}
export interface BatchDeleteParams {
ids: string[];
}
export interface FolderCreateParams {
name: string;
parent_id?: string;
path?: string;
}
export interface CreateAccessTokenParams {
fileId: string;
expiresIn?: number;
}
export interface AccessTokenResponse {
token: string;
expiresAt: string;
fileId: string;
}
export interface AccessTokenUrlResponse {
url: string;
token: string;
expiresAt: string;
}
}
/**
* 获取文件列表
* @param params 查询参数
*/
async function getFileList(params?: SystemFileManagerApi.FileListParams) {
return requestClient.get<SystemFileManagerApi.FileListResult>(
'/api/core/file_manager',
{ params },
);
}
/**
* 获取最近上传的图片
* @param limit 数量,默认20
*/
async function getRecentImages(limit: number = 20) {
return requestClient.get<SystemFileManagerApi.FileItem[]>(
'/api/core/file_manager/recent/images',
{ params: { limit } },
);
}
/**
* 获取最近上传的文件(所有类型)
* @param limit 数量,默认20
*/
async function getRecentFiles(limit: number = 20) {
return requestClient.get<SystemFileManagerApi.FileItem[]>(
'/api/core/file_manager/recent/files',
{ params: { limit } },
);
}
/**
* 上传文件
* @param file 文件对象
* @param options 上传选项
* @param options.parentId 父文件夹ID
* @param options.isPublic 是否公开(公开文件无需认证即可访问)
* @param options.onProgress 上传进度回调
*/
async function uploadFile(
file: File,
options?: {
isPublic?: boolean;
onProgress?: (progressEvent: {
loaded: number;
percentage: number;
total: number;
}) => void;
parentId?: string;
source?: string;
},
) {
const { parentId, isPublic = false, source, onProgress } = options || {};
return requestClient.upload(
'/api/core/file_manager/upload',
{
file,
parent_id: parentId || undefined,
is_public: isPublic,
source: source || undefined,
},
{
onUploadProgress: (progressEvent: any) => {
if (onProgress && progressEvent.total) {
const percentage = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress({
loaded: progressEvent.loaded,
total: progressEvent.total,
percentage,
});
}
},
},
);
}
/**
* 创建文件夹
* @param data 文件夹数据
*/
async function createFolder(data: SystemFileManagerApi.FolderCreateParams) {
return requestClient.post('/api/core/file_manager/folder', data);
}
/**
* 获取文件夹树
*/
async function getFolderTree() {
return requestClient.get<Array<SystemFileManagerApi.FolderTree>>(
'/api/core/file_manager/tree',
);
}
/**
* 重命名文件/文件夹
* @param id 文件/文件夹ID
* @param data 重命名数据
*/
async function renameItem(id: string, data: SystemFileManagerApi.RenameParams) {
return requestClient.put(`/api/core/file_manager/${id}/rename`, data);
}
/**
* 移动文件/文件夹
* @param data 移动参数
*/
async function moveItems(data: SystemFileManagerApi.MoveParams) {
return requestClient.put('/api/core/file_manager/move', data);
}
/**
* 删除文件/文件夹
* @param id 文件/文件夹ID
*/
async function deleteItem(id: string) {
return requestClient.delete(`/api/core/file_manager/${id}`);
}
/**
* 批量删除文件/文件夹
* @param data 批量删除参数
*/
async function batchDelete(data: SystemFileManagerApi.BatchDeleteParams) {
return requestClient.post('/api/core/file_manager/batch/delete', data);
}
/**
* 获取下载链接
* @param path 文件路径
*/
function getDownloadUrl(path: string): string {
return `/basic-api/api/core/file_manager/file/download?path=${encodeURIComponent(path)}`;
}
/**
* 获取存储配置
*/
async function getStorageConfig() {
return requestClient.get<SystemFileManagerApi.StorageConfig>(
'/api/core/file_manager/storage_config',
);
}
/**
* 更新存储配置
* @param data 存储配置数据
*/
async function updateStorageConfig(data: SystemFileManagerApi.StorageConfig) {
return requestClient.put('/api/core/file_manager/storage_config', data);
}
/**
* 通过文件ID获取文件访问URL
* @param fileId 文件ID
*/
async function getFileUrlById(fileId: string) {
return requestClient.get<{ url: string }>(
`/api/core/file_manager/url/${fileId}`,
);
}
/**
* 批量获取文件访问URL
* @param fileIds 文件ID数组
*/
async function getBatchFileUrls(fileIds: string[]) {
const ids = fileIds.join(',');
return requestClient.get<{ data: Record<string, string> }>(
`/api/core/file_manager/batch/urls`,
{
params: { ids },
},
);
}
/**
* 创建临时访问令牌
* @param params 创建参数
*/
async function createAccessToken(
params: SystemFileManagerApi.CreateAccessTokenParams,
) {
return requestClient.post<SystemFileManagerApi.AccessTokenResponse>(
'/api/core/file_manager/access-token',
params,
);
}
/**
* 获取带临时令牌的文件URL
* @param fileId 文件ID
* @param expiresIn 过期时间(秒),默认1小时
*/
async function getFileUrlWithToken(fileId: string, expiresIn: number = 3600) {
return requestClient.get<SystemFileManagerApi.AccessTokenUrlResponse>(
`/api/core/file_manager/access-token/url/${fileId}`,
{
params: { expiresIn },
},
);
}
/**
* 撤销临时访问令牌
* @param token 令牌
*/
async function revokeAccessToken(token: string) {
return requestClient.delete(`/api/core/file_manager/access-token/${token}`);
}
/**
* 获取文件流式传输URL(用于img src等直接访问)
* 注意:此方法已废弃,建议使用 getFileUrlWithToken 获取带临时令牌的URL
* @param fileId 文件ID
* @deprecated 使用 getFileUrlWithToken 替代
*/
function getFileStreamUrl(fileId: string): string {
return `/basic-api/api/core/file_manager/stream/${fileId}`;
}
/**
* 获取文件流式传输URL(带临时令牌)
* @param fileId 文件ID
* @param token 临时访问令牌
*/
function getFileStreamUrlWithToken(fileId: string, token: string): string {
return `/basic-api/api/core/file_manager/stream/${fileId}?token=${token}`;
}
/**
* 获取文件代理访问URL(用于img src等直接访问)
* @param fileId 文件ID
* @param download 是否下载
*/
function getFileProxyUrl(fileId: string, download = false): string {
const params = download ? '?download=true' : '';
return `/api/core/file_manager/proxy/${fileId}${params}`;
}
/**
* 通过API客户端获取文件流(返回blob数据)
* @param fileId 文件ID
*/
async function getFileStream(fileId: string) {
return requestClient.get(`/api/core/file_manager/stream/${fileId}`, {
responseType: 'blob',
});
}
/**
* 通过id获取文件信息
* @param fileId 文件ID
*/
async function getFileInfo(fileId: string) {
return requestClient.get(`/api/core/file_manager/file_info/${fileId}`);
}
/**
* 通过多个文件ID批量获取文件信息
* @param fileIds 文件ID数组
* @returns 所有文件信息的数组
*/
async function getFilesInfo(fileIds: string[]) {
if (!fileIds || fileIds.length === 0) {
return [];
}
// 使用Promise.all并行请求所有文件信息
const promises = fileIds.map((fileId) => getFileInfo(fileId));
try {
return await Promise.all(promises);
} catch (error) {
console.error('批量获取文件信息失败:', error);
throw error; // 可以根据需要处理错误,这里选择抛出以便上层处理
}
}
/**
* 通过API客户端获取文件代理数据(返回blob数据)
* @param fileId 文件ID
* @param download 是否下载
*/
async function getFileProxy(fileId: string, download = false) {
return requestClient.get(`/api/core/file_manager/proxy/${fileId}`, {
params: download ? { download: true } : undefined,
responseType: 'blob',
});
}
/**
* 获取公开文件的URL(无需认证)
* 公开文件可以直接通过URL访问,不需要临时令牌
* @param fileId 文件ID
*/
function getPublicFileUrl(fileId: string): string {
return `/basic-api/api/core/file_manager/stream/${fileId}`;
}
/**
* 设置文件的公开状态
* @param fileId 文件ID
* @param isPublic 是否公开
*/
async function setFilePublic(fileId: string, isPublic: boolean): Promise<any> {
return requestClient.put(`/api/core/file_manager/${fileId}/public`, null, {
params: { isPublic },
});
}
// ==================== 签名令牌相关 API ====================
export interface SignatureTokenResponse {
token: string;
callback_key: string;
expired_at: string;
}
export interface SignatureStatusResponse {
status: 'completed' | 'expired' | 'not_found' | 'pending';
message: string;
file_id?: string;
}
/**
* 创建签名令牌(用于手机扫码签名)
*/
export async function createSignatureToken(
source: string = 'form',
expireMinutes: number = 30,
): Promise<SignatureTokenResponse> {
const formData = new FormData();
formData.append('source', source);
formData.append('expire_minutes', String(expireMinutes));
return requestClient.post('/api/core/file_manager/signature/token', formData);
}
/**
* 检查签名状态(用于前端轮询)
*/
export async function checkSignatureStatus(
callbackKey: string,
): Promise<SignatureStatusResponse> {
return requestClient.get(
`/api/core/file_manager/signature/status/${callbackKey}`,
);
}
/**
* 获取签名令牌信息(移动端使用,无需登录)
*/
export async function getSignatureTokenInfo(token: string): Promise<{
expired_at: string;
source: string;
token: string;
}> {
return requestClient.get(`/api/core/file_manager/signature/info/${token}`);
}
/**
* 上传签名图片并完成签名(移动端使用,无需登录)
*/
export async function uploadSignatureImage(
token: string,
file: File,
): Promise<{ file_id: string; message: string; success: boolean }> {
return requestClient.upload(
`/api/core/file_manager/signature/upload/${token}`,
{ file },
);
}
/**
* 完成签名(移动端使用)
*/
export async function completeSignature(
token: string,
signatureFileId: string,
): Promise<{ file_id: string; message: string; success: boolean }> {
const formData = new FormData();
formData.append('signature_file_id', signatureFileId);
return requestClient.post(
`/api/core/file_manager/signature/complete/${token}`,
formData,
);
}
export {
batchDelete,
createAccessToken,
createFolder,
deleteItem,
getBatchFileUrls,
getDownloadUrl,
getFileInfo,
getFileList,
getFileProxy,
getFileProxyUrl,
getFilesInfo,
getFileStream,
getFileStreamUrl,
getFileStreamUrlWithToken,
getFileUrlById,
getFileUrlWithToken,
getFolderTree,
getPublicFileUrl,
getRecentFiles,
getRecentImages,
getStorageConfig,
moveItems,
renameItem,
revokeAccessToken,
setFilePublic,
updateStorageConfig,
uploadFile,
};
+23
View File
@@ -0,0 +1,23 @@
export * from './announcement';
export * from './api-token';
export * from './application';
export * from './auth';
export * from './data-source';
export * from './database-connection';
export * from './database-manager';
export * from './dept';
export * from './dict';
export * from './file';
export * from './field-permission';
export * from './menu';
export * from './message';
export * from './org-chart';
export * from './oauth';
export * from './permission';
export * from './post';
export * from './role';
export * from './resource-scope';
export * from './system-config';
export * from './ui-config';
export * from './user';
export * from './websocket';
+307
View File
@@ -0,0 +1,307 @@
import type { PaginatedResponse } from './user';
import { requestClient } from '#/api/request';
/**
* 登录日志相关类型定义
*/
export interface LoginLog {
id: string;
user_id?: string;
username: string;
status: number;
failure_reason?: number;
failure_message?: string;
login_ip: string;
ip_location?: string;
user_agent?: string;
browser_type?: string;
os_type?: string;
device_type?: string;
duration?: number;
session_id?: string;
remark?: string;
status_display?: string;
failure_reason_display?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface LoginLogListParams {
page?: number;
pageSize?: number;
username?: string;
user_id?: string;
status?: number;
failure_reason?: number;
login_ip?: string;
device_type?: string;
browser_type?: string;
os_type?: string;
start_datetime?: string;
end_datetime?: string;
}
export interface LoginLogStats {
total_logins: number;
success_logins: number;
failed_logins: number;
success_rate: number;
unique_users: number;
unique_ips: number;
}
export interface LoginLogIpStats {
login_ip: string;
ip_location?: string;
login_count: number;
failed_count: number;
last_login_time?: string;
}
export interface LoginLogDeviceStats {
device_type?: string;
browser_type?: string;
os_type?: string;
login_count: number;
last_login_time?: string;
}
export interface LoginLogUserStats {
user_id?: string;
username: string;
total_logins: number;
failed_logins: number;
last_login_time?: string;
last_login_ip?: string;
}
export interface LoginLogDailyStats {
date: string;
total_logins: number;
success_logins: number;
failed_logins: number;
unique_users: number;
}
export interface LoginLogBatchDeleteInput {
ids: string[];
}
/**
* 获取登录日志列表(分页)
*/
export async function getLoginLogListApi(params?: LoginLogListParams) {
return requestClient.get<PaginatedResponse<LoginLog>>('/api/core/login-log', {
params,
});
}
/**
* 获取登录日志详情
*/
export async function getLoginLogDetailApi(logId: string) {
return requestClient.get<LoginLog>(`/api/core/login-log/${logId}`);
}
/**
* 删除登录日志
*/
export async function deleteLoginLogApi(logId: string) {
return requestClient.delete(`/api/core/login-log/${logId}`);
}
/**
* 批量删除登录日志
*/
export async function batchDeleteLoginLogApi(ids: string[]) {
return requestClient.delete('/api/core/login-log/batch/delete', {
params: { ids },
});
}
/**
* 获取登录统计概览
*/
export async function getLoginStatsApi(days: number = 30) {
return requestClient.get<LoginLogStats>(
'/api/core/login-log/stats/overview',
{
params: { days },
},
);
}
/**
* 获取IP登录统计
*/
export async function getIpStatsApi(days: number = 30, limit: number = 10) {
return requestClient.get<LoginLogIpStats[]>('/api/core/login-log/stats/ip', {
params: { days, limit },
});
}
/**
* 获取设备登录统计
*/
export async function getDeviceStatsApi(days: number = 30) {
return requestClient.get<LoginLogDeviceStats[]>(
'/api/core/login-log/stats/device',
{
params: { days },
},
);
}
/**
* 获取用户登录统计
*/
export async function getUserStatsApi(days: number = 30, limit: number = 10) {
return requestClient.get<LoginLogUserStats[]>(
'/api/core/login-log/stats/user',
{
params: { days, limit },
},
);
}
/**
* 获取每日登录统计
*/
export async function getDailyStatsApi(days: number = 30) {
return requestClient.get<LoginLogDailyStats[]>(
'/api/core/login-log/stats/daily',
{
params: { days },
},
);
}
/**
* 获取用户的登录日志
*/
export async function getUserLoginLogsApi(
userId: string,
days: number = 30,
page: number = 1,
pageSize: number = 10,
) {
return requestClient.get<PaginatedResponse<LoginLog>>(
`/api/core/login-log/user/${userId}`,
{
params: { days, page, pageSize },
},
);
}
/**
* 获取用户登录次数
*/
export async function getUserLoginCountApi(userId: string, days: number = 30) {
return requestClient.get<{
failed_logins: number;
success_logins: number;
total_logins: number;
user_id: string;
}>(`/api/core/login-log/user/${userId}/count`, {
params: { days },
});
}
/**
* 获取用户最后一次登录
*/
export async function getUserLastLoginApi(userId: string) {
return requestClient.get<LoginLog>(`/api/core/login-log/user/${userId}/last`);
}
/**
* 获取用户登录过的IP地址
*/
export async function getUserLoginIpsApi(userId: string, days: number = 30) {
return requestClient.get<{
ip_count: number;
ips: string[];
user_id: string;
}>(`/api/core/login-log/user/${userId}/ips`, {
params: { days },
});
}
/**
* 获取可疑登录记录
*/
export async function getSuspiciousLoginsApi(
failedThreshold: number = 5,
hours: number = 1,
) {
return requestClient.get<{
records: any[];
suspicious_count: number;
}>('/api/core/login-log/suspicious', {
params: { failed_threshold: failedThreshold, hours },
});
}
/**
* 清理旧的登录日志
*/
export async function cleanOldLogsApi(days: number = 90) {
return requestClient.post<{ deleted_count: number }>(
'/api/core/login-log/clean',
{},
{
params: { days },
},
);
}
/**
* 根据用户名获取登录日志
*/
export async function getLogsByUsernameApi(
username: string,
days: number = 30,
page: number = 1,
pageSize: number = 10,
) {
return requestClient.get<PaginatedResponse<LoginLog>>(
`/api/core/login-log/username/${username}`,
{
params: { days, page, pageSize },
},
);
}
/**
* 根据IP地址获取登录日志
*/
export async function getLogsByIpApi(
loginIp: string,
days: number = 30,
page: number = 1,
pageSize: number = 10,
) {
return requestClient.get<PaginatedResponse<LoginLog>>(
`/api/core/login-log/ip/${loginIp}`,
{
params: { days, page, pageSize },
},
);
}
/**
* 获取用户登录失败次数
*/
export async function getFailedAttemptsApi(
username: string,
hours: number = 1,
) {
return requestClient.get<{
failed_attempts: number;
should_lock: boolean;
username: string;
}>(`/api/core/login-log/failed-attempts/${username}`, {
params: { hours },
});
}
+260
View File
@@ -0,0 +1,260 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 菜单相关类型定义
*/
export interface Menu {
id: string;
application_id?: string;
is_system?: boolean;
parent_id?: string;
name: string;
title?: string;
authCode?: string;
path: string;
type: string;
component?: string;
redirect?: string;
activePath?: string;
query?: Record<string, any>;
noBasicLayout?: boolean;
icon?: string;
activeIcon?: string;
order: number;
hideInMenu?: boolean;
hideChildrenInMenu?: boolean;
hideInBreadcrumb?: boolean;
hideInTab?: boolean;
affixTab?: boolean;
affixTabOrder?: number;
keepAlive?: boolean;
maxNumOfOpenTab?: number;
fullPathKey?: boolean;
link?: string;
iframeSrc?: string;
openInNewWindow?: boolean;
badge?: string;
badgeType?: string;
badgeVariants?: string;
level?: number;
child_count?: number;
full_path?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface MenuTreeNode extends Menu {
children?: MenuTreeNode[];
}
export interface MenuCreateInput {
application_id?: string;
is_system?: boolean;
parent_id?: string;
name: string;
title?: string;
authCode?: string;
path: string;
type: string;
component?: string;
redirect?: string;
activePath?: string;
query?: Record<string, any>;
noBasicLayout?: boolean;
icon?: string;
activeIcon?: string;
order?: number;
hideInMenu?: boolean;
hideChildrenInMenu?: boolean;
hideInBreadcrumb?: boolean;
hideInTab?: boolean;
affixTab?: boolean;
affixTabOrder?: number;
keepAlive?: boolean;
maxNumOfOpenTab?: number;
fullPathKey?: boolean;
link?: string;
iframeSrc?: string;
openInNewWindow?: boolean;
badge?: string;
badgeType?: string;
badgeVariants?: string;
}
export interface MenuUpdateInput extends Partial<MenuCreateInput> {}
export interface MenuMoveInput {
target_parent_id?: string;
position?: number;
}
export interface MenuListParams {
page?: number;
pageSize?: number;
name?: string;
title?: string;
type?: string;
parent_id?: string;
applicationId?: string;
}
export interface MenuStats {
total_count: number;
type_counts: Record<string, number>;
max_level: number;
type_choices: Array<[string, string]>;
}
/**
* 获取用户所有菜单(路由树)
* @param applicationCode 应用编码,用于过滤应用专属菜单
* @param devMode 开发模式:true只返回系统菜单,false只返回应用菜单
*/
export async function getAllMenusApi(
applicationCode?: string,
devMode?: boolean,
) {
const params: Record<string, any> = {};
if (applicationCode) {
params.application_code = applicationCode;
}
if (devMode !== undefined) {
params.devMode = devMode;
}
return requestClient.get<RouteRecordStringComponent[]>(
'/api/core/menu/route/tree',
{ params },
);
}
/**
* 获取用户路由树(Core 版本)
*/
export async function getUserRouteTreeApi() {
return requestClient.get<MenuTreeNode[]>('/api/core/menu/user_route_tree');
}
/**
* 创建菜单
*/
export async function createMenuApi(data: MenuCreateInput) {
return requestClient.post<Menu>('/api/core/menu', data);
}
/**
* 获取菜单列表(分页)
*/
export async function getMenuListApi(params?: MenuListParams) {
return requestClient.get<Menu[]>('/api/core/menu', { params });
}
/**
* 获取所有菜单(树形结构)
*/
export async function getAllMenuTreeApi(
applicationId?: string,
useCache: boolean = true,
includeSystem: boolean = true,
) {
const params: any = { use_cache: useCache, includeSystem };
if (applicationId) {
params.applicationId = applicationId;
}
return requestClient.get<MenuTreeNode[]>('/api/core/menu/get/tree', {
params,
});
}
/**
* 获取菜单详情
*/
export async function getMenuDetailApi(menuId: string) {
return requestClient.get<Menu>(`/api/core/menu/${menuId}`);
}
/**
* 更新菜单
*/
export async function updateMenuApi(menuId: string, data: MenuUpdateInput) {
return requestClient.put<Menu>(`/api/core/menu/${menuId}`, data);
}
/**
* 删除菜单
*/
export async function deleteMenuApi(menuId: string) {
return requestClient.delete<Menu>(`/api/core/menu/${menuId}`);
}
/**
* 根据父菜单ID获取子菜单
*/
export async function getMenuByParentApi(parentId?: string) {
// 后端使用 "null" 字符串表示根菜单
const id = parentId || 'null';
return requestClient.get<Menu[]>(`/api/core/menu/by/parent/${id}`);
}
/**
* 搜索菜单
*/
export async function searchMenuApi(keyword: string) {
return requestClient.get<Menu[]>('/api/core/menu/search', {
params: { keyword },
});
}
/**
* 移动菜单
*/
export async function moveMenuApi(menuId: string, data: MenuMoveInput) {
return requestClient.post<Menu>(`/api/core/menu/${menuId}/move`, data);
}
/**
* 获取菜单路径
*/
export async function getMenuPathApi(menuId: string) {
return requestClient.get<Menu[]>(`/api/core/menu/${menuId}/path`);
}
/**
* 获取菜单统计信息
*/
export async function getMenuStatsApi() {
return requestClient.get<MenuStats>('/api/core/menu/stats');
}
/**
* 检查菜单名称是否存在
*/
export async function checkMenuNameApi(
name: string,
excludeId?: string,
applicationId?: string,
) {
const params = applicationId ? { applicationId } : {};
return requestClient.post<{ exists: boolean; message?: string }>(
'/api/core/menu/check/name',
{ name, exclude_id: excludeId },
{ params },
);
}
/**
* 检查路由路径是否存在
*/
export async function checkMenuPathApi(
path: string,
excludeId?: string,
applicationId?: string,
) {
const params = applicationId ? { applicationId } : {};
return requestClient.post<{ exists: boolean; message?: string }>(
'/api/core/menu/check/path',
{ path, exclude_id: excludeId },
{ params },
);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 消息中心 API
*/
import { requestClient } from '#/api/request';
const BASE_URL = '/api/core/message';
// ============ 类型定义 ============
export interface Message {
id: string;
title: string;
content: string;
msg_type: 'announcement' | 'system' | 'todo' | 'workflow';
status: 'read' | 'unread';
link_type: string;
link_id: string;
sender_id?: string;
sender_name?: string;
read_at?: string;
created_at: string;
}
export interface UnreadCount {
total: number;
by_type: Record<string, number>;
}
// ============ API 函数 ============
/**
* 获取消息列表
*/
export async function getMessageListApi(params?: {
msg_type?: string;
page?: number;
pageSize?: number;
status?: string;
}) {
return requestClient.get<{
items: Message[];
total: number;
}>(`${BASE_URL}/list`, { params });
}
/**
* 获取未读消息数量
*/
export async function getUnreadCountApi() {
return requestClient.get<UnreadCount>(`${BASE_URL}/unread-count`);
}
/**
* 获取消息详情
*/
export async function getMessageDetailApi(messageId: string) {
return requestClient.get<Message>(`${BASE_URL}/${messageId}`);
}
/**
* 标记单条消息为已读
*/
export async function markAsReadApi(messageId: string) {
return requestClient.post(`${BASE_URL}/${messageId}/read`);
}
/**
* 标记所有消息为已读
*/
export async function markAllAsReadApi(msgType?: string) {
return requestClient.post(`${BASE_URL}/read-all`, { msg_type: msgType });
}
/**
* 删除消息
*/
export async function deleteMessageApi(messageId: string) {
return requestClient.delete(`${BASE_URL}/${messageId}`);
}
/**
* 清空已读消息
*/
export async function clearReadMessagesApi() {
return requestClient.delete(`${BASE_URL}/clear-read`);
}
/**
* 发送消息
*/
export async function sendMessageApi(data: {
channels?: string[];
content: string;
msg_type?: string;
recipient_ids: string[];
title: string;
}) {
return requestClient.post<{
data: { recipient_count: number; results: Record<string, boolean> };
message: string;
}>(`${BASE_URL}/send`, data);
}
+218
View File
@@ -0,0 +1,218 @@
import { requestClient } from '#/api/request';
export namespace OAuthApi {
/** OAuth 提供商类型 */
export type OAuthProvider =
| 'dingtalk'
| 'feishu'
| 'gitee'
| 'github'
| 'google'
| 'microsoft'
| 'qq'
| 'wechat'
| 'wecom';
/** OAuth 回调参数 */
export interface OAuthCallbackParams {
code: string;
state?: string;
}
/** OAuth 登录返回值 */
export interface OAuthLoginResult {
access_token: string;
refresh_token: string;
expire: number;
user_info: {
avatar?: string;
email?: string;
id: string;
is_superuser: boolean;
name: string;
user_type: number;
username: string;
};
}
/** 授权 URL 返回值 */
export interface AuthorizeUrlResult {
authorize_url: string;
}
// 兼容旧接口
export type GiteeCallbackParams = OAuthCallbackParams;
}
/**
* 获取 OAuth 授权 URL (通用接口)
* @param provider OAuth 提供商 (gitee/github)
* @param state 状态参数
*/
export async function getOAuthAuthorizeUrlApi(
provider: OAuthApi.OAuthProvider,
state?: string,
) {
const params = state ? { state } : {};
return requestClient.get<OAuthApi.AuthorizeUrlResult>(
`/api/core/oauth/${provider}/authorize`,
{ params },
);
}
/**
* OAuth 回调处理 (通用接口)
* @param provider OAuth 提供商 (gitee/github)
* @param data 回调数据
*/
export async function oauthCallbackApi(
provider: OAuthApi.OAuthProvider,
data: OAuthApi.OAuthCallbackParams,
) {
return requestClient.post<OAuthApi.OAuthLoginResult>(
`/api/core/oauth/${provider}/callback`,
data,
);
}
/**
* 获取 Gitee 授权 URL (兼容旧接口)
*/
export async function getGiteeAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('gitee', state);
}
/**
* Gitee OAuth 回调处理 (兼容旧接口)
*/
export async function giteeOAuthCallbackApi(
data: OAuthApi.GiteeCallbackParams,
) {
return oauthCallbackApi('gitee', data);
}
/**
* 获取 GitHub 授权 URL
*/
export async function getGitHubAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('github', state);
}
/**
* GitHub OAuth 回调处理
*/
export async function gitHubOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('github', data);
}
/**
* 获取 QQ 授权 URL
*/
export async function getQQAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('qq', state);
}
/**
* QQ OAuth 回调处理
*/
export async function qqOAuthCallbackApi(data: OAuthApi.OAuthCallbackParams) {
return oauthCallbackApi('qq', data);
}
/**
* 获取 Google 授权 URL
*/
export async function getGoogleAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('google', state);
}
/**
* Google OAuth 回调处理
*/
export async function googleOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('google', data);
}
/**
* 获取微信授权 URL
*/
export async function getWeChatAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('wechat', state);
}
/**
* 微信 OAuth 回调处理
*/
export async function wechatOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('wechat', data);
}
/**
* 获取微软授权 URL
*/
export async function getMicrosoftAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('microsoft', state);
}
/**
* 微软 OAuth 回调处理
*/
export async function microsoftOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('microsoft', data);
}
/**
* 获取钉钉授权 URL
*/
export async function getDingTalkAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('dingtalk', state);
}
/**
* 钉钉 OAuth 回调处理
*/
export async function dingtalkOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('dingtalk', data);
}
/**
* 获取飞书授权 URL
*/
export async function getFeishuAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('feishu', state);
}
/**
* 飞书 OAuth 回调处理
*/
export async function feishuOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('feishu', data);
}
/**
* 获取企业微信授权 URL
*/
export async function getWeComAuthorizeUrlApi(state?: string) {
return getOAuthAuthorizeUrlApi('wecom', state);
}
/**
* 企业微信 OAuth 回调处理
*/
export async function wecomOAuthCallbackApi(
data: OAuthApi.OAuthCallbackParams,
) {
return oauthCallbackApi('wecom', data);
}
@@ -0,0 +1,53 @@
import { requestClient } from '#/api/request';
/**
* 组织架构节点类型
*/
export interface OrgChartNode {
id: string;
name?: string;
username: string;
avatar?: string;
dept_name?: string;
post_name?: string;
subordinate_count: number;
}
/**
* 获取组织架构顶层节点
*/
export async function getOrgChartTopApi() {
return requestClient.get<OrgChartNode[]>('/api/core/user/org-chart/top');
}
/**
* 获取指定用户的组织架构节点
*/
export async function getOrgChartNodeApi(userId: string) {
return requestClient.get<OrgChartNode>(`/api/core/user/org-chart/${userId}`);
}
/**
* 汇报链节点(嵌套结构)
*/
export interface OrgChartChainNode extends OrgChartNode {
children: OrgChartChainNode[];
}
/**
* 获取用户汇报链(从顶层到当前用户的嵌套树)
*/
export async function getOrgChartChainApi(userId: string) {
return requestClient.get<OrgChartChainNode>(
`/api/core/user/org-chart/${userId}/chain`,
);
}
/**
* 获取组织架构子节点
*/
export async function getOrgChartChildrenApi(userId: string) {
return requestClient.get<OrgChartNode[]>(
`/api/core/user/org-chart/${userId}/children`,
);
}
+199
View File
@@ -0,0 +1,199 @@
import { requestClient } from '#/api/request';
/**
* 权限相关类型定义
*/
export interface Permission {
id: string;
menu_id: string;
name: string;
code: string;
permission_type: number;
api_path?: string;
http_method?: number;
description?: string;
is_active: boolean;
permission_type_display?: string;
http_method_display?: string;
menu_name?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface PermissionCreateInput {
menu_id: string;
name: string;
code: string;
permission_type?: number;
api_path?: string;
http_method?: number;
description?: string;
is_active?: boolean;
}
export interface PermissionUpdateInput extends Partial<PermissionCreateInput> {}
export interface PermissionBatchDeleteInput {
ids: string[];
}
export interface PermissionBatchUpdateStatusInput {
ids: string[];
is_active: boolean;
}
export interface PermissionListParams {
page?: number;
pageSize?: number;
name?: string;
code?: string;
permission_type?: number;
is_active?: boolean;
menu_id?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface PermissionStats {
total_count: number;
active_count: number;
inactive_count: number;
type_counts: Record<string, number>;
}
/**
* 创建权限
*/
export async function createPermissionApi(data: PermissionCreateInput) {
return requestClient.post<Permission>('/api/core/permission', data);
}
/**
* 获取权限列表(分页)
*/
export async function getPermissionListApi(params?: PermissionListParams) {
return requestClient.get<PaginatedResponse<Permission>>(
'/api/core/permission',
{ params },
);
}
/**
* 获取权限详情
*/
export async function getPermissionDetailApi(permissionId: string) {
return requestClient.get<Permission>(`/api/core/permission/${permissionId}`);
}
/**
* 更新权限
*/
export async function updatePermissionApi(
permissionId: string,
data: PermissionUpdateInput,
) {
return requestClient.put<Permission>(
`/api/core/permission/${permissionId}`,
data,
);
}
/**
* 删除权限
*/
export async function deletePermissionApi(permissionId: string) {
return requestClient.delete<Permission>(
`/api/core/permission/${permissionId}`,
);
}
/**
* 批量删除权限
*/
export async function batchDeletePermissionApi(
data: PermissionBatchDeleteInput,
) {
return requestClient.post<{ count: number }>(
'/api/core/permission/batch/delete',
data,
);
}
/**
* 批量更新权限状态
*/
export async function batchUpdatePermissionStatusApi(
data: PermissionBatchUpdateStatusInput,
) {
return requestClient.post<{ count: number }>(
'/api/core/permission/batch_update_status',
data,
);
}
/**
* 根据菜单ID获取权限列表
*/
export async function getPermissionsByMenuApi(menuId: string) {
return requestClient.get<Permission[]>(
`/api/core/permission/by_menu/${menuId}`,
);
}
/**
* 获取权限统计信息
*/
export async function getPermissionStatsApi() {
return requestClient.get<PermissionStats>('/api/core/permission/stats');
}
/**
* 检查权限编码是否可用
*/
export async function checkPermissionCodeApi(code: string, menuId: string) {
return requestClient.get<{ available: boolean }>(
'/api/core/permission/check_code',
{
params: { code, menu_id: menuId },
},
);
}
/**
* 获取所有可用的 API 路由
*/
export async function getAllRoutesApi() {
return requestClient.get<any[]>('/api/core/permission/all/routes');
}
/**
* 从路由批量创建权限
*/
export async function batchCreatePermissionsFromRoutesApi(data: {
menu_id: string;
routes: any[];
}) {
return requestClient.post<{
created: number;
errors: string[];
failed: number;
skipped: number;
}>('/api/core/permission/batch/create-from-routes', data);
}
/**
* 从 Router 自动扫描并生成权限
*/
export async function autoScanPermissionsApi(dryRun: boolean = false) {
return requestClient.post<{
created: number;
failed: number;
permissions: any[];
skipped: number;
}>('/api/core/permission/auto/scan', { dry_run: dryRun });
}
+235
View File
@@ -0,0 +1,235 @@
import { requestClient } from '#/api/request';
/**
* 岗位相关类型定义
*/
export interface Post {
id: string;
name: string;
code: string;
post_type: number;
post_level: number;
status: boolean;
description?: string;
dept_id?: string;
post_type_display?: string;
post_level_display?: string;
dept_name?: string;
user_count?: number;
can_delete?: boolean;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface PostCreateInput {
name: string;
code: string;
post_type?: number;
post_level?: number;
status?: boolean;
description?: string;
dept_id?: string;
}
export interface PostUpdateInput extends Partial<PostCreateInput> {}
export interface PostBatchDeleteInput {
ids: string[];
}
export interface PostBatchUpdateStatusInput {
ids: string[];
status: boolean;
}
export interface PostListParams {
page?: number;
pageSize?: number;
name?: string;
code?: string;
post_type?: number;
post_level?: number;
status?: boolean;
dept_id?: string;
}
export interface PostUser {
id: string;
username: string;
name?: string;
email?: string;
mobile?: string;
dept_name?: string;
}
export interface PostStats {
total_count: number;
active_count: number;
inactive_count: number;
type_counts: Record<string, number>;
level_counts: Record<string, number>;
total_users: number;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/**
* 创建岗位
*/
export async function createPostApi(data: PostCreateInput) {
return requestClient.post<Post>('/api/core/post', data);
}
/**
* 获取岗位列表(分页)
*/
export async function getPostListApi(params?: PostListParams) {
return requestClient.get<PaginatedResponse<Post>>('/api/core/post', {
params,
});
}
/**
* 获取岗位详情
*/
export async function getPostDetailApi(postId: string) {
return requestClient.get<Post>(`/api/core/post/${postId}`);
}
/**
* 更新岗位
*/
export async function updatePostApi(postId: string, data: PostUpdateInput) {
return requestClient.put<Post>(`/api/core/post/${postId}`, data);
}
/**
* 删除岗位
*/
export async function deletePostApi(postId: string) {
return requestClient.delete<Post>(`/api/core/post/${postId}`);
}
/**
* 批量删除岗位
*/
export async function batchDeletePostApi(data: PostBatchDeleteInput) {
return requestClient.post<{ count: number }>(
'/api/core/post/batch_delete',
data,
);
}
/**
* 批量更新岗位状态
*/
export async function batchUpdatePostStatusApi(
data: PostBatchUpdateStatusInput,
) {
return requestClient.post<{ count: number }>(
'/api/core/post/batch_update_status',
data,
);
}
/**
* 根据部门ID获取岗位列表
*/
export async function getPostsByDeptApi(deptId: string) {
return requestClient.get<Post[]>(`/api/core/post/by_dept/${deptId}`);
}
/**
* 获取岗位用户列表
*/
export async function getPostUsersApi(
postId: string,
params?: { page?: number; pageSize?: number; username?: string },
) {
return requestClient.get<PaginatedResponse<PostUser>>(
'/api/core/post/users/by/post_id',
{ params: { ...params, post_id: postId } },
);
}
/**
* 为岗位添加用户
*/
export async function addPostUsersApi(
postId: string,
data: { user_ids: string[] },
) {
return requestClient.post<{ count: number }>(
'/api/core/post/users/by/post_id',
{ ...data, post_id: postId },
);
}
/**
* 从岗位移除用户
*/
export async function removePostUsersApi(
postId: string,
data: { user_ids: string[] },
) {
return requestClient.delete<{ count: number }>(
'/api/core/post/users/by/post_id',
{ data: { ...data, post_id: postId } },
);
}
/**
* 获取岗位统计信息
*/
export async function getPostStatsApi() {
return requestClient.get<PostStats>('/api/core/post/stats');
}
/**
* 导出岗位数据
*/
export async function exportPostApi(params?: PostListParams) {
return requestClient.get<Blob>('/api/core/post/export', {
params,
responseType: 'blob',
});
}
/**
* 导入岗位数据
*/
export async function importPostApi(file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post<{ error_count: number; success_count: number }>(
'/api/core/post/import',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
}
/**
* 获取简单岗位列表(用于选择器)
*/
export async function getSimplePostListApi() {
return requestClient.get<Post[]>('/api/core/post/simple');
}
/**
* 根据ID列表获取岗位
*/
export async function getPostsByIds(ids: string[]) {
if (!ids || ids.length === 0) return [];
return requestClient.get<Post[]>('/api/core/post/by/ids', {
params: { ids: ids.join(',') },
});
}
@@ -0,0 +1,129 @@
import { requestClient } from '#/api/request';
/**
* 资源数据权限配置相关类型定义
*/
// 资源类型信息
export interface ResourceType {
resource_type: string;
display_name: string;
model_name?: string;
table_name?: string;
}
// 资源权限配置
export interface ResourceScopeConfig {
id?: string;
role_id: string;
resource_type: string;
data_scope: number;
dept_ids?: null | string[];
sys_create_datetime?: string;
sys_update_datetime?: string;
}
// 角色资源权限配置(用于批量更新)
export interface RoleResourceScopeConfig {
resource_type: string;
data_scope: number;
dept_ids?: null | string[];
}
export interface RoleResourceScopeBatchUpdate {
role_id: string;
configs: RoleResourceScopeConfig[];
}
// 数据权限范围选项
export const DATA_SCOPE_OPTIONS = [
{ label: '全部数据', value: 0 },
{ label: '仅本人数据', value: 1 },
{ label: '本部门数据', value: 2 },
{ label: '本部门及下级部门数据', value: 3 },
{ label: '自定义数据', value: 4 },
];
/**
* 获取所有资源类型
* @param applicationId 应用ID,子应用访问时只显示该应用的资源
*/
export async function getResourceTypesApi(applicationId?: string) {
return requestClient.get<ResourceType[]>('/api/core/resource-scope/types', {
params: applicationId ? { applicationId } : undefined,
});
}
/**
* 获取资源类型列表(简单)
*/
export async function getResourceTypeListApi() {
return requestClient.get<string[]>('/api/core/resource-scope/types/list');
}
/**
* 获取注册表信息
*/
export async function getRegistryInfoApi() {
return requestClient.get<{
data: {
resource_types: string[];
resources: ResourceType[];
total_count: number;
};
message: string;
}>('/api/core/resource-scope/registry/info');
}
/**
* 获取角色的资源权限配置
*/
export async function getRoleResourceScopesApi(roleId: string) {
return requestClient.get<ResourceScopeConfig[]>(
`/api/core/resource-scope/role/${roleId}`,
);
}
/**
* 批量更新角色的资源权限配置
*/
export async function batchUpdateRoleResourceScopesApi(
data: RoleResourceScopeBatchUpdate,
) {
return requestClient.put<ResourceScopeConfig[]>(
'/api/core/resource-scope/role/batch',
data,
);
}
/**
* 创建资源权限配置
*/
export async function createResourceScopeConfigApi(
data: Omit<ResourceScopeConfig, 'id'>,
) {
return requestClient.post<ResourceScopeConfig>(
'/api/core/resource-scope',
data,
);
}
/**
* 更新资源权限配置
*/
export async function updateResourceScopeConfigApi(
configId: string,
data: Partial<ResourceScopeConfig>,
) {
return requestClient.put<ResourceScopeConfig>(
`/api/core/resource-scope/${configId}`,
data,
);
}
/**
* 删除资源权限配置
*/
export async function deleteResourceScopeConfigApi(configId: string) {
return requestClient.delete(`/api/core/resource-scope/${configId}`);
}
+258
View File
@@ -0,0 +1,258 @@
import { requestClient } from '#/api/request';
/**
* 角色相关类型定义
*/
export interface Role {
id: string;
name: string;
code: string;
role_type: number;
status: boolean;
priority: number;
description?: string;
remark?: string;
role_type_display?: string;
user_count?: number;
menu_count?: number;
permission_count?: number;
can_delete?: boolean;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface RoleCreateInput {
name: string;
code: string;
role_type?: number;
status?: boolean;
priority?: number;
description?: string;
remark?: string;
menu?: string[];
permission?: string[];
group?: string[];
}
export interface RoleUpdateInput extends Partial<RoleCreateInput> {}
export interface RoleBatchDeleteInput {
ids: string[];
}
export interface RoleBatchUpdateStatusInput {
ids: string[];
status: boolean;
}
export interface RoleUserInput {
user_ids: string[];
}
export interface RoleListParams {
page?: number;
pageSize?: number;
name?: string;
code?: string;
status?: boolean;
role_type?: number;
id?: string[];
}
export interface RoleUser {
id: string;
username: string;
name?: string;
email?: string;
mobile?: string;
dept_name?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface MenuPermissionTree {
menu_tree: any[];
permission_tree: any[];
selected_menu_ids: string[];
selected_permission_ids: string[];
}
/**
* 创建角色
*/
export async function createRoleApi(data: RoleCreateInput) {
return requestClient.post<Role>('/api/core/role', data);
}
/**
* 获取角色列表(分页)
*/
export async function getRoleListApi(params?: RoleListParams) {
return requestClient.get<PaginatedResponse<Role>>('/api/core/role', {
params,
});
}
/**
* 获取角色详情
*/
export async function getRoleDetailApi(roleId: string) {
return requestClient.get<Role>(`/api/core/role/${roleId}`);
}
/**
* 更新角色
*/
export async function updateRoleApi(roleId: string, data: RoleUpdateInput) {
return requestClient.put<Role>(`/api/core/role/${roleId}`, data);
}
/**
* 删除角色
*/
export async function deleteRoleApi(roleId: string) {
return requestClient.delete<Role>(`/api/core/role/${roleId}`);
}
/**
* 批量删除角色
*/
export async function batchDeleteRoleApi(data: RoleBatchDeleteInput) {
return requestClient.post<{ count: number }>(
'/api/core/role/batch_delete',
data,
);
}
/**
* 批量更新角色状态
*/
export async function batchUpdateRoleStatusApi(
data: RoleBatchUpdateStatusInput,
) {
return requestClient.post<{ count: number }>(
'/api/core/role/batch_update_status',
data,
);
}
/**
* 获取角色用户列表
*/
export async function getRoleUsersApi(
roleId: string,
params?: { page?: number; pageSize?: number; username?: string },
) {
return requestClient.get<PaginatedResponse<RoleUser>>(
'/api/core/role/users/by/role_id',
{ params: { ...params, role_id: roleId } },
);
}
/**
* 为角色添加用户
*/
export async function addRoleUsersApi(roleId: string, data: RoleUserInput) {
return requestClient.post<{ count: number }>(
'/api/core/role/users/by/role_id',
{ ...data, role_id: roleId },
);
}
/**
* 从角色移除用户
*/
export async function removeRoleUsersApi(roleId: string, data: RoleUserInput) {
return requestClient.delete<{ count: number }>(
'/api/core/role/users/by/role_id',
{ data: { ...data, role_id: roleId } },
);
}
/**
* 获取角色的菜单权限树
*/
export async function getRoleMenuPermissionTreeApi(roleId: string) {
return requestClient.get<MenuPermissionTree>(
`/api/core/role/menu-permission-tree/${roleId}`,
);
}
/**
* 获取简单角色列表(用于选择器)
*/
export async function getSimpleRoleListApi() {
return requestClient.get<Role[]>('/api/core/role/all');
}
/**
* 获取角色权限列表(按菜单分组)
*/
export async function getRolePermissionTreeApi(roleId: string) {
return requestClient.get<MenuPermissionTree>(
`/api/core/role/menu-permission-tree/${roleId}`,
);
}
/**
* 更新角色权限
*/
export async function updateRolePermissionsApi(
roleId: string,
data: { permission_ids: string[] },
) {
return requestClient.put(`/api/core/role/${roleId}/permissions`, data);
}
/**
* 更新角色菜单和权限
*/
export async function updateRoleMenusPermissionsApi(
roleId: string,
data: {
menu_ids: string[];
permission_ids: string[];
loaded_menu_ids: string[];
},
) {
return requestClient.put(`/api/core/role/${roleId}/menus-permissions`, data);
}
/**
* 获取角色菜单列表(不包含权限)
* @param roleId 角色ID
* @param applicationId 应用ID,子应用访问时只显示该应用的菜单
*/
export async function getRoleMenusApi(roleId: string, applicationId?: string) {
return requestClient.get<{
menu_tree: any[];
selected_menu_ids: string[];
}>(`/api/core/role/${roleId}/menus`, {
params: applicationId ? { applicationId } : undefined,
});
}
/**
* 获取菜单的权限列表
*/
export async function getMenuPermissionsApi(roleId: string, menuId: string) {
return requestClient.get<{
menu_id: string;
permissions: any[];
}>(`/api/core/role/${roleId}/menu/${menuId}/permissions`);
}
/**
* 根据ID列表获取角色
*/
export async function getRolesByIds(ids: string[]) {
if (!ids || ids.length === 0) return [];
return requestClient.get<Role[]>('/api/core/role/by/ids', {
params: { ids: ids.join(',') },
});
}
@@ -0,0 +1,72 @@
import { requestClient } from '#/api/request';
/**
* 配置分组定义
*/
export interface ConfigGroupDef {
key: string;
fields: string[];
}
/**
* 分组配置更新参数
*/
export interface GroupConfigUpdateInput {
configs: Record<string, null | string>;
}
/**
* 获取所有配置分组定义
*/
export async function getConfigGroupsApi() {
return requestClient.get<ConfigGroupDef[]>('/api/core/system-config/groups');
}
/**
* 获取所有分组配置(敏感字段脱敏)
*/
export async function getAllConfigsApi() {
return requestClient.get<Record<string, Record<string, any>>>(
'/api/core/system-config/all',
);
}
/**
* 获取指定分组配置
*/
export async function getGroupConfigApi(group: string) {
return requestClient.get<Record<string, any>>(
`/api/core/system-config/group/${group}`,
);
}
/**
* 更新指定分组配置
*/
export async function updateGroupConfigApi(
group: string,
data: GroupConfigUpdateInput,
) {
return requestClient.put(`/api/core/system-config/group/${group}`, data);
}
/**
* 删除指定分组配置(恢复默认)
*/
export async function deleteGroupConfigApi(group: string) {
return requestClient.delete(`/api/core/system-config/group/${group}`);
}
/**
* 预热配置缓存
*/
export async function warmupCacheApi() {
return requestClient.post('/api/core/system-config/cache/warmup');
}
/**
* 清除配置缓存
*/
export async function clearCacheApi() {
return requestClient.delete('/api/core/system-config/cache');
}
+208
View File
@@ -0,0 +1,208 @@
import { merge } from '@vben/utils';
import { requestClient } from '#/api/request';
/**
* UI配置相关类型定义
*/
export interface UIConfig {
id: string;
config_key: string;
config_value?: string;
config_type: string;
description?: string;
status: boolean;
sort: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface UIConfigCreateInput {
config_key: string;
config_value?: string;
config_type?: string;
description?: string;
status?: boolean;
sort?: number;
}
export interface UIConfigUpdateInput extends Partial<UIConfigCreateInput> {}
export interface UIConfigListParams {
page?: number;
pageSize?: number;
configKey?: string;
configType?: string;
status?: boolean;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
}
export interface LoginConfig {
enableThirdPartyLogin?: boolean;
enabledProviders?: string[];
}
export interface PreferencesConfig {
app?: Record<string, any>;
theme?: Record<string, any>;
logo?: Record<string, any>;
copyright?: Record<string, any>;
sidebar?: Record<string, any>;
header?: Record<string, any>;
footer?: Record<string, any>;
tabbar?: Record<string, any>;
breadcrumb?: Record<string, any>;
navigation?: Record<string, any>;
shortcutKeys?: Record<string, any>;
transition?: Record<string, any>;
widget?: Record<string, any>;
loginConfig?: LoginConfig;
}
/**
* 深度合并偏好配置:updates 中的字段覆盖 current,未涉及的字段保留
* 避免各配置面板分别保存时用浅合并覆盖掉 app.name 等字段
*/
export function mergePreferencesConfig(
current: PreferencesConfig,
updates: PreferencesConfig,
): PreferencesConfig {
return merge({}, updates, current) as PreferencesConfig;
}
// ============ 前端偏好配置API ============
/**
* 获取前端偏好配置(无需认证)
* @param applicationId 应用ID,不传则获取主应用配置
*/
export async function getPreferencesConfigApi(applicationId?: string) {
return requestClient.get<PreferencesConfig>(
'/api/core/ui_config/preferences',
{
params: applicationId ? { applicationId } : undefined,
},
);
}
/**
* 更新前端偏好配置
* @param data 配置数据
* @param applicationId 应用ID,不传则更新主应用配置
*/
export async function updatePreferencesConfigApi(
data: PreferencesConfig,
applicationId?: string,
) {
return requestClient.put<{ id: string }>(
'/api/core/ui_config/preferences',
data,
{
params: applicationId ? { applicationId } : undefined,
},
);
}
// ============ UI配置CRUD API ============
/**
* 创建UI配置
*/
export async function createUIConfigApi(data: UIConfigCreateInput) {
return requestClient.post<UIConfig>('/api/core/ui_config', data);
}
/**
* 获取UI配置列表(分页)
*/
export async function getUIConfigListApi(params?: UIConfigListParams) {
return requestClient.get<PaginatedResponse<UIConfig>>('/api/core/ui_config', {
params,
});
}
/**
* 获取所有UI配置(不分页)
*/
export async function getAllUIConfigApi() {
return requestClient.get<UIConfig[]>('/api/core/ui_config/all');
}
/**
* 获取UI配置详情
*/
export async function getUIConfigDetailApi(configId: string) {
return requestClient.get<UIConfig>(`/api/core/ui_config/${configId}`);
}
/**
* 根据配置键获取UI配置
*/
export async function getUIConfigByKeyApi(configKey: string) {
return requestClient.get<UIConfig>(`/api/core/ui_config/by/key/${configKey}`);
}
/**
* 根据配置类型获取UI配置列表
*/
export async function getUIConfigByTypeApi(configType: string) {
return requestClient.get<UIConfig[]>(
`/api/core/ui_config/by/type/${configType}`,
);
}
/**
* 获取配置值
*/
export async function getUIConfigValueApi(configKey: string) {
return requestClient.get<any>(`/api/core/ui_config/value/${configKey}`);
}
/**
* 更新配置值
*/
export async function updateUIConfigValueApi(
configKey: string,
configValue: string,
) {
return requestClient.put(`/api/core/ui_config/value/${configKey}`, {
config_value: configValue,
});
}
/**
* 更新UI配置
*/
export async function updateUIConfigApi(
configId: string,
data: UIConfigUpdateInput,
) {
return requestClient.put<UIConfig>(`/api/core/ui_config/${configId}`, data);
}
/**
* 删除UI配置
*/
export async function deleteUIConfigApi(configId: string) {
return requestClient.delete(`/api/core/ui_config/${configId}`);
}
/**
* 检查配置键唯一性
*/
export async function checkUIConfigUniqueApi(
field: string,
value: string,
excludeId?: string,
) {
return requestClient.get<{ unique: boolean }>(
'/api/core/ui_config/check/unique',
{
params: { field, value, excludeId },
},
);
}
+247
View File
@@ -0,0 +1,247 @@
import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 用户相关类型定义
*/
export interface User {
id: string;
username: string;
email?: string;
mobile?: string;
avatar?: string; // UUID string
name?: string;
gender?: number;
user_type?: number;
user_status?: number;
birthday?: string;
city?: string;
address?: string;
bio?: string;
dept_id?: string; // UUID string
dept_name?: string;
post_id?: string; // UUID string
post_name?: string;
manager_id?: string; // UUID string
manager_name?: string;
user_type_display?: string;
user_status_display?: string;
gender_display?: string;
role_ids?: string[];
role_names?: string[];
sys_create_datetime?: string;
sys_update_datetime?: string;
is_active: number;
}
export interface UserCreateInput {
username: string;
email?: string;
mobile?: string;
avatar?: string;
name?: string;
gender?: number;
user_type?: number;
user_status?: number;
birthday?: string;
city?: string;
address?: string;
bio?: string;
dept_id?: string;
manager_id?: string;
post?: string[];
core_roles?: string[];
}
export interface UserUpdateInput extends Partial<UserCreateInput> {}
export interface UserPasswordResetInput {
new_password: string;
confirm_password: string;
}
export interface UserChangePasswordInput {
old_password: string;
new_password: string;
confirm_password: string;
}
export interface UserProfileUpdateInput {
name?: string;
email?: string;
mobile?: string;
avatar?: string;
gender?: number;
birthday?: string;
city?: string;
address?: string;
bio?: string;
}
export interface UserBatchDeleteInput {
ids: string[];
}
export interface UserBatchUpdateStatusInput {
ids: string[];
user_status: number;
}
export interface UserPermissionCheckInput {
permission_code: string;
}
export interface UserListParams {
page?: number;
pageSize?: number;
name?: string;
username?: string;
user_status?: number;
user_type?: number;
dept_ids?: string[];
mobile?: string;
email?: string;
last_login_type?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/**
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/api/core/userinfo');
}
/**
* 创建用户
*/
export async function createUserApi(data: UserCreateInput) {
return requestClient.post<User>('/api/core/user', data);
}
/**
* 获取用户列表(分页)
*/
export async function getUserListApi(params?: UserListParams) {
return requestClient.get<PaginatedResponse<User>>('/api/core/user', {
params,
});
}
/**
* 获取用户详情
*/
export async function getUserDetailApi(userId: string) {
return requestClient.get<User>(`/api/core/user/${userId}`);
}
/**
* 更新用户
*/
export async function updateUserApi(userId: string, data: UserUpdateInput) {
return requestClient.put<User>(`/api/core/user/${userId}`, data);
}
/**
* 删除用户
*/
export async function deleteUserApi(userId: string) {
return requestClient.delete<User>(`/api/core/user/${userId}`);
}
/**
* 批量删除用户
*/
export async function batchDeleteUserApi(data: UserBatchDeleteInput) {
return requestClient.post<{ count: number }>(
'/api/core/user/batch_delete',
data,
);
}
/**
* 批量更新用户状态
*/
export async function batchUpdateUserStatusApi(
data: UserBatchUpdateStatusInput,
) {
return requestClient.post<{ count: number }>(
'/api/core/user/batch_update_status',
data,
);
}
/**
* 重置用户密码
*/
export async function resetUserPasswordApi(
userId: string,
data: UserPasswordResetInput,
) {
return requestClient.post<User>(
`/api/core/user/${userId}/reset-password`,
data,
);
}
/**
* 更新用户个人信息
*/
export async function updateUserProfileApi(data: UserProfileUpdateInput) {
return requestClient.put<User>('/api/core/user/profile', data);
}
/**
* 检查用户权限
*/
export async function checkUserPermissionApi(data: UserPermissionCheckInput) {
return requestClient.post<{ has_permission: boolean }>(
'/api/core/user/check_permission',
data,
);
}
/**
* 获取用户下属列表
*/
export async function getUserSubordinatesApi(userId: string) {
return requestClient.get<User[]>(`/api/core/user/${userId}/subordinates`);
}
/**
* 获取简单用户列表(用于选择器)
*/
export async function getSimpleUserListApi() {
return requestClient.get<User[]>('/api/core/user/simple');
}
/**
* 获取当前用户个人信息
*/
export async function getCurrentUserProfileApi() {
return requestClient.get<User>('/api/core/userinfo');
}
/**
* 部分更新用户个人信息
*/
export async function patchUserProfileApi(data: UserProfileUpdateInput) {
return requestClient.put<User>('/api/core/user/profile', data);
}
/**
* 修改密码
*/
export async function changePasswordApi(data: UserChangePasswordInput) {
return requestClient.post<{ message: string }>(
'/api/core/user/change-password',
data,
);
}
+644
View File
@@ -0,0 +1,644 @@
/**
* WebSocket API
*/
import { useAccessStore } from '@vben/stores';
import { refreshTokenApi } from './auth';
export namespace WebSocketApi {
/** WebSocket消息类型 */
export interface WebSocketMessage {
type: string;
content?: string;
timestamp?: string;
data?: any;
interval?: number;
[key: string]: any;
}
/** WebSocket回调函数 */
export interface WebSocketCallbacks {
onOpen?: (event: Event) => void;
onMessage?: (message: WebSocketMessage) => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
onReconnect?: (attempt: number) => void;
}
/** WebSocket连接状态 */
export type ConnectionStatus = 'CLOSED' | 'CLOSING' | 'CONNECTING' | 'OPEN';
/** WebSocket连接配置 */
export interface WebSocketConfig {
url: string;
protocols?: string | string[];
reconnect?: boolean;
maxReconnectAttempts?: number;
reconnectInterval?: number;
heartbeat?: boolean;
heartbeatInterval?: number;
}
/** 监控数据类型 */
export interface MonitorMessage {
type:
| 'get_overview'
| 'get_realtime'
| 'set_interval'
| 'start_monitor'
| 'stop_monitor'
| 'test_connection';
interval?: number;
timestamp?: string;
}
/** 服务器监控数据 */
export interface ServerMonitorData {
basic_info?: any;
cpu_info?: any;
memory_info?: any;
disk_info?: any;
network_info?: any;
process_info?: any;
system_load?: any;
boot_time?: any;
users_info?: any;
timestamp?: string;
}
/** Redis监控数据 */
export interface RedisMonitorData {
connection_id?: string;
connection_name?: string;
status?: string;
info?: any;
memory?: any;
stats?: any;
keyspace?: any[];
clients?: any[];
slow_log?: any[];
timestamp?: string;
}
}
/**
* WebSocket管理类
*/
export class WebSocketManager {
/**
* 是否已连接
*/
get isConnected(): boolean {
return this.status === 'OPEN';
}
/**
* 获取连接状态
*/
get status(): WebSocketApi.ConnectionStatus {
if (!this.ws) return 'CLOSED';
switch (this.ws.readyState) {
case WebSocket.CLOSING: {
return 'CLOSING';
}
case WebSocket.CONNECTING: {
return 'CONNECTING';
}
case WebSocket.OPEN: {
return 'OPEN';
}
default: {
return 'CLOSED';
}
}
}
private callbacks: WebSocketApi.WebSocketCallbacks;
private config: WebSocketApi.WebSocketConfig;
private heartbeatTimer: null | number = null;
private isManualClose = false;
private isTokenExpiredClose = false;
private reconnectAttempts = 0;
private reconnectTimer: null | number = null;
private ws: null | WebSocket = null;
constructor(
config: WebSocketApi.WebSocketConfig,
callbacks: WebSocketApi.WebSocketCallbacks = {},
) {
this.config = {
reconnect: true,
maxReconnectAttempts: 5,
reconnectInterval: 3000,
heartbeat: true,
heartbeatInterval: 30_000,
...config,
};
this.callbacks = callbacks;
}
/**
* 关闭连接
*/
close(code?: number, reason?: string): void {
this.isManualClose = true;
this.stopHeartbeat();
this.clearReconnectTimer();
if (this.ws) {
this.ws.close(code, reason);
this.ws = null;
}
}
/**
* 连接WebSocket
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.isManualClose = false;
// 获取访问令牌
const accessStore = useAccessStore();
const token = accessStore.accessToken;
if (!token) {
reject(new Error('未找到访问令牌'));
return;
}
// 构建带token的URL
const separator = this.config.url.includes('?') ? '&' : '?';
const wsUrl = `${this.config.url}${separator}token=${encodeURIComponent(token)}`;
console.log(
'Connecting to WebSocket:',
wsUrl.replace(/token=[^&]+/, 'token=***'),
);
console.log('WebSocket URL详情:', {
originalUrl: this.config.url,
finalUrl: wsUrl.replace(/token=[^&]+/, 'token=***'),
isDev: import.meta.env.DEV,
});
this.ws = new WebSocket(wsUrl, this.config.protocols);
this.ws.addEventListener('open', (event) => {
console.log('WebSocket连接已建立');
this.reconnectAttempts = 0;
this.startHeartbeat();
this.callbacks.onOpen?.(event);
resolve();
});
this.ws.onmessage = (event) => {
try {
const message: WebSocketApi.WebSocketMessage = JSON.parse(
event.data,
);
// 处理心跳响应
if (message.type === 'pong') {
return;
}
// 处理token过期通知:刷新token后自动重连
if (message.type === 'token_expired') {
console.warn(
'[WebSocket] 收到token_expired,准备刷新token并重连',
);
this.isTokenExpiredClose = true;
return;
}
this.callbacks.onMessage?.(message);
} catch (error) {
console.error('解析WebSocket消息失败:', error);
}
};
this.ws.addEventListener('close', (event) => {
console.log('WebSocket连接已关闭', event.code, event.reason);
this.stopHeartbeat();
this.callbacks.onClose?.(event);
// token过期关闭:刷新token后立即重连(不计入重连次数)
if (this.isTokenExpiredClose || event.code === 4002) {
this.isTokenExpiredClose = false;
this.handleTokenExpiredReconnect();
return;
}
// 如果不是手动关闭且启用了重连,则尝试重连
if (!this.isManualClose && this.config.reconnect) {
this.handleReconnect();
}
});
this.ws.onerror = (event) => {
console.error('WebSocket连接错误', event);
this.callbacks.onError?.(event);
reject(new Error('WebSocket连接失败'));
};
} catch (error) {
reject(error);
}
});
}
/**
* 发送消息
*/
send(message: WebSocketApi.WebSocketMessage): boolean {
if (!this.isConnected) {
console.warn('WebSocket未连接,无法发送消息');
return false;
}
try {
this.ws!.send(JSON.stringify(message));
return true;
} catch (error) {
console.error('发送WebSocket消息失败:', error);
return false;
}
}
/**
* 清除重连定时器
*/
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
/**
* 确保accessToken有效,如果无效则用refreshToken刷新
* 用于普通重连场景
*/
private async ensureFreshToken(): Promise<void> {
const accessStore = useAccessStore();
// 如果没有refreshToken,无法刷新
if (!accessStore.refreshToken) {
throw new Error('No refresh token available');
}
// 如果已有accessToken,先尝试使用(可能还没过期)
if (accessStore.accessToken) {
return;
}
// accessToken为空,需要刷新
await this.forceRefreshToken();
}
/**
* 强制刷新token,用于后端明确通知token_expired的场景
*/
private async forceRefreshToken(): Promise<void> {
const accessStore = useAccessStore();
if (!accessStore.refreshToken) {
throw new Error('No refresh token available');
}
console.log('[WebSocket] 使用refreshToken刷新accessToken...');
const resp = await refreshTokenApi(accessStore.refreshToken);
const newToken = resp.data?.accessToken || '';
if (!newToken) {
throw new Error('Token refresh returned empty token');
}
accessStore.setAccessToken(newToken);
if (typeof resp.data === 'object' && resp.data?.refreshToken) {
accessStore.setRefreshToken(resp.data.refreshToken);
}
}
/**
* 处理重连
*/
private handleReconnect(): void {
if (
this.reconnectAttempts >= (this.config.maxReconnectAttempts || 5) ||
this.isManualClose
) {
console.log('WebSocket重连次数已达上限或手动关闭');
return;
}
this.reconnectAttempts++;
console.log(
`WebSocket重连中... (${this.reconnectAttempts}/${this.config.maxReconnectAttempts})`,
);
this.callbacks.onReconnect?.(this.reconnectAttempts);
this.reconnectTimer = window.setTimeout(async () => {
try {
// 重连前确保token有效
await this.ensureFreshToken();
this.ws = null;
await this.connect();
} catch (error) {
console.error('WebSocket重连失败:', error);
}
}, this.config.reconnectInterval || 3000);
}
/**
* 处理token过期后的重连:先刷新token再重连,不计入重连次数
*/
private handleTokenExpiredReconnect(): void {
if (this.isManualClose) return;
console.log('[WebSocket] Token过期,尝试刷新token后重连...');
this.forceRefreshToken()
.then(() => {
console.log('[WebSocket] Token刷新成功,重新连接...');
this.ws = null;
return this.connect();
})
.catch((error) => {
console.error('[WebSocket] Token刷新失败,无法重连:', error);
});
}
/**
* 开始心跳检测
*/
private startHeartbeat(): void {
if (!this.config.heartbeat) return;
this.heartbeatTimer = window.setInterval(() => {
if (this.isConnected) {
this.send({
type: 'ping',
timestamp: new Date().toISOString(),
});
}
}, this.config.heartbeatInterval || 30_000);
}
/**
* 停止心跳检测
*/
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}
/**
* 创建WebSocket连接
*/
export function createWebSocket(
endpoint: string,
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
// 构建WebSocket URL
let wsUrl: string;
if (import.meta.env.DEV) {
// 开发环境:直接连接到后端服务器
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${wsProtocol}//localhost:8000${endpoint}`;
} else {
// 生产环境:使用当前域名
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = location.host;
wsUrl = `${wsProtocol}//${wsHost}${endpoint}`;
}
const config: WebSocketApi.WebSocketConfig = {
url: wsUrl,
reconnect: true,
maxReconnectAttempts: 5,
reconnectInterval: 3000,
heartbeat: true,
heartbeatInterval: 30_000,
};
return new WebSocketManager(config, callbacks);
}
/**
* 创建测试WebSocket连接
*/
export function createTestWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/test/', callbacks);
}
/**
* 创建通知WebSocket连接
*/
export function createNotificationWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/notifications/', callbacks);
}
/**
* 创建服务器监控WebSocket连接
*/
export function createServerMonitorWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/server-monitor/', callbacks);
}
/**
* 创建Redis监控WebSocket连接
*/
export function createRedisMonitorWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/redis-monitor/', callbacks);
}
/**
* 创建数据库监控WebSocket连接
*/
export function createDatabaseMonitorWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/database-monitor/', callbacks);
}
/**
* 监控WebSocket管理器类
*/
export class MonitorWebSocketManager extends WebSocketManager {
/**
* 获取概览信息
*/
getOverview(): boolean {
return this.send({
type: 'get_overview',
timestamp: new Date().toISOString(),
});
}
/**
* 获取实时统计
*/
getRealtime(): boolean {
return this.send({
type: 'get_realtime',
timestamp: new Date().toISOString(),
});
}
/**
* 开始监控
*/
startMonitoring(): boolean {
return this.send({
type: 'start_monitor',
timestamp: new Date().toISOString(),
});
}
/**
* 停止监控
*/
stopMonitoring(): boolean {
return this.send({
type: 'stop_monitor',
timestamp: new Date().toISOString(),
});
}
/**
* 测试连接(适用于Redis监控)
*/
testConnection(): boolean {
return this.send({
type: 'test_connection',
timestamp: new Date().toISOString(),
});
}
}
/**
* 创建服务器监控WebSocket管理器
*/
export function createServerMonitorManager(
callbacks?: WebSocketApi.WebSocketCallbacks,
): MonitorWebSocketManager {
const wsManager = createServerMonitorWebSocket(callbacks);
// 创建增强版管理器
return Object.setPrototypeOf(wsManager, MonitorWebSocketManager.prototype);
}
/**
* 创建Redis监控WebSocket管理器
*/
export function createRedisMonitorManager(
callbacks?: WebSocketApi.WebSocketCallbacks,
): MonitorWebSocketManager {
const wsManager = createRedisMonitorWebSocket(callbacks);
// 创建增强版管理器
return Object.setPrototypeOf(wsManager, MonitorWebSocketManager.prototype);
}
/**
* 数据库监控WebSocket管理器类
*/
export class DatabaseMonitorWebSocketManager extends WebSocketManager {
/**
* 获取数据库配置列表
*/
getConfigs(): boolean {
return this.send({
type: 'get_configs',
timestamp: new Date().toISOString(),
});
}
/**
* 获取概览信息
*/
getOverview(dbName: string): boolean {
return this.send({
type: 'get_overview',
db_name: dbName,
timestamp: new Date().toISOString(),
});
}
/**
* 获取实时统计
*/
getRealtime(dbName: string): boolean {
return this.send({
type: 'get_realtime',
db_name: dbName,
timestamp: new Date().toISOString(),
});
}
/**
* 开始监控
*/
startMonitoring(dbName: string): boolean {
return this.send({
type: 'start_monitor',
db_name: dbName,
timestamp: new Date().toISOString(),
});
}
/**
* 停止监控
*/
stopMonitoring(): boolean {
return this.send({
type: 'stop_monitor',
timestamp: new Date().toISOString(),
});
}
/**
* 测试连接
*/
testConnection(dbName: string): boolean {
return this.send({
type: 'test_connection',
db_name: dbName,
timestamp: new Date().toISOString(),
});
}
}
/**
* 创建聊天WebSocket连接
*/
export function createChatWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/chat/', callbacks);
}
/**
* 创建数据库监控WebSocket管理器
*/
export function createDatabaseMonitorManager(
callbacks?: WebSocketApi.WebSocketCallbacks,
): DatabaseMonitorWebSocketManager {
const wsManager = createDatabaseMonitorWebSocket(callbacks);
// 创建增强版管理器
return Object.setPrototypeOf(
wsManager,
DatabaseMonitorWebSocketManager.prototype,
);
}
+1
View File
@@ -0,0 +1 @@
export * from './core';
+257
View File
@@ -0,0 +1,257 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
/** 解析 API 错误体中的用户可读文案(兼容 FastAPI 字符串 detail 与结构化 detail */
function resolveApiErrorMessage(
responseData: Record<string, unknown>,
fallback: string,
): string {
const detail = responseData?.detail;
if (typeof detail === 'string' && detail.trim()) {
return detail;
}
if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
const nested = (detail as Record<string, unknown>).message;
if (typeof nested === 'string' && nested.trim()) {
return nested;
}
}
for (const key of ['message', 'error', 'msg'] as const) {
const val = responseData[key];
if (typeof val === 'string' && val.trim()) {
return val;
}
}
return fallback;
}
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
paramsSerializer: (params) => {
const searchParams = new URLSearchParams();
for (const key in params) {
const value = params[key];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
value.forEach((v) => searchParams.append(key, v));
} else {
searchParams.append(key, value);
}
}
return searchParams.toString();
},
});
/**
* 重新认证逻辑
*/
async function doReAuthenticate() {
console.warn(
'[认证] Access token or refresh token is invalid or expired. ',
);
const accessStore = useAccessStore();
const authStore = useAuthStore();
// 检查是否有 token,如果没有说明是登录接口返回的 401(账号密码错误)
// 这种情况不需要显示"登录已失效"的消息,也不需要跳转
const hasToken = accessStore.accessToken || accessStore.refreshToken;
accessStore.setAccessToken(null);
accessStore.setRefreshToken(null);
// 只有在有 token 的情况下才显示提示消息(说明是 token 失效)
if (hasToken) {
ElMessage.warning('登录已失效,请重新登录');
if (
preferences.app.loginExpiredMode === 'modal' &&
accessStore.isAccessChecked
) {
accessStore.setLoginExpired(true);
} else {
// 被强制登出时,不需要再调用后端 logout 接口(会 401
await authStore.logout(true, false);
}
}
}
/**
* 刷新token逻辑
*/
// 刷新Token的Promise缓存,用于请求去重
let refreshPromise: null | Promise<string> = null;
async function doRefreshToken() {
// 如果已经有正在进行的刷新请求,直接返回该Promise
// 这样多个并发请求会共享同一个刷新操作,避免重复刷新
if (refreshPromise) {
console.log('[Token刷新] 已有刷新请求进行中,等待...');
return refreshPromise;
}
console.log('[Token刷新] 开始刷新token...');
// 创建新的刷新Promise
refreshPromise = (async () => {
try {
const accessStore = useAccessStore();
console.log(
'[Token刷新] 当前refreshToken:',
accessStore.refreshToken ? '存在' : '不存在',
);
// 检查 refreshToken 是否存在
if (!accessStore.refreshToken) {
console.error('[Token刷新] Refresh token is missing');
await doReAuthenticate();
throw new Error($t('ui.fallback.http.unauthorized'));
}
// 传递 refreshToken 给 API
const resp = await refreshTokenApi(accessStore.refreshToken);
// 处理响应中的新 token
// 后端支持两种格式:直接返回 token 字符串 或 { token, accessToken } 对象
const newToken = resp.data?.accessToken || '';
// 更新 access token
accessStore.setAccessToken(newToken);
// 如果响应中有新的 refresh token,也保存
if (typeof resp.data === 'object' && resp.data?.refreshToken) {
accessStore.setRefreshToken(resp.data.refreshToken);
}
return newToken;
} finally {
// 无论成功或失败,都清空Promise缓存,允许下次刷新
refreshPromise = null;
}
})();
return refreshPromise;
}
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;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 0,
}),
);
// SSE 模块 401 时的 token 刷新
client.refreshToken = async () => {
try {
return await doRefreshToken();
} catch {
await doReAuthenticate();
throw new Error('Token refresh failed');
}
};
// token过期的处理
// 注意:enableRefreshToken 需要动态获取,因为 preferences 可能在运行时更新
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: true, // 始终启用 token 刷新
formatToken,
}),
);
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
const responseData = error?.response?.data ?? {};
const status = error?.response?.status;
// 401 错误由 authenticateResponseInterceptor 处理,这里跳过
// 避免在 token 刷新过程中显示错误消息
if (status === 401) {
return;
}
// 优先级顺序:后端自定义消息 > 传入的消息 > 状态码默认消息
let errorMessage = resolveApiErrorMessage(
responseData as Record<string, unknown>,
msg,
);
// 特殊处理429限流
if (status === 429) {
errorMessage =
errorMessage || '请求过于频繁,请稍后再试(5分钟内不能重试)';
}
// 特殊处理403禁止访问
if (status === 403) {
errorMessage = errorMessage || '您没有权限访问此资源';
}
// 打印完整错误信息便于调试
console.error('[API Error]', {
status,
message: errorMessage,
data: responseData,
});
// 显示错误消息
if (errorMessage) {
ElMessage.error(errorMessage || msg);
}
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'body',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
+675
View File
@@ -0,0 +1,675 @@
import { requestClient } from '#/api/request';
const BASE = '/api/smart-table';
// ==================== Types ====================
export interface SmartTableItem {
id: string;
name: string;
icon: string;
type?: string;
parent_id?: string | null;
sort?: number;
description?: string;
active_view_id?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface SmartFieldItem {
id: string;
table_id: string;
name: string;
type: string;
width: number;
visible: boolean;
required: boolean;
description?: string;
config: Record<string, any>;
sort: number;
sys_create_datetime?: string;
}
export interface SmartRecordItem {
id: string;
table_id: string;
values: Record<string, any>;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
sys_modifier_id?: string;
}
export interface SmartViewItem {
id: string;
table_id: string;
name: string;
type: string;
config: Record<string, any>;
sort: number;
sys_create_datetime?: string;
}
export interface SmartTableFull extends SmartTableItem {
type?: string;
content?: any;
fields: SmartFieldItem[];
records: SmartRecordItem[];
views: SmartViewItem[];
record_total: number;
next_cursor: string | null;
has_more: boolean;
sys_creator_id?: string;
creator_name?: string;
creator_avatar?: string;
}
export interface CursorPaginatedRecords {
items: SmartRecordItem[];
total: number;
next_cursor: string | null;
has_more: boolean;
}
export interface RecordFilterParam {
field_id: string;
operator: string;
value?: any;
}
export interface RecordSortParam {
field_id: string;
direction: 'asc' | 'desc';
}
export interface RecordQueryParam {
filters?: RecordFilterParam[];
filter_logic?: 'and' | 'or';
sorts?: RecordSortParam[];
search?: string;
search_field_ids?: string[];
group_field_id?: string;
cursor?: string | null;
limit?: number;
}
export interface RecordGroupItem {
key: string;
label: string;
records: SmartRecordItem[];
}
export interface GroupedRecordsResponse {
groups: RecordGroupItem[];
total: number;
}
// ==================== Table API ====================
export function getTableListApi(wikiSpaceId?: string | null) {
const params: Record<string, any> = {};
if (wikiSpaceId) params.wiki_space_id = wikiSpaceId;
return requestClient.get<SmartTableItem[]>(`${BASE}/tables`, { params });
}
export function getTableFullApi(
tableId: string,
opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string },
) {
const params: Record<string, any> = {};
if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters);
if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts);
if (opts?.search) params.search = opts.search;
if (opts?.filter_logic) params.filter_logic = opts.filter_logic;
return requestClient.get<SmartTableFull>(`${BASE}/tables/${tableId}/full`, { params });
}
export function createTableApi(data: { name: string; icon?: string; description?: string; type?: string; content?: any; parent_id?: string | null; wiki_space_id?: string | null }) {
return requestClient.post<SmartTableItem>(`${BASE}/tables`, data);
}
export function updateTableApi(tableId: string, data: Partial<SmartTableItem>) {
return requestClient.put<SmartTableItem>(`${BASE}/tables/${tableId}`, data);
}
export function updateDocumentContentApi(tableId: string, content: any) {
return requestClient.patch(`${BASE}/tables/${tableId}/content`, { content });
}
export function exportDocumentPdfApi(tableId: string, html: string, title: string) {
return requestClient.post<Blob>(`${BASE}/tables/${tableId}/export-pdf`, { html, title }, {
responseType: 'blob',
});
}
export function deleteTableApi(tableId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}`);
}
export function moveTableApi(tableId: string, parentId: string | null, afterId?: string | null) {
return requestClient.put<SmartTableItem>(`${BASE}/tables/${tableId}/move`, {
parent_id: parentId,
after_id: afterId ?? null,
});
}
// ==================== Field API ====================
export function getFieldListApi(tableId: string) {
return requestClient.get<SmartFieldItem[]>(`${BASE}/tables/${tableId}/fields`);
}
export function createFieldApi(tableId: string, data: Omit<SmartFieldItem, 'id' | 'table_id' | 'sys_create_datetime'>) {
return requestClient.post<SmartFieldItem>(`${BASE}/tables/${tableId}/fields`, data);
}
export function updateFieldApi(fieldId: string, data: Partial<SmartFieldItem>) {
return requestClient.put<SmartFieldItem>(`${BASE}/fields/${fieldId}`, data);
}
export function deleteFieldApi(fieldId: string) {
return requestClient.delete(`${BASE}/fields/${fieldId}`);
}
export function reorderFieldsApi(tableId: string, fieldIds: string[]) {
return requestClient.put(`${BASE}/tables/${tableId}/fields/reorder`, { field_ids: fieldIds });
}
// ==================== Record API ====================
export function getRecordListApi(
tableId: string,
cursor?: string | null,
limit = 200,
opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string },
) {
const params: Record<string, any> = { cursor: cursor ?? undefined, limit };
if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters);
if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts);
if (opts?.search) params.search = opts.search;
if (opts?.filter_logic) params.filter_logic = opts.filter_logic;
return requestClient.get<CursorPaginatedRecords>(
`${BASE}/tables/${tableId}/records`,
{ params },
);
}
export function queryRecordsApi(tableId: string, query: RecordQueryParam) {
return requestClient.post<CursorPaginatedRecords | GroupedRecordsResponse>(
`${BASE}/tables/${tableId}/records/query`,
query,
);
}
export function reorderRecordsApi(tableId: string, recordIds: string[]) {
return requestClient.put(`${BASE}/tables/${tableId}/records/reorder`, { record_ids: recordIds });
}
export function createRecordApi(tableId: string, values: Record<string, any> = {}) {
return requestClient.post<SmartRecordItem>(`${BASE}/tables/${tableId}/records`, { table_id: tableId, values });
}
export function updateRecordApi(recordId: string, values: Record<string, any>) {
return requestClient.put<SmartRecordItem>(`${BASE}/records/${recordId}`, { values });
}
export function updateCellApi(recordId: string, fieldId: string, value: any) {
return requestClient.patch<SmartRecordItem>(`${BASE}/records/${recordId}/cells`, {
field_id: fieldId,
value,
});
}
export function batchUpdateCellsApi(recordId: string, cells: Record<string, any>) {
return requestClient.patch<SmartRecordItem>(`${BASE}/records/${recordId}/cells/batch`, { cells });
}
export function batchUpdateMultiRecordCellsApi(
tableId: string,
updates: Array<{ record_id: string; cells: Record<string, any> }>,
) {
return requestClient.patch(`${BASE}/tables/${tableId}/records/batch-cells`, { updates });
}
export function deleteRecordApi(recordId: string) {
return requestClient.delete(`${BASE}/records/${recordId}`);
}
export function batchDeleteRecordsApi(tableId: string, ids: string[]) {
return requestClient.post(`${BASE}/tables/${tableId}/records/batch-delete`, { ids });
}
// ==================== Trash / Recycle Bin ====================
export interface TrashRecordItem {
id: string;
table_id: string;
values: Record<string, any>;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
}
export function getTrashRecordsApi(tableId: string, page = 1, pageSize = 50) {
return requestClient.get<{ items: TrashRecordItem[]; total: number }>(
`${BASE}/tables/${tableId}/trash`,
{ params: { page, page_size: pageSize } },
);
}
export function restoreTrashRecordsApi(tableId: string, ids: string[]) {
return requestClient.post(`${BASE}/tables/${tableId}/trash/restore`, { ids });
}
export function permanentDeleteTrashRecordApi(tableId: string, recordId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/trash/${recordId}`);
}
export function emptyTrashApi(tableId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/trash`);
}
export function exportTableApi(tableId: string, format: 'csv' | 'xlsx' = 'csv') {
return requestClient.get(`${BASE}/tables/${tableId}/export`, {
params: { format },
responseType: 'blob',
});
}
export function importTableApi(tableId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post(`${BASE}/tables/${tableId}/import`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
export interface RecordSearchResultItem {
id: string;
title: string;
}
export function searchRecordsApi(tableId: string, keyword: string = '', limit: number = 20) {
return requestClient.post<RecordSearchResultItem[]>(
`${BASE}/tables/${tableId}/records/search`,
{ keyword, limit },
);
}
// ==================== Summary API ====================
export interface SummaryResult {
summaries: Record<string, any>;
total_count: number;
}
export function getSummaryApi(
tableId: string,
data: {
aggregations: Record<string, string>;
filters?: RecordFilterParam[];
filter_logic?: string;
search?: string;
},
) {
return requestClient.post<SummaryResult>(`${BASE}/tables/${tableId}/summary`, data);
}
// ==================== Comment API ====================
export interface CommentItem {
id: string;
record_id: string;
user_id: string;
content: string;
mentions: string[];
parent_id: string | null;
sys_create_datetime?: string;
sys_update_datetime?: string;
user_name?: string;
user_avatar?: string;
replies: CommentItem[];
}
export function getCommentsApi(recordId: string) {
return requestClient.get<CommentItem[]>(`${BASE}/records/${recordId}/comments`);
}
export function createCommentApi(recordId: string, data: { content: string; mentions?: string[]; parent_id?: string }) {
return requestClient.post<CommentItem>(`${BASE}/records/${recordId}/comments`, data);
}
export function updateCommentApi(commentId: string, data: { content: string; mentions?: string[] }) {
return requestClient.put<CommentItem>(`${BASE}/comments/${commentId}`, data);
}
export function deleteCommentApi(commentId: string) {
return requestClient.delete(`${BASE}/comments/${commentId}`);
}
// ==================== View API ====================
export function getViewListApi(tableId: string) {
return requestClient.get<SmartViewItem[]>(`${BASE}/tables/${tableId}/views`);
}
export function createViewApi(tableId: string, data: { name: string; type: string; config?: Record<string, any> }) {
return requestClient.post<SmartViewItem>(`${BASE}/tables/${tableId}/views`, { ...data, table_id: tableId });
}
export function updateViewApi(viewId: string, data: Partial<SmartViewItem>) {
return requestClient.put<SmartViewItem>(`${BASE}/views/${viewId}`, data);
}
export function deleteViewApi(viewId: string) {
return requestClient.delete(`${BASE}/views/${viewId}`);
}
// ==================== Permission API ====================
export interface MyPermission {
role_type: string;
role_name: string;
capabilities: Record<string, boolean>;
field_permissions: Record<string, string>;
row_view_mode: string;
row_edit_mode: string;
}
export interface TableRole {
id: string;
table_id: string | null;
name: string;
role_type: string;
capabilities: Record<string, boolean>;
is_system: boolean;
sys_create_datetime?: string;
}
export interface Collaborator {
id: string;
table_id: string;
subject_type: string;
subject_id: string;
role_id: string;
role_name?: string;
role_type?: string;
subject_name?: string;
subject_avatar?: string;
sys_create_datetime?: string;
}
export interface FieldPermItem {
field_id: string;
access: string;
}
export interface FieldPermMatrix {
role_id: string;
role_name: string;
role_type: string;
fields: FieldPermItem[];
}
export interface RowRule {
id: string;
table_id: string;
role_id: string;
rule_type: string;
mode: string;
conditions: Record<string, any>[];
}
export function getMyPermissionApi(tableId: string) {
return requestClient.get<MyPermission>(`${BASE}/tables/${tableId}/my-permission`);
}
export function getRolesApi(tableId: string) {
return requestClient.get<TableRole[]>(`${BASE}/tables/${tableId}/roles`);
}
export function createRoleApi(tableId: string, data: { name: string; capabilities: Record<string, boolean> }) {
return requestClient.post<TableRole>(`${BASE}/tables/${tableId}/roles`, data);
}
export function updateRoleApi(tableId: string, roleId: string, data: { name?: string; capabilities?: Record<string, boolean> }) {
return requestClient.put<TableRole>(`${BASE}/tables/${tableId}/roles/${roleId}`, data);
}
export function deleteRoleApi(tableId: string, roleId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/roles/${roleId}`);
}
export function getCollaboratorsApi(tableId: string) {
return requestClient.get<Collaborator[]>(`${BASE}/tables/${tableId}/collaborators`);
}
export function addCollaboratorApi(tableId: string, data: { subject_type: string; subject_id: string; role_id: string }) {
return requestClient.post<Collaborator>(`${BASE}/tables/${tableId}/collaborators`, data);
}
export function updateCollaboratorApi(tableId: string, collabId: string, data: { role_id: string }) {
return requestClient.put<Collaborator>(`${BASE}/tables/${tableId}/collaborators/${collabId}`, data);
}
export function removeCollaboratorApi(tableId: string, collabId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/collaborators/${collabId}`);
}
export function getFieldPermissionsApi(tableId: string) {
return requestClient.get<FieldPermMatrix[]>(`${BASE}/tables/${tableId}/field-permissions`);
}
export function updateFieldPermissionsApi(tableId: string, data: { role_id: string; permissions: FieldPermItem[] }) {
return requestClient.put(`${BASE}/tables/${tableId}/field-permissions`, data);
}
export function getRowRulesApi(tableId: string) {
return requestClient.get<RowRule[]>(`${BASE}/tables/${tableId}/row-rules`);
}
export function updateRowRuleApi(tableId: string, data: { role_id: string; rule_type: string; mode: string; conditions: Record<string, any>[] }) {
return requestClient.put<RowRule>(`${BASE}/tables/${tableId}/row-rules`, data);
}
// ==================== Document Version API ====================
export interface DocumentVersionItem {
id: string;
document_id: string;
version: number;
title?: string;
change_summary?: string;
content_size: number;
sys_create_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
creator_avatar?: string;
}
export interface DocumentVersionDetail extends DocumentVersionItem {
content: Record<string, any>;
}
export interface DocumentVersionCompare {
version_from: DocumentVersionDetail;
version_to: DocumentVersionDetail;
}
export function getDocumentVersionsApi(tableId: string, page = 1, pageSize = 20) {
return requestClient.get<{ items: DocumentVersionItem[]; total: number }>(
`${BASE}/tables/${tableId}/versions`,
{ params: { page, pageSize } },
);
}
export function getDocumentVersionDetailApi(versionId: string) {
return requestClient.get<DocumentVersionDetail>(`${BASE}/versions/${versionId}`);
}
export function createDocumentVersionApi(tableId: string, changeSummary?: string) {
return requestClient.post<DocumentVersionItem>(
`${BASE}/tables/${tableId}/versions`,
{ change_summary: changeSummary },
);
}
export function restoreDocumentVersionApi(tableId: string, versionId: string) {
return requestClient.post(`${BASE}/tables/${tableId}/versions/${versionId}/restore`);
}
export function compareDocumentVersionsApi(tableId: string, fromId: string, toId: string) {
return requestClient.get<DocumentVersionCompare>(
`${BASE}/tables/${tableId}/versions/compare`,
{ params: { from: fromId, to: toId } },
);
}
export function deleteDocumentVersionApi(versionId: string) {
return requestClient.delete(`${BASE}/versions/${versionId}`);
}
// ==================== Document Template API ====================
export interface DocumentTemplateItem {
id: string;
name: string;
description?: string;
icon: string;
category: string;
preview_image?: string;
is_system: boolean;
use_count: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
}
export interface DocumentTemplateDetail extends DocumentTemplateItem {
content: Record<string, any>;
}
export function getDocumentTemplatesApi(params?: { category?: string; keyword?: string; page?: number; pageSize?: number }) {
return requestClient.get<{ items: DocumentTemplateItem[]; total: number }>(
`${BASE}/document-templates`,
{ params },
);
}
export function getDocumentTemplateCategoriesApi() {
return requestClient.get<string[]>(`${BASE}/document-templates/categories`);
}
export function getDocumentTemplateDetailApi(templateId: string) {
return requestClient.get<DocumentTemplateDetail>(`${BASE}/document-templates/${templateId}`);
}
export function createDocumentTemplateApi(data: {
name: string;
description?: string;
icon?: string;
category?: string;
content: Record<string, any>;
preview_image?: string;
}) {
return requestClient.post<DocumentTemplateItem>(`${BASE}/document-templates`, data);
}
export function createTemplateFromDocumentApi(documentId: string, data: {
name: string;
description?: string;
category?: string;
content: Record<string, any>;
}) {
return requestClient.post<DocumentTemplateItem>(
`${BASE}/document-templates/from-document/${documentId}`,
data,
);
}
export function updateDocumentTemplateApi(templateId: string, data: {
name?: string;
description?: string;
icon?: string;
category?: string;
content?: Record<string, any>;
preview_image?: string;
}) {
return requestClient.put<DocumentTemplateItem>(`${BASE}/document-templates/${templateId}`, data);
}
export function deleteDocumentTemplateApi(templateId: string) {
return requestClient.delete(`${BASE}/document-templates/${templateId}`);
}
export function useDocumentTemplateApi(templateId: string) {
return requestClient.post(`${BASE}/document-templates/${templateId}/use`);
}
// ==================== Wiki Space API ====================
export interface WikiSpaceItem {
id: string;
name: string;
icon: string;
avatar?: string | null;
description?: string;
cover?: string;
category: string;
visibility: string;
sort: number;
document_count?: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
}
export interface WikiSpaceDetail extends WikiSpaceItem {
documents: SmartTableItem[];
}
export function getWikiSpacesApi() {
return requestClient.get<WikiSpaceItem[]>(`${BASE}/wiki-spaces`);
}
export function getWikiSpaceDetailApi(spaceId: string) {
return requestClient.get<WikiSpaceDetail>(`${BASE}/wiki-spaces/${spaceId}`);
}
export function createWikiSpaceApi(data: {
name: string;
icon?: string;
avatar?: string | null;
description?: string;
cover?: string;
category?: string;
visibility?: string;
}) {
return requestClient.post<WikiSpaceItem>(`${BASE}/wiki-spaces`, data);
}
export function updateWikiSpaceApi(spaceId: string, data: Partial<WikiSpaceItem>) {
return requestClient.put<WikiSpaceItem>(`${BASE}/wiki-spaces/${spaceId}`, data);
}
export function deleteWikiSpaceApi(spaceId: string) {
return requestClient.delete(`${BASE}/wiki-spaces/${spaceId}`);
}
export function getWikiSpaceDocumentsApi(spaceId: string) {
return requestClient.get<SmartTableItem[]>(`${BASE}/wiki-spaces/${spaceId}/documents`);
}
export function createWikiDocumentApi(spaceId: string, data: {
name: string;
parent_id?: string | null;
content?: any;
}) {
return requestClient.post<SmartTableItem>(
`${BASE}/wiki-spaces/${spaceId}/documents`,
{ ...data, type: 'document', icon: 'FileText' },
);
}
+403
View File
@@ -0,0 +1,403 @@
import { preferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
/**
* 流式请求配置
*/
interface StreamRequestOptions {
/** 请求方法 */
method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
/** 请求头 */
headers?: Record<string, string>;
/** 请求体 */
body?: any;
/** 超时时间(毫秒) */
timeout?: number;
/** 是否自动处理JSON */
json?: boolean;
}
/**
* 流式请求事件处理器
*/
export interface StreamEventHandler<T> {
/** 处理数据事件 */
onData?: (data: T) => void;
/** 处理错误事件 */
onError?: (error: Error) => void;
/** 处理完成事件 */
onComplete?: () => void;
}
// 是否正在刷新 token
let isRefreshing = false;
// 等待刷新 token 的请求队列
let refreshSubscribers: Array<(token: string) => void> = [];
/**
* 添加到刷新队列
*/
function subscribeTokenRefresh(callback: (token: string) => void) {
refreshSubscribers.push(callback);
}
/**
* 通知所有等待的请求
*/
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach((callback) => callback(token));
refreshSubscribers = [];
}
/**
* 重新认证逻辑
*/
async function doReAuthenticate() {
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 {
await authStore.logout();
}
}
/**
* 刷新 token 逻辑
*/
async function doRefreshToken(): Promise<string> {
const accessStore = useAccessStore();
// 检查 refreshToken 是否存在
if (!accessStore.refreshToken) {
console.error('Refresh token is missing');
await doReAuthenticate();
throw new Error('Refresh token is missing');
}
// 传递 refreshToken 给 API
const resp = await refreshTokenApi(accessStore.refreshToken);
// 处理响应中的新 token
const newToken = resp.data?.accessToken || '';
// 更新 access token
accessStore.setAccessToken(newToken);
// 如果响应中有新的 refresh token,也保存
if (typeof resp.data === 'object' && resp.data?.refreshToken) {
accessStore.setRefreshToken(resp.data.refreshToken);
}
return newToken;
}
/**
* 执行流式请求(内部实现)
*/
function doStreamRequest<T = any>(
url: string,
options: StreamRequestOptions = {},
eventHandler: StreamEventHandler<T> = {},
token?: string,
): () => void {
const {
method = 'GET',
headers = {},
body,
timeout = 30_000,
json = true,
} = options;
const controller = new AbortController();
const signal = controller.signal;
let isAborted = false;
// 设置请求头
const requestHeaders = new Headers({
'Content-Type': 'application/json',
...headers,
});
// 添加认证头
const accessStore = useAccessStore();
const accessToken = token || accessStore.accessToken;
if (accessToken) {
requestHeaders.set('Authorization', `Bearer ${accessToken}`);
}
// 添加语言头
requestHeaders.set('Accept-Language', preferences.app.locale);
// 准备请求体
let requestBody: BodyInit | null = null;
if (body) {
requestBody = json ? JSON.stringify(body) : body;
}
// 发起请求
const requestPromise = fetch(url, {
method,
headers: requestHeaders,
body: requestBody,
signal,
});
// 设置超时
const timeoutId = setTimeout(() => {
if (!isAborted) {
controller.abort();
const error = new Error('请求超时');
eventHandler.onError?.(error);
}
}, timeout);
// 处理响应
requestPromise
.then(async (response) => {
clearTimeout(timeoutId);
// 处理 401 错误 - 尝试刷新 token
if (response.status === 401 && preferences.app.enableRefreshToken) {
// 如果正在刷新 token,等待刷新完成后重试
if (isRefreshing) {
return new Promise<void>((resolve) => {
subscribeTokenRefresh((newToken) => {
// 使用新 token 重试请求
const cancel = doStreamRequest(
url,
options,
eventHandler,
newToken,
);
// 如果原请求被取消,也取消重试的请求
if (isAborted) {
cancel();
}
resolve();
});
});
}
// 开始刷新 token
isRefreshing = true;
try {
const newToken = await doRefreshToken();
isRefreshing = false;
onTokenRefreshed(newToken);
// 使用新 token 重试请求
const cancel = doStreamRequest(url, options, eventHandler, newToken);
// 如果原请求被取消,也取消重试的请求
if (isAborted) {
cancel();
}
return;
} catch {
isRefreshing = false;
refreshSubscribers = [];
await doReAuthenticate();
const error = new Error('认证失败,请重新登录');
(error as any).status = 401;
eventHandler.onError?.(error);
return;
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(
errorData.message ||
errorData.detail ||
`请求失败: ${response.statusText}`,
);
(error as any).status = response.status;
(error as any).data = errorData;
throw error;
}
if (!response.body) {
throw new Error('响应体为空');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
eventHandler.onComplete?.();
return;
}
buffer += decoder.decode(value, { stream: true });
// 处理可能的多条消息
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
eventHandler.onComplete?.();
return;
}
try {
const parsed = JSON.parse(data);
eventHandler.onData?.(parsed);
} catch (error) {
console.error('解析流数据失败:', error, '原始数据:', data);
}
}
}
}
} catch (error) {
if (!isAborted) {
console.error('处理流数据时出错:', error);
eventHandler.onError?.(error as Error);
}
}
};
await processStream();
})
.catch((error) => {
clearTimeout(timeoutId);
if (!isAborted) {
console.error('请求失败:', error);
// 处理取消的请求
if (error.name === 'AbortError') {
return;
}
// 处理HTTP错误
if (error.status) {
let errorMessage = error.message;
// 根据状态码显示更友好的错误信息
switch (error.status) {
case 401: {
errorMessage = '认证失败,请重新登录';
break;
}
case 403: {
errorMessage = '没有权限访问此资源';
break;
}
case 429: {
errorMessage = '请求过于频繁,请稍后再试';
break;
}
default: {
if (error.status >= 500) {
errorMessage = '服务器内部错误,请稍后再试';
}
}
}
ElMessage.error(errorMessage);
}
eventHandler.onError?.(error);
}
});
// 返回取消函数
return () => {
isAborted = true;
controller.abort();
clearTimeout(timeoutId);
};
}
/**
* 执行流式请求
* @param url 请求URL
* @param options 请求选项
* @param eventHandler 事件处理器
* @returns 取消函数
*/
export function streamRequest<T = any>(
url: string,
options: StreamRequestOptions = {},
eventHandler: StreamEventHandler<T> = {},
): () => void {
return doStreamRequest(url, options, eventHandler);
}
/**
* 创建流式请求的取消令牌
*/
export class StreamRequestToken {
/**
* 检查是否已取消
*/
get isCancelled() {
return this.controller?.signal.aborted ?? false;
}
private controller: AbortController | null = null;
/**
* 取消请求
*/
cancel() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
/**
* 创建一个新的取消令牌
*/
create() {
this.controller = new AbortController();
return this.controller.signal;
}
}
/**
* 使用流式请求配置创建请求函数
*/
export function createStreamRequest(
baseURL: string = '',
options: StreamRequestOptions = {},
) {
return <T>(url: string, data?: any, eventHandler?: StreamEventHandler<T>) => {
const fullUrl = `${baseURL}${url}`;
return streamRequest<T>(
fullUrl,
{
...options,
method: data ? 'POST' : 'GET',
body: data,
},
eventHandler,
);
};
}
// 创建默认的流式请求客户端
export const streamRequestClient = createStreamRequest(
import.meta.env.VITE_GLOB_API_URL,
);
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { useElementPlusDesignTokens } from '@vben/hooks';
import { ElConfigProvider } from 'element-plus';
import { elementLocale } from '#/locales';
defineOptions({ name: 'App' });
useElementPlusDesignTokens();
</script>
<template>
<ElConfigProvider :locale="elementLocale">
<RouterView />
</ElConfigProvider>
</template>
@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#039}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zM7.1 6.4h3.6l1.3 6c.2.9.4 2.4.4 2.4h.1s.2-1.5.4-2.4l1.3-6H18l.9 14.3h-3l-.4-6.4c-.1-1.1 0-2.4 0-2.4h-.1s-.2 1.5-.4 2.4l-.9 4.1H11l-.9-4.1c-.2-.9-.4-2.4-.4-2.4h-.1s.1 1.3 0 2.4l-.4 6.4h-3l.9-14.3zm15.1 0h4.5c2.6 0 4.4 2 4.4 4.9 0 2.9-1.7 5-4.4 5h-1.2v4.4h-3.3V6.4zm3.9 7c1 0 1.6-.9 1.6-2.1s-.7-2-1.6-2h-.7v4.1h.7zm8.7 3.6c.4.4 1.1.7 1.7.7.9 0 1.6-.4 1.6-1.6 0-1-.7-1.5-2.3-1.5h-.9l-.7-1.8 2-2.5c.5-.6.9-1 .9-1s-.4.1-1.1.1h-2.3v-3h7.2V9l-2.6 3.1c2 .5 3.1 2.1 3.1 4.1 0 2.4-1.4 4.8-4.7 4.8-1.3 0-2.5-.4-3.5-1.3l1.6-2.7z" fill="#fff"/><path class="st0" d="M47 66c-3.1-1.2-6.7.3-8 3.4-1.2 3.1.3 6.7 3.4 8 .6.3 1.3.4 2 .4 3.5.1 6.4-2.6 6.4-6V47.9c-.2 0-.3.1-.5.1-6.8 1.9-13.5 3.9-20.3 5.8-.6.2-.6.5-.6 1v16.8c-.5-.2-.7-.3-1.1-.4-2.5-.9-4.7-.4-6.5 1.4-1.8 1.7-2.3 4.3-1.3 6.6 1.3 3.1 4.9 4.6 8 3.4 2.3-1 3.8-3.2 3.8-5.7V61.3c0-.7.2-.9.9-1.1 3.3-.9 6.6-1.9 10-2.8L48 56v10.4c-.5-.2-.7-.3-1-.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#307ac6}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm-.3-25.7H9v1.1c0 1.5.4 1.6.7 1.6.5 0 .7-.3.7-1.6v-7H7.7v-3h6v9.9c0 3.3-2 4.6-4 4.6-2.1 0-4-1.4-4-4.6v-1zM18 16.7c.6.6 1.4 1.2 2.3 1.2s1.3-.6 1.3-1.2c0-2-4.8-2.1-4.8-6.2 0-2.4 1.7-4.3 4.5-4.3 1.2 0 2.4.3 3.4 1l-1.2 2.9c-.5-.4-1.4-.9-2.2-.9-.9 0-1.3.6-1.3 1.2 0 2.1 4.8 2 4.8 6.1 0 2.3-1.5 4.4-4.4 4.4-1.4 0-2.9-.5-4-1.4l1.6-2.8z" fill="#fff"/><path d="M16.4 67.5v-4.1c.9-.1 1.6-.2 2-.4s.8-.6 1.2-1c.4-.5.5-1.1.7-1.9.1-.6.2-1.5.2-2.8 0-2.2.1-3.7.4-4.6.2-.8.6-1.6 1.2-2 .5-.5 1.4-.9 2.5-1.2.7-.2 1.9-.4 3.5-.4h.9V53c-1.3 0-2.2.1-2.6.3s-.6.4-.9.6c-.2.3-.3.7-.3 1.5s-.1 2-.2 4.1c-.1 1.2-.2 2-.4 2.8-.3.6-.6 1.2-1 1.8-.4.4-1 .9-1.8 1.4.7.4 1.3.8 1.8 1.3s.8 1.2 1.1 1.9c.3.7.4 1.8.4 3 .1 1.9.1 3.1.1 3.6 0 .7.1 1.2.3 1.6.2.4.5.5.9.6.4.2 1.2.3 2.6.3v4.1h-1c-1.6 0-2.9-.1-3.7-.4-.9-.3-1.6-.6-2.2-1.2s-1-1.2-1.2-2-.3-2.1-.3-4c0-2-.1-3.5-.3-4.1-.3-.9-.7-1.6-1.2-2-.7-.5-1.5-.7-2.7-.7zm39.1 0c-.9.1-1.6.2-2 .4-.4.2-.8.6-1.2 1-.4.5-.5 1.1-.7 1.9-.1.6-.2 1.5-.2 2.8 0 2.2-.1 3.7-.4 4.6-.2.9-.6 1.6-1.2 2-.5.5-1.4.9-2.5 1.2-.7.2-1.9.4-3.5.4h-1v-4.1c1.3 0 2.1-.1 2.6-.3s.7-.4.9-.6c.2-.3.3-.7.3-1.5 0-.6.1-2 .2-4 .1-1.2.3-2.1.5-2.8.3-.7.6-1.3 1.1-1.9.4-.5 1-.9 1.7-1.3-.9-.6-1.6-1.1-2-1.6-.5-.7-1-1.8-1.2-2.8-.2-.8-.3-2.6-.3-5.2 0-.8-.1-1.4-.3-1.8-.2-.3-.4-.5-.8-.6-.2-.3-1-.3-2.5-.3v-4h1c1.6 0 2.9.1 3.7.4.9.3 1.6.6 2.2 1.2s1 1.2 1.2 2 .4 2.1.4 4c0 2 .1 3.4.3 4.1.3.9.7 1.6 1.2 1.9.5.4 1.4.6 2.5.6.1.1 0 4.3 0 4.3z" fill="#307ac6" stroke="#307ac6" stroke-miterlimit="10"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#1f7244}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm2.6-27.4L5.5 6.4h3.3l1 2.9c.3.9.5 1.8.5 1.8s.2-.9.5-1.8l1-2.9H15l-3.1 7.2 3 7.1h-3.2l-1-3c-.3-.9-.5-1.7-.5-1.7s-.2.8-.5 1.7l-1 3H5.6l3-7.1zm8-7.2h3v11.4h3.9v2.9h-6.8V6.4zm9 10.3c.5.6 1.3 1.2 2.1 1.2s1.2-.6 1.2-1.2c0-2-4.3-2.1-4.3-6.2 0-2.4 1.5-4.3 4.1-4.3 1.1 0 2.2.3 3.1 1l-1.1 2.9c-.4-.4-1.3-.9-1.9-.9-.8 0-1.2.6-1.2 1.2 0 2.1 4.3 2 4.3 6.1 0 2.3-1.3 4.4-4 4.4-1.3 0-2.6-.5-3.6-1.4l1.3-2.8zm10.2-3.1l-3.1-7.2H36l1 2.9c.3.9.5 1.8.5 1.8s.2-.9.5-1.8l1-2.9h3.2l-3.1 7.2 3 7.1h-3.2l-1-3c-.3-.9-.5-1.7-.5-1.7s-.2.8-.5 1.7l-1 3h-3.2l3.1-7.1z" fill="#fff"/><path class="st0" d="M46.8 49.2H59v8.1H46.8zm0 12.5H59v8.1H46.8zm0 12.4H59v8.1H46.8zm-16.5 0h12.2v8.1H30.3zm-16.5 0H26v8.1H13.8zm27.1-4.8l-8-10.1 7.3-9.2H33l-3.8 5-3.5-5h-7.5l7.4 9.4-7.8 9.9h7.3l4.1-5.5 4.1 5.5z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#3c3}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm-.3-25.7H9v1.1c0 1.5.4 1.6.7 1.6.5 0 .7-.3.7-1.6v-7H7.7v-3h6v9.9c0 3.3-2 4.6-4 4.6-2.1 0-4-1.4-4-4.6v-1zm11.6-8.9h4.5c2.6 0 4.4 2 4.4 4.9 0 2.9-1.7 5-4.4 5h-1.2v4.4h-3.3V6.4zm3.9 7c1 0 1.6-.9 1.6-2.1s-.7-2-1.6-2h-.7v4.1h.7zm13.6-7.3c1.4 0 2.9.4 3.9 1.3l-1.4 2.8c-.6-.6-1.4-.9-2.3-.9-2.2 0-3.2 1.9-3.2 4.2 0 2.6 1.2 4.3 2.9 4.3.6 0 1.3-.2 1.7-.6v-1.7H35v-2.9h4.3v8.1h-2.6v-.9c-.5.7-1.6 1.2-2.7 1.2-2.8 0-5.6-2.7-5.6-7.3.1-4.5 2.5-7.6 6.4-7.6z" fill="#fff"/><path class="st0" d="M14 48.5v33.9h44V48.5H14zM55.5 51v14.8l-10.5-8-11.5 11.5-8.1-4.5-8.8 7V51h38.9z"/><circle class="st0" cx="23" cy="57.6" r="3.1"/></svg>

After

Width:  |  Height:  |  Size: 897 B

@@ -0,0 +1,138 @@
import audioIcon from './audio.svg';
import codeIcon from './code.svg';
import excelIcon from './excel.svg';
import imageIcon from './image.svg';
import pdfIcon from './pdf.svg';
import pptIcon from './ppt.svg';
import txtIcon from './txt.svg';
import unknownIcon from './unknown.svg';
import videoIcon from './video.svg';
import wordIcon from './word.svg';
import zipIcon from './zip.svg';
const EXT_MAP: Record<string, string> = {
// PDF
pdf: pdfIcon,
// Word
doc: wordIcon,
docx: wordIcon,
// Excel
xls: excelIcon,
xlsx: excelIcon,
csv: excelIcon,
// PPT
ppt: pptIcon,
pptx: pptIcon,
// 图片
jpg: imageIcon,
jpeg: imageIcon,
png: imageIcon,
gif: imageIcon,
bmp: imageIcon,
svg: imageIcon,
webp: imageIcon,
ico: imageIcon,
tiff: imageIcon,
// 视频
mp4: videoIcon,
avi: videoIcon,
mov: videoIcon,
mkv: videoIcon,
webm: videoIcon,
flv: videoIcon,
wmv: videoIcon,
// 音频
mp3: audioIcon,
wav: audioIcon,
ogg: audioIcon,
flac: audioIcon,
aac: audioIcon,
wma: audioIcon,
// 压缩
zip: zipIcon,
rar: zipIcon,
'7z': zipIcon,
tar: zipIcon,
gz: zipIcon,
bz2: zipIcon,
// 代码
js: codeIcon,
ts: codeIcon,
jsx: codeIcon,
tsx: codeIcon,
vue: codeIcon,
svelte: codeIcon,
py: codeIcon,
java: codeIcon,
c: codeIcon,
cpp: codeIcon,
h: codeIcon,
hpp: codeIcon,
cs: codeIcon,
go: codeIcon,
rs: codeIcon,
rb: codeIcon,
php: codeIcon,
swift: codeIcon,
kt: codeIcon,
scala: codeIcon,
lua: codeIcon,
r: codeIcon,
pl: codeIcon,
sh: codeIcon,
bash: codeIcon,
zsh: codeIcon,
bat: codeIcon,
cmd: codeIcon,
ps1: codeIcon,
html: codeIcon,
htm: codeIcon,
css: codeIcon,
scss: codeIcon,
sass: codeIcon,
less: codeIcon,
sql: codeIcon,
graphql: codeIcon,
proto: codeIcon,
dockerfile: codeIcon,
makefile: codeIcon,
cmake: codeIcon,
// 纯文本
txt: txtIcon,
md: txtIcon,
log: txtIcon,
json: txtIcon,
xml: txtIcon,
yaml: txtIcon,
yml: txtIcon,
toml: txtIcon,
ini: txtIcon,
conf: txtIcon,
cfg: txtIcon,
env: txtIcon,
};
/**
* 根据文件扩展名获取对应的文件类型图标
* @param ext 文件扩展名(带或不带点号)
* @returns SVG 图标路径
*/
export function getFileTypeIcon(ext?: null | string): string {
if (!ext) return unknownIcon;
const normalized = ext.toLowerCase().replace(/^\./, '');
return EXT_MAP[normalized] || unknownIcon;
}
export {
audioIcon,
codeIcon,
excelIcon,
imageIcon,
pdfIcon,
pptIcon,
txtIcon,
unknownIcon,
videoIcon,
wordIcon,
zipIcon,
};
@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#c11e07}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm.6-34.6h4.5c2.6 0 4.4 2 4.4 4.9 0 2.9-1.7 5-4.4 5H9.9v4.4H6.6V6.4zm3.9 7c1 0 1.6-.9 1.6-2.1s-.7-2-1.6-2h-.6v4.1h.6zm7.9-7h4.2c3.7 0 5.9 2.6 5.9 7.1s-2.2 7.2-5.9 7.2h-4.2V6.4zm4.1 11.4c1.7 0 2.6-1.5 2.6-4.3s-1-4.2-2.6-4.2h-.8v8.5h.8zm9.1-11.4h7.1v2.9h-3.8v3.1H38v2.9h-3.1v5.4h-3.3V6.4z" fill="#fff"/><path d="M33.5 46.538c-2.3 0-4.1 1.9-4.1 4.1 0 2.8 1.6 6.3 3.2 9.6-1.3 4-2.7 8.3-4.6 11.9-3.8 1.5-7.2 2.6-9.2 4.3l-.1.1c-.7.8-1.2 1.8-1.2 2.9 0 2.3 1.9 4.1 4.1 4.1 1.1 0 2.2-.4 2.9-1.2 0 0 .1 0 .1-.1 1.5-1.8 3.3-5.1 4.9-8 3.6-1.4 7.5-2.9 11.1-3.8 2.7 2.2 6.6 3.6 9.8 3.6 2.3 0 4.1-1.9 4.1-4.1 0-2.3-1.9-4.1-4.1-4.1-2.6 0-6.3.9-9.1 1.9-2.3-2.2-4.4-4.9-6-7.8 1.1-3.4 2.4-6.7 2.4-9.3-.1-2.3-1.9-4.1-4.2-4.1zm0 2.4c.9 0 1.6.7 1.6 1.6 0 1.2-.7 3.5-1.4 5.9-1-2.4-1.9-4.7-1.9-5.9.1-.8.8-1.6 1.7-1.6zm.7 14.1c1.2 2 2.7 3.8 4.3 5.4-2.4.7-4.8 1.5-7.2 2.4 1.2-2.5 2.1-5.2 2.9-7.8zm16.2 5.3c.9 0 1.6.7 1.6 1.6 0 .9-.7 1.6-1.6 1.6-1.9 0-4.5-.8-6.6-2 2.4-.6 5.1-1.2 6.6-1.2zm-24.5 7.3c-1.2 2.1-2.3 4-3.1 5-.3.3-.6.4-1.1.4-.9 0-1.6-.7-1.6-1.6 0-.4.2-.9.4-1.1.9-.8 3-1.7 5.4-2.7z" fill="#c11e07" stroke="#c11e07" stroke-width=".75" stroke-miterlimit="10"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0,.st2{fill:#f57e00}.st2{stroke:#f57e00;stroke-miterlimit:10}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm.5-34.6h4.1c2.4 0 3.9 2 3.9 4.9 0 2.9-1.6 5-3.9 5H9.5v4.4h-3V6.4zm3.5 7c.9 0 1.5-.9 1.5-2.1s-.6-2-1.4-2h-.6v4.1h.5zm5.7-7h4.1c2.4 0 3.9 2 3.9 4.9 0 2.9-1.6 5-3.9 5h-1.1v4.4h-3V6.4zm3.5 7c.9 0 1.5-.9 1.5-2.1s-.6-2-1.4-2h-.6v4.1h.5zM27 9.3h-2.8V6.4h8.6v2.9H30v11.4h-3V9.3zm9 4.3l-3.1-7.2h3.3l1 2.9c.3.9.5 1.8.5 1.8s.2-.9.5-1.8l1-2.9h3.2l-3.1 7.2 3 7.1h-3.2l-1-3c-.1-.9-.3-1.7-.3-1.7s-.2.8-.5 1.7l-1 3H33l3-7.1z" fill="#fff"/><path class="st2" d="M53 47.7H19c-1.7 0-3.1 1.4-3.1 3.1v21.8c0 1.7 1.4 3.1 3.1 3.1h14.2L31.8 81h-1.6c-.7 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2h11.5c.7 0 1.2-.5 1.2-1.2s-.5-1.2-1.2-1.2H40l-1.4-5.4H53c1.7 0 3.1-1.4 3.1-3.1V50.8c0-1.7-1.4-3.1-3.1-3.1zm.3 25.1H18.7V50.6h34.5l.1 22.2z"/><path class="st2" d="M27.6 63.9h2.8v5.6h-2.8zm4.7-1.8h2.8v7.4h-2.8zm4.6-1.9h2.8v9.3h-2.8zm4.6-1.8h2.8v11.1h-2.8zm-.2-4.1L36.4 57l-3.6-1.2-6.1 3.3.9 1 5.4-2.8 3.6 1.2 5.6-3.1.9 1 2.1-3.5-4.8.3z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#666}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zM8.9 9.3H5.7V6.4h9.6v2.9h-3.2v11.4H8.9V9.3zm11.5 4.3L17 6.4h3.6l1.1 2.9.6 1.8s.2-.9.6-1.8l1.2-2.9h3.6l-3.4 7.2 3.3 7.1H24l-1.1-3c-.4-.9-.6-1.7-.6-1.7s-.2.8-.6 1.7l-1.1 3h-3.5l3.3-7.1zm12.1-4.3h-3.1V6.4H39v2.9h-3.2v11.4h-3.3V9.3z" fill="#fff"/><path class="st0" d="M14.6 77.2h42.8v6.2H14.6zm0-14.8h42.8v6.2H14.6zm0-15h42.8v6.2H14.6z"/></svg>

After

Width:  |  Height:  |  Size: 637 B

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#fff}.st1{fill:#bababa}</style><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st1" d="M0 94V2C0 .9.9 0 2 0h48c.6 0 1.1.2 1.5.7l20 22.3c.3.4.5.8.5 1.3V94c0 1.1-.9 2-2 2H2c-1.1 0-2-.9-2-2zm68-68.9L49.1 4H4v88h64V25.1z"/><path class="st1" d="M48 26V4c0-1.1.9-2 2-2s2 .9 2 2v20h16c1.1 0 2 .9 2 2s-.9 2-2 2H50c-1.1 0-2-.9-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 557 B

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#f60}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zM7.1 6.4h3.6l1.3 6c.2.9.4 2.4.4 2.4h.1s.2-1.5.4-2.4l1.3-6H18l.9 14.3h-3l-.4-6.4c-.1-1.1 0-2.4 0-2.4h-.1s-.2 1.5-.4 2.4l-.9 4.1H11l-.9-4.1c-.2-.9-.4-2.4-.4-2.4h-.1s.1 1.3 0 2.4l-.4 6.4h-3l.9-14.3zm15.1 0h4.5c2.6 0 4.4 2 4.4 4.9 0 2.9-1.7 5-4.4 5h-1.2v4.4h-3.3V6.4zm3.9 7c1 0 1.6-.9 1.6-2.1s-.7-2-1.6-2h-.7v4.1h.7zm6.8 1.8l3.7-8.8h4.2v8.1H42v3h-1.2v3.2h-3.2v-3.2h-4.7v-2.3zm4.7-.7v-3.4c0-.8.1-1.3.1-1.3s-.1.6-.3 1.2L36 14.5h1.6z" fill="#fff"/><path class="st0" d="M13 83.1h46V47.9H13v35.2zm8.9-2.7h-5.5v-3.9h5.5v3.9zm16.9 0h-5.5v-3.9h5.5v3.9zm11.3-30.1h5.5v4h-5.5v-4zm0 26.1h5.5v3.9h-5.5v-3.9zm-8.5-26.1H47v3.9h-5.5v-3.9zm0 26.1H47v3.9h-5.5l.1-3.9zm-8.4-26.1h5.5v3.9h-5.5v-3.9zm-.8 9.7l7.2 4.1c1.3.8 1.3 2 0 2.8L32.4 71c-1.3.8-2.4.1-2.4-1.4v-8.3c0-1.4 1.1-2.1 2.4-1.3zm-7.7-9.7h5.4v3.9h-5.4v-3.9zm0 26.1h5.4v3.9h-5.4v-3.9zm-8.3-26.1h5.5v4h-5.5v-4z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#1a75ce}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm.6-34.6h4.2c3.7 0 5.9 2.6 5.9 7.1s-2.2 7.2-5.9 7.2H6.6V6.4zm4.1 11.4c1.7 0 2.6-1.5 2.6-4.3s-1-4.2-2.6-4.2h-.8v8.5h.8zM24.1 6.1c3.8 0 5.9 3.1 5.9 7.3 0 4.2-2.1 7.5-5.9 7.5s-5.8-3.2-5.8-7.5c0-4.1 2.1-7.3 5.8-7.3zm0 11.7c1.7 0 2.5-1.8 2.5-4.3s-.8-4.2-2.5-4.2-2.5 1.7-2.5 4.2.8 4.3 2.5 4.3zM38 6.1c1.3 0 2.6.4 3.6 1.2l-1.4 2.8c-.4-.5-1.3-.9-2.1-.9-2.1 0-3.2 1.9-3.2 4.2 0 2.3 1.2 4.4 3.2 4.4 1 0 1.7-.5 2.3-1l1.4 2.8c-.9.7-2 1.3-3.9 1.3-4 0-6.3-3.1-6.3-7.4-.1-4.2 2.3-7.4 6.4-7.4z" fill="#fff"/><path class="st0" d="M19.5 67.1l-4.8-14.8c-.2-.6-.3-.8-.4-.8-.1-.1-.2-.1-.3-.2l-1.7-.5-.1-2.1h9l.2 2.1-1.7.5v.2c0 .2.1.6.3 1l1.8 5.7 3.2-9.5 2.4-.1 2.9 9.5 2-5.7c.1-.4.3-.8.3-1v-.1l-1.5-.4-.1-2.2h7l.2 2.1-1.9.6c-.1 0-.2.1-.3.1 0 .1-.2.2-.3.7l-5.3 14.7-2.5.2-2.7-9.1-3.2 8.9-2.5.2z"/><path d="M42.4 48.6H60v2.8H42.4zm0 7.4H60v2.8H42.4zm0 7.7H60v2.8H42.4zM13 71.5h47v2.8H13zm0 7.6h47v2.8H13z" fill="#1a75ce" stroke="#1a75ce" stroke-width=".5" stroke-miterlimit="10"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96"><style>.st0{fill:#90c}</style><path class="st0" d="M0 2v92c0 1.1.9 2 2 2h68c1.1 0 2-.9 2-2V28H50c-1.1 0-2-.9-2-2V0H2C.9 0 0 .9 0 2z"/><path class="st0" d="M71.9 24c-.1-.4-.2-.7-.4-1L52 1.2V24h19.9z"/><path d="M6 41h60v49H6zm0-22.5l4.2-7.9c.5-.9.8-1.3.8-1.3s-.3.1-.8.1h-4v-3h8.7v2.2l-4.2 8c-.5.9-.8 1.3-.8 1.3s.3-.1.8-.1H15v2.9H6v-2.2zM18.1 6.4h3.3v14.3h-3.3V6.4zm7 0h4.5c2.6 0 4.4 2 4.4 4.9 0 2.9-1.7 5-4.4 5h-1.2v4.4h-3.3V6.4zm3.9 7c1 0 1.6-.9 1.6-2.1s-.7-2-1.6-2h-.7v4.1h.7z" fill="#fff"/><path class="st0" d="M51.1 57.5h-5.7v-5.7l5.7 5.7zm-15.3-5.7v20.7h15.4V58.9H44v-7.1h-8.2zm2.4-5.4h-17v4h6.7l-4.2 3.3v3.5l6.2-4.8v-1.9h21.6v-1.6l-13.3-2.5zm-8.3 8.7l-6.2 4.8v3.5l6.2-4.8v-3.5zm-6.3 14.4l6.2-4.8v-3.5L23.6 66v3.5zm6.3 1.4v-3.5l-6.2 4.8V74h-2.5v4H25v2.8h-2.2c-.4-.7-1.1-1.2-2-1.2-1.3 0-2.3 1-2.3 2.3 0 1.3 1 2.3 2.3 2.3.9 0 1.6-.5 2-1.2H25v2.1h3.4V83h2.2c.4.7 1.1 1.2 2 1.2 1.3 0 2.3-1 2.3-2.3 0-1.3-1-2.3-2.3-2.3-.9 0-1.6.5-2 1.2h-2.2V78h9.7l13.3-2.4V74H25.7l4.2-3.1z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+14
View File
@@ -0,0 +1,14 @@
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
+27
View File
@@ -0,0 +1,27 @@
import mssqlIcon from './mssql.svg';
import mysqlIcon from './mysql.svg';
import oracleIcon from './oracle.svg';
import psqlIcon from './psql.svg';
const DB_TYPE_ICON_MAP: Record<string, string> = {
mysql: mysqlIcon,
postgresql: psqlIcon,
postgres: psqlIcon,
psql: psqlIcon,
sqlserver: mssqlIcon,
mssql: mssqlIcon,
oracle: oracleIcon,
};
/**
* 根据数据库类型获取对应 SVG 图标路径
*/
export function getDatabaseTypeIcon(dbType?: string): string {
if (!dbType) {
return psqlIcon;
}
const normalized = dbType.toLowerCase().trim();
return DB_TYPE_ICON_MAP[normalized] || psqlIcon;
}
export { mssqlIcon, mysqlIcon, oracleIcon, psqlIcon };
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="SVGfp3H9qRq" x1="-2901.952" x2="-2061.249" y1="923.573" y2="1420.331" gradientTransform="matrix(.01102 0 0 -.01102 56.808 125.521)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#909ca9" />
<stop offset="1" stop-color="#ededee" />
</linearGradient>
<linearGradient id="SVGi3e5AgbE" x1="-2882.7" x2="-2206.249" y1="10288.81" y2="10288.81" gradientTransform="matrix(.01102 0 0 -.01102 56.808 125.521)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#939fab" />
<stop offset="1" stop-color="#dcdee1" />
</linearGradient>
<radialGradient id="SVG01wvtQjR" cx="-14217.448" cy="7277.705" r="898.12" gradientTransform="matrix(-.01059 -.0016 -.00321 .02118 -64.462 -130.43)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ee352c" />
<stop offset="1" stop-color="#a91d22" />
</radialGradient>
</defs>
<path fill="url(#SVGfp3H9qRq)" d="m79.363 59.755l-25.634 8.37l-22.3 9.842l-6.24 1.648a136 136 0 0 1-5.057 4.592c-1.976 1.704-3.816 3.255-5.23 4.378c-1.57 1.24-3.895 3.565-5.077 5.038c-1.764 2.209-3.158 4.553-3.759 6.355c-1.066 3.255-.542 6.549 1.511 9.591c2.636 3.875 7.886 7.828 14.008 10.52c3.12 1.377 8.37 3.14 12.324 4.127c6.567 1.667 19.278 3.47 26.272 3.74c1.414.059 3.313.059 3.39 0c.156-.097 1.241-2.17 2.501-4.746c4.3-8.778 7.4-17.012 9.087-24.046c1.007-4.262 1.801-9.939 2.324-16.662c.136-1.88.194-8.177.078-10.308c-.175-3.487-.485-6.316-.97-9.086c-.077-.408-.096-.776-.057-.796c.077-.057.31-.135 3.468-1.046zm-5.851 3.43c.233 0 .852 5.947 1.007 9.706c.039.795.02 1.318-.02 1.318c-.154 0-3.274-1.84-5.501-3.236c-1.938-1.22-5.62-3.661-6.2-4.127c-.195-.135-.176-.155 1.413-.697c2.693-.91 9.088-2.965 9.3-2.965zm-13.06 4.3c.175 0 .62.252 1.686.911c3.991 2.5 9.417 5.523 11.742 6.53c.716.31.794.193-.853 1.318c-3.526 2.402-7.924 4.766-13.31 7.149c-.95.426-1.745.755-1.764.755c-.039 0 .078-.484.233-1.065c1.297-4.825 2.034-9.707 2.073-13.621c.02-1.938.02-1.938.194-1.996c-.04.02-.02.02 0 .02zm-2.692 1.027c.116.117.038 4.457-.117 5.639a49.4 49.4 0 0 1-1.782 8.428c-.213.717-.407 1.318-.446 1.356c-.078.097-2.732-2.5-3.604-3.507c-1.511-1.744-2.693-3.487-3.565-5.192c-.445-.872-1.143-2.577-1.085-2.635c.31-.214 10.521-4.166 10.599-4.089m-12.672 4.98c.019 0 .038 0 .058.019c.039.039.175.35.291.698c.62 1.685 2.014 4.165 3.216 5.754c1.318 1.744 3.042 3.605 4.476 4.825c.465.387.891.755.949.813c.116.117.155.097-3.004 1.299c-3.66 1.395-7.652 2.79-12.225 4.262a610 610 0 0 0-3.274 1.066c-.175.058-.116-.04.387-.834c2.267-3.544 5.715-10.5 7.653-15.422c.33-.853.66-1.705.718-1.899c.077-.271.174-.368.425-.504c.136-.038.272-.077.33-.077M41.213 75.1c.058.039-.93 2.112-1.899 4.01c-1.88 3.663-3.933 7.267-6.684 11.646c-.466.755-.91 1.453-.97 1.53c-.096.136-.134.097-.445-.503c-.659-1.299-1.201-2.965-1.492-4.496c-.29-1.511-.232-4.146.098-5.774c.25-1.2.232-1.181.813-1.472c2.48-1.26 10.502-5.018 10.58-4.941zm33.422 1.357v.813c0 4.321-.465 10.25-1.143 14.57c-.116.756-.213 1.376-.232 1.396c0 0-.562-.155-1.22-.349a50 50 0 0 1-8.914-3.817c-1.88-1.027-4.61-2.713-4.533-2.79c.019-.02.833-.446 1.782-.95c3.798-1.976 7.44-4.107 10.599-6.22c1.182-.794 2.964-2.072 3.351-2.421zm-48.05 5.734c.077 0 .057.155-.059.853a28 28 0 0 0-.213 2.072c-.155 2.83.31 4.923 1.705 7.79c.388.794.698 1.453.678 1.472c-.135.117-12.962 3.876-16.992 4.98c-1.201.33-2.247.62-2.325.639c-.136.04-.155.02-.097-.31c.446-2.848 2.616-6.568 5.639-9.707c2.014-2.093 3.623-3.313 6.374-4.882c1.976-1.124 5.018-2.81 5.25-2.887c0-.02.02-.02.04-.02m30.225 5.406c.02-.02.484.233 1.046.562c4.147 2.403 9.92 4.631 14.841 5.774l.446.097l-.62.349c-2.576 1.434-11.044 4.96-19.704 8.195c-1.26.465-2.5.93-2.732 1.027s-.446.155-.446.135s.349-.697.794-1.53c2.422-4.534 4.863-10.056 6.104-13.892c.155-.368.251-.697.27-.717zm-3.08 1.007c.019.02-.136.427-.33.892c-1.686 4.088-3.895 8.545-6.724 13.543c-.716 1.28-1.317 2.306-1.336 2.306s-.601-.349-1.299-.775c-4.107-2.519-7.75-5.619-10.132-8.622l-.35-.426l1.764-.485c6.316-1.724 11.683-3.584 17.011-5.87c.756-.31 1.376-.563 1.395-.563zm19.142 6.685s.02.02 0 0c.02.446-.969 4.437-1.783 7.324c-.678 2.422-1.259 4.32-2.325 7.672c-.464 1.474-.87 2.693-.89 2.693s-.136-.018-.253-.057c-5.754-1.047-10.908-2.5-15.752-4.437c-1.356-.543-3.293-1.415-3.41-1.512c-.038-.039 1.124-.581 2.597-1.22c8.816-3.856 17.96-8.235 21.1-10.114c.368-.233.658-.349.716-.349M28.677 96.8c.039.04-2.422 3.585-5.87 8.41c-1.202 1.685-2.597 3.661-3.12 4.397a78 78 0 0 0-1.763 2.597l-.814 1.26l-.872-.737c-1.027-.853-2.809-2.674-3.604-3.681c-1.666-2.073-2.79-4.263-3.235-6.258c-.214-.93-.214-1.396-.02-1.453a1459 1459 0 0 1 10.308-2.423a862 862 0 0 0 6.936-1.627c1.124-.271 2.035-.485 2.054-.485m2.48.95l.62.697c2.79 3.12 5.638 5.426 9.087 7.44c.62.35 1.085.66 1.046.68c-.135.096-11.974 4.3-17.457 6.199a463 463 0 0 1-5.638 1.957c-.019 0-.194-.117-.387-.252l-.349-.252l.562-.814c1.82-2.635 4.107-5.522 9.086-11.528zm15.462 11.063c.019-.02.871.29 1.918.679c2.519.949 4.514 1.55 7.188 2.228c3.294.833 8.06 1.647 10.87 1.88c.426.038.658.077.581.135c-.136.077-2.984 1.027-5.076 1.685c-3.333 1.047-13.505 4.05-21.798 6.433a219 219 0 0 1-2.925.834c-.194.038-.834-.137-.834-.214c0-.038.465-.639 1.027-1.298c2.79-3.333 5.561-7.053 7.867-10.579c.64-.969 1.182-1.764 1.182-1.783m-3.41.097c.019.02-1.357 2.228-3.76 6.026c-1.026 1.608-2.17 3.43-2.576 4.069c-.388.62-.97 1.589-1.298 2.131l-.562.988l-.291-.077c-.698-.194-5.6-1.919-6.898-2.442a48 48 0 0 1-4.514-2.072c-1.55-.834-3.487-2.074-3.332-2.113c.038-.02 2.693-.736 5.89-1.608c8.485-2.306 13.194-3.642 16.275-4.611c.562-.175 1.046-.31 1.065-.29zm24.122 5.657h.02c.077.195-3.062 8.913-4.206 11.664c-.251.62-.348.776-.484.756c-.329-.02-4.882-.658-7.653-1.065c-4.824-.736-12.924-2.151-14.957-2.616l-.466-.097l2.887-.659c6.2-1.395 9.184-2.15 12.207-3.08a86 86 0 0 0 11.412-4.399c.6-.27 1.104-.484 1.24-.503z" />
<path fill="url(#SVGi3e5AgbE)" d="M52.935.001c-.426-.058-7.305 2.422-11.741 4.224c-5.988 2.441-10.637 4.766-13.505 6.781c-1.066.756-2.403 2.093-2.616 2.616a1.8 1.8 0 0 0-.116.659l2.597 2.46l6.18 1.977l14.706 2.635l16.817 2.887l.175-1.453c-.058 0-.097-.02-.155-.02l-2.209-.348l-.445-.795c-2.287-4.03-4.805-9.029-6.278-12.4c-1.142-2.616-2.228-5.638-2.828-7.808C53.187.098 53.149.02 52.935 0Zm-.31.988h.02c.019.02.096.563.174 1.202c.33 2.712.93 5.328 1.88 8.157c.716 2.13.716 2.015-.117 1.763c-1.976-.542-10.83-2.073-17.244-2.965c-1.027-.135-1.899-.27-1.899-.29c-.077-.078 4.63-2.538 6.704-3.507c2.654-1.22 9.94-4.263 10.482-4.36M33.947 9.67l.756.252c4.108 1.395 14.434 3.372 20.131 3.837c.639.058 1.182.116 1.2.116c.02.02-.522.31-1.22.639c-2.751 1.376-5.774 3.062-7.866 4.36c-.62.387-1.182.698-1.26.698c-.077 0-.484-.078-.91-.137l-.775-.116l-1.938-1.899a804 804 0 0 0-7.11-6.84zm-.775.601l2.732 3.41c1.492 1.88 3.004 3.72 3.333 4.127s.6.736.58.756c-.077.058-3.952-.698-6.005-1.162c-2.112-.485-2.984-.718-4.282-1.125l-1.066-.349v-.27c.02-1.3 1.667-3.237 4.456-5.212zm23.212 4.65c.077 0 .174.174.406.697c.66 1.453 2.713 5.367 3.217 6.123c.155.252.426.272-2.306-.174c-6.568-1.066-8.68-1.415-8.68-1.453c0-.02.194-.155.446-.291c2.035-1.124 4.088-2.557 5.91-4.088c.445-.368.852-.717.93-.775c.019-.039.057-.058.077-.039" />
<path fill="url(#SVG01wvtQjR)" d="M25.209 13.35s-.426.679-.02 1.687c.252.62.988 1.375 1.822 2.15c0 0 8.621 8.409 9.668 9.61c4.766 5.503 6.84 10.928 7.033 18.407c.117 4.805-.794 9.029-3.061 13.931c-4.03 8.796-12.536 18.504-25.653 29.276l1.918-.64c1.24-.93 2.926-1.917 6.879-4.087c9.125-5 19.394-9.591 31.988-14.32c18.135-6.82 47.954-14.802 64.926-17.398l1.764-.271l-.272-.427c-1.55-2.403-2.616-3.894-3.895-5.483c-3.72-4.611-8.233-8.35-13.756-11.45c-7.595-4.244-17.418-7.557-29.857-10.017c-2.345-.466-7.499-1.357-11.684-1.996a1194 1194 0 0 1-20.925-3.41c-2.267-.388-5.658-.969-7.905-1.454c-1.163-.252-3.39-.775-5.134-1.375c-1.395-.543-3.41-1.085-3.837-2.732Zm4.999 4.844c.019-.018.329.098.736.233a50 50 0 0 0 2.81.853a143 143 0 0 0 2.557.678c1.162.29 2.131.561 2.15.561c.136.136 2.093 6.394 2.752 8.797c.252.91.446 1.685.427 1.685c-.02.02-.233-.31-.485-.755c-2.267-3.991-5.851-8.04-9.998-11.296c-.542-.387-.95-.736-.95-.756Zm9.532 2.636c.098 0 .524.058 1.047.174c3.293.736 9.203 1.86 12.98 2.5c.64.097 1.144.213 1.144.251c0 .04-.232.175-.523.33c-.64.329-3.216 1.86-4.069 2.44c-2.15 1.435-4.088 2.985-5.483 4.38c-.562.562-1.046 1.027-1.046 1.027s-.116-.33-.214-.736c-.697-2.694-2.15-6.685-3.468-9.495c-.213-.445-.387-.852-.387-.89c0 .038 0 .019.02.019zm16.78 3.196c.116.04.31.698.697 2.151a31.7 31.7 0 0 1 .93 8.874c-.039.814-.078 1.57-.117 1.667l-.058.193l-1.007-.33c-2.073-.658-5.444-1.646-8.331-2.46c-1.647-.446-2.984-.852-2.984-.89c0-.117 2.403-2.52 3.43-3.43c1.956-1.725 7.265-5.832 7.44-5.775m1.336.194c.058-.058 8.022 1.317 11.645 2.015c2.694.523 6.607 1.337 6.84 1.434c.115.039-.291.27-1.59.853c-5.115 2.305-8.912 4.378-12.69 6.897c-.988.659-1.822 1.202-1.84 1.202c-.02 0-.04-.562-.04-1.24c0-3.681-.735-7.402-2.092-10.54c-.136-.31-.252-.601-.233-.62zm20.596 4.07c.058.057-.193 1.627-.426 2.557c-.698 2.887-2.577 7.169-4.882 11.199c-.408.717-.776 1.298-.815 1.317c-.038.02-.56-.271-1.162-.62c-2.247-1.318-4.805-2.557-7.595-3.72c-.775-.33-1.453-.6-1.472-.64c-.136-.115 6.103-4.242 9.396-6.219c2.617-1.589 6.88-3.952 6.956-3.875zm1.473.232c.174 0 3.7.968 5.541 1.511c4.553 1.356 9.785 3.274 13.195 4.824l1.414.64l-.988.232c-8.33 1.918-15.461 4.128-22.34 6.917c-.562.233-1.066.427-1.104.427s.155-.446.407-.988c2.073-4.399 3.41-8.99 3.74-12.905c.019-.368.077-.658.135-.658m-35.108 8.06c.058-.058 2.75.581 4.204.988c2.21.62 6.898 2.19 6.898 2.305c0 .02-.523.466-1.143 1.008c-2.538 2.112-4.98 4.34-7.906 7.169c-.871.833-1.607 1.511-1.646 1.511c-.04 0-.058-.116-.04-.271c.446-3.255.35-7.44-.27-11.683c-.059-.543-.117-1.008-.098-1.027zm56.595.058c.038.039-1.24 2.053-2.054 3.196c-1.162 1.667-2.868 3.876-6.723 8.72a1290 1290 0 0 0-5.076 6.413c-.775.969-1.414 1.782-1.435 1.782c-.018 0-.27-.348-.542-.774c-2.17-3.256-4.766-6.104-7.847-8.661a45 45 0 0 0-1.433-1.163c-.214-.155-.388-.31-.388-.33c0-.057 3.293-1.472 5.793-2.479c4.38-1.783 10.346-3.914 14.823-5.29c2.344-.736 4.843-1.453 4.882-1.414m1.492.387c.077-.019.543.214 1.104.543c4.709 2.693 9.32 6.162 12.962 9.726c1.027 1.008 3.566 3.643 3.527 3.662c0 0-.892.078-1.938.155c-8.157.62-18.6 2.344-28.636 4.766c-.679.155-1.28.29-1.318.29s.717-.755 1.667-1.665c5.89-5.677 8.583-9.261 11.76-15.656c.446-.948.833-1.762.872-1.82q-.03 0 0 0zm-43.149 4.418c.271.058 2.79 1.24 4.689 2.19c1.744.871 4.36 2.266 4.495 2.383c.02.019-.91.503-2.054 1.066a135 135 0 0 0-10.017 5.521c-.93.562-1.705 1.027-1.724 1.027c-.078 0-.058-.078.465-1.027c1.744-3.177 3.139-6.975 3.933-10.676c.077-.29.155-.484.213-.484m-2.519.465c.058.058-.6 2.441-1.007 3.74c-.795 2.46-2.132 5.54-3.43 7.866c-.31.542-.775 1.337-1.027 1.782l-.484.775l-1.085-1.046c-1.26-1.22-2.286-1.976-3.603-2.655c-.524-.27-.931-.503-.931-.542c0-.155 3.314-3.158 5.852-5.328c1.82-1.57 5.657-4.65 5.715-4.592m15.404 6.336l.95.62c2.17 1.415 4.727 3.294 6.684 4.94c1.104.91 3.235 2.83 3.662 3.294l.232.252l-1.57.446c-8.873 2.46-15.732 4.65-23.734 7.595c-.892.33-1.647.6-1.705.6c-.116 0-.213.097 1.783-1.744c5.115-4.707 9.648-9.9 13.02-14.957zm-4.05 1.007c.04.039-2.615 3.778-4.204 5.89c-1.899 2.519-5.27 6.743-7.596 9.494c-.968 1.144-1.8 2.093-1.84 2.112c-.058.02-.078-.27-.078-.717c0-2.344-.6-4.844-1.646-6.975c-.446-.891-.524-1.104-.426-1.201c.368-.33 6.006-3.546 9.57-5.464c2.404-1.279 6.162-3.177 6.22-3.139M44.1 55.26c.058 0 .503.232 1.008.503a21.3 21.3 0 0 1 3.332 2.248c.039.038-.465.446-1.124.93c-1.84 1.317-4.63 3.43-6.258 4.728c-1.705 1.356-1.763 1.394-1.57 1.104c1.28-1.957 1.919-3.061 2.597-4.476a36 36 0 0 0 1.627-4.05c.155-.56.349-.987.388-.987m6.53 5.114c.096-.018.213.156.735.931c1.104 1.647 1.957 3.856 2.17 5.638l.04.387l-2.655 1.028c-4.747 1.84-9.126 3.661-12.09 5.018a217 217 0 0 0-3.236 1.55c-.95.484-1.724.852-1.724.833s.6-.465 1.336-1.008C41 70.547 46.018 65.935 49.777 61.324c.407-.484.775-.93.813-.949zm-3.004.737c.078.077-2.131 2.577-3.642 4.108c-3.74 3.816-7.44 6.8-12.032 9.706c-.582.368-1.105.698-1.163.736c-.135.078.038-.116 2.054-2.305a53 53 0 0 0 3.352-3.972c.736-.95.871-1.085 1.937-1.84c2.849-2.055 9.417-6.511 9.494-6.434z" />
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<path fill="#00618a" d="M117.688 98.242c-6.973-.191-12.297.461-16.852 2.379c-1.293.547-3.355.559-3.566 2.18c.711.746.82 1.859 1.387 2.777c1.086 1.754 2.922 4.113 4.559 5.352c1.789 1.348 3.633 2.793 5.551 3.961c3.414 2.082 7.223 3.27 10.504 5.352c1.938 1.23 3.859 2.777 5.75 4.164c.934.684 1.563 1.75 2.773 2.18v-.195c-.637-.812-.801-1.93-1.387-2.777l-2.578-2.578c-2.52-3.344-5.719-6.281-9.117-8.719c-2.711-1.949-8.781-4.578-9.91-7.73l-.199-.199c1.922-.219 4.172-.914 5.949-1.391c2.98-.797 5.645-.59 8.719-1.387l4.164-1.187v-.793c-1.555-1.594-2.664-3.707-4.359-5.152c-4.441-3.781-9.285-7.555-14.273-10.703c-2.766-1.746-6.184-2.883-9.117-4.363c-.988-.496-2.719-.758-3.371-1.586c-1.539-1.961-2.379-4.449-3.566-6.738c-2.488-4.793-4.93-10.023-7.137-15.066c-1.504-3.437-2.484-6.828-4.359-9.91c-9-14.797-18.687-23.73-33.695-32.508c-3.195-1.867-7.039-2.605-11.102-3.57l-6.543-.395c-1.332-.555-2.715-2.184-3.965-2.977C16.977 3.52 4.223-3.312.539 5.672C-1.785 11.34 4.016 16.871 6.09 19.746c1.457 2.012 3.32 4.273 4.359 6.539c.688 1.492.805 2.984 1.391 4.559c1.438 3.883 2.695 8.109 4.559 11.695c.941 1.816 1.98 3.727 3.172 5.352c.727.996 1.98 1.438 2.18 2.973c-1.227 1.715-1.297 4.375-1.984 6.543c-3.098 9.77-1.926 21.91 2.578 29.137c1.383 2.223 4.641 6.98 9.117 5.156c3.918-1.598 3.043-6.539 4.164-10.902c.254-.988.098-1.715.594-2.379v.199l3.57 7.133c2.641 4.254 7.324 8.699 11.297 11.699c2.059 1.555 3.68 4.242 6.344 5.152v-.199h-.199c-.516-.805-1.324-1.137-1.98-1.781c-1.551-1.523-3.277-3.414-4.559-5.156c-3.613-4.902-6.805-10.27-9.711-15.855c-1.391-2.668-2.598-5.609-3.77-8.324c-.453-1.047-.445-2.633-1.387-3.172c-1.281 1.988-3.172 3.598-4.164 5.945c-1.582 3.754-1.789 8.336-2.375 13.082c-.348.125-.195.039-.398.199c-2.762-.668-3.73-3.508-4.758-5.949c-2.594-6.164-3.078-16.09-.793-23.191c.59-1.836 3.262-7.617 2.18-9.316c-.516-1.691-2.219-2.672-3.172-3.965c-1.18-1.598-2.355-3.703-3.172-5.551c-2.125-4.805-3.113-10.203-5.352-15.062c-1.07-2.324-2.875-4.676-4.359-6.738c-1.645-2.289-3.484-3.977-4.758-6.742c-.453-.984-1.066-2.559-.398-3.566c.215-.684.516-.969 1.191-1.191c1.148-.887 4.352.297 5.547.793c3.18 1.32 5.832 2.578 8.527 4.363c1.289.855 2.598 2.512 4.16 2.973h1.785c2.789.641 5.914.195 8.523.988c4.609 1.402 8.738 3.582 12.488 5.949c11.422 7.215 20.766 17.48 27.156 29.734c1.027 1.973 1.473 3.852 2.379 5.945c1.824 4.219 4.125 8.559 5.941 12.688c1.816 4.113 3.582 8.27 6.148 11.695c1.348 1.801 6.551 2.766 8.918 3.766c1.66.699 4.379 1.43 5.949 2.379c3 1.809 5.906 3.965 8.723 5.945c1.402.992 5.73 3.168 5.945 4.957zm-88.605-75.52c-1.453-.027-2.48.156-3.566.395v.199h.195c.695 1.422 1.918 2.34 2.777 3.566l1.98 4.164l.199-.195c1.227-.867 1.789-2.25 1.781-4.363c-.492-.52-.562-1.164-.992-1.785c-.562-.824-1.66-1.289-2.375-1.98zm0 0" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32">
<path d="M0 0h32v32H0z" fill="none" />
<path fill="#e61010" d="M21.88 5.88H10.125C4.536 5.88.005 10.407 0 15.995c-.005 5.589 4.527 10.12 10.115 10.125H21.88C27.469 26.12 32 21.588 32 16S27.469 5.88 21.88 5.88m-.255 16.672H10.38c-8.563-.172-8.563-12.932 0-13.104h11.245c8.735 0 8.735 13.104 0 13.104" />
</svg>

After

Width:  |  Height:  |  Size: 398 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

+163
View File
@@ -0,0 +1,163 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/ele';
import { useTitle } from '@vueuse/core';
import ElementPlus from 'element-plus';
import { ElLoading } from 'element-plus';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm, useVbenForm } from './adapter/form';
import App from './app.vue';
import { setupZqTable } from './components/zq-table';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// 初始化 ZqTable (注入 form)
setupZqTable({
useVbenForm,
});
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// // 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 2000,
// });
const app = createApp(App);
app.use(ElementPlus);
// 注册Element Plus提供的v-loading指令
app.directive('loading', ElLoading.directive);
// 注册Vben提供的v-loading和v-spinning指令
registerLoadingDirective(app, {
loading: false, // Vben提供的v-loading指令和Element Plus提供的v-loading指令二选一即可,此处false表示不注册Vben提供的v-loading指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);
// 配置 pinia-tore
await initStores(app, { namespace });
// 初始化跨tab的token同步机制
const { useAccessStore } = await import('@vben/stores');
const accessStore = useAccessStore();
// 从共享存储加载 token(主应用和子应用共享)
const SHARED_TOKEN_KEY = 'vben-shared-auth-tokens';
try {
const stored = localStorage.getItem(SHARED_TOKEN_KEY);
if (stored) {
const tokens = JSON.parse(stored);
console.log('[Bootstrap] 从共享存储加载token:', {
hasAccessToken: !!tokens.accessToken,
hasRefreshToken: !!tokens.refreshToken,
});
// 直接修改 $state,避免触发 saveSharedTokens(因为数据本来就是从 localStorage 读取的)
if (tokens.accessToken) {
accessStore.$state.accessToken = tokens.accessToken;
}
if (tokens.refreshToken) {
accessStore.$state.refreshToken = tokens.refreshToken;
}
}
} catch (error) {
console.error('[Bootstrap] 加载共享token失败:', error);
}
// 监听 localStorage 的变化(跨tab通信)
// Token 使用固定 key 'vben-shared-auth-tokens' 存储,主应用和子应用共享
window.addEventListener('storage', (event) => {
// 只处理共享的 token key 的变化
if (event.key === 'vben-shared-auth-tokens') {
try {
// 当其他 tab 更新了 token,同步到当前 tab 的 Pinia Store
const newValue = event.newValue ? JSON.parse(event.newValue) : null;
if (newValue) {
// 直接修改 $state,避免触发 saveSharedTokens 导致循环
if (
newValue.accessToken !== undefined &&
newValue.accessToken !== accessStore.accessToken
) {
console.log(
'[跨Tab同步] 检测到其他tab更新了accessToken,同步中...',
);
accessStore.$state.accessToken = newValue.accessToken;
}
if (
newValue.refreshToken !== undefined &&
newValue.refreshToken !== accessStore.refreshToken
) {
console.log(
'[跨Tab同步] 检测到其他tab更新了refreshToken,同步中...',
);
accessStore.$state.refreshToken = newValue.refreshToken;
}
} else {
// token 被清除(退出登录),同步清除当前 tab 的 token
if (accessStore.accessToken) {
console.log('[跨Tab同步] 检测到其他tab退出登录,同步清除token...');
accessStore.$state.accessToken = null;
accessStore.$state.refreshToken = null;
}
}
} catch (error) {
console.error('[跨Tab同步] 解析localStorage数据失败:', error);
}
}
});
// 初始化应用上下文(从 URL 参数中获取 appCode
const { useAppContextStore } = await import('#/store/app-context');
const appContextStore = useAppContextStore();
await appContextStore.initFromUrl();
// 安装权限指令
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 };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
/**
* ChatBox 通用聊天组件
*/
export { default as ChatBox } from './index.vue';
export { default as InputArea } from './InputArea.vue';
export { hasMarkdownSyntax, renderMarkdown } from './markdown';
export { default as MessageBubble } from './MessageBubble.vue';
export * from './types';
@@ -0,0 +1,552 @@
<script setup lang="ts">
import type { Attachment, ChatMessage, VoiceMessage } from './types';
/**
* ChatBox 通用聊天组件 - ChatGPT 风格
*
* 适配场景:
* - 工作流对话
* - 智能体对话
* - AI 对话
*/
import { computed, nextTick, ref, watch } from 'vue';
import { ChevronDown, ChevronUp, Trash2 } from '@vben/icons';
import { useUserStore } from '@vben/stores';
import { ElScrollbar } from 'element-plus';
import AiWorkingAnimation from '#/components/ai-loading/AiWorkingAnimation.vue';
import { getFileUrl } from '#/composables/useFileUrl';
import InputArea from './InputArea.vue';
import MessageBubble from './MessageBubble.vue';
const props = withDefaults(
defineProps<{
acceptFileTypes?: string;
assistantAvatar?: string;
assistantName?: string;
disabled?: boolean;
emptyText?: string;
enableAttachment?: boolean;
enableFeedback?: boolean;
enableImage?: boolean;
enableRegenerate?: boolean;
enableVoice?: boolean;
loading?: boolean;
maxAttachmentSize?: number;
messages: ChatMessage[];
placeholder?: string;
showAvatar?: boolean;
showClearButton?: boolean;
showReasoningSteps?: boolean;
streamingContent?: string;
userAvatar?: string;
// 欢迎配置
welcomeConfig?: {
description?: string;
suggestions?: string[];
title?: string;
};
}>(),
{
loading: false,
showAvatar: true,
placeholder: '输入消息...',
disabled: false,
enableAttachment: false,
enableImage: false,
enableVoice: false,
enableFeedback: false,
enableRegenerate: false,
showReasoningSteps: false,
maxAttachmentSize: 10,
acceptFileTypes: '.pdf,.doc,.docx,.txt,.md,.json,.csv,.xlsx,.ppt,.pptx',
showClearButton: false,
emptyText: '开始对话吧',
},
);
const emit = defineEmits<{
(e: 'send', message: string, attachments?: Attachment[]): void;
(e: 'stop'): void;
(e: 'regenerate', messageId: string): void;
(e: 'feedback', messageId: string, feedback: 'dislike' | 'like'): void;
(e: 'clear'): void;
(e: 'upload', file: File): Promise<Attachment>;
/** 对话流交互提交 */
(e: 'interaction-submit', messageId: string, value: any): void;
/** 对话流交互取消 */
(e: 'interaction-cancel', messageId: string): void;
/** 添加消息(内部使用,用于语音消息) */
(e: 'add-message', message: ChatMessage): void;
/** 更新消息(内部使用,用于语音识别完成) */
(e: 'update-message', messageId: string, updates: Partial<ChatMessage>): void;
}>();
// 滚动容器 - ElScrollbar 实例
const scrollbarRef = ref<InstanceType<typeof ElScrollbar> | null>(null);
// 用户头像预加载(避免每条消息重复加载)
const userStore = useUserStore();
const currentUser = computed(() => userStore.userInfo);
const cachedUserAvatarUrl = ref<string | undefined>(undefined);
// 预加载用户头像 URL
async function loadUserAvatarUrl() {
if (currentUser.value?.avatar) {
cachedUserAvatarUrl.value = await getFileUrl(currentUser.value.avatar);
}
}
// 监听用户信息变化,重新加载头像
watch(
() => currentUser.value?.avatar,
() => {
loadUserAvatarUrl();
},
{ immediate: true },
);
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (scrollbarRef.value) {
const wrapElement = scrollbarRef.value.wrapRef;
if (wrapElement) {
scrollbarRef.value.setScrollTop(wrapElement.scrollHeight);
}
}
});
};
// 监听消息变化,自动滚动
watch(
() => props.messages.length,
() => {
scrollToBottom();
},
);
// 监听流式内容变化,自动滚动
watch(
() => props.streamingContent,
() => {
scrollToBottom();
},
);
// 处理发送
const handleSend = (message: string, attachments?: Attachment[]) => {
emit('send', message, attachments);
scrollToBottom();
};
// 处理停止
const handleStop = () => {
emit('stop');
};
// 处理重新生成
const handleRegenerate = (messageId: string) => {
emit('regenerate', messageId);
};
// 处理反馈
const handleFeedback = (messageId: string, feedback: 'dislike' | 'like') => {
emit('feedback', messageId, feedback);
};
// 处理清空
const handleClear = () => {
emit('clear');
};
// 处理复制
const handleCopy = () => {
// 复制成功提示由 MessageBubble 处理
};
// 处理语音消息发送(内部处理,添加语音消息到列表)
const handleSendVoice = (voice: VoiceMessage & { voiceId?: string }) => {
const voiceId = voice.voiceId || `voice-${Date.now()}`;
// 创建用户语音消息
const userMsg: ChatMessage = {
id: voiceId,
role: 'user',
content: '', // 语音消息内容为空,通过 voice 字段显示
timestamp: new Date(),
voice,
};
// 通知父组件添加消息
emit('add-message', userMsg);
scrollToBottom();
};
// 处理语音识别完成(内部处理,更新消息并触发 send)
const handleVoiceTranscribed = (voiceId: string, text: string) => {
// 更新语音消息状态
emit('update-message', voiceId, {
content: text,
voice: {
transcribeStatus: 'completed',
transcribedText: text,
} as any,
});
// 识别成功后,触发 send 事件(让父组件处理发送逻辑)
if (text && !text.startsWith('[')) {
emit('send', text);
}
};
// 推荐问题展开状态
const suggestionsExpanded = ref(false);
const MAX_VISIBLE_SUGGESTIONS = 6;
// 显示的推荐问题
const visibleSuggestions = computed(() => {
const suggestions = props.welcomeConfig?.suggestions || [];
if (
suggestionsExpanded.value ||
suggestions.length <= MAX_VISIBLE_SUGGESTIONS
) {
return suggestions;
}
return suggestions.slice(0, MAX_VISIBLE_SUGGESTIONS);
});
// 是否有更多推荐问题
const hasMoreSuggestions = computed(() => {
const suggestions = props.welcomeConfig?.suggestions || [];
return suggestions.length > MAX_VISIBLE_SUGGESTIONS;
});
// 点击建议问题
const handleSuggestionClick = (suggestion: string) => {
emit('send', suggestion);
};
// 切换推荐问题展开状态
const toggleSuggestionsExpand = () => {
suggestionsExpanded.value = !suggestionsExpanded.value;
};
// 暴露方法
defineExpose({
scrollToBottom,
});
</script>
<template>
<div class="chatbox" :class="{ 'chatbox-empty': messages.length === 0 }">
<!-- 内容区域欢迎页或消息列表 -->
<div v-if="messages.length === 0" class="chatbox-content">
<div class="chatbox-center">
<!-- AI 工作动画 -->
<AiWorkingAnimation
:title="welcomeConfig?.title || ''"
:subtitle="welcomeConfig?.description || emptyText || '开始对话吧'"
:show-progress="false"
:show-steps="false"
:show-background="false"
class="h-full min-h-[400px] flex-1"
>
<!-- 建议问题 -->
<div
v-if="welcomeConfig?.suggestions?.length"
class="welcome-suggestions"
>
<div class="suggestions-grid">
<button
v-for="(suggestion, idx) in visibleSuggestions"
:key="idx"
class="suggestion-btn"
@click="handleSuggestionClick(suggestion)"
>
<span>{{ suggestion }}</span>
</button>
</div>
<!-- 展开/收起按钮 -->
<button
v-if="hasMoreSuggestions"
class="expand-btn"
@click="toggleSuggestionsExpand"
>
<span>{{
suggestionsExpanded
? '收起'
: `查看更多 (${welcomeConfig.suggestions.length - MAX_VISIBLE_SUGGESTIONS})`
}}</span>
<component
:is="suggestionsExpanded ? ChevronUp : ChevronDown"
class="size-4"
/>
</button>
</div>
</AiWorkingAnimation>
</div>
</div>
<!-- 有消息时消息列表 -->
<template v-else>
<!-- 消息列表 -->
<ElScrollbar ref="scrollbarRef" class="chatbox-messages">
<div class="messages-container">
<MessageBubble
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:show-avatar="showAvatar"
:user-avatar="userAvatar"
:cached-user-avatar-url="cachedUserAvatarUrl"
:current-user="currentUser"
:assistant-avatar="assistantAvatar"
:assistant-name="assistantName"
:enable-feedback="enableFeedback"
:enable-regenerate="enableRegenerate"
:show-reasoning-steps="showReasoningSteps"
:is-last="index === messages.length - 1"
@regenerate="handleRegenerate"
@feedback="handleFeedback"
@copy="handleCopy"
@interaction-submit="
(msgId, value) => $emit('interaction-submit', msgId, value)
"
@interaction-cancel="(msgId) => $emit('interaction-cancel', msgId)"
/>
</div>
</ElScrollbar>
<!-- 清空按钮 -->
<button
v-if="showClearButton && messages.length > 0"
class="clear-btn"
title="清空对话"
@click="handleClear"
>
<Trash2 class="size-4" />
</button>
</template>
<!-- 输入区域(始终固定在底部) -->
<div class="input-wrapper-bottom">
<InputArea
:loading="loading"
:disabled="disabled"
:placeholder="placeholder"
:enable-attachment="enableAttachment"
:enable-image="enableImage"
:enable-voice="enableVoice"
:max-attachment-size="maxAttachmentSize"
:accept-file-types="acceptFileTypes"
@send="handleSend"
@stop="handleStop"
@send-voice="handleSendVoice"
@voice-transcribed="handleVoiceTranscribed"
/>
</div>
</div>
</template>
<style scoped>
.chatbox {
display: flex;
flex-direction: column;
height: 100%;
background: var(--el-bg-color);
border-radius: 8px;
/* border: 1px solid var(--el-border-color-lighter); */
overflow: hidden;
position: relative;
}
/* 内容区域(欢迎页或消息列表) */
.chatbox-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.chatbox-messages {
flex: 1;
padding: 16px;
}
.chatbox-messages :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.chatbox-messages :deep(.el-scrollbar__view) {
height: 100%;
}
/* 初始状态居中布局 */
.chatbox-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
gap: 32px;
}
/* 输入框包装器 - 底部 */
.input-wrapper-bottom {
width: 100%;
max-width: 830px;
margin: 0 auto;
padding: 0 16px 16px;
}
/* 欢迎页 */
.chatbox-welcome {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.welcome-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.welcome-title {
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0 0 8px;
}
.welcome-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
max-width: 400px;
}
.welcome-suggestions {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: calc(100% - 0px);
max-width: 800px;
}
.suggestions-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%;
}
@media (max-width: 640px) {
.suggestions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.suggestions-grid {
grid-template-columns: 1fr;
}
}
.suggestion-btn {
padding: 8px 16px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
line-height: 1.5;
min-height: 48px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.suggestion-btn span {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
.suggestion-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.expand-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 16px;
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.expand-btn:hover {
background: var(--el-fill-color);
color: var(--el-text-color-primary);
}
/* 消息容器 */
.messages-container {
max-width: 800px;
margin: 0 auto;
}
/* 清空按钮 */
.clear-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
opacity: 0.6;
}
.clear-btn:hover {
opacity: 1;
background: var(--el-fill-color-dark);
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,96 @@
/**
* Markdown 渲染工具
* 支持流式输出的实时渲染
*/
import DOMPurify from 'dompurify';
import { Marked } from 'marked';
// 创建 marked 实例
const marked = new Marked({
breaks: true, // 支持换行
gfm: true, // 支持 GitHub 风格 Markdown
});
// 配置 DOMPurify
DOMPurify.setConfig({
ADD_ATTR: ['target', 'rel'],
ADD_TAGS: ['iframe'],
});
/**
* 渲染 Markdown 为 HTML
* 支持流式输出的不完整 Markdown
*/
export function renderMarkdown(content: string): string {
if (!content) return '';
try {
// 处理流式输出中可能不完整的代码块
const processedContent = handleIncompleteCodeBlocks(content);
// 渲染 Markdown
const html = marked.parse(processedContent) as string;
// 清理 HTML 防止 XSS
return DOMPurify.sanitize(html, {
ADD_ATTR: ['target', 'rel'],
});
} catch (error) {
console.error('Markdown 渲染错误:', error);
return escapeHtml(content);
}
}
/**
* 处理不完整的代码块
* 流式输出时代码块可能还没闭合
*/
function handleIncompleteCodeBlocks(content: string): string {
// 计算代码块标记数量
const codeBlockMatches = content.match(/```/g);
const codeBlockCount = codeBlockMatches ? codeBlockMatches.length : 0;
// 如果代码块标记是奇数,说明有未闭合的代码块
if (codeBlockCount % 2 !== 0) {
// 添加闭合标记
return `${content}\n\`\`\``;
}
return content;
}
/**
* 转义 HTML 特殊字符
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replaceAll(/[&<>"']/g, (m) => map[m] || m);
}
/**
* 检查内容是否包含 Markdown 语法
*/
export function hasMarkdownSyntax(content: string): boolean {
// 常见 Markdown 语法模式
const patterns = [
/^#{1,6}\s/m, // 标题
/\*\*[^*]+\*\*/, // 粗体
/\*[^*]+\*/, // 斜体
/`[^`]+`/, // 行内代码
/```[\s\S]*```/, // 代码块
/^\s*[-*+]\s/m, // 无序列表
/^\s*\d+\.\s/m, // 有序列表
/\[.+\]\(.+\)/, // 链接
/!\[.*\]\(.+\)/, // 图片
/^\s*>/m, // 引用
/\|.+\|/, // 表格
];
return patterns.some((pattern) => pattern.test(content));
}
@@ -0,0 +1,162 @@
/**
* ChatBox 通用聊天组件类型定义
* 适配:工作流对话、智能体对话、AI 对话
*/
// 消息角色
export type MessageRole = 'assistant' | 'system' | 'user';
// 消息状态
export type MessageStatus = 'completed' | 'failed' | 'pending' | 'streaming';
// 附件类型
export type AttachmentType = 'audio' | 'file' | 'image' | 'video';
// 附件 - 通过 file_id 关联文件管理系统
export interface Attachment {
/** 文件管理系统中的文件 ID(新格式) */
file_id?: string;
/** 附件 ID(兼容旧格式,等同于 file_id) */
id?: string;
/** 附件类型 */
type: AttachmentType;
/** 文件名 */
name?: string;
/** 文件 URL(可选,用于预览) */
url?: string;
/** 文件大小 */
size?: number;
/** 缩略图 URL */
thumbnail?: string;
/** 音频时长 */
duration?: number;
/** 上传进度 */
progress?: number;
/** 上传状态 */
status?: 'error' | 'success' | 'uploading';
}
// 推理步骤(智能体)
export interface ReasoningStep {
type:
| 'action'
| 'annotation_reply'
| 'knowledge_retrieval'
| 'node_complete'
| 'node_start'
| 'observation'
| 'thought'
| 'tool_call'
| 'tool_result';
content: string;
tool?: string;
params?: Record<string, any>;
result?: any;
node_id?: string;
node_type?: string;
output?: any;
/** 步骤状态:running 执行中,completed 已完成 */
status?: 'completed' | 'running';
timestamp?: string;
}
// 语音消息
export interface VoiceMessage {
audioUrl: string;
duration: number;
/** 识别状态:transcribing 识别中,completed 已识别,failed 识别失败 */
transcribeStatus?: 'completed' | 'failed' | 'transcribing';
/** 识别出的文字 */
transcribedText?: string;
}
// 对话流交互配置
export interface InteractionConfig {
type: 'choice' | 'confirm' | 'question';
// 问答输入
question?: string;
input_type?: string;
placeholder?: string;
default_value?: string;
required?: boolean;
render_input?: boolean;
// 选项选择
options?: Array<{ label: string; value: string }>;
multiple?: boolean;
min_select?: number;
max_select?: number;
// 确认操作
title?: string;
content?: string;
confirm_text?: string;
cancel_text?: string;
}
// 设计预览类型
export type DesignPreviewType =
| 'database_design'
| 'form_basic_info'
| 'form_ui_design'
| 'list_config';
// 设计预览配置
export interface DesignPreview {
/** 预览类型 */
type: DesignPreviewType;
/** 预览标题 */
title: string;
/** 预览数据 */
data: Record<string, any>;
/** 节点ID */
nodeId: string;
/** 是否可编辑 */
editable?: boolean;
}
// 聊天消息
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
status?: MessageStatus;
timestamp?: Date | string;
attachments?: Attachment[];
reasoning_steps?: ReasoningStep[];
elapsed_time?: number;
tokens_used?: number;
error_message?: string;
feedback?: 'dislike' | 'like' | null;
model_name?: string;
/** 语音消息 */
voice?: VoiceMessage;
/** 对话流交互配置(等待用户输入时) */
interaction?: InteractionConfig;
}
// ChatBox Props
export interface ChatBoxProps {
messages: ChatMessage[];
loading?: boolean;
streamingContent?: string;
showAvatar?: boolean;
userAvatar?: string;
assistantAvatar?: string;
assistantName?: string;
placeholder?: string;
disabled?: boolean;
enableAttachment?: boolean;
enableImage?: boolean;
enableVoice?: boolean;
enableFeedback?: boolean;
enableRegenerate?: boolean;
showReasoningSteps?: boolean;
showClearButton?: boolean;
emptyText?: string;
maxFileSize?: number;
// 欢迎消息配置
welcomeConfig?: {
description?: string;
suggestions?: string[];
title?: string;
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,299 @@
<script setup lang="ts">
import type { AgentConversation } from '#/api/ai-platform/ai-platform';
/**
* 对话历史列表组件
* 显示智能体的历史对话列表,支持选择、删除、新建对话
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElEmpty,
ElMessage,
ElMessageBox,
ElScrollbar,
ElSkeleton,
ElSkeletonItem,
} from 'element-plus';
import {
createAgentConversationApi,
deleteAgentConversationApi,
getAgentConversationDetailApi,
getAgentConversationsApi,
} from '#/api/ai-platform/ai-platform';
import { formatRelativeTime } from '#/utils/time';
// Props
const props = defineProps<{
/** Agent ID */
agentId: string;
/** 当前选中的对话 ID */
currentConversationId?: null | string;
/** 是否显示新建按钮 */
showNewButton?: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'select', conversationId: null | string): void;
(e: 'new'): void;
}>();
// 状态
const loading = ref(false);
const conversations = ref<AgentConversation[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 20;
// 加载对话列表
async function loadConversations(reset = false) {
if (!props.agentId) return;
if (reset) {
page.value = 1;
conversations.value = [];
}
loading.value = true;
try {
const res = await getAgentConversationsApi(props.agentId, {
page: page.value,
pageSize,
});
conversations.value = reset
? res.items
: [...conversations.value, ...res.items];
total.value = res.total;
} catch (error: any) {
console.error('加载对话列表失败:', error);
ElMessage.error(
error?.message || $t('ai-platform.conversation.loadFailed'),
);
} finally {
loading.value = false;
}
}
// 加载更多
function loadMore() {
if (conversations.value.length < total.value && !loading.value) {
page.value++;
loadConversations();
}
}
// 选择对话
function selectConversation(conversationId: string) {
emit('select', conversationId);
}
// 新建对话
async function newConversation() {
if (!props.agentId) return;
try {
loading.value = true;
// 调用API创建新对话,默认标题为"新建对话"
const newConv = await createAgentConversationApi(props.agentId, {
title: '新建对话',
});
// 直接在顶部添加新对话,不重新加载整个列表
conversations.value.unshift(newConv);
total.value += 1;
// 选中新创建的对话
emit('select', newConv.id);
emit('new');
ElMessage.success('创建成功');
} catch (error: any) {
console.error('创建对话失败:', error);
ElMessage.error(error?.message || '创建对话失败');
} finally {
loading.value = false;
}
}
// 删除对话
async function deleteConversation(conversationId: string) {
try {
await ElMessageBox.confirm(
$t('ai-platform.conversation.deleteConfirm'),
$t('common.warning'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
);
await deleteAgentConversationApi(conversationId);
ElMessage.success($t('ai-platform.conversation.deleteSuccess'));
// 从列表中移除
conversations.value = conversations.value.filter(
(c) => c.id !== conversationId,
);
total.value--;
// 如果删除的是当前选中的对话,触发新建对话
if (props.currentConversationId === conversationId) {
emit('select', null);
emit('new');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除对话失败:', error);
ElMessage.error(
error?.message || $t('ai-platform.conversation.deleteFailed'),
);
}
}
}
// 更新对话标题(无感更新,不刷新整个列表)
async function updateConversationTitle(conversationId: string) {
try {
// 获取对话详情
const conversation = await getAgentConversationDetailApi(conversationId);
// 在列表中找到对应的对话
const index = conversations.value.findIndex((c) => c.id === conversationId);
if (index === -1) {
// 对话不存在(首次发送消息的情况),添加到列表顶部
conversations.value.unshift(conversation);
total.value += 1;
} else {
// 对话已存在,只更新字段
const current = conversations.value[index];
if (current) {
current.title = conversation.title;
current.message_count = conversation.message_count;
if (conversation.sys_create_datetime) {
current.sys_create_datetime = conversation.sys_create_datetime;
}
}
}
} catch (error: any) {
console.error('更新对话标题失败:', error);
}
}
// 是否有更多
const hasMore = computed(() => conversations.value.length < total.value);
// 监听 agentId 变化
watch(
() => props.agentId,
() => {
loadConversations(true);
},
);
// 初始化
onMounted(() => {
loadConversations(true);
});
// 暴露方法
defineExpose({
refresh: () => loadConversations(true),
updateConversationTitle,
});
</script>
<template>
<div class="flex h-full flex-col">
<!-- 头部 -->
<div class="border-border flex items-center justify-between px-4 pb-2 pt-4">
<span class="text-foreground text-sm font-medium">
{{ $t('ai-platform.conversation.history') }}
</span>
<ElButton
v-if="showNewButton !== false"
size="small"
:icon="Plus"
@click="newConversation"
>
<!-- {{ $t('ai-platform.conversation.new') }} -->
</ElButton>
</div>
<!-- 列表 -->
<ElScrollbar class="flex-1">
<div class="p-2">
<!-- 骨架屏 -->
<div v-if="loading && conversations.length === 0" class="space-y-2">
<div v-for="i in 5" :key="i" class="rounded-lg px-3 py-2">
<ElSkeleton animated>
<template #template>
<ElSkeletonItem
variant="text"
style="width: 100%; height: 32px"
/>
</template>
</ElSkeleton>
</div>
</div>
<!-- 空状态 -->
<ElEmpty
v-else-if="!loading && conversations.length === 0"
:description="$t('ai-platform.conversation.empty')"
:image-size="60"
/>
<!-- 对话列表 -->
<div v-else class="space-y-1">
<div
v-for="conv in conversations"
:key="conv.id"
class="hover:bg-accent group relative cursor-pointer rounded-lg px-3 py-2 transition-colors"
:class="{
'bg-accent': currentConversationId === conv.id,
}"
@click="selectConversation(conv.id)"
>
<!-- 标题 -->
<div class="text-foreground truncate text-sm font-medium">
{{ conv.title || $t('ai-platform.conversation.untitled') }}
</div>
<!-- 时间 -->
<div class="text-muted-foreground mt-1 text-xs">
{{
formatRelativeTime(conv.sys_create_datetime || conv.created_at)
}}
</div>
<!-- 删除按钮 -->
<ElButton
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
type="danger"
link
size="small"
:icon="Trash2"
@click.stop="deleteConversation(conv.id)"
/>
</div>
<!-- 加载更多 -->
<div
v-if="hasMore"
class="text-muted-foreground hover:text-primary cursor-pointer py-2 text-center text-xs"
@click="loadMore"
>
{{ loading ? $t('common.loading') : $t('common.loadMore') }}
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
@@ -0,0 +1,6 @@
/**
* AI Chat Panel Composables
*/
export { useChatApi } from './useChatApi';
export { useEventHandler } from './useEventHandler';
@@ -0,0 +1,268 @@
/**
* Chat API 适配器
*
* 根据不同模式调用不同的 API
*/
import type {
AiChatPanelProps,
Attachment,
ChatApiAdapter,
ChatMessage,
StreamEvent,
} from '../types';
import { ref, toRef, watch } from 'vue';
import {
feedbackAgentMessageApi,
getAgentMessagesApi,
resumeWorkflowStreamApi,
runWorkflowStreamApi,
sendAgentMessageStream,
} from '#/api/ai-platform/ai-platform';
import { getFileUrl } from '#/composables/useFileUrl';
/**
* 创建 Chat API 适配器
*/
export function useChatApi(props: AiChatPanelProps): {
adapter: ChatApiAdapter;
conversationId: ReturnType<typeof ref<null | string>>;
} {
// 对话 IDAgent 模式使用)
const conversationId = ref<null | string>(props.conversationId || null);
// 使用 toRef 确保响应式追踪
const propsConversationId = toRef(props, 'conversationId');
// 监听 props.conversationId 变化,同步到内部状态
watch(propsConversationId, (newId) => {
// 同步 props 的 conversationId 到内部状态
conversationId.value = newId || null;
});
// ========== 工作流模式适配器 ==========
if (props.mode === 'workflow') {
const adapter: ChatApiAdapter = {
sendMessage: (
_message: string,
inputs: Record<string, any> | undefined,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
) => {
if (!props.workflowId) {
onError(new Error('工作流 ID 未配置'));
return null;
}
return runWorkflowStreamApi(
props.workflowId,
inputs || {},
onEvent,
onError,
onComplete,
props.useDraft ?? true, // 工作流模式默认使用草稿版本
);
},
resumeExecution: (
runId: string,
userInput: any,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
) => {
return resumeWorkflowStreamApi(
runId,
userInput,
onEvent,
onError,
onComplete,
);
},
};
return { adapter, conversationId };
}
// ========== Agent 模式适配器 ==========
const adapter: ChatApiAdapter = {
sendMessage: (
message: string,
inputs: Record<string, any> | undefined,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
attachments?: Attachment[],
) => {
if (!props.agentId) {
onError(new Error('Agent ID 未配置'));
return null;
}
// 转换附件格式,使用 file_id(优先)或 id
const attachmentInputs = attachments
?.filter((att) => att.file_id || att.id)
.map((att) => ({
file_id: att.file_id || att.id!,
type: att.type,
}));
// 从 inputs 中提取 form_code(如果存在)
const formCode = inputs?.form_code;
return sendAgentMessageStream(
props.agentId,
{
message,
conversation_id: conversationId.value || undefined,
attachments: attachmentInputs,
form_code: formCode,
},
(event) => {
// 捕获 conversation_id
if (event.type === 'start' && event.conversation_id) {
conversationId.value = event.conversation_id;
}
onEvent(event);
},
onError,
onComplete,
);
},
resumeExecution: (
_runId: string,
userInput: any,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
) => {
if (!props.agentId) {
onError(new Error('Agent ID 未配置'));
return null;
}
// Agent 模式的恢复逻辑与发送相同
return sendAgentMessageStream(
props.agentId,
{
message:
typeof userInput === 'string'
? userInput
: JSON.stringify(userInput),
conversation_id: conversationId.value || undefined,
},
onEvent,
onError,
onComplete,
);
},
// Agent-chat 模式支持历史加载
loadHistory:
props.mode === 'agent-chat' && props.enableHistory !== false
? async (convId: string): Promise<ChatMessage[]> => {
const res = await getAgentMessagesApi(convId, { pageSize: 100 });
conversationId.value = convId;
// 后端返回的是数组或分页对象,需要兼容处理
const items = Array.isArray(res) ? res : res.items || [];
// 转换为 ChatMessage 格式
const messages = await Promise.all(
items.map(async (msg: any) => {
// 处理附件,为图片加载完整 URL
let attachments: Attachment[] | undefined;
if (msg.attachments?.length) {
attachments = await Promise.all(
msg.attachments.map(async (att: any) => {
const fileId = att.file_id || att.id;
const attType = att.type || 'file';
const mimeType = att.mime_type || '';
// 为图片附件加载完整 URL
let url = att.url;
let thumbnail = att.thumbnail;
if (
(attType === 'image' ||
mimeType.startsWith('image/')) &&
fileId
) {
try {
const imageUrl = await getFileUrl(fileId);
url = imageUrl || url;
thumbnail = imageUrl || thumbnail;
} catch (error) {
console.warn(
'Failed to load image URL for attachment:',
fileId,
error,
);
}
}
return {
id: fileId,
file_id: fileId,
type: attType,
name: att.name,
url,
size: att.size,
thumbnail,
mime_type: mimeType,
} as Attachment;
}),
);
}
return {
id: msg.id,
role: msg.role as 'assistant' | 'user',
content: msg.content,
status: msg.status as any,
timestamp: msg.sys_create_datetime || msg.created_at,
elapsed_time: msg.elapsed_time,
tokens_used: msg.total_tokens,
error_message: msg.error_message,
feedback: msg.feedback || null,
reasoning_steps: msg.reasoning_steps?.map((step: any) => ({
type: step.type,
content: step.content || '',
tool: step.tool,
params: step.params,
node_id: step.node_id,
node_type: step.node_type,
output: step.output,
status: step.status,
timestamp: step.timestamp,
})),
interaction: msg.interaction,
voice: msg.voice,
attachments,
};
}),
);
// 按时间升序排序
return messages.sort(
(a, b) =>
new Date(a.timestamp).getTime() -
new Date(b.timestamp).getTime(),
);
}
: undefined,
// Agent-chat 模式支持反馈
sendFeedback:
props.mode === 'agent-chat' && props.enableFeedback !== false
? async (messageId: string, feedback: 'dislike' | 'like') => {
await feedbackAgentMessageApi(messageId, { feedback });
}
: undefined,
};
return { adapter, conversationId };
}
@@ -0,0 +1,515 @@
/**
* 流式事件处理器
*
* 统一处理工作流和 Agent 的流式事件
*/
import type { Ref } from 'vue';
import type {
AiChatPanelEmits,
AiChatPanelProps,
ChatMessage,
DesignPreviewData,
ReasoningStep,
StreamEvent,
} from '../types';
/** 事件处理器配置 */
interface EventHandlerConfig {
props: AiChatPanelProps;
emit: AiChatPanelEmits;
messages: Ref<ChatMessage[]>;
currentSteps: Ref<ReasoningStep[]>;
currentRunId: Ref<string>;
currentAssistantMsgId: Ref<null | string>;
waitingForInput: Ref<boolean>;
waitingConfig: Ref<any>;
running: Ref<boolean>;
conversationId: Ref<null | string>;
// 设计预览回调
onDesignPreview?: (data: DesignPreviewData) => void;
}
/** 生成消息 ID */
const generateId = () =>
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
/**
* 创建事件处理器
*/
export function useEventHandler(config: EventHandlerConfig) {
const {
props,
emit,
messages,
currentSteps,
currentRunId,
// currentAssistantMsgId 保留用于未来扩展
waitingForInput,
waitingConfig,
running,
onDesignPreview,
} = config;
/** 更新助手消息 */
const updateAssistantMessage = (
msgId: string,
updates: Partial<ChatMessage>,
) => {
const index = messages.value.findIndex((m) => m.id === msgId);
if (index !== -1) {
const currentMsg = messages.value[index];
if (currentMsg) {
messages.value[index] = { ...currentMsg, ...updates } as ChatMessage;
}
}
};
/** 获取等待提示文本 */
const getWaitingPrompt = (waitConfig: any): string => {
if (!waitConfig) return '等待输入...';
switch (waitConfig.type) {
case 'choice': {
return waitConfig.question || '请选择:';
}
case 'confirm': {
return `**${waitConfig.title || '确认'}**\n\n${waitConfig.content || '请确认是否继续?'}`;
}
case 'question': {
return waitConfig.question || '请输入:';
}
default: {
return '等待输入...';
}
}
};
/** 处理设计预览事件 */
const handleDesignPreview = (
event: StreamEvent,
msgId: string,
configData: any,
) => {
const previewType = configData.preview_type;
const previewData: DesignPreviewData = {
type: previewType,
title: configData.title || '设计预览',
data: configData.data || {},
nodeId: event.node_id || '',
form_fields: configData.form_fields,
table_configs: configData.table_configs,
layoutOptions: configData.layoutOptions,
themeOptions: configData.themeOptions,
};
// 调用设计预览回调
onDesignPreview?.(previewData);
// 显示提示消息
const waitingContent = `**${configData.title}**\n\n${configData.message || '请在右侧面板中确认或编辑设计'}`;
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
status: 'completed',
content: waitingContent,
});
} else {
const newMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: waitingContent,
timestamp: new Date(),
status: 'completed',
};
messages.value.push(newMsg);
}
};
/** 处理普通对话流交互 */
const handleDialogFlowInteraction = (msgId: string, configData: any) => {
const waitingContent = getWaitingPrompt(configData);
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
status: 'completed',
content: waitingContent,
interaction: configData,
});
} else {
const newMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: waitingContent,
timestamp: new Date(),
status: 'completed',
interaction: configData,
};
messages.value.push(newMsg);
}
};
/** 获取节点显示名称(优先从前端 nodes 中获取) */
const getNodeLabel = (
nodeId?: string,
nodeType?: string,
fallbackLabel?: string,
) => {
if (!nodeId) return fallbackLabel || nodeType || '';
if (props.nodes?.length) {
const node = props.nodes.find((n) => n.id === nodeId);
if (node?.data?.label) return node.data.label as string;
}
return fallbackLabel || nodeType || '';
};
/** 处理流式事件 */
const handleStreamEvent = (event: StreamEvent, msgId: string) => {
switch (event.type) {
// ========== Agent 特有事件 ==========
case 'action': {
currentSteps.value.push({
type: 'action',
content: '',
tool: event.tool,
params: event.params,
timestamp: new Date().toISOString(),
});
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
break;
}
case 'answer': {
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
status: 'completed',
content: event.content || '',
});
} else {
const answerMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: event.content || '',
timestamp: new Date(),
status: 'completed',
};
messages.value.push(answerMsg);
}
break;
}
case 'complete': {
let endOutput = '';
if (
event.outputs &&
typeof event.outputs.output === 'string' &&
event.outputs.output.trim()
) {
endOutput = event.outputs.output;
}
const initialMsg = messages.value.find((m) => m.id === msgId);
const initialMsgHasContent = initialMsg?.content?.trim();
if (endOutput) {
if (initialMsg && !initialMsgHasContent) {
updateAssistantMessage(msgId, {
status: 'completed',
content: endOutput,
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used || event.total_tokens,
});
} else {
const endMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: endOutput,
timestamp: new Date(),
status: 'completed',
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used || event.total_tokens,
};
messages.value.push(endMsg);
}
} else if (!initialMsgHasContent) {
const otherAssistantMessages = messages.value.filter(
(m) =>
m.role === 'assistant' && m.id !== msgId && m.content?.trim(),
);
if (otherAssistantMessages.length > 0) {
const initialMsgIndex = messages.value.findIndex(
(m) => m.id === msgId,
);
if (initialMsgIndex !== -1) {
messages.value.splice(initialMsgIndex, 1);
}
} else {
updateAssistantMessage(msgId, {
status: 'completed',
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used || event.total_tokens,
content: '工作流执行完成',
});
}
} else if (initialMsg) {
// 流式输出已有内容,只更新状态和统计信息
updateAssistantMessage(msgId, {
status: 'completed',
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used || event.total_tokens,
});
}
running.value = false;
// 触发消息完成事件,用于刷新对话列表(更新标题)
emit('message-complete', config.conversationId.value);
break;
}
case 'error': {
updateAssistantMessage(msgId, {
status: 'failed',
error_message: event.message || event.content || '执行失败',
});
running.value = false;
break;
}
case 'llm_chunk': {
// 更新助手消息内容(流式输出)
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg) {
updateAssistantMessage(msgId, {
content: event.accumulated_content || event.content || '',
status: 'streaming',
});
} else {
// 如果消息不存在,创建新消息
const streamMsg: ChatMessage = {
id: msgId,
role: 'assistant',
content: event.accumulated_content || event.content || '',
timestamp: new Date(),
status: 'streaming',
};
messages.value.push(streamMsg);
}
if (props.enableNodeEvents) {
emit('node-streaming', {
node_id: event.node_id,
content: event.content,
accumulated_content: event.accumulated_content,
});
}
break;
}
// ========== 循环事件 ==========
case 'loop_complete': {
const loopEvent = event as any;
const outputVar = loopEvent.output_variable || 'loop_results';
const outputs: Record<string, any> = {
total_iterations: loopEvent.total_iterations,
results_count: loopEvent.results_count,
};
if (loopEvent.loop_results) {
outputs[outputVar] = loopEvent.loop_results;
}
if (props.enableNodeEvents) {
emit('node-complete', {
node_id: loopEvent.node_id,
node_type: 'loop',
status: 'success',
elapsed_time: 0,
tokens_used: 0,
outputs,
});
}
break;
}
case 'loop_iteration_complete': {
if (props.enableNodeEvents) {
emit('loop-iteration', {
node_id: event.node_id,
type: 'complete',
iteration: event.iteration,
total: event.total,
output: event.output,
});
}
break;
}
case 'loop_iteration_error': {
if (props.enableNodeEvents) {
emit('loop-iteration', {
node_id: event.node_id,
type: 'error',
iteration: event.iteration,
total: event.total,
error: event.error,
});
}
break;
}
case 'loop_iteration_start': {
if (props.enableNodeEvents) {
emit('loop-iteration', {
node_id: event.node_id,
type: 'start',
iteration: event.iteration,
total: event.total,
item: event.item,
});
}
break;
}
case 'node_complete': {
const existingIndex = currentSteps.value.findIndex(
(s) => s.node_id === event.node_id && s.type === 'node_start',
);
const completeLabel = getNodeLabel(
event.node_id,
event.node_type,
event.node_label,
);
if (existingIndex === -1) {
currentSteps.value.push({
type: 'node_complete',
content: completeLabel,
node_id: event.node_id,
node_type: event.node_type,
output: event.outputs,
status: 'completed',
timestamp: new Date().toISOString(),
});
} else {
currentSteps.value[existingIndex] = {
...currentSteps.value[existingIndex]!,
type: 'node_complete',
content: completeLabel,
output: event.outputs,
status: 'completed',
};
}
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
if (props.enableNodeEvents) {
emit('node-complete', {
node_id: event.node_id,
node_type: event.node_type,
status: event.status,
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used,
error_message: event.error_message,
outputs: event.outputs,
inputs: event.inputs,
loop_iteration: event.loop_iteration,
warnings: event.warnings,
});
}
break;
}
case 'node_event': {
const nodeEvent = event.event;
if (nodeEvent?.type === 'message') {
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
status: 'completed',
content: nodeEvent.content || '',
});
} else {
const newMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: nodeEvent.content || '',
timestamp: new Date(),
status: 'completed',
};
messages.value.push(newMsg);
}
}
break;
}
// ========== 节点事件 ==========
case 'node_start': {
const existingIndex = currentSteps.value.findIndex(
(s) => s.node_id === event.node_id,
);
if (existingIndex === -1) {
currentSteps.value.push({
type: 'node_start',
content: getNodeLabel(
event.node_id,
event.node_type,
event.node_label,
),
node_id: event.node_id,
node_type: event.node_type,
status: 'running',
timestamp: new Date().toISOString(),
});
}
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
if (props.enableNodeEvents) {
emit('node-start', {
node_id: event.node_id,
node_type: event.node_type,
node_label: event.node_label,
inputs: event.inputs,
loop_iteration: event.loop_iteration,
});
}
break;
}
// ========== 通用事件 ==========
case 'start': {
currentRunId.value = event.run_id || '';
break;
}
// ========== 对话流交互事件 ==========
case 'waiting_input': {
waitingForInput.value = true;
waitingConfig.value = event.waiting_config || event.config;
const configData = event.waiting_config || (event as any).config;
if (configData?.type === 'design_preview') {
handleDesignPreview(event, msgId, configData);
} else {
handleDialogFlowInteraction(msgId, configData);
}
running.value = false;
break;
}
}
};
return {
handleStreamEvent,
updateAssistantMessage,
getWaitingPrompt,
generateId,
};
}
@@ -0,0 +1,60 @@
/**
* AI Chat Panel - 统一聊天面板组件
*
* 支持三种模式:
* - workflow: 工作流编辑器调试模式(使用草稿版本)
* - agent-debug: 智能体编辑器调试模式
* - agent-chat: 智能体正式对话模式(支持历史、反馈)
*
* @example
* ```vue
* <!-- 工作流编辑器模式 -->
* <AiChatPanel
* mode="workflow"
* :workflow-id="workflowId"
* :nodes="nodes"
* @node-start="handleNodeStart"
* @node-complete="handleNodeComplete"
* />
*
* <!-- Agent 编辑器调试模式 -->
* <AiChatPanel
* mode="agent-debug"
* :agent-id="agentId"
* :agent="agent"
* :disabled="agent?.status !== 'published'"
* />
*
* <!-- Agent 正式对话模式 -->
* <AiChatPanel
* mode="agent-chat"
* :agent-id="agentId"
* :agent="agent"
* :conversation-id="currentConversationId"
* @conversation-created="handleConversationCreated"
* />
* ```
*/
export { default as AiChatPanel } from './AiChatPanel.vue';
export { useChatApi, useEventHandler } from './composables';
export { default as ConversationList } from './ConversationList.vue';
export type {
AgentInfo,
AiChatPanelEmits,
AiChatPanelProps,
ChatApiAdapter,
ChatMessage,
ChatPanelMode,
ChatPanelState,
DesignPreviewData,
DesignPreviewType,
LoopIterationEvent,
NodeEvent,
ReasoningStep,
StreamEvent,
WorkflowNode,
WorkflowVariable,
} from './types';
@@ -0,0 +1,275 @@
/**
* AI Chat Panel 统一聊天面板类型定义
*
* 支持三种模式:
* - workflow: 工作流编辑器调试模式
* - agent-debug: 智能体编辑器调试模式
* - agent-chat: 智能体正式对话模式
*/
import type { ChatMessage, ReasoningStep } from '#/components/ChatBox';
// ==================== 运行模式 ====================
/** 聊天面板运行模式 */
export type ChatPanelMode = 'agent-chat' | 'agent-debug' | 'workflow';
// ==================== 工作流相关类型 ====================
/** 工作流变量定义 */
export interface WorkflowVariable {
variable: string;
type: 'boolean' | 'number' | 'string' | 'text';
required?: boolean;
default_value?: string;
description?: string;
}
/** 工作流节点(简化版,用于变量提取) */
export interface WorkflowNode {
id: string;
type: string;
data?: {
[key: string]: any;
variables?: WorkflowVariable[];
};
}
// ==================== Agent 相关类型 ====================
/** Agent 信息(简化版) */
export interface AgentInfo {
id: string;
name: string;
code?: string;
description?: string;
avatar?: string;
status: 'disabled' | 'draft' | 'published';
mode?: 'autonomous' | 'dialog_flow';
workflow_id?: null | string;
workflow_type?: null | string;
welcome_message?: null | string;
suggested_questions?: string[];
}
// ==================== 设计预览相关类型 ====================
/** 设计预览类型 */
export type DesignPreviewType =
| 'app_design'
| 'app_settings'
| 'dashboard_basic_info'
| 'dashboard_design'
| 'dashboard_publish'
| 'database_design'
| 'form_basic_info'
| 'form_ui_design'
| 'list_config'
| 'system_summary';
/** 设计预览数据 */
export interface DesignPreviewData {
type: DesignPreviewType | string;
title: string;
data: Record<string, any>;
nodeId: string;
form_fields?: any[];
table_configs?: any[];
layoutOptions?: any[];
themeOptions?: any[];
}
// ==================== 流式事件类型 ====================
/** 通用流式事件 */
export interface StreamEvent {
type: string;
[key: string]: any;
}
/** 节点执行事件 */
export interface NodeEvent {
node_id: string;
node_type?: string;
node_label?: string;
status?: 'failed' | 'running' | 'success';
inputs?: Record<string, any>;
outputs?: Record<string, any>;
elapsed_time?: number;
tokens_used?: number;
error_message?: string;
loop_iteration?: number;
}
/** 循环迭代事件 */
export interface LoopIterationEvent {
node_id: string;
type: 'complete' | 'error' | 'start';
iteration: number;
total: number;
item?: any;
output?: any;
error?: string;
}
// ==================== Props 类型 ====================
/** ChatPanel Props */
export interface AiChatPanelProps {
// ========== 模式配置 ==========
/** 运行模式 */
mode: ChatPanelMode;
// ========== 工作流模式配置 ==========
/** 工作流 IDworkflow 模式必填) */
workflowId?: string;
/** 工作流节点列表(用于变量提取和节点高亮) */
nodes?: WorkflowNode[];
/** 是否使用草稿版本(默认 true) */
useDraft?: boolean;
// ========== Agent 模式配置 ==========
/** Agent IDagent-* 模式必填) */
agentId?: string;
/** Agent 详情(用于欢迎消息、推荐问题) */
agent?: AgentInfo | null;
/** 当前对话 IDagent-chat 模式用于加载历史) */
conversationId?: null | string;
/** 初始输入参数(如 form_code 等系统变量) */
initialInputs?: Record<string, any>;
// ========== 功能开关 ==========
/** 是否启用多变量输入表单(默认 workflow 模式启用) */
enableVariablesForm?: boolean;
/** 是否启用对话历史加载(默认 agent-chat 模式启用) */
enableHistory?: boolean;
/** 是否启用消息反馈(默认 agent-chat 模式启用) */
enableFeedback?: boolean;
/** 是否启用欢迎消息(默认 agent-* 模式启用) */
enableWelcome?: boolean;
/** 是否启用节点执行事件(用于画布高亮,默认 workflow 模式启用) */
enableNodeEvents?: boolean;
/** 是否显示清空按钮 */
showClearButton?: boolean;
/** 是否显示关闭按钮 */
showCloseButton?: boolean;
/** 是否显示头部 */
showHeader?: boolean;
// ========== UI 配置 ==========
/** 面板标题 */
title?: string;
/** 占位符文本 */
placeholder?: string;
/** 空状态文本 */
emptyText?: string;
/** 是否禁用输入 */
disabled?: boolean;
/** 是否启用语音 */
enableVoice?: boolean;
/** 是否启用附件 */
enableAttachment?: boolean;
/** 是否启用图片 */
enableImage?: boolean;
/** 是否显示头像 */
showAvatar?: boolean;
/** 助手名称 */
assistantName?: string;
/** 布局模式:sidebar-侧边栏固定宽度,fullscreen-全屏响应式 */
layout?: 'fullscreen' | 'sidebar';
}
// ==================== Emits 类型 ====================
/** ChatPanel Emits */
export interface AiChatPanelEmits {
// 工作流模式事件
(e: 'node-start', data: NodeEvent): void;
(e: 'node-complete', data: NodeEvent): void;
(
e: 'node-streaming',
data: { accumulated_content?: string; content: string; node_id: string },
): void;
(e: 'loop-iteration', data: LoopIterationEvent): void;
(e: 'clear-status'): void;
// Agent 模式事件
(e: 'conversation-created', conversationId: string): void;
// 通用事件
(e: 'close'): void;
(e: 'message-sent', message: string): void;
(e: 'message-complete', conversationId: null | string): void;
}
// ==================== API 适配器类型 ====================
/** 附件类型 - 通过 file_id 关联文件管理系统 */
export interface Attachment {
/** 文件管理系统中的文件 ID(新格式) */
file_id?: string;
/** 附件 ID(兼容旧格式,等同于 file_id) */
id?: string;
/** 附件类型 */
type: 'audio' | 'file' | 'image' | 'video';
/** 文件名(可选,用于显示) */
name?: string;
/** 文件 URL(可选,用于预览) */
url?: string;
/** MIME 类型(可选) */
mime_type?: string;
/** 文件大小(可选) */
size?: number;
/** 缩略图 URL */
thumbnail?: string;
}
/** API 适配器接口 */
export interface ChatApiAdapter {
/** 发送消息 */
sendMessage: (
message: string,
inputs: Record<string, any> | undefined,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
attachments?: Attachment[],
) => (() => void) | null;
/** 恢复执行(对话流模式) */
resumeExecution: (
runId: string,
userInput: any,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
) => (() => void) | null;
/** 加载历史消息(仅 agent-chat 模式) */
loadHistory?: (conversationId: string) => Promise<ChatMessage[]>;
/** 发送消息反馈(仅 agent-chat 模式) */
sendFeedback?: (
messageId: string,
feedback: 'dislike' | 'like',
) => Promise<void>;
}
// ==================== 内部状态类型 ====================
/** 聊天面板内部状态 */
export interface ChatPanelState {
messages: ChatMessage[];
running: boolean;
streamingContent: string;
currentSteps: ReasoningStep[];
currentRunId: string;
currentAssistantMsgId: null | string;
conversationId: null | string;
waitingForInput: boolean;
waitingConfig: any;
}
// ==================== 导出 ====================
export type { ChatMessage, ReasoningStep };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export { default as AiWorkingAnimation } from './AiWorkingAnimation.vue';
@@ -0,0 +1,306 @@
<script lang="ts" setup generic="T extends CardListItem">
import type { CardListItem, CardListOptions } from './types';
import { computed, ref } from 'vue';
import { Plus, Search } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElInput,
ElSkeleton,
ElSkeletonItem,
} from 'element-plus';
interface Props<T extends CardListItem = CardListItem> {
/**
* 列表数据
*/
items: T[];
/**
* 正在加载状态
*/
loading?: boolean;
/**
* 当前选中的项ID
*/
selectedId?: string;
/**
* 悬停的项ID(用于显示操作按钮)
*/
hoveredId?: string;
/**
* 搜索关键词
*/
searchKeyword?: string;
/**
* 卡片列表配置
*/
options: CardListOptions<T>;
}
interface Emits {
/**
* 项目被选中
*/
select: [id: string | undefined];
/**
* 搜索关键词变化
*/
'update:searchKeyword': [value: string];
/**
* 悬停状态变化
*/
'update:hoveredId': [id: string | undefined];
/**
* 添加按钮点击
*/
add: [];
/**
* 编辑按钮点击
*/
edit: [item: T, event: Event];
/**
* 删除按钮点击
*/
delete: [item: T, event: Event];
/**
* 详情按钮点击
*/
detail: [item: T, event: Event];
/**
* 表单提交成功
*/
'form-success': [];
}
const props = withDefaults(defineProps<Props<T>>(), {
loading: false,
selectedId: undefined,
hoveredId: undefined,
searchKeyword: '',
});
const emit = defineEmits<Emits>();
// 显示模式
const displayMode = computed(() => props.options.displayMode || 'default');
const isCenterMode = computed(() => displayMode.value === 'center');
const localSearchKeyword = ref(props.searchKeyword);
// 监听搜索关键词变化
function onSearchChange(value: string) {
localSearchKeyword.value = value;
emit('update:searchKeyword', value);
}
// 计算过滤后的列表
const filteredItems = computed(() => {
if (!localSearchKeyword.value.trim()) {
return props.items;
}
const keyword = localSearchKeyword.value.toLowerCase();
return props.items.filter((item) => {
return props.options.searchFields.some((field) => {
const value = item[field.field];
if (value === null || value === undefined) {
return false;
}
return String(value).toLowerCase().includes(keyword);
});
});
});
function onItemSelect(id: string) {
emit('select', id);
}
function onAddClick() {
emit('add');
}
function onEditClick(item: T, event: Event) {
emit('edit', item, event);
}
function onDeleteClick(item: T, event: Event) {
emit('delete', item, event);
}
function onDetailClick(item: T, event: Event) {
emit('detail', item, event);
}
function onMouseEnter(id: string) {
emit('update:hoveredId', id);
}
function onMouseLeave() {
emit('update:hoveredId', undefined);
}
// 获取项目标题
function getItemTitle(item: T): string {
return String(item[props.options.titleField]);
}
</script>
<template>
<ElCard shadow="never" style="border: none" class="flex h-full flex-col">
<!-- 搜索和添加区域 -->
<div class="mb-4 flex gap-2">
<ElInput
:model-value="localSearchKeyword"
:placeholder="$t('common.search')"
clearable
:prefix-icon="Search"
@update:model-value="onSearchChange"
/>
<ElButton :icon="Plus" @click="onAddClick" />
</div>
<!-- 项目列表 -->
<div class="flex-1 overflow-auto">
<ElSkeleton
:loading="loading"
animated
:count="options.skeletonCount ?? 8"
>
<template #template>
<div class="space-y-1">
<div
v-for="i in options.skeletonCount ?? 8"
:key="i"
class="card-list-skeleton-item"
>
<ElSkeletonItem
variant="text"
style="width: 100%; height: 40px"
/>
</div>
</div>
</template>
<template #default>
<div class="space-y-2">
<div
v-for="item in filteredItems"
:key="item.id"
class="card-list-item cursor-pointer rounded-[8px] px-3 py-2 transition-colors"
:class="[
selectedId === item.id
? 'bg-primary/15 dark:bg-accent text-primary'
: 'hover:bg-[var(--el-fill-color-light)]',
isCenterMode ? 'card-list-item-center' : '',
]"
@mouseenter="onMouseEnter(item.id)"
@mouseleave="onMouseLeave"
@click="onItemSelect(item.id)"
>
<!-- 居中模式一行靠左显示 -->
<template v-if="isCenterMode">
<div class="flex w-full items-center justify-start">
<!-- 插槽自定义项目内容 -->
<slot
name="item"
:item="item"
:hovered="hoveredId === item.id"
>
<!-- 默认显示标题 -->
<div
class="text-sm font-medium"
:title="getItemTitle(item)"
>
{{ getItemTitle(item) }}
</div>
</slot>
</div>
</template>
<!-- 默认模式两行显示 -->
<template v-else>
<!-- 第一行标题和操作按钮 -->
<div class="mb-1 flex items-center justify-between">
<div class="min-w-0 flex-1">
<!-- 插槽自定义项目内容 -->
<slot
name="item"
:item="item"
:hovered="hoveredId === item.id"
>
<!-- 默认显示标题 -->
<div
class="truncate text-sm font-medium"
:title="getItemTitle(item)"
>
{{ getItemTitle(item) }}
</div>
</slot>
</div>
<!-- 操作按钮插槽 -->
<slot
v-if="hoveredId === item.id"
name="actions"
:item="item"
@edit="onEditClick(item, $event)"
@delete="onDeleteClick(item, $event)"
@detail="onDetailClick(item, $event)"
></slot>
</div>
<!-- 第二行及以后详细信息 -->
<slot name="details" :item="item"></slot>
</template>
</div>
</div>
</template>
</ElSkeleton>
</div>
<!-- 其他插槽 -->
<slot name="modal"></slot>
</ElCard>
</template>
<style scoped>
/* 输入框前置图标样式 */
:deep(.el-input__icon) {
cursor: pointer;
}
/* 文本按钮样式 */
:deep(.el-button--text) {
padding: 0 4px;
}
/* 让 ElCard 的 body 参与 flex 布局,使列表区域可滚动 */
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 骨架屏样式 */
.card-list-skeleton-item {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
}
/* 项目样式 */
.card-list-item {
min-height: 56px;
}
/* 居中模式样式(一行靠左显示) */
.card-list-item-center {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 40px;
}
</style>
@@ -0,0 +1,8 @@
export { default as CardList } from './card-list.vue';
export type {
CardListDisplayMode,
CardListEmits,
CardListItem,
CardListOptions,
SearchField,
} from './types';
@@ -0,0 +1,50 @@
/**
* 卡片列表项配置接口
*/
export interface CardListItem {
id: string;
[key: string]: any;
}
/**
* 卡片列表搜索字段配置
*/
export interface SearchField {
field: string;
/** 如果为true,则进行模糊搜索 */
fuzzy?: boolean;
}
/**
* 卡片列表显示模式
*/
export type CardListDisplayMode = 'center' | 'default';
/**
* 卡片列表配置选项
*/
export interface CardListOptions<T extends CardListItem = CardListItem> {
/** 搜索关键词字段,支持多字段搜索 */
searchFields: SearchField[];
/** 标题字段,必需 */
titleField: keyof T;
/** 是否显示骨架屏加载 */
showSkeleton?: boolean;
/** 加载时的骨架屏数量 */
skeletonCount?: number;
/** 项目渲染函数 */
renderItem?: (item: T, hovered: boolean) => any;
/** 显示模式:'default' - 两行显示(标题+详情),'center' - 一行居中显示 */
displayMode?: CardListDisplayMode;
}
/**
* 卡片列表事件
*/
export interface CardListEmits {
select: [id: string | undefined];
edit: [item: CardListItem, event: Event];
delete: [item: CardListItem, event: Event];
add: [];
'form-success': [];
}
@@ -0,0 +1,981 @@
<script setup lang="ts">
import type { PartyType } from '../store/contractDesignStore';
import { ref, watch } from 'vue';
import { Settings } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElColorPicker,
ElDivider,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElRadioButton,
ElRadioGroup,
ElScrollbar,
ElSelect,
ElSwitch,
ElTabPane,
ElTabs,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import {
predefinedVariables,
useContractDesignStore,
} from '../store/contractDesignStore';
const store = useContractDesignStore();
const { activeElement, templateConfig } = storeToRefs(store);
const activeTab = ref('element');
// 当前元素的本地副本
const localProps = ref<Record<string, any>>({});
const localSignature = ref<any>({});
const localTableColumns = ref<any[]>([]);
// 监听选中元素变化
watch(
activeElement,
(element) => {
if (element) {
localProps.value = { ...element.props };
localSignature.value = element.signature ? { ...element.signature } : {};
localTableColumns.value = element.tableColumns
? [...element.tableColumns]
: [];
} else {
localProps.value = {};
localSignature.value = {};
localTableColumns.value = [];
}
},
{ immediate: true, deep: true },
);
// 更新属性
const updateProps = (key: string, value: any) => {
if (activeElement.value) {
localProps.value[key] = value;
store.updateElementProps(activeElement.value.id, { [key]: value });
}
};
// 更新签名配置
const updateSignature = (key: string, value: any) => {
if (activeElement.value) {
localSignature.value[key] = value;
store.updateElement(activeElement.value.id, {
signature: { ...localSignature.value },
});
}
};
// 更新表格列
const updateTableColumns = () => {
if (activeElement.value) {
store.updateElement(activeElement.value.id, {
tableColumns: [...localTableColumns.value],
});
}
};
// 添加表格列
const addTableColumn = () => {
const key = `col${localTableColumns.value.length + 1}`;
localTableColumns.value.push({
key,
title: `${localTableColumns.value.length + 1}`,
width: 100,
align: 'center',
});
updateTableColumns();
};
// 删除表格列
const removeTableColumn = (index: number) => {
localTableColumns.value.splice(index, 1);
updateTableColumns();
};
// 添加表格行
const addTableRow = () => {
if (activeElement.value && activeElement.value.tableData) {
const newRow: Record<string, any> = {};
localTableColumns.value.forEach((col) => {
newRow[col.key] = '';
});
activeElement.value.tableData.push(newRow);
store.recordSnapshot();
}
};
// 签署方选项
const partyOptions: { label: string; value: PartyType }[] = [
{ label: $t('contract-design.partyA'), value: 'party_a' },
{ label: $t('contract-design.partyB'), value: 'party_b' },
{ label: $t('contract-design.partyC'), value: 'party_c' },
{ label: $t('contract-design.witness'), value: 'witness' },
];
// 元素类型标签
const typeLabels: Record<string, string> = {
title: $t('contract-design.typeTitle'),
paragraph: $t('contract-design.typeParagraph'),
'rich-text': $t('contract-design.typeRichText'),
variable: $t('contract-design.typeVariable'),
table: $t('contract-design.typeTable'),
'signature-zone': $t('contract-design.typeSignatureZone'),
'seal-zone': $t('contract-design.typeSealZone'),
'date-zone': $t('contract-design.typeDateZone'),
divider: $t('contract-design.typeDivider'),
'page-break': $t('contract-design.typePageBreak'),
image: $t('contract-design.typeImage'),
};
// 更新模板配置
const updateTemplateField = (field: string, value: any) => {
store.updateTemplateConfig({ [field]: value });
};
// 获取变量信息
const getVariableInfo = (code: string) => {
return predefinedVariables.find((v) => v.code === code);
};
// 变量类型标签
const variableTypeLabels: Record<string, string> = {
text: $t('contract-design.text'),
number: $t('contract-design.number'),
date: $t('contract-design.date'),
money: $t('contract-design.money'),
select: $t('contract-design.select'),
};
// 检查变量是否已使用
const isVariableUsed = (code: string) => {
return templateConfig.value.variables.some((v) => v.code === code);
};
// 添加变量到模板
const addVariableToTemplate = (variable: (typeof predefinedVariables)[0]) => {
if (!isVariableUsed(variable.code)) {
store.addVariable({ ...variable });
}
};
// 更新页边距
const updateMargin = (side: string, value: number) => {
const margin = { ...templateConfig.value.pageMargin, [side]: value };
store.updateTemplateConfig({ pageMargin: margin });
};
</script>
<template>
<div
class="attribute-panel flex h-full w-80 flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- 标题 -->
<div
class="flex flex-shrink-0 items-center gap-2 border-b border-[var(--el-border-color)] px-4 py-3"
>
<ElIcon :size="16" class="text-[var(--el-color-primary)]">
<Settings />
</ElIcon>
<span class="text-sm font-bold">{{
$t('contract-design.attributePanel')
}}</span>
</div>
<!-- 标签页 -->
<ElTabs v-model="activeTab" class="flex-1 overflow-hidden">
<!-- 元素属性 -->
<ElTabPane
:label="$t('contract-design.elementPanel')"
name="element"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-4">
<!-- 无选中元素 -->
<div
v-if="!activeElement"
class="flex h-40 items-center justify-center text-sm text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.selectElement') }}
</div>
<!-- 元素属性表单 -->
<ElForm v-else label-position="top" label-width="auto" size="small">
<!-- 元素类型 -->
<div
class="mb-4 rounded bg-[var(--el-fill-color-light)] p-2 text-center text-sm"
>
{{ typeLabels[activeElement.type] || activeElement.type }}
</div>
<!-- 标题属性 -->
<template v-if="activeElement.type === 'title'">
<ElFormItem :label="$t('contract-design.titleContent')">
<ElInput
v-model="localProps.content"
@change="updateProps('content', localProps.content)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.titleLevel')">
<ElRadioGroup
v-model="localProps.level"
@change="updateProps('level', localProps.level)"
>
<ElRadioButton :value="1">H1</ElRadioButton>
<ElRadioButton :value="2">H2</ElRadioButton>
<ElRadioButton :value="3">H3</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.left') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.center') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.right') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.fontSize')">
<ElInputNumber
v-model="localProps.fontSize"
:min="12"
:max="48"
@change="updateProps('fontSize', localProps.fontSize)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.fontWeight')">
<ElSelect
v-model="localProps.fontWeight"
@change="updateProps('fontWeight', localProps.fontWeight)"
>
<ElOption
:label="$t('contract-design.normal')"
value="normal"
/>
<ElOption
:label="$t('contract-design.bold')"
value="bold"
/>
</ElSelect>
</ElFormItem>
</template>
<!-- 段落属性 -->
<template v-else-if="activeElement.type === 'paragraph'">
<ElFormItem :label="$t('contract-design.paragraphContent')">
<ElInput
v-model="localProps.content"
type="textarea"
:rows="4"
@change="updateProps('content', localProps.content)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.left') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.center') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.right') }}
</ElRadioButton>
<ElRadioButton value="justify">
{{ $t('contract-design.justify') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.fontSize')">
<ElInputNumber
v-model="localProps.fontSize"
:min="12"
:max="24"
@change="updateProps('fontSize', localProps.fontSize)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.lineHeight')">
<ElInputNumber
v-model="localProps.lineHeight"
:min="1"
:max="3"
:step="0.1"
:precision="1"
@change="updateProps('lineHeight', localProps.lineHeight)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.indent')">
<ElInputNumber
v-model="localProps.indent"
:min="0"
:max="8"
@change="updateProps('indent', localProps.indent)"
/>
</ElFormItem>
</template>
<!-- 变量属性 -->
<template v-else-if="activeElement.type === 'variable'">
<ElFormItem :label="$t('contract-design.selectVariable')">
<ElSelect
v-model="localProps.variableCode"
filterable
:placeholder="$t('contract-design.placeholderVariable')"
@change="
updateProps('variableCode', localProps.variableCode)
"
>
<ElOption
v-for="v in predefinedVariables"
:key="v.code"
:label="`${v.name} (${v.code})`"
:value="v.code"
/>
</ElSelect>
</ElFormItem>
<!-- 显示选中变量的信息 -->
<div
v-if="localProps.variableCode"
class="mb-4 rounded bg-[var(--el-fill-color-light)] p-3"
>
<div
class="mb-1 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ getVariableInfo(localProps.variableCode)?.name }}
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span
class="rounded bg-[var(--el-color-primary-light-9)] px-1.5 py-0.5 text-[var(--el-color-primary)]"
>
{{ getVariableInfo(localProps.variableCode)?.type }}
</span>
<span
v-if="getVariableInfo(localProps.variableCode)?.required"
class="rounded bg-[var(--el-color-danger-light-9)] px-1.5 py-0.5 text-[var(--el-color-danger)]"
>
{{ $t('contract-design.required') }}
</span>
<span
v-else
class="rounded bg-[var(--el-fill-color)] px-1.5 py-0.5 text-[var(--el-text-color-secondary)]"
>
{{ $t('contract-design.optional') }}
</span>
</div>
<div
class="mt-1 text-xs text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.codeLabel') }}:
{{ localProps.variableCode }}
</div>
</div>
<ElFormItem :label="$t('contract-design.placeholderText')">
<ElInput
v-model="localProps.placeholder"
:placeholder="$t('contract-design.notFilledText')"
@change="updateProps('placeholder', localProps.placeholder)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.minWidth')">
<ElInputNumber
v-model="localProps.minWidth"
:min="50"
:max="500"
@change="updateProps('minWidth', localProps.minWidth)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showUnderline')">
<ElSwitch
v-model="localProps.underline"
@change="updateProps('underline', localProps.underline)"
/>
</ElFormItem>
<ElDivider />
<div
class="rounded bg-[var(--el-color-info-light-9)] p-3 text-xs text-[var(--el-text-color-secondary)]"
>
<div class="mb-1 font-bold">
{{ $t('contract-design.usageInstructions') }}
</div>
<div>
{{ $t('contract-design.variableUsageDesc') }}
</div>
</div>
</template>
<!-- 表格属性 -->
<template v-else-if="activeElement.type === 'table'">
<ElFormItem :label="$t('contract-design.showBorder')">
<ElSwitch
v-model="localProps.bordered"
@change="updateProps('bordered', localProps.bordered)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.headerBgColor')">
<ElColorPicker
v-model="localProps.headerBgColor"
@change="
updateProps('headerBgColor', localProps.headerBgColor)
"
/>
</ElFormItem>
<ElDivider>{{ $t('contract-design.tableColumns') }}</ElDivider>
<div
v-for="(col, index) in localTableColumns"
:key="index"
class="mb-2 rounded border border-[var(--el-border-color)] p-2"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold"
>{{ $t('contract-design.column') }} {{ index + 1 }}</span
>
<ElButton
type="danger"
text
size="small"
@click="removeTableColumn(index)"
>
{{ $t('contract-design.delete') }}
</ElButton>
</div>
<ElInput
v-model="col.title"
:placeholder="$t('contract-design.columnTitle')"
size="small"
class="mb-2"
@change="updateTableColumns"
/>
<div class="flex gap-2">
<ElInputNumber
v-model="col.width"
:min="50"
:max="500"
size="small"
:placeholder="$t('contract-design.width')"
@change="updateTableColumns"
/>
<ElSelect
v-model="col.align"
size="small"
@change="updateTableColumns"
>
<ElOption
:label="$t('contract-design.left')"
value="left"
/>
<ElOption
:label="$t('contract-design.center')"
value="center"
/>
<ElOption
:label="$t('contract-design.right')"
value="right"
/>
</ElSelect>
</div>
</div>
<ElButton size="small" @click="addTableColumn">
{{ $t('contract-design.addColumn') }}
</ElButton>
<ElButton size="small" class="ml-2" @click="addTableRow">
{{ $t('contract-design.addRow') }}
</ElButton>
</template>
<!-- 签名区属性 -->
<template
v-else-if="
activeElement.type === 'signature-zone' ||
activeElement.type === 'seal-zone'
"
>
<ElFormItem :label="$t('contract-design.signatory')">
<ElSelect
v-model="localSignature.partyType"
@change="
updateSignature('partyType', localSignature.partyType)
"
>
<ElOption
v-for="opt in partyOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.labelText')">
<ElInput
v-model="localSignature.partyLabel"
@change="
updateSignature('partyLabel', localSignature.partyLabel)
"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.alignLeft') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.alignCenter') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.alignRight') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.width')">
<ElInputNumber
v-model="localProps.width"
:min="100"
:max="400"
@change="updateProps('width', localProps.width)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.height')">
<ElInputNumber
v-model="localProps.height"
:min="60"
:max="200"
@change="updateProps('height', localProps.height)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showBorder')">
<ElSwitch
v-model="localProps.showBorder"
@change="updateProps('showBorder', localProps.showBorder)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showLabel')">
<ElSwitch
v-model="localProps.showLabel"
@change="updateProps('showLabel', localProps.showLabel)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showDate')">
<ElSwitch
v-model="localSignature.showDate"
@change="
updateSignature('showDate', localSignature.showDate)
"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.mustSign')">
<ElSwitch
v-model="localSignature.required"
@change="
updateSignature('required', localSignature.required)
"
/>
</ElFormItem>
</template>
<!-- 日期区属性 -->
<template v-else-if="activeElement.type === 'date-zone'">
<ElFormItem :label="$t('contract-design.label')">
<ElInput
v-model="localProps.label"
@change="updateProps('label', localProps.label)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.alignLeft') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.alignCenter') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.alignRight') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.dateFormat')">
<ElSelect
v-model="localProps.format"
@change="updateProps('format', localProps.format)"
>
<ElOption label="YYYY年MM月DD日" value="YYYY年MM月DD日" />
<ElOption label="YYYY-MM-DD" value="YYYY-MM-DD" />
<ElOption label="YYYY/MM/DD" value="YYYY/MM/DD" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showUnderline')">
<ElSwitch
v-model="localProps.showUnderline"
@change="
updateProps('showUnderline', localProps.showUnderline)
"
/>
</ElFormItem>
</template>
<!-- 分割线属性 -->
<template v-else-if="activeElement.type === 'divider'">
<ElFormItem :label="$t('contract-design.lineStyle')">
<ElSelect
v-model="localProps.style"
@change="updateProps('style', localProps.style)"
>
<ElOption
:label="$t('contract-design.solid')"
value="solid"
/>
<ElOption
:label="$t('contract-design.dashed')"
value="dashed"
/>
<ElOption
:label="$t('contract-design.dotted')"
value="dotted"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.lineColor')">
<ElColorPicker
v-model="localProps.color"
@change="updateProps('color', localProps.color)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.marginVertical')">
<ElInputNumber
v-model="localProps.margin"
:min="0"
:max="100"
@change="updateProps('margin', localProps.margin)"
/>
</ElFormItem>
</template>
<!-- 图片属性 -->
<template v-else-if="activeElement.type === 'image'">
<ElFormItem :label="$t('contract-design.imageUrl')">
<ElInput
v-model="localProps.src"
:placeholder="$t('contract-design.enterImageUrl')"
@change="updateProps('src', localProps.src)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.width')">
<ElInputNumber
v-model="localProps.width"
:min="50"
:max="700"
@change="updateProps('width', localProps.width)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.left') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.center') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.right') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
</template>
<!-- 富文本属性 -->
<template v-else-if="activeElement.type === 'rich-text'">
<ElFormItem :label="$t('contract-design.minHeight')">
<ElInputNumber
v-model="localProps.minHeight"
:min="50"
:max="800"
@change="updateProps('minHeight', localProps.minHeight)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.padding')">
<ElInputNumber
v-model="localProps.padding"
:min="0"
:max="50"
@change="updateProps('padding', localProps.padding)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showBorder')">
<ElSwitch
v-model="localProps.showBorder"
@change="updateProps('showBorder', localProps.showBorder)"
/>
</ElFormItem>
<ElFormItem
v-if="localProps.showBorder"
:label="$t('contract-design.borderColor')"
>
<ElColorPicker
v-model="localProps.borderColor"
@change="updateProps('borderColor', localProps.borderColor)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.backgroundColor')">
<ElColorPicker
v-model="localProps.backgroundColor"
@change="
updateProps('backgroundColor', localProps.backgroundColor)
"
/>
</ElFormItem>
<ElDivider />
<div
class="rounded bg-[var(--el-color-primary-light-9)] p-3 text-xs text-[var(--el-color-primary)]"
>
<div class="mb-1 font-bold">
{{ $t('contract-design.editTip') }}
</div>
<div>{{ $t('contract-design.richTextEditDesc') }}</div>
<ul class="mt-1 list-inside list-disc">
<li>{{ $t('contract-design.boldItalicUnderline') }}</li>
<li>{{ $t('contract-design.headingListQuote') }}</li>
<li>{{ $t('contract-design.insertContent') }}</li>
</ul>
</div>
</template>
</ElForm>
</div>
</ElScrollbar>
</ElTabPane>
<!-- 模板设置 -->
<ElTabPane
:label="$t('contract-design.templatePanel')"
name="template"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-4">
<ElForm label-position="top" size="small">
<ElFormItem :label="$t('contract-design.templateName')">
<ElInput
:model-value="templateConfig.name"
@change="(val: string) => updateTemplateField('name', val)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.templateCode')">
<ElInput
:model-value="templateConfig.code"
@change="(val: string) => updateTemplateField('code', val)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.templateCategory')">
<ElInput
:model-value="templateConfig.category"
@change="
(val: string) => updateTemplateField('category', val)
"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.templateDescription')">
<ElInput
:model-value="templateConfig.description"
type="textarea"
:rows="3"
@change="
(val: string) => updateTemplateField('description', val)
"
/>
</ElFormItem>
<ElDivider>{{ $t('contract-design.pageSettings') }}</ElDivider>
<ElFormItem :label="$t('contract-design.pageSize')">
<ElSelect
:model-value="templateConfig.pageSize"
@change="
(val: 'A4' | 'A5' | 'Letter') =>
updateTemplateField('pageSize', val)
"
>
<ElOption label="A4" value="A4" />
<ElOption label="A5" value="A5" />
<ElOption label="Letter" value="Letter" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.pageMargin')">
<div class="grid grid-cols-2 gap-2">
<ElInputNumber
:model-value="templateConfig.pageMargin.top"
:min="0"
:max="200"
:placeholder="$t('contract-design.top')"
@change="(val: number) => updateMargin('top', val)"
/>
<ElInputNumber
:model-value="templateConfig.pageMargin.right"
:min="0"
:max="200"
:placeholder="$t('contract-design.rightLabel')"
@change="(val: number) => updateMargin('right', val)"
/>
<ElInputNumber
:model-value="templateConfig.pageMargin.bottom"
:min="0"
:max="200"
:placeholder="$t('contract-design.bottom')"
@change="(val: number) => updateMargin('bottom', val)"
/>
<ElInputNumber
:model-value="templateConfig.pageMargin.left"
:min="0"
:max="200"
:placeholder="$t('contract-design.leftLabel')"
@change="(val: number) => updateMargin('left', val)"
/>
</div>
</ElFormItem>
</ElForm>
</div>
</ElScrollbar>
</ElTabPane>
<!-- 变量管理 -->
<ElTabPane
:label="$t('contract-design.variablePanel')"
name="variables"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-4">
<div
class="mb-4 rounded bg-[var(--el-color-info-light-9)] p-3 text-xs text-[var(--el-text-color-secondary)]"
>
<div class="mb-1 font-bold">
{{ $t('contract-design.variableDescription') }}
</div>
<div>
{{ $t('contract-design.variableDescText') }}
</div>
</div>
<!-- 已使用的变量 -->
<div v-if="templateConfig.variables.length > 0" class="mb-4">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ $t('contract-design.usedVariables') }} ({{
templateConfig.variables.length
}})
</div>
<div
v-for="variable in templateConfig.variables"
:key="variable.code"
class="mb-2 rounded border border-[var(--el-border-color)] p-2"
>
<div class="flex items-center justify-between">
<div class="text-sm font-medium">{{ variable.name }}</div>
<ElButton
type="danger"
text
size="small"
@click="store.removeVariable(variable.code)"
>
{{ $t('contract-design.remove') }}
</ElButton>
</div>
<div class="mt-1 flex items-center gap-2">
<span class="text-xs text-[var(--el-text-color-placeholder)]">
{{ variable.code }}
</span>
<span
class="rounded px-1.5 py-0.5 text-xs"
:class="{
'bg-[var(--el-color-danger-light-9)] text-[var(--el-color-danger)]':
variable.required,
'bg-[var(--el-fill-color)] text-[var(--el-text-color-secondary)]':
!variable.required,
}"
>
{{ variable.required ? '必填' : '选填' }}
</span>
<span
class="rounded bg-[var(--el-fill-color)] px-1.5 py-0.5 text-xs text-[var(--el-text-color-secondary)]"
>
{{ variableTypeLabels[variable.type] || variable.type }}
</span>
</div>
</div>
</div>
<div
v-else
class="mb-4 py-8 text-center text-sm text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.noVariablesText') }}
</div>
<!-- 可用变量列表 -->
<ElDivider>
{{ $t('contract-design.availableVariables') }}
</ElDivider>
<div class="grid grid-cols-2 gap-2">
<div
v-for="v in predefinedVariables"
:key="v.code"
class="cursor-pointer rounded border border-[var(--el-border-color)] p-2 text-xs transition-colors hover:border-[var(--el-color-primary)] hover:bg-[var(--el-color-primary-light-9)]"
:class="{
'border-[var(--el-color-success)] bg-[var(--el-color-success-light-9)]':
isVariableUsed(v.code),
}"
@click="addVariableToTemplate(v)"
>
<div class="font-medium">{{ v.name }}</div>
<div class="mt-0.5 text-[var(--el-text-color-placeholder)]">
{{ v.code }}
</div>
</div>
</div>
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</template>
<style scoped>
:deep(.el-tabs__content) {
height: calc(100% - 40px);
overflow: hidden;
}
:deep(.el-tab-pane) {
height: 100%;
}
</style>
@@ -0,0 +1,569 @@
<script setup lang="ts">
import type {
ContractElement,
ContractTemplateConfig,
} from '../store/contractDesignStore';
import { computed, nextTick, ref } from 'vue';
import { Check, Pencil } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElDialog, ElIcon, ElInput } from 'element-plus';
import ElementRenderer from './ElementRenderer.vue';
import SignatureDialog from './SignatureDialog.vue';
const props = withDefaults(
defineProps<{
config: ContractTemplateConfig;
editable?: boolean; // 是否可编辑变量
seals?: Record<string, string>;
showBorder?: boolean;
signable?: boolean; // 是否可签署模式
signatures?: Record<string, string>;
variables?: Record<string, any>;
}>(),
{
variables: () => ({}),
signatures: () => ({}),
seals: () => ({}),
showBorder: false,
signable: false,
editable: false,
},
);
const emit = defineEmits<{
(e: 'sign', elementId: string, signatureData: string): void;
(e: 'update:variables', variables: Record<string, any>): void;
(e: 'variableChange', code: string, value: string): void;
}>();
// 签名弹窗状态
const signDialogVisible = ref(false);
const currentSignElement = ref<ContractElement | null>(null);
// 页面尺寸映射
const pageSizeMap = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
// 画布样式
const canvasStyle = computed(() => {
const size = pageSizeMap[props.config.pageSize];
const margin = props.config.pageMargin;
return {
width: `${size.width}px`,
minHeight: `${size.height}px`,
// padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 合并变量值到元素
const getElementVariables = (element: any) => {
if (element.type === 'variable' && element.props.variableCode) {
return {
...props.variables,
[element.props.variableCode]: props.variables[element.props.variableCode],
};
}
return props.variables;
};
// 获取签名图片
const getSignatureImage = (element: any) => {
if (element.signature?.partyType) {
return props.signatures[element.signature.partyType];
}
return '';
};
// 获取印章图片
const getSealImage = (element: any) => {
if (element.signature?.partyType) {
return props.seals[element.signature.partyType];
}
return '';
};
// 检查元素是否已签署
const isSigned = (element: ContractElement) => {
return !!props.signatures[element.id];
};
// 获取元素签名图片(按元素ID
const getElementSignature = (element: ContractElement) => {
return props.signatures[element.id] || '';
};
// 点击签名区
const handleSignClick = (element: ContractElement) => {
if (!props.signable) return;
currentSignElement.value = element;
signDialogVisible.value = true;
};
// 确认签名
const handleSignConfirm = (signatureData: string) => {
if (currentSignElement.value) {
emit('sign', currentSignElement.value.id, signatureData);
}
currentSignElement.value = null;
};
// 签署区容器对齐样式
const getSignContainerStyle = (element: ContractElement) => {
const align = element.props.align || 'right';
return {
display: 'flex',
justifyContent:
align === 'center'
? 'center'
: align === 'right'
? 'flex-end'
: 'flex-start',
};
};
// ============ 变量编辑功能 ============
// 当前编辑的变量
const editingVariableCode = ref<null | string>(null);
const editingVariableValue = ref('');
const variableInputRef = ref<InstanceType<typeof ElInput> | null>(null);
// 获取变量显示值
const getVariableDisplayValue = (code: string, placeholder?: string) => {
const value = props.variables[code];
if (value !== undefined && value !== '') {
return value;
}
return placeholder || $t('contract-design.clickToFill');
};
// 检查变量是否已填写
const isVariableFilled = (code: string) => {
const value = props.variables[code];
return value !== undefined && value !== '';
};
// 点击变量开始编辑
const handleVariableClick = (code: string) => {
if (!props.editable) return;
editingVariableCode.value = code;
editingVariableValue.value = props.variables[code] || '';
nextTick(() => {
variableInputRef.value?.focus();
});
};
// 保存变量编辑
const handleVariableSave = () => {
if (editingVariableCode.value) {
const newVariables = {
...props.variables,
[editingVariableCode.value]: editingVariableValue.value,
};
emit('update:variables', newVariables);
emit(
'variableChange',
editingVariableCode.value,
editingVariableValue.value,
);
}
editingVariableCode.value = null;
};
// 取消变量编辑
const handleVariableCancel = () => {
editingVariableCode.value = null;
};
</script>
<template>
<div class="contract-renderer">
<div
class="contract-paper bg-[var(--el-bg-color)]"
:class="{ 'shadow-lg': showBorder }"
:style="canvasStyle"
>
<template v-if="config.elements.length > 0">
<div
v-for="element in config.elements"
:key="element.id"
class="contract-element"
>
<!-- 签名区特殊处理 -->
<template v-if="element.type === 'signature-zone'">
<div
class="signature-container"
:style="getSignContainerStyle(element)"
>
<div class="signature-wrapper">
<!-- 标签 -->
<div
v-if="element.props.showLabel !== false"
class="signature-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel ||
element.props.label ||
$t('contract-design.signature')
}}
</div>
<!-- 已签名显示 -->
<div
v-if="getElementSignature(element)"
class="signature-display signed"
>
<img
:src="getElementSignature(element)"
class="signature-image"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
}"
alt="签名"
/>
<div class="signed-badge">
<ElIcon :size="12"><Check /></ElIcon>
{{ $t('contract-design.signed') }}
</div>
</div>
<!-- 未签名 - 可签署模式 -->
<div
v-else-if="signable"
class="signature-placeholder signable"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
border: element.props.showBorder
? '2px dashed var(--el-color-primary)'
: 'none',
}"
@click="handleSignClick(element)"
>
<ElIcon :size="24" class="mb-1"><Pencil /></ElIcon>
<span class="text-sm">{{
$t('contract-design.clickToSign')
}}</span>
</div>
<!-- 未签名 - 只读模式 -->
<ElementRenderer
v-else
:element="element"
:is-design="false"
:variables="getElementVariables(element)"
/>
<!-- 签署日期 -->
<div
v-if="
element.signature?.showDate && getElementSignature(element)
"
class="signature-date mt-1 text-sm text-[var(--el-text-color-secondary)]"
>
{{ $t('contract-design.dateLabel') }}{{
new Date().toLocaleDateString('zh-CN')
}}
</div>
</div>
</div>
</template>
<!-- 盖章区特殊处理 -->
<template v-else-if="element.type === 'seal-zone'">
<div class="seal-container" :style="getSignContainerStyle(element)">
<div class="seal-wrapper">
<!-- 标签 -->
<div
v-if="element.props.showLabel !== false"
class="seal-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel ||
element.props.label ||
$t('contract-design.sealArea')
}}
</div>
<!-- 已盖章显示 -->
<div
v-if="getSealImage(element) || getElementSignature(element)"
class="seal-display signed"
>
<img
:src="getSealImage(element) || getElementSignature(element)"
class="seal-image"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
}"
alt="印章"
/>
<div class="signed-badge">
<ElIcon :size="12"><Check /></ElIcon>
{{ $t('contract-design.sealed') }}
</div>
</div>
<!-- 未盖章 - 可签署模式 -->
<div
v-else-if="signable"
class="seal-placeholder signable"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
border: element.props.showBorder
? '2px dashed var(--el-color-primary)'
: 'none',
}"
@click="handleSignClick(element)"
>
<ElIcon :size="32" class="mb-1"><Pencil /></ElIcon>
<span class="text-sm">{{
$t('contract-design.clickToSeal')
}}</span>
</div>
<!-- 未盖章 - 只读模式 -->
<ElementRenderer
v-else
:element="element"
:is-design="false"
:variables="getElementVariables(element)"
/>
</div>
</div>
</template>
<!-- 变量元素 - 可编辑模式 -->
<template v-else-if="element.type === 'variable' && editable">
<span
class="editable-variable"
:class="{
'variable-filled': isVariableFilled(element.props.variableCode),
'variable-empty': !isVariableFilled(element.props.variableCode),
}"
:style="{
borderBottom: element.props.underline
? '1px solid currentColor'
: undefined,
minWidth: element.props.minWidth
? `${element.props.minWidth}px`
: '60px',
display: 'inline-block',
}"
@click="handleVariableClick(element.props.variableCode)"
>
{{
getVariableDisplayValue(
element.props.variableCode,
element.props.placeholder,
)
}}
</span>
</template>
<!-- 其他元素 -->
<template v-else>
<ElementRenderer
:element="element"
:is-design="false"
:variables="getElementVariables(element)"
:editable="editable"
@variable-click="handleVariableClick"
/>
</template>
</div>
</template>
<div
v-else
class="flex h-40 items-center justify-center text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.noContent') }}
</div>
</div>
<!-- 签名弹窗 -->
<SignatureDialog
v-model:visible="signDialogVisible"
:party-label="
currentSignElement?.signature?.partyLabel ||
currentSignElement?.props?.label
"
:width="currentSignElement?.props?.width || 400"
:height="currentSignElement?.props?.height || 150"
@confirm="handleSignConfirm"
/>
<!-- 变量编辑弹窗 -->
<ElDialog
:model-value="!!editingVariableCode"
:title="$t('contract-design.fillVariable')"
width="400px"
:close-on-click-modal="false"
@close="handleVariableCancel"
>
<div class="variable-edit-dialog">
<div class="mb-2 text-sm text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.enterVariableValue') }}
</div>
<ElInput
ref="variableInputRef"
v-model="editingVariableValue"
:placeholder="$t('contract-design.enterVariableValue')"
size="large"
clearable
@keyup.enter="handleVariableSave"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="var-btn" @click="handleVariableCancel">
{{ $t('contract-design.cancel') }}
</button>
<button class="var-btn var-btn-primary" @click="handleVariableSave">
{{ $t('contract-design.confirm') }}
</button>
</div>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.contract-renderer {
display: flex;
justify-content: center;
}
.contract-paper {
box-sizing: border-box;
}
.contract-element {
margin-bottom: 4px;
}
.signature-display,
.seal-display {
position: relative;
display: inline-block;
}
.signature-display.signed,
.seal-display.signed {
padding: 4px;
background: var(--el-color-success-light-9);
border: 1px solid var(--el-color-success-light-5);
border-radius: 4px;
}
.signature-image,
.seal-image {
display: block;
object-fit: contain;
}
.signature-placeholder,
.seal-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
border-radius: 4px;
transition: all 0.2s;
}
.signature-placeholder.signable,
.seal-placeholder.signable {
color: var(--el-color-primary);
cursor: pointer;
background: var(--el-color-primary-light-9);
}
.signature-placeholder.signable:hover,
.seal-placeholder.signable:hover {
background: var(--el-color-primary-light-8);
border-color: var(--el-color-primary) !important;
}
.signed-badge {
position: absolute;
top: -8px;
right: -8px;
display: flex;
gap: 2px;
align-items: center;
padding: 2px 8px;
font-size: 11px;
color: var(--el-color-success);
background: var(--el-bg-color);
border: 1px solid var(--el-color-success);
border-radius: 10px;
}
/* 可编辑变量样式 */
.editable-variable {
padding: 2px 6px;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
}
.editable-variable:hover {
background: var(--el-color-primary-light-9);
}
.editable-variable.variable-filled {
color: var(--el-text-color-primary);
}
.editable-variable.variable-empty {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px dashed var(--el-color-primary-light-5);
}
.editable-variable.variable-empty:hover {
background: var(--el-color-primary-light-8);
border-color: var(--el-color-primary);
}
/* 变量编辑弹窗按钮 */
.var-btn {
padding: 4px 12px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
}
.var-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.var-btn-primary {
color: white;
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.var-btn-primary:hover {
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
</style>
@@ -0,0 +1,356 @@
<script setup lang="ts">
import type {
ContractTemplateConfig,
PartyType,
VariableConfig,
} from '../store/contractDesignStore';
import { computed, ref, watch } from 'vue';
import { Check, FileSignature, Pencil } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDatePicker,
ElDialog,
ElDivider,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElMessage,
ElScrollbar,
ElStep,
ElSteps,
ElTabPane,
ElTabs,
} from 'element-plus';
import ContractRenderer from './ContractRenderer.vue';
import SignaturePad from './SignaturePad.vue';
const props = defineProps<{
config: ContractTemplateConfig;
partyType?: PartyType;
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(
e: 'signed',
data: { seal?: string; signature: string; variables: Record<string, any> },
): void;
}>();
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 当前步骤
const currentStep = ref(0);
// 变量值
const variableValues = ref<Record<string, any>>({});
// 签名数据
const signatureData = ref('');
const sealData = ref('');
// 签名板引用
const signaturePadRef = ref<InstanceType<typeof SignaturePad> | null>(null);
// 当前签署方标签
const currentPartyLabel = computed(() => {
const party = props.config.parties.find((p) => p.type === props.partyType);
return party?.label || $t('contract-design.signer');
});
// 需要填写的变量(根据签署方过滤)
const requiredVariables = computed(() => {
const partyPrefix = props.partyType?.replace('party_', '') || '';
return props.config.variables.filter((v) => {
// 通用变量
if (!v.code.startsWith('party_')) return true;
// 当前签署方的变量
if (v.code.startsWith(`party_${partyPrefix}_`)) return true;
return false;
});
});
// 初始化变量值
watch(
() => props.visible,
(visible) => {
if (visible) {
currentStep.value = 0;
signatureData.value = '';
sealData.value = '';
// 初始化变量默认值
const values: Record<string, any> = {};
requiredVariables.value.forEach((v) => {
values[v.code] = v.defaultValue || '';
});
variableValues.value = values;
}
},
);
// 获取变量输入类型
const getVariableInputType = (variable: VariableConfig) => {
switch (variable.type) {
case 'date': {
return 'date';
}
case 'money':
case 'number': {
return 'number';
}
default: {
return 'text';
}
}
};
// 下一步
const handleNext = () => {
if (currentStep.value === 0) {
// 验证必填变量
const missingRequired = requiredVariables.value.filter(
(v) => v.required && !variableValues.value[v.code],
);
if (missingRequired.length > 0) {
ElMessage.warning(
$t('contract-design.pleaseCompleteMandatoryFields', {
fields: missingRequired.map((v) => v.name).join('、'),
}),
);
return;
}
}
currentStep.value++;
};
// 上一步
const handlePrev = () => {
currentStep.value--;
};
// 确认签署
const handleSign = () => {
if (!signatureData.value) {
ElMessage.warning($t('contract-design.pleaseSignFirst'));
return;
}
emit('signed', {
variables: variableValues.value,
signature: signatureData.value,
seal: sealData.value,
});
dialogVisible.value = false;
ElMessage.success($t('contract-design.signedSuccessfully'));
};
// 签名变化
const handleSignatureChange = (data: string) => {
signatureData.value = data;
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="`${$t('contract-design.contractSigning')} - ${currentPartyLabel}`"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<template #header>
<div class="flex items-center gap-2">
<ElIcon :size="20" class="text-[var(--el-color-primary)]">
<FileSignature />
</ElIcon>
<span
>{{ $t('contract-design.contractSigning') }} -
{{ currentPartyLabel }}</span
>
</div>
</template>
<!-- 步骤条 -->
<ElSteps :active="currentStep" finish-status="success" class="mb-6">
<ElStep :title="$t('contract-design.fillInformation')" />
<ElStep :title="$t('contract-design.previewContract')" />
<ElStep :title="$t('contract-design.signatureConfirmation')" />
</ElSteps>
<!-- 步骤内容 -->
<div class="step-content">
<!-- 步骤1填写变量 -->
<div v-show="currentStep === 0" class="step-panel">
<ElScrollbar max-height="400px">
<ElForm label-position="top" label-width="auto">
<div class="grid grid-cols-2 gap-4">
<ElFormItem
v-for="variable in requiredVariables"
:key="variable.code"
:label="variable.name"
:required="variable.required"
>
<!-- 文本输入 -->
<ElInput
v-if="getVariableInputType(variable) === 'text'"
v-model="variableValues[variable.code]"
:placeholder="
$t('contract-design.pleaseEnter', { field: variable.name })
"
/>
<!-- 数字输入 -->
<ElInputNumber
v-else-if="getVariableInputType(variable) === 'number'"
v-model="variableValues[variable.code]"
:placeholder="
$t('contract-design.pleaseEnter', { field: variable.name })
"
:precision="variable.type === 'money' ? 2 : 0"
:controls="false"
class="w-full"
/>
<!-- 日期选择 -->
<ElDatePicker
v-else-if="getVariableInputType(variable) === 'date'"
v-model="variableValues[variable.code]"
type="date"
:placeholder="
$t('contract-design.pleaseSelect', { field: variable.name })
"
:format="variable.format || 'YYYY-MM-DD'"
:value-format="variable.format || 'YYYY-MM-DD'"
class="w-full"
/>
</ElFormItem>
</div>
</ElForm>
</ElScrollbar>
</div>
<!-- 步骤2预览合同 -->
<div v-show="currentStep === 1" class="step-panel">
<ElScrollbar max-height="500px">
<div class="flex justify-center bg-[var(--el-fill-color-light)] p-4">
<ContractRenderer
:config="config"
:variables="variableValues"
show-border
/>
</div>
</ElScrollbar>
</div>
<!-- 步骤3签名确认 -->
<div v-show="currentStep === 2" class="step-panel">
<ElTabs type="border-card">
<ElTabPane>
<template #label>
<span class="flex items-center gap-1">
<ElIcon><Pencil /></ElIcon>
{{ $t('contract-design.drawSignature') }}
</span>
</template>
<div class="flex flex-col items-center py-4">
<div class="mb-2 text-sm text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.pleaseSignInArea') }}
</div>
<SignaturePad
ref="signaturePadRef"
:width="400"
:height="150"
:model-value="signatureData"
@change="handleSignatureChange"
/>
</div>
</ElTabPane>
</ElTabs>
<ElDivider />
<!-- 签名预览 -->
<div v-if="signatureData" class="signature-preview">
<div class="mb-2 text-sm font-bold">
{{ $t('contract-design.signaturePreview') }}
</div>
<div class="flex items-center gap-4">
<div class="rounded border border-[var(--el-border-color)] p-2">
<img
:src="signatureData"
:alt="$t('contract-design.signaturePreview')"
class="h-16"
/>
</div>
<div class="text-sm text-[var(--el-text-color-secondary)]">
<div>
{{ $t('contract-design.signer') }}{{ currentPartyLabel }}
</div>
<div>
{{ $t('contract-design.signingTime') }}{{
new Date().toLocaleString('zh-CN')
}}
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-between">
<div>
<ElButton v-if="currentStep > 0" @click="handlePrev">
{{ $t('contract-design.previousStep') }}
</ElButton>
</div>
<div>
<ElButton @click="dialogVisible = false">
{{ $t('contract-design.cancel') }}
</ElButton>
<ElButton v-if="currentStep < 2" type="primary" @click="handleNext">
{{ $t('contract-design.nextStep') }}
</ElButton>
<ElButton
v-else
type="primary"
:disabled="!signatureData"
@click="handleSign"
>
<ElIcon class="mr-1"><Check /></ElIcon>
{{ $t('contract-design.confirmSigning') }}
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.step-content {
min-height: 300px;
}
.step-panel {
padding: 16px 0;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input-number .el-input__wrapper) {
padding-right: 11px;
padding-left: 11px;
}
</style>
@@ -0,0 +1,376 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import {
Code,
Download,
Eye,
FileText,
RotateCcw,
RotateCw,
Save,
Trash2,
Upload,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElEmpty,
ElIcon,
ElScrollbar,
ElTooltip,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import draggable from 'vuedraggable';
import { useContractDesignStore } from '../store/contractDesignStore';
import ElementWrapper from './ElementWrapper.vue';
const emit = defineEmits<{
(e: 'preview'): void;
(e: 'view-code'): void;
(e: 'clear'): void;
(e: 'import'): void;
(e: 'export'): void;
(e: 'save'): void;
}>();
const store = useContractDesignStore();
const { templateConfig, canUndo, canRedo, isDragging } = storeToRefs(store);
// 页面尺寸映射(单位:px,96dpi)
const pageSizeMap = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
// 画布引用
const canvasPaperRef = ref<HTMLElement | null>(null);
// 分页线位置列表
const pageBreakLines = ref<number[]>([]);
// 计算内容区域高度(减去上下边距)
const contentAreaHeight = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return size.height - margin.top - margin.bottom;
});
// 画布样式
const canvasStyle = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return {
width: `${size.width}px`,
minHeight: `${size.height}px`,
padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 计算分页线位置
const calculatePageBreaks = () => {
if (!canvasPaperRef.value) return;
const pageHeight = pageSizeMap[templateConfig.value.pageSize].height;
const margin = templateConfig.value.pageMargin;
// 获取内容区域的实际高度(不包含 padding)
const contentEl = canvasPaperRef.value.querySelector(
'.sortable-content',
) as HTMLElement;
const actualContentHeight = contentEl ? contentEl.scrollHeight : 0;
// 每页内容区域高度(不含边距)
const pageContentHeight = pageHeight - margin.top - margin.bottom;
// 计算需要多少页
const pageCount = Math.ceil(actualContentHeight / pageContentHeight);
// 生成分页线位置
// 分页线应该在每页内容区域结束的位置(相对于画布顶部)
const lines: number[] = [];
for (let i = 1; i < pageCount; i++) {
// 分页线位置 = 顶部边距 + 每页内容高度 * 页数
lines.push(margin.top + pageContentHeight * i);
}
pageBreakLines.value = lines;
};
// 监听元素变化,重新计算分页
watch(
() => templateConfig.value.elements,
() => {
nextTick(() => {
calculatePageBreaks();
});
},
{ deep: true },
);
// 使用 ResizeObserver 监听画布大小变化
let resizeObserver: null | ResizeObserver = null;
onMounted(() => {
nextTick(() => {
calculatePageBreaks();
if (canvasPaperRef.value) {
resizeObserver = new ResizeObserver(() => {
calculatePageBreaks();
});
resizeObserver.observe(canvasPaperRef.value);
}
});
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
// 点击画布空白处取消选中
const handleCanvasClick = () => {
store.setActive(null);
};
// 拖拽结束后记录快照
const handleDragEnd = () => {
store.setDragging(false);
store.recordSnapshot();
};
// 处理拖拽添加
const handleDragAdd = () => {
store.recordSnapshot();
};
</script>
<template>
<div
class="design-canvas flex h-full flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- 工具栏 -->
<div
class="canvas-toolbar flex flex-shrink-0 items-center justify-between border-b border-[var(--el-border-color)] px-4 py-2"
>
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<FileText />
</ElIcon>
<span class="text-sm font-bold">{{
templateConfig.name || $t('contract-design.contractTemplate')
}}</span>
<span class="text-xs text-[var(--el-text-color-placeholder)]">
({{ templateConfig.elements.length }}
{{ $t('contract-design.elements') }})
</span>
</div>
<div class="flex items-center gap-1">
<!-- 撤销/重做 -->
<ElTooltip
:content="`${$t('contract-design.undo')} (Ctrl+Z)`"
placement="bottom"
>
<ElButton text :disabled="!canUndo" @click="store.undo()">
<ElIcon :size="16"><RotateCcw /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="`${$t('contract-design.redo')} (Ctrl+Y)`"
placement="bottom"
>
<ElButton text :disabled="!canRedo" @click="store.redo()">
<ElIcon :size="16"><RotateCw /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 预览 -->
<ElTooltip :content="$t('contract-design.preview')" placement="bottom">
<ElButton text @click="emit('preview')">
<ElIcon :size="16"><Eye /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 查看代码 -->
<ElTooltip :content="$t('contract-design.viewJSON')" placement="bottom">
<ElButton text @click="emit('view-code')">
<ElIcon :size="16"><Code /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 导入 -->
<ElTooltip :content="$t('contract-design.import')" placement="bottom">
<ElButton text @click="emit('import')">
<ElIcon :size="16"><Upload /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 导出 -->
<ElTooltip :content="$t('contract-design.export')" placement="bottom">
<ElButton text @click="emit('export')">
<ElIcon :size="16"><Download /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 清空 -->
<ElTooltip :content="$t('contract-design.clear')" placement="bottom">
<ElButton text @click="emit('clear')">
<ElIcon :size="16"><Trash2 /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 保存 -->
<ElButton type="primary" size="small" @click="emit('save')">
<ElIcon :size="14" class="mr-1"><Save /></ElIcon>
{{ $t('contract-design.save') }}
</ElButton>
</div>
</div>
<!-- 画布区域 -->
<ElScrollbar class="flex-1">
<div
class="canvas-container flex justify-center bg-[var(--el-fill-color-light)] p-6"
>
<div class="canvas-paper-wrapper relative">
<div
ref="canvasPaperRef"
class="canvas-paper relative bg-[var(--el-bg-color)] shadow-lg"
:style="canvasStyle"
:class="{ 'is-dragging': isDragging }"
@click.self="handleCanvasClick"
>
<!-- 空状态 -->
<ElEmpty
v-if="templateConfig.elements.length === 0 && !isDragging"
:description="$t('contract-design.dragOrClickToAdd')"
:image-size="80"
/>
<!-- 元素列表 -->
<draggable
v-model="templateConfig.elements"
group="contract-design"
item-key="id"
handle=".drag-handle"
ghost-class="ghost-element"
chosen-class="chosen-element"
class="sortable-content"
:animation="200"
@start="store.setDragging(true)"
@end="handleDragEnd"
@add="handleDragAdd"
>
<template #item="{ element, index }">
<ElementWrapper
:element="element"
:index="index"
:total="templateConfig.elements.length"
/>
</template>
</draggable>
<!-- 拖拽提示 -->
<div
v-if="isDragging && templateConfig.elements.length === 0"
class="drop-hint flex h-20 items-center justify-center rounded border-2 border-dashed border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]"
>
<span class="text-sm text-[var(--el-color-primary)]">{{
$t('contract-design.releaseToAdd')
}}</span>
</div>
<!-- 分页线 -->
<div
v-for="(linePos, index) in pageBreakLines"
:key="`page-break-${index}`"
class="page-break-line"
:style="{ top: `${linePos}px` }"
>
<div class="page-break-label">
{{
$t('contract-design.pageBreakLabel', {
page1: index + 1,
page2: index + 2,
})
}}
</div>
</div>
</div>
</div>
</div>
</ElScrollbar>
<!-- 底部状态栏 -->
<div
class="canvas-footer flex flex-shrink-0 items-center justify-between border-t border-[var(--el-border-color)] px-4 py-1.5 text-xs text-[var(--el-text-color-secondary)]"
>
<div>
{{ $t('contract-design.pageSize') }}: {{ templateConfig.pageSize }}
</div>
<div>
{{ $t('contract-design.margin') }}:
{{ templateConfig.pageMargin.top }}px /
{{ templateConfig.pageMargin.right }}px /
{{ templateConfig.pageMargin.bottom }}px /
{{ templateConfig.pageMargin.left }}px
</div>
</div>
</div>
</template>
<style scoped>
.canvas-paper {
transition: box-shadow 0.2s;
}
.canvas-paper.is-dragging {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.ghost-element {
background: var(--el-color-primary-light-9);
border: 1px dashed var(--el-color-primary);
opacity: 0.5;
}
.chosen-element {
opacity: 0.8;
}
/* 分页线样式 */
.page-break-line {
position: absolute;
right: -20px;
left: -20px;
z-index: 100;
height: 0;
pointer-events: none;
border-top: 2px dashed var(--el-color-danger);
}
.page-break-label {
position: absolute;
top: -10px;
left: 50%;
padding: 2px 12px;
font-size: 11px;
color: var(--el-color-danger);
white-space: nowrap;
background-color: var(--el-color-danger-light-9);
border-radius: 10px;
transform: translateX(-50%);
}
</style>
@@ -0,0 +1,714 @@
<script setup lang="ts">
import type { ContractElement } from '../store/contractDesignStore';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Calendar, Circle, Pencil } from '@vben/icons';
import { ElDivider, ElIcon, ElTable, ElTableColumn } from 'element-plus';
const props = defineProps<{
editable?: boolean;
element: ContractElement;
isDesign?: boolean;
variables?: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'variableClick', code: string): void;
}>();
// 富文本容器引用
const richTextRef = ref<HTMLElement | null>(null);
// 获取变量值
const getVariableValue = (code: string) => {
if (props.variables && props.variables[code]) {
return props.variables[code];
}
return props.element.props.placeholder || `{{${code}}}`;
};
// 检查变量是否已填写
const isVariableFilled = (code: string) => {
return (
props.variables &&
props.variables[code] !== undefined &&
props.variables[code] !== ''
);
};
// 处理富文本中的变量替换
const processRichTextContent = computed(() => {
let content = props.element.props.content || '';
// 替换 【变量名称|变量code】 格式的变量(新格式)
content = content.replaceAll(
/【([^|【】]+)\|([^【】]+)】/g,
(_match: string, name: string, code: string) => {
const trimmedCode = code.trim();
const value = props.variables?.[trimmedCode];
const displayValue =
value !== undefined && value !== '' ? value : name.trim();
const filledClass =
value !== undefined && value !== '' ? 'filled' : 'empty';
if (props.editable) {
return `<span class="rich-text-variable ${filledClass}" data-var-code="${trimmedCode}">${displayValue}</span>`;
} else if (value !== undefined && value !== '') {
return `<span class="variable-replaced">${value}</span>`;
}
// 设计模式下显示带下划线的变量名称
return `<span class="contract-variable-tag" data-variable="${trimmedCode}">${name.trim()}</span>`;
},
);
// 替换 {{variableCode}} 格式的变量(旧格式,兼容)
content = content.replaceAll(
/\{\{([^}]+)\}\}/g,
(match: string, code: string) => {
const trimmedCode = code.trim();
const value = props.variables?.[trimmedCode];
const displayValue =
value !== undefined && value !== '' ? value : `点击填写`;
const filledClass =
value !== undefined && value !== '' ? 'filled' : 'empty';
if (props.editable) {
return `<span class="rich-text-variable ${filledClass}" data-var-code="${trimmedCode}">${displayValue}</span>`;
} else if (value !== undefined && value !== '') {
return `<span class="variable-replaced">${value}</span>`;
}
return match;
},
);
// 替换 contract-variable-tag 标签中的变量(HTML格式,兼容)
content = content.replaceAll(
/<span[^>]*class="contract-variable-tag"[^>]*data-variable="([^"]+)"[^>]*>[^<]*<\/span>/g,
(match: string, code: string) => {
const trimmedCode = code.trim();
const value = props.variables?.[trimmedCode];
const displayValue =
value !== undefined && value !== '' ? value : `点击填写`;
const filledClass =
value !== undefined && value !== '' ? 'filled' : 'empty';
if (props.editable) {
return `<span class="rich-text-variable ${filledClass}" data-var-code="${trimmedCode}">${displayValue}</span>`;
} else if (value !== undefined && value !== '') {
return `<span class="variable-replaced">${value}</span>`;
}
return match;
},
);
return content;
});
// 处理富文本中变量的点击事件
const handleRichTextClick = (e: MouseEvent) => {
if (!props.editable) return;
const target = e.target as HTMLElement;
if (target.classList.contains('rich-text-variable')) {
const varCode = target.dataset.varCode;
if (varCode) {
emit('variableClick', varCode);
}
}
};
// 挂载和卸载事件监听
onMounted(() => {
if (richTextRef.value) {
richTextRef.value.addEventListener('click', handleRichTextClick);
}
});
onUnmounted(() => {
if (richTextRef.value) {
richTextRef.value.removeEventListener('click', handleRichTextClick);
}
});
// 计算样式
const titleStyle = computed(() => {
const { fontSize, fontWeight, align } = props.element.props;
return {
fontSize: `${fontSize}px`,
fontWeight,
textAlign: align,
};
});
const paragraphStyle = computed(() => {
const { fontSize, lineHeight, align, indent } = props.element.props;
return {
fontSize: `${fontSize}px`,
lineHeight,
textAlign: align,
textIndent: indent ? `${indent}em` : undefined,
};
});
const variableStyle = computed(() => {
const { underline, minWidth } = props.element.props;
return {
minWidth: `${minWidth}px`,
borderBottom: underline ? '1px solid var(--el-text-color-primary)' : 'none',
display: 'inline-block',
textAlign: 'center' as const,
};
});
const signatureStyle = computed(() => {
const { width, height, showBorder } = props.element.props;
return {
width: `${width}px`,
height: `${height}px`,
border: showBorder ? '1px dashed var(--el-border-color)' : 'none',
};
});
// 签署区容器对齐样式
const signatureContainerStyle = computed(() => {
const align = props.element.props.align || 'right';
return {
display: 'flex',
justifyContent:
align === 'center'
? 'center'
: align === 'right'
? 'flex-end'
: 'flex-start',
};
});
const imageStyle = computed(() => {
const { width, height, align } = props.element.props;
return {
width: typeof width === 'number' ? `${width}px` : width,
height: height === 'auto' ? 'auto' : `${height}px`,
display: 'block',
margin:
align === 'center' ? '0 auto' : (align === 'right' ? '0 0 0 auto' : '0'),
};
});
</script>
<template>
<div class="element-renderer">
<!-- 标题 -->
<template v-if="element.type === 'title'">
<h1
v-if="element.props.level === 1"
:style="titleStyle"
class="contract-title"
>
{{ element.props.content }}
</h1>
<h2
v-else-if="element.props.level === 2"
:style="titleStyle"
class="contract-title"
>
{{ element.props.content }}
</h2>
<h3 v-else :style="titleStyle" class="contract-title">
{{ element.props.content }}
</h3>
</template>
<!-- 段落 -->
<template v-else-if="element.type === 'paragraph'">
<p :style="paragraphStyle" class="contract-paragraph">
{{ element.props.content }}
</p>
</template>
<!-- 富文本 -->
<template v-else-if="element.type === 'rich-text'">
<div
ref="richTextRef"
class="contract-rich-text"
:class="{ 'rich-text-editable': editable }"
:style="{
minHeight: `${element.props.minHeight}px`,
padding: element.props.padding
? `${element.props.padding}px`
: undefined,
border: element.props.showBorder
? `1px solid ${element.props.borderColor || 'var(--el-border-color)'}`
: undefined,
borderRadius: element.props.showBorder ? '4px' : undefined,
backgroundColor: element.props.backgroundColor || undefined,
}"
v-html="processRichTextContent"
></div>
</template>
<!-- 变量占位符 -->
<template v-else-if="element.type === 'variable'">
<span
:style="variableStyle"
class="contract-variable"
:class="{
'variable-filled': isVariableFilled(element.props.variableCode),
'variable-empty': !isVariableFilled(element.props.variableCode),
}"
>
{{ getVariableValue(element.props.variableCode) }}
</span>
</template>
<!-- 表格 -->
<template v-else-if="element.type === 'table'">
<ElTable
:data="element.tableData || []"
:border="element.props.bordered"
size="small"
class="contract-table"
:header-cell-style="{ backgroundColor: element.props.headerBgColor }"
>
<ElTableColumn
v-for="col in element.tableColumns"
:key="col.key"
:prop="col.key"
:label="col.title"
:width="col.width"
:align="col.align"
/>
</ElTable>
</template>
<!-- 签名区 -->
<template v-else-if="element.type === 'signature-zone'">
<div class="signature-zone-container" :style="signatureContainerStyle">
<div class="signature-zone-wrapper">
<!-- 标签始终显示在签名区上方 -->
<div
v-if="element.props.showLabel !== false"
class="signature-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel || element.props.label || '签名'
}}
</div>
<div class="signature-zone" :style="signatureStyle">
<div class="signature-content">
<ElIcon
:size="24"
class="mb-2 text-[var(--el-text-color-placeholder)]"
>
<Pencil />
</ElIcon>
<div class="text-xs text-[var(--el-text-color-placeholder)]">
点击签名
</div>
</div>
</div>
<div
v-if="element.signature?.showDate"
class="signature-date mt-2 text-xs text-[var(--el-text-color-secondary)]"
>
日期____________
</div>
</div>
</div>
</template>
<!-- 盖章区 -->
<template v-else-if="element.type === 'seal-zone'">
<div class="seal-zone-container" :style="signatureContainerStyle">
<div class="seal-zone-wrapper">
<!-- 标签始终显示在盖章区上方 -->
<div
v-if="element.props.showLabel !== false"
class="seal-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel || element.props.label || '盖章处'
}}
</div>
<div class="seal-zone" :style="signatureStyle">
<div class="seal-content">
<ElIcon
:size="32"
class="mb-2 text-[var(--el-text-color-placeholder)]"
>
<Circle />
</ElIcon>
<div class="text-xs text-[var(--el-text-color-placeholder)]">
点击盖章
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 日期区 -->
<template v-else-if="element.type === 'date-zone'">
<div class="date-zone-container" :style="signatureContainerStyle">
<div class="date-zone inline-flex items-center">
<ElIcon :size="16" class="mr-1 text-[var(--el-text-color-secondary)]">
<Calendar />
</ElIcon>
<span class="text-sm">{{ element.props.label }}</span>
<span
:class="{
'border-b border-[var(--el-text-color-primary)]':
element.props.showUnderline,
}"
class="inline-block min-w-[120px] text-center"
>
{{ element.props.format }}
</span>
</div>
</div>
</template>
<!-- 分割线 -->
<template v-else-if="element.type === 'divider'">
<ElDivider
:border-style="element.props.style"
:style="{
margin: `${element.props.margin}px 0`,
borderColor: element.props.color,
}"
/>
</template>
<!-- 分页符 -->
<template v-else-if="element.type === 'page-break'">
<div class="page-break">
<div class="page-break-line"></div>
<span class="page-break-text">分页符</span>
<div class="page-break-line"></div>
</div>
</template>
<!-- 图片 -->
<template v-else-if="element.type === 'image'">
<img
v-if="element.props.src"
:src="element.props.src"
:style="imageStyle"
class="contract-image"
alt="合同图片"
/>
<div
v-else
:style="{ ...imageStyle, height: '100px' }"
class="image-placeholder flex items-center justify-center rounded border border-dashed border-[var(--el-border-color)] bg-[var(--el-fill-color-light)]"
>
<span class="text-xs text-[var(--el-text-color-placeholder)]"
>图片占位</span
>
</div>
</template>
</div>
</template>
<style scoped>
.contract-title {
padding: 8px 0;
margin: 0;
}
.contract-paragraph {
padding: 4px 0;
margin: 0;
}
.contract-rich-text {
padding: 4px 0;
}
/* 富文本内容样式 */
.contract-rich-text :deep(h1) {
margin: 0.67em 0;
font-size: 2em;
font-weight: bold;
}
.contract-rich-text :deep(h2) {
margin: 0.83em 0;
font-size: 1.5em;
font-weight: bold;
}
.contract-rich-text :deep(h3) {
margin: 1em 0;
font-size: 1.17em;
font-weight: bold;
}
.contract-rich-text :deep(p) {
margin: 1em 0;
}
/* 空段落保持高度,确保空行正常显示 */
.contract-rich-text :deep(p:empty),
.contract-rich-text :deep(p:has(br:only-child)) {
min-height: 1em;
}
.contract-rich-text :deep(p:empty::before) {
content: '\00a0'; /* 不间断空格 */
}
.contract-rich-text :deep(strong),
.contract-rich-text :deep(b) {
font-weight: bold;
}
.contract-rich-text :deep(em),
.contract-rich-text :deep(i) {
font-style: italic;
}
.contract-rich-text :deep(u) {
text-decoration: underline;
}
.contract-rich-text :deep(s),
.contract-rich-text :deep(strike) {
text-decoration: line-through;
}
.contract-rich-text :deep(ul) {
padding-left: 2em;
margin: 1em 0;
list-style-type: disc;
}
.contract-rich-text :deep(ol) {
padding-left: 2em;
margin: 1em 0;
list-style-type: decimal;
}
.contract-rich-text :deep(li) {
margin: 0.5em 0;
}
.contract-rich-text :deep(blockquote) {
padding-left: 1em;
margin: 1em 0;
color: var(--el-text-color-secondary);
border-left: 3px solid var(--el-border-color);
}
.contract-rich-text :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
.contract-rich-text :deep(code) {
padding: 0.2em 0.4em;
font-family: monospace;
background-color: var(--el-fill-color-light);
border-radius: 3px;
}
.contract-rich-text :deep(pre) {
padding: 1em;
overflow-x: auto;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.contract-rich-text :deep(table) {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
}
.contract-rich-text :deep(th),
.contract-rich-text :deep(td) {
padding: 8px 12px;
text-align: left;
border: 1px solid var(--el-border-color);
}
.contract-rich-text :deep(th) {
font-weight: bold;
background-color: var(--el-fill-color-light);
}
.contract-rich-text :deep(img) {
max-width: 100%;
height: auto;
}
/* 图片对齐 - 通过 data-alignment 属性 */
.contract-rich-text :deep(img[data-alignment='center']) {
display: block !important;
margin-right: auto !important;
margin-left: auto !important;
}
.contract-rich-text :deep(img[data-alignment='left']) {
display: block !important;
margin-right: auto !important;
margin-left: 0 !important;
}
.contract-rich-text :deep(img[data-alignment='right']) {
display: block !important;
margin-right: 0 !important;
margin-left: auto !important;
}
/* 没有 data-alignment 的图片默认居中 */
.contract-rich-text :deep(img:not([data-alignment])) {
display: block;
margin-right: auto;
margin-left: auto;
}
.contract-rich-text :deep(hr) {
margin: 1em 0;
border: none;
border-top: 1px solid var(--el-border-color);
}
.contract-rich-text :deep(mark) {
padding: 0 0.2em;
background-color: #ff0;
}
/* 文本对齐 */
.contract-rich-text :deep([style*='text-align: center']),
.contract-rich-text :deep([style*='text-align:center']) {
text-align: center;
}
.contract-rich-text :deep([style*='text-align: right']),
.contract-rich-text :deep([style*='text-align:right']) {
text-align: right;
}
.contract-rich-text :deep([style*='text-align: justify']),
.contract-rich-text :deep([style*='text-align:justify']) {
text-align: justify;
}
.contract-variable {
padding: 0 4px;
}
.contract-variable.variable-filled {
font-weight: 500;
color: var(--el-text-color-primary);
}
.contract-variable.variable-empty {
color: var(--el-text-color-placeholder);
}
.contract-table {
width: 100%;
}
.signature-zone,
.seal-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.signature-content,
.seal-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.page-break {
display: flex;
align-items: center;
padding: 16px 0;
}
.page-break-line {
flex: 1;
height: 1px;
background: repeating-linear-gradient(
90deg,
var(--el-border-color) 0,
var(--el-border-color) 4px,
transparent 4px,
transparent 8px
);
}
.page-break-text {
padding: 0 12px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.contract-image {
max-width: 100%;
}
/* 富文本中的变量标签样式 - 添加下划线 */
.contract-rich-text :deep(.contract-variable-tag) {
display: inline-block;
padding: 0 6px;
padding-bottom: 2px;
margin: 0 2px;
font-family: monospace;
font-size: inherit;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-bottom: 2px solid var(--el-color-primary);
border-radius: 0;
}
/* 变量替换后的样式 - 添加下划线 */
.contract-rich-text :deep(.variable-replaced) {
padding-bottom: 1px;
font-weight: inherit;
color: inherit;
border-bottom: 1px solid currentcolor;
}
/* 富文本中可编辑变量样式 - 添加下划线 */
.contract-rich-text :deep(.rich-text-variable) {
display: inline-block;
padding: 2px 8px;
padding-bottom: 3px;
margin: 0 2px;
cursor: pointer;
border-bottom: 2px solid var(--el-color-primary);
border-radius: 3px 3px 0 0;
transition: all 0.2s;
}
.contract-rich-text :deep(.rich-text-variable.filled) {
color: var(--el-text-color-primary);
background: var(--el-color-success-light-9);
border-bottom: 2px solid var(--el-color-success);
}
.contract-rich-text :deep(.rich-text-variable.filled:hover) {
background: var(--el-color-success-light-8);
}
.contract-rich-text :deep(.rich-text-variable.empty) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-bottom: 2px dashed var(--el-color-primary);
}
.contract-rich-text :deep(.rich-text-variable.empty:hover) {
background: var(--el-color-primary-light-8);
}
</style>
@@ -0,0 +1,538 @@
<script setup lang="ts">
import type { ContractElement } from '../store/contractDesignStore';
import { computed, nextTick, ref } from 'vue';
import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from '@vben/icons';
import {
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElInput,
ElTooltip,
} from 'element-plus';
import RichTextEditor from '#/components/zq-form/rich-text-editor/rich-text-editor.vue';
import {
predefinedVariables,
useContractDesignStore,
} from '../store/contractDesignStore';
import ElementRenderer from './ElementRenderer.vue';
const props = defineProps<{
element: ContractElement;
index: number;
total: number;
}>();
const store = useContractDesignStore();
const isActive = computed(() => store.activeId === props.element.id);
// 编辑状态
const isEditing = ref(false);
const editingContent = ref('');
const inputRef = ref<InstanceType<typeof ElInput> | null>(null);
// 是否可编辑的元素类型
const isEditable = computed(() => {
return ['paragraph', 'rich-text', 'title'].includes(props.element.type);
});
// 富文本编辑内容
const richTextContent = ref('');
// 富文本编辑器实例引用
const richTextEditorRef = ref<any>(null);
// 可用变量列表(预定义 + 模板自定义)
const availableVariables = computed(() => {
const customVars = store.templateConfig.variables || [];
return [...predefinedVariables, ...customVars];
});
const handleSelect = (e: MouseEvent) => {
e.stopPropagation();
store.setActive(props.element.id);
};
// 双击进入编辑模式
const handleDoubleClick = (e: MouseEvent) => {
e.stopPropagation();
if (!isEditable.value) return;
isEditing.value = true;
editingContent.value = props.element.props.content || '';
// 富文本直接进入编辑模式
if (props.element.type === 'rich-text') {
richTextContent.value = props.element.props.content || '';
return;
}
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus();
}
});
};
// 保存富文本
const handleSaveRichText = () => {
// 优先使用组件暴露的 getHTML 方法获取最新内容
let content = richTextContent.value;
if (richTextEditorRef.value) {
// 尝试使用 getHTML 方法
const html = richTextEditorRef.value.getHTML?.();
if (html && html !== '<p></p>') {
content = html;
} else {
// 备用:尝试从编辑器实例获取
const editor = richTextEditorRef.value.getEditor?.();
if (editor) {
const editorHtml = editor.getHTML();
if (editorHtml && editorHtml !== '<p></p>') {
content = editorHtml;
}
}
}
}
// 如果获取到的内容为空或只有空段落,使用原始内容
if (!content || content === '<p></p>') {
content = props.element.props.content || '';
}
store.updateElementProps(props.element.id, {
content,
});
store.recordSnapshot();
isEditing.value = false;
};
// 富文本内容变化
const handleRichTextChange = (content: string) => {
richTextContent.value = content;
};
// 根据 code 获取变量名称
const getVariableName = (code: string) => {
const variable = availableVariables.value.find((v) => v.code === code);
return variable?.name || code;
};
// 插入变量到富文本
const handleInsertVariable = (variableCode: string) => {
// 获取编辑器实例并插入
const editor = richTextEditorRef.value?.getEditor?.();
const variableName = getVariableName(variableCode);
// 使用特殊格式:【变量名称|变量code】,便于后续解析
const variableText = `${variableName}|${variableCode}`;
if (editor) {
// 直接插入文本格式的变量标记
editor.chain().focus().insertContent(variableText).run();
} else {
// 备用方案:直接追加到内容末尾
richTextContent.value += variableText;
}
};
// 保存编辑
const handleSaveEdit = () => {
if (editingContent.value !== props.element.props.content) {
store.updateElementProps(props.element.id, {
content: editingContent.value,
});
store.recordSnapshot();
}
isEditing.value = false;
};
// 取消编辑
const handleCancelEdit = () => {
isEditing.value = false;
editingContent.value = props.element.props.content || '';
};
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && props.element.type === 'title') {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
handleCancelEdit();
}
};
const handleDelete = (e: MouseEvent) => {
e.stopPropagation();
store.deleteElement(props.element.id);
};
const handleCopy = (e: MouseEvent) => {
e.stopPropagation();
store.copyElement(props.element.id);
};
const handleMoveUp = (e: MouseEvent) => {
e.stopPropagation();
store.moveElement(props.element.id, 'up');
};
const handleMoveDown = (e: MouseEvent) => {
e.stopPropagation();
store.moveElement(props.element.id, 'down');
};
// 元素类型标签
const typeLabels: Record<string, string> = {
title: '标题',
paragraph: '段落',
'rich-text': '富文本',
variable: '变量',
table: '表格',
'signature-zone': '签名区',
'seal-zone': '盖章区',
'date-zone': '日期区',
divider: '分割线',
'page-break': '分页符',
image: '图片',
};
</script>
<template>
<div
class="element-wrapper group relative"
:class="{ 'is-active': isActive, 'is-editing': isEditing }"
@click="handleSelect"
@dblclick="handleDoubleClick"
>
<!-- 元素类型标签 -->
<div
v-if="isActive && !isEditing"
class="element-type-label absolute -top-5 left-0 rounded-t bg-[var(--el-color-primary)] px-2 py-0.5 text-xs text-white"
>
{{ typeLabels[element.type] || element.type }}
<span v-if="isEditable" class="ml-1 opacity-70">(双击编辑)</span>
</div>
<!-- 操作按钮 -->
<div
v-show="isActive && !isEditing"
class="element-actions absolute -right-1 -top-1 z-10 flex gap-1 rounded bg-[var(--el-bg-color)] p-1 shadow"
>
<ElTooltip content="上移" placement="top" :show-after="500">
<button
class="action-btn"
:disabled="index === 0"
@click="handleMoveUp"
>
<ElIcon :size="14"><ArrowUp /></ElIcon>
</button>
</ElTooltip>
<ElTooltip content="下移" placement="top" :show-after="500">
<button
class="action-btn"
:disabled="index === total - 1"
@click="handleMoveDown"
>
<ElIcon :size="14"><ArrowDown /></ElIcon>
</button>
</ElTooltip>
<ElTooltip content="复制" placement="top" :show-after="500">
<button class="action-btn" @click="handleCopy">
<ElIcon :size="14"><Copy /></ElIcon>
</button>
</ElTooltip>
<ElTooltip content="删除" placement="top" :show-after="500">
<button class="action-btn action-btn-danger" @click="handleDelete">
<ElIcon :size="14"><Trash2 /></ElIcon>
</button>
</ElTooltip>
</div>
<!-- 编辑模式 -->
<div v-if="isEditing" class="editing-content" @click.stop>
<!-- 标题编辑 -->
<ElInput
v-if="element.type === 'title'"
ref="inputRef"
v-model="editingContent"
placeholder="请输入标题"
:style="{
fontSize: `${element.props.fontSize}px`,
fontWeight: element.props.fontWeight,
textAlign: element.props.align,
}"
@blur="handleSaveEdit"
@keydown="handleKeydown"
/>
<!-- 段落编辑 -->
<ElInput
v-else-if="element.type === 'paragraph'"
ref="inputRef"
v-model="editingContent"
type="textarea"
:rows="4"
placeholder="请输入段落内容"
:autosize="{ minRows: 2, maxRows: 10 }"
@blur="handleSaveEdit"
@keydown="handleKeydown"
/>
<!-- 富文本编辑 -->
<div v-else-if="element.type === 'rich-text'" class="rich-text-editing">
<!-- 变量插入工具栏 -->
<div class="variable-toolbar">
<ElDropdown trigger="click" @command="handleInsertVariable">
<button class="variable-insert-btn">
<ElIcon :size="14"><Plus /></ElIcon>
<span>插入变量</span>
</button>
<template #dropdown>
<ElDropdownMenu>
<div class="variable-dropdown-header">预定义变量</div>
<ElDropdownItem
v-for="v in availableVariables.slice(0, 14)"
:key="v.code"
:command="v.code"
>
<span class="variable-name">{{ v.name }}</span>
<span class="variable-code">{{ v.code }}</span>
</ElDropdownItem>
<template v-if="availableVariables.length > 14">
<div class="variable-dropdown-header">自定义变量</div>
<ElDropdownItem
v-for="v in availableVariables.slice(14)"
:key="v.code"
:command="v.code"
>
<span class="variable-name">{{ v.name }}</span>
<span class="variable-code">{{ v.code }}</span>
</ElDropdownItem>
</template>
</ElDropdownMenu>
</template>
</ElDropdown>
<span class="variable-tip"
>提示插入的变量将在合同签署时自动替换为实际值</span
>
</div>
<RichTextEditor
ref="richTextEditorRef"
v-model="richTextContent"
:min-height="150"
:max-height="400"
placeholder="请输入内容..."
@change="handleRichTextChange"
/>
<div class="mt-2 flex justify-end gap-2">
<button class="edit-btn" @click="handleCancelEdit">取消</button>
<button class="edit-btn edit-btn-primary" @click="handleSaveRichText">
保存
</button>
</div>
</div>
<!-- 标题/段落的保存按钮 -->
<div
v-if="element.type !== 'rich-text'"
class="mt-2 flex justify-end gap-2"
>
<button class="edit-btn" @click="handleCancelEdit">取消</button>
<button class="edit-btn edit-btn-primary" @click="handleSaveEdit">
保存
</button>
</div>
</div>
<!-- 元素内容非编辑模式 -->
<div v-else class="element-content">
<ElementRenderer :element="element" :is-design="true" />
</div>
<!-- 拖拽手柄 -->
<div
v-show="isActive && !isEditing"
class="drag-handle absolute left-0 top-1/2 -translate-x-full -translate-y-1/2 cursor-move rounded-l bg-[var(--el-color-primary)] px-1 py-2 text-white opacity-80"
>
<div class="flex flex-col gap-0.5">
<div class="h-0.5 w-2 rounded bg-white"></div>
<div class="h-0.5 w-2 rounded bg-white"></div>
<div class="h-0.5 w-2 rounded bg-white"></div>
</div>
</div>
</div>
</template>
<style scoped>
.element-wrapper {
position: relative;
padding: 8px;
margin: 4px 0;
cursor: pointer;
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s;
}
.element-wrapper:hover {
background-color: var(--el-fill-color-lighter);
border-color: var(--el-border-color);
}
.element-wrapper.is-active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.action-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.action-btn-danger:hover:not(:disabled) {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
.element-content {
min-height: 20px;
}
.element-wrapper.is-editing {
background-color: var(--el-bg-color);
border-color: var(--el-color-primary);
}
.editing-content {
padding: 4px;
}
.edit-btn {
padding: 4px 12px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
}
.edit-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.edit-btn-primary {
color: white;
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.edit-btn-primary:hover {
color: white;
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
.rich-text-editing {
overflow: hidden;
border: 1px solid var(--el-color-primary);
border-radius: 4px;
}
.rich-text-editing :deep(.rich-text-editor) {
border: none;
}
/* 变量插入工具栏 */
.variable-toolbar {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-light);
}
.variable-insert-btn {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
color: var(--el-color-primary);
cursor: pointer;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary);
border-radius: 4px;
transition: all 0.2s;
}
.variable-insert-btn:hover {
color: white;
background: var(--el-color-primary);
}
.variable-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.variable-dropdown-header {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
.variable-name {
margin-right: 8px;
}
.variable-code {
font-family: monospace;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
/* 变量标签样式 */
:deep(.contract-variable-tag) {
display: inline-block;
padding: 0 6px;
margin: 0 2px;
font-family: monospace;
font-size: 12px;
color: var(--el-color-primary);
cursor: default;
user-select: none;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 3px;
}
</style>
@@ -0,0 +1,971 @@
<script setup lang="ts">
import type {
ContractMaterial,
ContractTemplateConfig,
} from '../store/contractDesignStore';
import { computed, ref } from 'vue';
import {
AlignLeft,
Calendar,
Circle,
Code,
FileText,
Heading1,
Image,
List,
Minus,
Pencil,
Search,
Table,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElIcon,
ElInput,
ElMessage,
ElScrollbar,
ElTabPane,
ElTabs,
} from 'element-plus';
import draggable from 'vuedraggable';
import {
contractMaterials,
useContractDesignStore,
} from '../store/contractDesignStore';
const store = useContractDesignStore();
// 当前 Tab
const activeTab = ref('elements');
const searchKeyword = ref('');
const activeGroups = ref(['text', 'variable', 'signature', 'layout']);
// 图标映射
const iconMap: Record<string, any> = {
Heading1,
AlignLeft,
FileText,
Code,
Table,
Pencil,
Circle,
Calendar,
Minus,
Image,
};
const getIcon = (iconName: string) => {
return iconMap[iconName] || FileText;
};
// 分组材料
const groupedMaterials = computed(() => {
const groups: Record<string, ContractMaterial[]> = {
text: [],
variable: [],
signature: [],
layout: [],
};
contractMaterials.forEach((material) => {
if (groups[material.category]) {
groups[material.category].push(material);
}
});
return groups;
});
// 过滤后的材料
const filteredMaterials = computed(() => {
if (!searchKeyword.value) return groupedMaterials.value;
const keyword = searchKeyword.value.toLowerCase();
const result: Record<string, ContractMaterial[]> = {
text: [],
variable: [],
signature: [],
layout: [],
};
Object.entries(groupedMaterials.value).forEach(([category, materials]) => {
result[category] = materials.filter(
(m) =>
m.title.toLowerCase().includes(keyword) ||
m.type.toLowerCase().includes(keyword),
);
});
return result;
});
const getGroupLabel = (group: string) => {
const labels: Record<string, string> = {
text: $t('contract-design.textElements'),
variable: $t('contract-design.variableElements'),
signature: $t('contract-design.signatureElements'),
layout: $t('contract-design.layoutElements'),
};
return labels[group] || group;
};
const toggleGroup = (group: string) => {
const index = activeGroups.value.indexOf(group);
if (index === -1) {
activeGroups.value.push(group);
} else {
activeGroups.value.splice(index, 1);
}
};
const onDragStart = () => store.setDragging(true);
const onDragEnd = () => store.setDragging(false);
// 点击添加元素
const handleClickAdd = (material: ContractMaterial) => {
store.addElement(material);
};
// 克隆函数
const cloneMaterial = (material: ContractMaterial) => {
return store.cloneElement(material);
};
// 示例模板列表
interface TemplateItem {
id: string;
name: string;
description: string;
category: string;
config: ContractTemplateConfig;
}
const getTemplateCategoryLabel = (key: string) => {
const labels: Record<string, string> = {
common: $t('contract-design.commonContracts'),
hr: $t('contract-design.hrContracts'),
business: $t('contract-design.businessContracts'),
};
return labels[key] || key;
};
// 获取元素标题的国际化文本
const getMaterialTitle = (type: string): string => {
const titleMap: Record<string, string> = {
title: $t('contract-design.contractTitle'),
paragraph: $t('contract-design.paragraphText'),
'rich-text': $t('contract-design.richText'),
variable: $t('contract-design.variablePlaceholder'),
table: $t('contract-design.table'),
'signature-zone': $t('contract-design.signatureZone'),
'seal-zone': $t('contract-design.sealZone'),
'date-zone': $t('contract-design.dateZone'),
divider: $t('contract-design.divider'),
'page-break': $t('contract-design.pageBreak'),
image: $t('contract-design.image'),
};
return titleMap[type] || type;
};
const templateCategories = computed(() => [
{ key: 'common', label: getTemplateCategoryLabel('common') },
{ key: 'hr', label: getTemplateCategoryLabel('hr') },
{ key: 'business', label: getTemplateCategoryLabel('business') },
]);
const sampleTemplates: TemplateItem[] = [
{
id: 'purchase',
name: '采购合同',
description: '标准采购合同模板',
category: 'common',
config: {
id: 'purchase-template',
name: '采购合同',
code: 'PURCHASE_CONTRACT',
category: '常用合同',
description: '标准采购合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '采购合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">合同编号:{{contract_no}}</p>
<p><br></p>
<p><strong>甲方(采购方):</strong>{{party_a_name}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><strong>联系电话:</strong>{{party_a_phone}}</p>
<p><br></p>
<p><strong>乙方(供应方):</strong>{{party_b_name}}</p>
<p><strong>地址:</strong>{{party_b_address}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国民法典》及相关法律法规,甲乙双方本着平等互利、诚实信用的原则,经友好协商,就甲方向乙方采购货物事宜达成如下协议:</p>
<p><br></p>
<h3>第一条 采购货物</h3>
<p>甲方向乙方采购货物,详见附件清单。</p>
<p><br></p>
<h3>第二条 合同金额</h3>
<p>本合同总金额为人民币{{contract_amount}}元。</p>
<p><br></p>
<h3>第三条 交货时间及地点</h3>
<p>1. 交货时间:{{start_date}}</p>
<p>2. 交货地点:{{sign_location}}</p>
<p><br></p>
<h3>第四条 付款方式</h3>
<p>合同签订后{{payment_days}}日内支付全款。</p>
<p><br></p>
<h3>第五条 验收标准</h3>
<p>货物到达后,甲方应在7日内完成验收。如有质量问题,应在验收期内书面通知乙方。</p>
<p><br></p>
<h3>第六条 违约责任</h3>
<p>任何一方违反本合同约定,应承担违约责任,赔偿对方因此遭受的损失。</p>
<p><br></p>
<h3>第七条 争议解决</h3>
<p>本合同在履行过程中发生争议,双方应协商解决;协商不成的,可向甲方所在地人民法院提起诉讼。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方签章' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方签章' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'payment_days',
name: '付款天数',
type: 'number',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(采购方)', required: true },
{ type: 'party_b', label: '乙方(供应方)', required: true },
],
},
},
{
id: 'labor',
name: '劳动合同',
description: '标准劳动合同模板',
category: 'hr',
config: {
id: 'labor-template',
name: '劳动合同',
code: 'LABOR_CONTRACT',
category: '人事合同',
description: '标准劳动合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '劳动合同书',
level: 1,
align: 'center',
fontSize: 26,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: center;"><br></p>
<p><strong>甲方(用人单位):</strong>{{party_a_name}}</p>
<p><strong>法定代表人:</strong>{{party_a_legal_person}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><br></p>
<p><strong>乙方(劳动者):</strong>{{party_b_name}}</p>
<p><strong>身份证号码:</strong>{{employee_id_card}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国劳动法》、《中华人民共和国劳动合同法》及相关法律法规,甲乙双方本着平等自愿、协商一致的原则,签订本劳动合同。</p>
<p><br></p>
<h3>第一条 合同期限</h3>
<p>本合同为{{contract_term_type}}期限劳动合同。</p>
<p>合同期限自{{start_date}}起至{{end_date}}止。</p>
<p><br></p>
<h3>第二条 工作内容和工作地点</h3>
<p>1. 乙方同意根据甲方工作需要,担任{{job_position}}岗位工作。</p>
<p>2. 工作地点:{{work_location}}</p>
<p><br></p>
<h3>第三条 工作时间和休息休假</h3>
<p>甲方安排乙方执行标准工时制度。</p>
<p><br></p>
<h3>第四条 劳动报酬</h3>
<p>乙方月工资为人民币{{monthly_salary}}元。</p>
<p><br></p>
<h3>第五条 社会保险和福利待遇</h3>
<p>甲方依法为乙方缴纳社会保险费,乙方应缴纳的部分由甲方从乙方工资中代扣代缴。</p>
<p><br></p>
<h3>第六条 劳动保护和劳动条件</h3>
<p>甲方为乙方提供符合国家规定的劳动安全卫生条件和必要的劳动防护用品。</p>
<p><br></p>
<h3>第七条 合同的变更、解除和终止</h3>
<p>按照《劳动合同法》相关规定执行。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方(盖章)' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方(签名)' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'employee_id_card',
name: '员工身份证号',
type: 'text',
required: true,
},
{
code: 'contract_term_type',
name: '合同期限类型',
type: 'text',
required: true,
},
{
code: 'job_position',
name: '工作岗位',
type: 'text',
required: true,
},
{
code: 'work_location',
name: '工作地点',
type: 'text',
required: true,
},
{
code: 'monthly_salary',
name: '月工资',
type: 'money',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(用人单位)', required: true },
{ type: 'party_b', label: '乙方(劳动者)', required: true },
],
},
},
{
id: 'nda',
name: '保密协议',
description: '员工保密协议模板',
category: 'hr',
config: {
id: 'nda-template',
name: '保密协议',
code: 'NDA_CONTRACT',
category: '人事合同',
description: '员工保密协议模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '保密协议',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">协议编号:{{contract_no}}</p>
<p><br></p>
<p><strong>甲方:</strong>{{party_a_name}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><br></p>
<p><strong>乙方:</strong>{{party_b_name}}</p>
<p><strong>身份证号:</strong>{{employee_id_card}}</p>
<p><br></p>
<p>鉴于乙方在甲方任职期间,将接触或知悉甲方的商业秘密和保密信息,为保护甲方的合法权益,双方本着平等自愿、诚实信用的原则,签订本协议。</p>
<p><br></p>
<h3>第一条 保密信息的范围</h3>
<p>本协议所称保密信息包括但不限于:技术信息、经营信息及甲方明确要求保密的其他信息。</p>
<p><br></p>
<h3>第二条 保密义务</h3>
<p>1. 乙方应对保密信息严格保密,未经甲方书面同意,不得向任何第三方披露。</p>
<p>2. 乙方不得将保密信息用于本职工作以外的任何目的。</p>
<p>3. 乙方离职时,应将所有保密信息及载体归还甲方。</p>
<p><br></p>
<h3>第三条 保密期限</h3>
<p>乙方的保密义务自本协议签订之日起生效,保密期限为{{confidentiality_years}}年。</p>
<p><br></p>
<h3>第四条 违约责任</h3>
<p>乙方违反本协议约定的,应向甲方支付违约金人民币{{penalty_amount}}元,并赔偿甲方因此遭受的全部损失。</p>
<p><br></p>
<p>本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方(盖章)' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方(签名)' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'employee_id_card',
name: '员工身份证号',
type: 'text',
required: true,
},
{
code: 'confidentiality_years',
name: '保密期限(年)',
type: 'number',
required: true,
},
{
code: 'penalty_amount',
name: '违约金金额',
type: 'money',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方', required: true },
{ type: 'party_b', label: '乙方', required: true },
],
},
},
{
id: 'service',
name: '服务合同',
description: '通用服务合同模板',
category: 'business',
config: {
id: 'service-template',
name: '服务合同',
code: 'SERVICE_CONTRACT',
category: '商务合同',
description: '通用服务合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '服务合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">合同编号:{{contract_no}}</p>
<p><br></p>
<p><strong>甲方(委托方):</strong>{{party_a_name}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><strong>联系电话:</strong>{{party_a_phone}}</p>
<p><br></p>
<p><strong>乙方(服务方):</strong>{{party_b_name}}</p>
<p><strong>地址:</strong>{{party_b_address}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国民法典》及相关法律法规,甲乙双方本着平等互利、诚实信用的原则,就甲方委托乙方提供服务事宜达成如下协议:</p>
<p><br></p>
<h3>第一条 服务内容</h3>
<p>乙方为甲方提供以下服务:{{service_content}}</p>
<p><br></p>
<h3>第二条 服务期限</h3>
<p>服务期限自{{start_date}}起至{{end_date}}止。</p>
<p><br></p>
<h3>第三条 服务费用及支付方式</h3>
<p>1. 服务费用总计人民币{{contract_amount}}元。</p>
<p>2. 支付方式:{{payment_method}}</p>
<p><br></p>
<h3>第四条 双方权利义务</h3>
<p><strong>甲方权利义务:</strong>按时支付服务费用;提供乙方完成服务所需的必要配合。</p>
<p><strong>乙方权利义务:</strong>按照约定提供服务;保证服务质量符合约定标准。</p>
<p><br></p>
<h3>第五条 违约责任</h3>
<p>任何一方违反本合同约定,应承担违约责任,赔偿对方因此遭受的损失。</p>
<p><br></p>
<h3>第六条 争议解决</h3>
<p>本合同在履行过程中发生争议,双方应协商解决;协商不成的,可向甲方所在地人民法院提起诉讼。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方签章' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方签章' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'service_content',
name: '服务内容',
type: 'text',
required: true,
},
{
code: 'payment_method',
name: '支付方式',
type: 'text',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(委托方)', required: true },
{ type: 'party_b', label: '乙方(服务方)', required: true },
],
},
},
{
id: 'rental',
name: '租赁合同',
description: '房屋/设备租赁合同模板',
category: 'business',
config: {
id: 'rental-template',
name: '租赁合同',
code: 'RENTAL_CONTRACT',
category: '商务合同',
description: '租赁合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '租赁合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">合同编号:{{contract_no}}</p>
<p><br></p>
<p><strong>出租方(甲方):</strong>{{party_a_name}}</p>
<p><strong>联系电话:</strong>{{party_a_phone}}</p>
<p><br></p>
<p><strong>承租方(乙方):</strong>{{party_b_name}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国民法典》及相关法律法规,甲乙双方在平等、自愿的基础上,就租赁事宜达成如下协议:</p>
<p><br></p>
<h3>第一条 租赁物</h3>
<p>甲方将位于{{rental_location}}的{{rental_property}}出租给乙方使用。</p>
<p><br></p>
<h3>第二条 租赁期限</h3>
<p>租赁期限自{{start_date}}起至{{end_date}}止。</p>
<p><br></p>
<h3>第三条 租金及支付方式</h3>
<p>1. 租金为人民币{{monthly_rent}}元/月。</p>
<p>2. 支付方式:{{payment_cycle}}</p>
<p><br></p>
<h3>第四条 押金</h3>
<p>乙方应于签订本合同时向甲方支付押金人民币{{deposit_amount}}元。租赁期满,乙方无违约行为且租赁物完好的,甲方应在15日内退还押金。</p>
<p><br></p>
<h3>第五条 租赁物的使用和维护</h3>
<p>1. 乙方应按约定用途使用租赁物,不得擅自改变用途。</p>
<p>2. 乙方应妥善保管和使用租赁物,如有损坏应照价赔偿。</p>
<p><br></p>
<h3>第六条 违约责任</h3>
<p>任何一方违反本合同约定,应承担违约责任,赔偿对方因此遭受的损失。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方(出租方)' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方(承租方)' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'rental_location',
name: '租赁物地址',
type: 'text',
required: true,
},
{
code: 'rental_property',
name: '租赁物名称',
type: 'text',
required: true,
},
{ code: 'monthly_rent', name: '月租金', type: 'money', required: true },
{
code: 'payment_cycle',
name: '支付周期',
type: 'text',
required: true,
},
{
code: 'deposit_amount',
name: '押金金额',
type: 'money',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(出租方)', required: true },
{ type: 'party_b', label: '乙方(承租方)', required: true },
],
},
},
];
// 按分类过滤模板
const getTemplatesByCategory = (category: string) => {
return sampleTemplates.filter((t) => t.category === category);
};
// 加载模板
const loadTemplate = (template: TemplateItem) => {
// 深拷贝配置,避免修改原始数据
const config = JSON.parse(JSON.stringify(template.config));
store.importConfig(JSON.stringify(config));
ElMessage.success(`已加载模板:${template.name}`);
activeTab.value = 'elements';
};
</script>
<template>
<div
class="material-panel flex h-full w-64 flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- Tab 切换 -->
<ElTabs v-model="activeTab" class="material-tabs flex-1">
<!-- 合同元素 Tab -->
<ElTabPane
:label="$t('contract-design.contractElements')"
name="elements"
class="h-full"
>
<div class="flex h-full flex-col">
<!-- 搜索框 -->
<div class="flex-shrink-0 p-3">
<ElInput
v-model="searchKeyword"
:placeholder="$t('contract-design.searchElements')"
size="small"
clearable
:prefix-icon="Search"
/>
</div>
<!-- 元素列表 -->
<ElScrollbar class="flex-1">
<div class="px-3 pb-3">
<template
v-for="(materials, category) in filteredMaterials"
:key="category"
>
<div v-if="materials.length > 0" class="component-group mb-4">
<!-- 分组标题 -->
<div
class="group-title mb-2 flex cursor-pointer select-none items-center justify-between text-xs text-[var(--el-text-color-regular)] hover:text-[var(--el-color-primary)]"
@click="toggleGroup(category)"
>
<span class="font-bold">{{ getGroupLabel(category) }}</span>
<ElIcon
class="h-4 w-4 transition-transform"
:class="{
'rotate-180': !activeGroups.includes(category),
}"
>
<Minus />
</ElIcon>
</div>
<!-- 元素网格 -->
<div v-show="activeGroups.includes(category)">
<draggable
:list="materials"
:group="{
name: 'contract-design',
pull: 'clone',
put: false,
}"
:sort="false"
:clone="cloneMaterial"
item-key="type"
class="grid grid-cols-2 gap-2"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="component-item flex cursor-move flex-col items-center justify-center rounded border border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] p-3 transition-colors hover:border-[var(--el-color-primary)] hover:text-[var(--el-color-primary)]"
@click.stop="handleClickAdd(element)"
>
<ElIcon class="mb-1.5" :size="20">
<component :is="getIcon(element.icon)" />
</ElIcon>
<span class="text-xs">{{
getMaterialTitle(element.type)
}}</span>
</div>
</template>
</draggable>
</div>
</div>
</template>
</div>
</ElScrollbar>
<!-- 底部提示 -->
<div
class="flex-shrink-0 border-t border-[var(--el-border-color)] px-3 py-2"
>
<div
class="text-center text-xs text-[var(--el-text-color-placeholder)]"
>
<List class="mr-1 inline-block h-3 w-3" />
{{ $t('contract-design.dragOrClickAddElements') }}
</div>
</div>
</div>
</ElTabPane>
<!-- 示例模板 Tab -->
<ElTabPane
:label="$t('contract-design.sampleTemplates')"
name="templates"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-3">
<div class="mb-3 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.selectTemplateToStart') }}
</div>
<template v-for="cat in templateCategories" :key="cat.key">
<div class="mb-4">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ cat.label }}
</div>
<div class="space-y-2">
<div
v-for="template in getTemplatesByCategory(cat.key)"
:key="template.id"
class="template-item cursor-pointer rounded border border-[var(--el-border-color)] p-3 transition-all hover:border-[var(--el-color-primary)] hover:bg-[var(--el-color-primary-light-9)]"
@click="loadTemplate(template)"
>
<div
class="mb-1 text-sm font-medium text-[var(--el-text-color-primary)]"
>
{{ template.name }}
</div>
<div class="text-xs text-[var(--el-text-color-secondary)]">
{{ template.description }}
</div>
</div>
</div>
</div>
</template>
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</template>
<style scoped>
.component-item {
min-height: 60px;
}
.material-panel {
overflow: hidden;
}
.material-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
:deep(.el-tabs__header) {
padding: 0 12px;
margin: 0;
border-bottom: 1px solid var(--el-border-color);
}
:deep(.el-tabs__content) {
flex: 1;
height: 0;
overflow: hidden;
}
:deep(.el-tab-pane) {
height: 100%;
}
.template-item:active {
transform: scale(0.98);
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { Download, Eye } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElIcon,
ElMessage,
ElScrollbar,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import { useContractDesignStore } from '../store/contractDesignStore';
import { exportPagesToPdf } from '../utils/exportPdf';
import ElementRenderer from './ElementRenderer.vue';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
}>();
const store = useContractDesignStore();
const { templateConfig } = storeToRefs(store);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 导出中状态
const exporting = ref(false);
// 页面元素引用数组
const pageRefs = ref<HTMLElement[]>([]);
// 隐藏的测量容器引用
const measureContainerRef = ref<HTMLElement | null>(null);
// 页面尺寸映射
const pageSizeMap = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
// 页面数量
const pageCount = ref(1);
// 单页样式(完整页面,包含边距)
const pageStyle = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return {
width: `${size.width}px`,
height: `${size.height}px`,
padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 内容区域高度(不含边距)
const contentHeight = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return size.height - margin.top - margin.bottom;
});
// 计算页数
const calculatePages = () => {
nextTick(() => {
if (!measureContainerRef.value) return;
const totalHeight = measureContainerRef.value.scrollHeight;
const pageContentHeight = contentHeight.value;
pageCount.value = Math.max(1, Math.ceil(totalHeight / pageContentHeight));
});
};
// 设置页面引用
const setPageRef = (el: any, index: number) => {
if (el) {
pageRefs.value[index] = el;
}
};
// 监听弹窗打开时计算页数
watch(dialogVisible, (val) => {
if (val) {
pageRefs.value = [];
nextTick(() => {
calculatePages();
});
}
});
// 导出 PDF
const handleExportPdf = async () => {
if (pageRefs.value.length === 0) {
ElMessage.error($t('contract-design.exportFailed'));
return;
}
exporting.value = true;
try {
// 获取所有页面元素
const pages = pageRefs.value.filter(Boolean);
await exportPagesToPdf(pages, {
filename: `${templateConfig.value.name || $t('contract-design.contract')}-${Date.now()}.pdf`,
pageSize: templateConfig.value.pageSize,
});
ElMessage.success($t('contract-design.exportSuccess'));
} catch (error) {
console.error('Export PDF failed:', error);
ElMessage.error($t('contract-design.exportFailed'));
} finally {
exporting.value = false;
}
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('contract-design.contractPreview')"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<template #header>
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<Eye />
</ElIcon>
<span
>{{ $t('contract-design.contractPreview') }} -
{{ templateConfig.name }}</span
>
<span
v-if="pageCount > 1"
class="ml-2 rounded bg-[var(--el-color-primary-light-9)] px-2 py-0.5 text-xs text-[var(--el-color-primary)]"
>
{{ $t('contract-design.totalPages', { count: pageCount }) }}
</span>
</div>
</template>
<ElScrollbar max-height="70vh">
<div class="preview-pages-container bg-[var(--el-fill-color-light)] p-6">
<!-- 隐藏的测量容器用于计算总高度 -->
<div
ref="measureContainerRef"
class="measure-container"
:style="{
width: `${pageSizeMap[templateConfig.pageSize].width - templateConfig.pageMargin.left - templateConfig.pageMargin.right}px`,
}"
>
<div
v-for="element in templateConfig.elements"
:key="`measure-${element.id}`"
class="preview-element"
>
<ElementRenderer :element="element" :is-design="false" />
</div>
</div>
<!-- 多页显示 -->
<div class="preview-pages flex flex-col items-center gap-6">
<template v-if="templateConfig.elements.length > 0">
<div
v-for="page in pageCount"
:key="page"
class="preview-page relative"
>
<!-- 页码标签 -->
<div class="page-number">
{{
$t('contract-design.pageLabel', {
current: page,
total: pageCount,
})
}}
</div>
<!-- 页面内容完整的一页包含边距 -->
<div
:ref="(el) => setPageRef(el, page - 1)"
class="preview-paper bg-[var(--el-bg-color)] shadow-lg"
:style="pageStyle"
>
<div class="preview-content-wrapper">
<div
class="preview-content"
:style="{
marginTop: `-${(page - 1) * contentHeight}px`,
}"
>
<div
v-for="element in templateConfig.elements"
:key="`page-${page}-${element.id}`"
class="preview-element"
>
<ElementRenderer :element="element" :is-design="false" />
</div>
</div>
</div>
</div>
</div>
</template>
<div
v-else
:ref="(el) => setPageRef(el, 0)"
class="preview-paper flex items-center justify-center bg-[var(--el-bg-color)] shadow-lg"
:style="pageStyle"
>
<span class="text-[var(--el-text-color-placeholder)]">{{
$t('contract-design.noContent')
}}</span>
</div>
</div>
</div>
</ElScrollbar>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('contract-design.close') }}
</ElButton>
<ElButton type="primary" :loading="exporting" @click="handleExportPdf">
<ElIcon v-if="!exporting" class="mr-1"><Download /></ElIcon>
{{
exporting
? $t('contract-design.exporting')
: $t('contract-design.exportPdf')
}}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.preview-element {
margin-bottom: 4px;
}
.preview-page {
position: relative;
}
.preview-paper {
box-sizing: border-box;
overflow: hidden;
}
.preview-content-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
}
.page-number {
position: absolute;
top: -24px;
right: 0;
padding: 2px 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-bg-color);
border-radius: 4px;
}
/* 隐藏测量容器 */
.measure-container {
position: absolute;
top: 0;
left: -9999px;
visibility: hidden;
}
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import type { UploadFile } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { Check, Pencil, Upload, X } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElIcon,
ElMessage,
ElTabPane,
ElTabs,
ElUpload,
} from 'element-plus';
import SignaturePad from './SignaturePad.vue';
const props = defineProps<{
height?: number;
partyLabel?: string;
title?: string;
visible: boolean;
width?: number;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'confirm', signatureData: string): void;
(e: 'cancel'): void;
}>();
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 当前选中的 Tab
const activeTab = ref('draw');
// 签名板引用
const signaturePadRef = ref<InstanceType<typeof SignaturePad> | null>(null);
// 手写签名数据
const drawSignature = ref('');
// 上传的签名图片
const uploadedSignature = ref('');
// 重置状态
const resetState = () => {
activeTab.value = 'draw';
drawSignature.value = '';
uploadedSignature.value = '';
};
// 监听弹窗打开
watch(dialogVisible, (val) => {
if (val) {
resetState();
}
});
// 处理图片上传
const handleUploadChange = (file: UploadFile) => {
if (!file.raw) return;
// 检查文件类型
const isImage = file.raw.type.startsWith('image/');
if (!isImage) {
ElMessage.error($t('contract-design.uploadImageFileOnly'));
return;
}
// 检查文件大小(最大 2MB
const isLt2M = file.raw.size / 1024 / 1024 < 2;
if (!isLt2M) {
ElMessage.error($t('contract-design.imageSizeExceeded'));
return;
}
// 读取图片为 base64
const reader = new FileReader();
reader.addEventListener('load', (e) => {
uploadedSignature.value = e.target?.result as string;
});
reader.readAsDataURL(file.raw);
};
// 确认签名
const handleConfirm = () => {
let signatureData = '';
if (activeTab.value === 'draw') {
// 手写签名
if (signaturePadRef.value?.isEmpty()) {
ElMessage.warning($t('contract-design.pleaseSignFirst'));
return;
}
signatureData = signaturePadRef.value?.getSignatureData() || '';
} else {
// 上传签名
if (!uploadedSignature.value) {
ElMessage.warning($t('contract-design.pleaseUploadSignatureImage'));
return;
}
signatureData = uploadedSignature.value;
}
emit('confirm', signatureData);
dialogVisible.value = false;
};
// 取消
const handleCancel = () => {
emit('cancel');
dialogVisible.value = false;
};
// 清空手写签名
const clearDrawSignature = () => {
signaturePadRef.value?.clear();
drawSignature.value = '';
};
// 清空上传签名
const clearUploadSignature = () => {
uploadedSignature.value = '';
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="title || $t('contract-design.signature')"
width="500px"
:close-on-click-modal="false"
destroy-on-close
>
<template #header>
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<Pencil />
</ElIcon>
<span>{{
partyLabel
? `${partyLabel} - ${$t('contract-design.signature')}`
: $t('contract-design.signature')
}}</span>
</div>
</template>
<ElTabs v-model="activeTab" class="signature-tabs">
<!-- 手写签名 -->
<ElTabPane name="draw">
<template #label>
<div class="flex items-center gap-1">
<ElIcon :size="14"><Pencil /></ElIcon>
<span>{{ $t('contract-design.drawSignature') }}</span>
</div>
</template>
<div class="flex flex-col items-center">
<SignaturePad
ref="signaturePadRef"
v-model="drawSignature"
:width="props.width || 400"
:height="props.height || 150"
/>
</div>
</ElTabPane>
<!-- 上传签名 -->
<ElTabPane name="upload">
<template #label>
<div class="flex items-center gap-1">
<ElIcon :size="14"><Upload /></ElIcon>
<span>{{ $t('contract-design.uploadSignature') }}</span>
</div>
</template>
<div class="flex flex-col items-center">
<div v-if="uploadedSignature" class="uploaded-preview relative mb-4">
<img
:src="uploadedSignature"
:alt="$t('contract-design.signaturePreview')"
class="max-h-[150px] max-w-[400px] rounded border border-[var(--el-border-color)]"
/>
<ElButton
type="danger"
size="small"
circle
class="absolute -right-2 -top-2"
@click="clearUploadSignature"
>
<ElIcon><X /></ElIcon>
</ElButton>
</div>
<ElUpload
v-else
class="signature-uploader"
:show-file-list="false"
:auto-upload="false"
accept="image/*"
@change="handleUploadChange"
>
<div
class="upload-area flex h-[150px] w-[400px] flex-col items-center justify-center rounded border-2 border-dashed border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] transition-colors hover:border-[var(--el-color-primary)]"
>
<ElIcon
:size="32"
class="mb-2 text-[var(--el-text-color-placeholder)]"
>
<Upload />
</ElIcon>
<span class="text-sm text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.clickOrDragToUpload') }}
</span>
<span
class="mt-1 text-xs text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.supportedFormatsAndSize') }}
</span>
</div>
</ElUpload>
</div>
</ElTabPane>
</ElTabs>
<template #footer>
<ElButton @click="handleCancel">
<ElIcon class="mr-1"><X /></ElIcon>
{{ $t('contract-design.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleConfirm">
<ElIcon class="mr-1"><Check /></ElIcon>
{{ $t('contract-design.confirmSignature') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.signature-tabs {
margin-top: -10px;
}
:deep(.el-tabs__content) {
padding-top: 16px;
}
</style>
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { Eraser, RotateCcw } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElIcon } from 'element-plus';
const props = withDefaults(
defineProps<{
backgroundColor?: string;
disabled?: boolean;
height?: number;
lineColor?: string;
lineWidth?: number;
modelValue?: string;
width?: number;
}>(),
{
width: 400,
height: 200,
lineWidth: 2,
lineColor: '#000000',
backgroundColor: '#ffffff',
disabled: false,
modelValue: '',
},
);
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
}>();
const canvasRef = ref<HTMLCanvasElement | null>(null);
const isDrawing = ref(false);
const hasSignature = ref(false);
let ctx: CanvasRenderingContext2D | null = null;
let lastX = 0;
let lastY = 0;
// 初始化画布
const initCanvas = () => {
if (!canvasRef.value) return;
ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
// 设置画布大小
canvasRef.value.width = props.width;
canvasRef.value.height = props.height;
// 设置背景
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, props.width, props.height);
// 设置画笔样式
ctx.strokeStyle = props.lineColor;
ctx.lineWidth = props.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 如果有初始值,加载图片
if (props.modelValue) {
loadImage(props.modelValue);
}
};
// 加载图片
const loadImage = (dataUrl: string) => {
if (!ctx || !canvasRef.value) return;
const img = new Image();
img.addEventListener('load', () => {
ctx!.fillStyle = props.backgroundColor;
ctx!.fillRect(0, 0, props.width, props.height);
ctx!.drawImage(img, 0, 0);
hasSignature.value = true;
});
img.src = dataUrl;
};
// 获取坐标
const getCoordinates = (e: MouseEvent | TouchEvent) => {
if (!canvasRef.value) return { x: 0, y: 0 };
const rect = canvasRef.value.getBoundingClientRect();
const scaleX = canvasRef.value.width / rect.width;
const scaleY = canvasRef.value.height / rect.height;
if ('touches' in e) {
const touch = e.touches[0];
return {
x: (touch!.clientX - rect.left) * scaleX,
y: (touch!.clientY - rect.top) * scaleY,
};
}
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
};
// 开始绘制
const startDrawing = (e: MouseEvent | TouchEvent) => {
if (props.disabled || !ctx) return;
e.preventDefault();
isDrawing.value = true;
const { x, y } = getCoordinates(e);
lastX = x;
lastY = y;
// 开始新路径
ctx.beginPath();
ctx.moveTo(x, y);
};
// 绘制中
const draw = (e: MouseEvent | TouchEvent) => {
if (!isDrawing.value || props.disabled || !ctx) return;
e.preventDefault();
const { x, y } = getCoordinates(e);
ctx.lineTo(x, y);
ctx.stroke();
lastX = x;
lastY = y;
hasSignature.value = true;
};
// 结束绘制
const stopDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
emitValue();
};
// 发送值
const emitValue = () => {
if (!canvasRef.value) return;
const dataUrl = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', dataUrl);
emit('change', dataUrl);
};
// 清空画布
const clear = () => {
if (!ctx || !canvasRef.value) return;
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, props.width, props.height);
hasSignature.value = false;
emit('update:modelValue', '');
emit('change', '');
};
// 撤销(简单实现:清空)
const undo = () => {
clear();
};
// 获取签名数据
const getSignatureData = () => {
if (!canvasRef.value || !hasSignature.value) return '';
return canvasRef.value.toDataURL('image/png');
};
// 检查是否为空
const isEmpty = () => !hasSignature.value;
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal && canvasRef.value) {
loadImage(newVal);
} else if (!newVal) {
clear();
}
},
);
// 暴露方法
defineExpose({
clear,
getSignatureData,
isEmpty,
});
onMounted(() => {
initCanvas();
// 添加全局事件监听
window.addEventListener('mouseup', stopDrawing);
window.addEventListener('touchend', stopDrawing);
});
onUnmounted(() => {
window.removeEventListener('mouseup', stopDrawing);
window.removeEventListener('touchend', stopDrawing);
});
</script>
<template>
<div class="signature-pad">
<div
class="canvas-container"
:class="{ disabled }"
:style="{ width: `${width}px`, height: `${height}px` }"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart.prevent="startDrawing"
@touchmove.prevent="draw"
@touchend.prevent="stopDrawing"
></canvas>
<!-- 提示文字 -->
<div v-if="!hasSignature && !disabled" class="placeholder">
{{ $t('contract-design.signHere') }}
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!disabled" class="actions mt-2 flex justify-end gap-2">
<ElButton size="small" @click="undo">
<ElIcon class="mr-1"><RotateCcw /></ElIcon>
{{ $t('contract-design.undo') }}
</ElButton>
<ElButton size="small" @click="clear">
<ElIcon class="mr-1"><Eraser /></ElIcon>
{{ $t('contract-design.clear') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.signature-pad {
display: inline-block;
}
.canvas-container {
position: relative;
overflow: hidden;
touch-action: none; /* 禁用默认触摸行为,确保签名流畅 */
cursor: crosshair;
border: 1px solid var(--el-border-color);
border-radius: 4px;
}
.canvas-container.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.canvas-container canvas {
display: block;
}
.placeholder {
position: absolute;
top: 50%;
left: 50%;
font-size: 14px;
color: var(--el-text-color-placeholder);
pointer-events: none;
user-select: none;
transform: translate(-50%, -50%);
}
</style>
@@ -0,0 +1,343 @@
<script setup lang="ts">
import type { VariableConfig } from '../store/contractDesignStore';
import { computed, ref, watch } from 'vue';
import { AlertCircle, Check, Edit3 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElCard,
ElDatePicker,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
const props = defineProps<{
disabled?: boolean;
modelValue: Record<string, string>;
variables: VariableConfig[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, string>): void;
(e: 'change', code: string, value: string): void;
}>();
// 本地表单数据
const formData = ref<Record<string, any>>({});
// 初始化表单数据
watch(
() => props.modelValue,
(val) => {
formData.value = { ...val };
},
{ immediate: true, deep: true },
);
// 更新值
const updateValue = (code: string, value: any) => {
let strValue = '';
if (value === null || value === undefined) {
strValue = '';
} else if (value instanceof Date) {
strValue = formatDate(value);
} else {
strValue = String(value);
}
formData.value[code] = strValue;
const newData = { ...formData.value };
emit('update:modelValue', newData);
emit('change', code, strValue);
};
// 格式化日期
const formatDate = (date: Date, format?: string) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
if (format === 'YYYY-MM-DD') {
return `${year}-${month}-${day}`;
} else if (format === 'YYYY/MM/DD') {
return `${year}/${month}/${day}`;
}
return `${year}${month}${day}`;
};
// 格式化金额
const formatMoney = (value: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(value);
};
// 按分类分组变量
const groupedVariables = computed(() => {
const groups: Record<string, VariableConfig[]> = {
party_a: [],
party_b: [],
contract: [],
other: [],
};
props.variables.forEach((v) => {
if (v.code.startsWith('party_a_')) {
groups.party_a.push(v);
} else if (v.code.startsWith('party_b_')) {
groups.party_b.push(v);
} else if (
v.code.startsWith('contract_') ||
v.code.includes('date') ||
v.code.includes('location')
) {
groups.contract.push(v);
} else {
groups.other.push(v);
}
});
return groups;
});
// 分组标签
const getGroupLabel = (group: string) => {
const labels: Record<string, string> = {
party_a: $t('contract-design.partyAInfo'),
party_b: $t('contract-design.partyBInfo'),
contract: $t('contract-design.contractInfo'),
other: $t('contract-design.otherInfo'),
};
return labels[group] || group;
};
// 变量类型标签
const typeLabels: Record<string, string> = {
text: '文本',
number: '数字',
date: '日期',
money: '金额',
select: '选择',
};
// 检查变量是否已填写
const isFilled = (code: string) => {
const value = formData.value[code];
return value !== undefined && value !== null && value !== '';
};
// 获取填写进度
const fillProgress = computed(() => {
const required = props.variables.filter((v) => v.required);
if (required.length === 0) return 100;
const filled = required.filter((v) => isFilled(v.code));
return Math.round((filled.length / required.length) * 100);
});
// 未填写的必填变量
const unfilledRequired = computed(() => {
return props.variables.filter((v) => v.required && !isFilled(v.code));
});
</script>
<template>
<div class="variable-form">
<!-- 填写进度 -->
<div
class="mb-4 flex items-center justify-between rounded bg-[var(--el-fill-color-light)] p-3"
>
<div class="flex items-center gap-2">
<ElIcon :size="16" class="text-[var(--el-color-primary)]">
<Edit3 />
</ElIcon>
<span class="text-sm">变量填写进度</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="{
'text-[var(--el-color-success)]': fillProgress === 100,
'text-[var(--el-color-warning)]': fillProgress < 100,
}"
>
{{ fillProgress }}%
</span>
<ElIcon
v-if="fillProgress === 100"
:size="16"
class="text-[var(--el-color-success)]"
>
<Check />
</ElIcon>
</div>
</div>
<!-- 未填写提示 -->
<div
v-if="unfilledRequired.length > 0"
class="mb-4 flex items-start gap-2 rounded border border-[var(--el-color-warning-light-5)] bg-[var(--el-color-warning-light-9)] p-3"
>
<ElIcon :size="16" class="mt-0.5 text-[var(--el-color-warning)]">
<AlertCircle />
</ElIcon>
<div class="flex-1">
<div class="mb-1 text-sm font-medium text-[var(--el-color-warning)]">
以下必填项尚未填写
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="v in unfilledRequired"
:key="v.code"
size="small"
type="warning"
>
{{ v.name }}
</ElTag>
</div>
</div>
</div>
<!-- 分组表单 -->
<template v-for="(groupVars, groupKey) in groupedVariables" :key="groupKey">
<ElCard v-if="groupVars.length > 0" shadow="never" class="mb-4">
<template #header>
<span class="text-sm font-medium">{{ groupLabels[groupKey] }}</span>
</template>
<ElForm label-position="top" size="default">
<div class="grid grid-cols-2 gap-x-4">
<ElFormItem
v-for="variable in groupVars"
:key="variable.code"
:label="variable.name"
:required="variable.required"
>
<!-- 文本输入 -->
<ElInput
v-if="variable.type === 'text'"
v-model="formData[variable.code]"
:placeholder="`请输入${variable.name}`"
:disabled="disabled"
clearable
@change="updateValue(variable.code, formData[variable.code])"
/>
<!-- 数字输入 -->
<ElInputNumber
v-else-if="variable.type === 'number'"
v-model="formData[variable.code]"
:placeholder="`请输入${variable.name}`"
:disabled="disabled"
:controls="false"
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
/>
<!-- 金额输入 -->
<ElInputNumber
v-else-if="variable.type === 'money'"
v-model="formData[variable.code]"
:placeholder="`请输入${variable.name}`"
:disabled="disabled"
:precision="2"
:controls="false"
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
>
<template #prefix>¥</template>
</ElInputNumber>
<!-- 日期选择 -->
<ElDatePicker
v-else-if="variable.type === 'date'"
v-model="formData[variable.code]"
type="date"
:placeholder="`请选择${variable.name}`"
:disabled="disabled"
:value-format="
variable.format === 'YYYY-MM-DD'
? 'YYYY-MM-DD'
: variable.format === 'YYYY/MM/DD'
? 'YYYY/MM/DD'
: 'YYYY年MM月DD日'
"
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
/>
<!-- 选择框 -->
<ElSelect
v-else-if="variable.type === 'select'"
v-model="formData[variable.code]"
:placeholder="`请选择${variable.name}`"
:disabled="disabled"
clearable
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
>
<ElOption
v-for="opt in variable.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<!-- 类型标签 -->
<template #label>
<div class="flex items-center gap-2">
<span>{{ variable.name }}</span>
<ElTag size="small" type="info">
{{ typeLabels[variable.type] }}
</ElTag>
<ElIcon
v-if="isFilled(variable.code)"
:size="14"
class="text-[var(--el-color-success)]"
>
<Check />
</ElIcon>
</div>
</template>
</ElFormItem>
</div>
</ElForm>
</ElCard>
</template>
<!-- 空状态 -->
<div
v-if="variables.length === 0"
class="flex h-32 items-center justify-center text-[var(--el-text-color-placeholder)]"
>
暂无需要填写的变量
</div>
</div>
</template>
<style scoped>
:deep(.el-form-item__label) {
font-weight: normal;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input-number .el-input__wrapper) {
padding-right: 11px;
padding-left: 11px;
}
</style>
@@ -0,0 +1,21 @@
export { default as ContractRenderer } from './components/ContractRenderer.vue';
export { default as ContractSignDialog } from './components/ContractSignDialog.vue';
export { default as ElementRenderer } from './components/ElementRenderer.vue';
export { default as SignatureDialog } from './components/SignatureDialog.vue';
export { default as SignaturePad } from './components/SignaturePad.vue';
export { default as VariableForm } from './components/VariableForm.vue';
export { default as ContractDesign } from './index.vue';
export { useContractDesignStore } from './store/contractDesignStore';
export type {
ContractElement,
ContractElementType,
ContractMaterial,
ContractTemplateConfig,
PartyType,
SignatureConfig,
TableColumnConfig,
VariableConfig,
VariableType,
} from './store/contractDesignStore';
export { useContractSignStore } from './store/contractSignStore';
export type { SignatureData, VariableData } from './store/contractSignStore';
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { Code, RotateCw, Upload } from '@vben/icons';
import {
ElButton,
ElDialog,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
import AttributePanel from './components/AttributePanel.vue';
import DesignCanvas from './components/DesignCanvas.vue';
import MaterialPanel from './components/MaterialPanel.vue';
import PreviewModal from './components/PreviewModal.vue';
import { useContractDesignStore } from './store/contractDesignStore';
const props = defineProps<{
// 初始配置 JSON
initialConfig?: string;
// 只读模式
readonly?: boolean;
}>();
const emit = defineEmits<{
(e: 'save', config: string): void;
}>();
const store = useContractDesignStore();
// 预览弹窗
const previewVisible = ref(false);
// 代码弹窗
const codeVisible = ref(false);
const codeContent = ref('');
// 导入弹窗
const importVisible = ref(false);
const importContent = ref('');
// 初始化配置
if (props.initialConfig) {
store.importConfig(props.initialConfig);
}
// 预览
const handlePreview = () => {
if (store.templateConfig.elements.length === 0) {
ElMessage.warning($t('contract-design.pleaseAddContractElements'));
return;
}
previewVisible.value = true;
};
// 查看代码
const handleViewCode = () => {
codeContent.value = store.exportConfig();
codeVisible.value = true;
};
// 保存
const handleSave = () => {
const config = store.exportConfig();
emit('save', config);
ElMessage.success($t('contract-design.saveSuccess'));
};
// 清空画布
const handleClear = async () => {
if (store.templateConfig.elements.length === 0) {
return;
}
try {
await ElMessageBox.confirm($t('contract-design.clearCanvasConfirm'), $t('contract-design.tip'), {
confirmButtonText: $t('contract-design.confirm'),
cancelButtonText: $t('contract-design.cancel'),
type: 'warning',
});
store.clearCanvas();
ElMessage.success($t('contract-design.cleared'));
} catch {
// 取消操作
}
};
// 导出配置
const handleExport = () => {
const config = store.exportConfig();
const blob = new Blob([config], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `contract-template-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success($t('contract-design.exportSuccess'));
};
// 打开导入弹窗
const handleOpenImport = () => {
importContent.value = '';
importVisible.value = true;
};
// 确认导入
const handleImport = () => {
if (!importContent.value.trim()) {
ElMessage.warning($t('contract-design.pleaseEnterConfig'));
return;
}
const success = store.importConfig(importContent.value);
if (success) {
importVisible.value = false;
ElMessage.success($t('contract-design.importSuccess'));
} else {
ElMessage.error($t('contract-design.configFormatError'));
}
};
// 复制代码
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(codeContent.value);
ElMessage.success($t('contract-design.copiedToClipboard'));
} catch {
ElMessage.error($t('contract-design.copyFailed'));
}
};
// 键盘快捷键处理
const handleKeyDown = (e: KeyboardEvent) => {
// 如果正在输入框中,不处理快捷键
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// 如果弹窗打开,不处理快捷键
if (previewVisible.value || codeVisible.value || importVisible.value) {
return;
}
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
// Ctrl+Z 撤销
if (ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (store.canUndo) {
store.undo();
ElMessage.success($t('contract-design.undone'));
}
return;
}
// Ctrl+Y 或 Ctrl+Shift+Z 重做
if ((ctrlKey && e.key === 'y') || (ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (store.canRedo) {
store.redo();
ElMessage.success($t('contract-design.redone'));
}
return;
}
// Ctrl+C 复制
if (ctrlKey && e.key === 'c') {
if (store.activeId) {
e.preventDefault();
store.copyToClipboard(store.activeId);
ElMessage.success($t('contract-design.copied'));
}
return;
}
// Ctrl+V 粘贴
if (ctrlKey && e.key === 'v') {
if (store.hasClipboard) {
e.preventDefault();
store.pasteFromClipboard();
ElMessage.success($t('contract-design.pasted'));
}
return;
}
// Delete 或 Backspace 删除
if (e.key === 'Delete' || e.key === 'Backspace') {
if (store.activeId) {
e.preventDefault();
store.deleteElement(store.activeId);
ElMessage.success($t('contract-design.deleted'));
}
return;
}
// Ctrl+S 保存
if (ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
return;
}
};
// 注册/注销键盘事件
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<template>
<div class="contract-design flex h-full w-full overflow-hidden">
<!-- 主体区域 -->
<div class="bg-background-deep flex flex-1 gap-3 overflow-hidden p-3">
<!-- 左侧元素面板 -->
<div class="h-full flex-shrink-0">
<MaterialPanel />
</div>
<!-- 中间设计画布 -->
<div class="relative h-full min-w-0 flex-1 overflow-hidden">
<DesignCanvas
@preview="handlePreview"
@view-code="handleViewCode"
@clear="handleClear"
@import="handleOpenImport"
@export="handleExport"
@save="handleSave"
/>
</div>
<!-- 右侧属性面板 -->
<div class="h-full flex-shrink-0">
<AttributePanel />
</div>
</div>
<!-- 预览弹窗 -->
<PreviewModal v-model:visible="previewVisible" />
<!-- JSON预览弹窗 -->
<ElDialog
v-model="codeVisible"
:title="$t('contract-design.jsonPreview')"
width="800px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Code class="h-5 w-5" />
<span>{{ $t('contract-design.jsonPreview') }}</span>
</div>
</template>
<div class="code-container">
<pre
class="bg-background-deep overflow-auto rounded-lg p-4 text-sm"
><code>{{ codeContent }}</code></pre>
</div>
<template #footer>
<ElButton @click="handleCopyCode"> {{ $t('contract-design.copyCode') }} </ElButton>
<ElButton type="primary" @click="codeVisible = false"> {{ $t('contract-design.close') }} </ElButton>
</template>
</ElDialog>
<!-- 导入弹窗 -->
<ElDialog
v-model="importVisible"
:title="$t('contract-design.importConfig')"
width="600px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Upload class="h-5 w-5" />
<span>{{ $t('contract-design.importConfig') }}</span>
</div>
</template>
<ElInput
v-model="importContent"
type="textarea"
:rows="15"
:placeholder="$t('contract-design.pasteJsonConfig')"
/>
<template #footer>
<ElButton @click="importVisible = false">{{ $t('contract-design.cancel') }}</ElButton>
<ElButton type="primary" @click="handleImport">
<RotateCw class="mr-1 h-4 w-4" />
{{ $t('contract-design.import') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.contract-design {
height: 100%;
}
.code-container {
max-height: 500px;
overflow: auto;
}
.code-container pre {
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
</style>
@@ -0,0 +1,659 @@
import { computed, nextTick, ref } from 'vue';
import { defineStore } from 'pinia';
import { v4 as uuidv4 } from 'uuid';
// 合同元素类型
export type ContractElementType =
| 'date-zone' // 日期区
| 'divider' // 分割线
| 'image' // 图片
| 'page-break' // 分页符
| 'paragraph' // 段落
| 'rich-text' // 富文本
| 'seal-zone' // 盖章区
| 'signature-zone' // 签名区
| 'table' // 表格
| 'title' // 标题
| 'variable'; // 变量占位符
// 变量类型
export type VariableType =
| 'date' // 日期
| 'money' // 金额
| 'number' // 数字
| 'select' // 选择
| 'text'; // 文本
// 签署方类型
export type PartyType = 'party_a' | 'party_b' | 'party_c' | 'witness';
// 变量配置
export interface VariableConfig {
code: string; // 变量编码
name: string; // 变量名称
type: VariableType; // 变量类型
required: boolean; // 是否必填
defaultValue?: string; // 默认值
options?: { label: string; value: string }[]; // 选项(select类型)
format?: string; // 格式(日期/金额)
}
// 签名区配置
export interface SignatureConfig {
partyType: PartyType; // 签署方
partyLabel: string; // 签署方标签
showDate: boolean; // 显示签署日期
showSeal: boolean; // 显示印章位置
required: boolean; // 是否必签
}
// 表格列配置
export interface TableColumnConfig {
key: string;
title: string;
width?: number;
align?: 'center' | 'left' | 'right';
}
// 合同元素
export interface ContractElement {
id: string;
type: ContractElementType;
props: Record<string, any>;
// 变量配置
variable?: VariableConfig;
// 签名配置
signature?: SignatureConfig;
// 表格配置
tableColumns?: TableColumnConfig[];
tableData?: Record<string, any>[];
}
// 合同模板配置
export interface ContractTemplateConfig {
id: string;
name: string;
code: string;
category: string;
description: string;
// 页面设置
pageSize: 'A4' | 'A5' | 'Letter';
pageMargin: { bottom: number; left: number; right: number; top: number };
// 元素列表
elements: ContractElement[];
// 变量定义
variables: VariableConfig[];
// 签署方定义
parties: { label: string; required: boolean; type: PartyType }[];
}
// 预定义变量
export const predefinedVariables: VariableConfig[] = [
{ code: 'party_a_name', name: '甲方名称', type: 'text', required: true },
{
code: 'party_a_legal_person',
name: '甲方法定代表人',
type: 'text',
required: false,
},
{ code: 'party_a_address', name: '甲方地址', type: 'text', required: false },
{ code: 'party_a_phone', name: '甲方电话', type: 'text', required: false },
{ code: 'party_b_name', name: '乙方名称', type: 'text', required: true },
{
code: 'party_b_legal_person',
name: '乙方法定代表人',
type: 'text',
required: false,
},
{ code: 'party_b_address', name: '乙方地址', type: 'text', required: false },
{ code: 'party_b_phone', name: '乙方电话', type: 'text', required: false },
{ code: 'contract_no', name: '合同编号', type: 'text', required: true },
{
code: 'contract_amount',
name: '合同金额',
type: 'money',
required: false,
format: '¥#,##0.00',
},
{
code: 'sign_date',
name: '签订日期',
type: 'date',
required: true,
format: 'YYYY年MM月DD日',
},
{
code: 'start_date',
name: '开始日期',
type: 'date',
required: false,
format: 'YYYY年MM月DD日',
},
{
code: 'end_date',
name: '结束日期',
type: 'date',
required: false,
format: 'YYYY年MM月DD日',
},
{ code: 'sign_location', name: '签订地点', type: 'text', required: false },
];
// 合同元素材料定义
export interface ContractMaterial {
type: ContractElementType;
title: string;
icon: string;
category: 'layout' | 'signature' | 'text' | 'variable';
defaultProps: Record<string, any>;
}
// 预定义合同元素材料
export const contractMaterials: ContractMaterial[] = [
// 文本元素
{
type: 'title',
title: '合同标题',
icon: 'Heading1',
category: 'text',
defaultProps: {
content: '合同标题',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
type: 'paragraph',
title: '段落文本',
icon: 'AlignLeft',
category: 'text',
defaultProps: {
content: '请输入段落内容...',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2, // 首行缩进(字符数)
},
},
{
type: 'rich-text',
title: '富文本',
icon: 'FileText',
category: 'text',
defaultProps: {
content: '<p>请输入富文本内容...</p>',
minHeight: 100,
padding: 8,
showBorder: false,
borderColor: 'var(--el-border-color)',
backgroundColor: '',
},
},
// 变量元素
{
type: 'variable',
title: '变量占位符',
icon: 'Code',
category: 'variable',
defaultProps: {
variableCode: '',
placeholder: '______',
underline: true,
minWidth: 100,
},
},
// 表格元素
{
type: 'table',
title: '表格',
icon: 'Table',
category: 'text',
defaultProps: {
bordered: true,
headerBgColor: 'var(--el-fill-color-light)',
},
},
// 签署元素
{
type: 'signature-zone',
title: '签名区',
icon: 'Pencil',
category: 'signature',
defaultProps: {
width: 200,
height: 80,
label: '签名',
showBorder: true,
showLabel: true, // 签字后是否显示标签
align: 'right', // 默认靠右对齐
},
},
{
type: 'seal-zone',
title: '盖章区',
icon: 'Circle',
category: 'signature',
defaultProps: {
width: 120,
height: 120,
label: '盖章处',
showBorder: true,
showLabel: true, // 盖章后是否显示标签
align: 'right', // 默认靠右对齐
},
},
{
type: 'date-zone',
title: '日期区',
icon: 'Calendar',
category: 'signature',
defaultProps: {
format: 'YYYY年MM月DD日',
label: '日期',
showUnderline: true,
align: 'right', // 默认靠右对齐
},
},
// 布局元素
{
type: 'divider',
title: '分割线',
icon: 'Minus',
category: 'layout',
defaultProps: {
style: 'solid',
color: 'var(--el-border-color)',
margin: 16,
},
},
{
type: 'page-break',
title: '分页符',
icon: 'FileText',
category: 'layout',
defaultProps: {},
},
{
type: 'image',
title: '图片',
icon: 'Image',
category: 'layout',
defaultProps: {
src: '',
width: 200,
height: 'auto',
align: 'center',
},
},
];
export const useContractDesignStore = defineStore('contract-design', () => {
// 当前选中的元素
const activeId = ref<null | string>(null);
// 合同模板配置
const templateConfig = ref<ContractTemplateConfig>({
id: uuidv4(),
name: '新建合同模板',
code: '',
category: '',
description: '',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [],
variables: [...predefinedVariables],
parties: [
{ type: 'party_a', label: '甲方', required: true },
{ type: 'party_b', label: '乙方', required: true },
],
});
// 历史记录
const history = ref<string[]>([]);
const historyIndex = ref(-1);
const isTimeTravel = ref(false);
// 剪贴板
const clipboard = ref<ContractElement | null>(null);
// 拖拽状态
const isDragging = ref(false);
// 预览模式
const isPreview = ref(false);
// 记录快照
const recordSnapshot = () => {
if (isTimeTravel.value) return;
if (historyIndex.value < history.value.length - 1) {
history.value.splice(historyIndex.value + 1);
}
history.value.push(JSON.stringify(templateConfig.value));
historyIndex.value = history.value.length - 1;
if (history.value.length > 30) {
history.value.shift();
historyIndex.value--;
}
};
const canUndo = computed(() => historyIndex.value > 0);
const canRedo = computed(() => historyIndex.value < history.value.length - 1);
const undo = () => {
if (!canUndo.value) return;
isTimeTravel.value = true;
historyIndex.value--;
const snapshot = history.value[historyIndex.value];
if (snapshot) {
templateConfig.value = JSON.parse(snapshot);
}
nextTick(() => {
isTimeTravel.value = false;
});
};
const redo = () => {
if (!canRedo.value) return;
isTimeTravel.value = true;
historyIndex.value++;
const snapshot = history.value[historyIndex.value];
if (snapshot) {
templateConfig.value = JSON.parse(snapshot);
}
nextTick(() => {
isTimeTravel.value = false;
});
};
// 设置当前选中
const setActive = (id: null | string) => {
activeId.value = id;
};
// 获取当前选中的元素
const activeElement = computed(() => {
if (!activeId.value) return null;
return (
templateConfig.value.elements.find((e) => e.id === activeId.value) || null
);
});
// 添加元素
const addElement = (material: ContractMaterial, index?: number) => {
const id = uuidv4();
const element: ContractElement = {
id,
type: material.type,
props: JSON.parse(JSON.stringify(material.defaultProps)),
};
// 如果是表格,初始化列和数据
if (material.type === 'table') {
element.tableColumns = [
{ key: 'col1', title: '列1', width: 100, align: 'center' },
{ key: 'col2', title: '列2', width: 100, align: 'center' },
{ key: 'col3', title: '列3', width: 100, align: 'center' },
];
element.tableData = [{ col1: '', col2: '', col3: '' }];
}
// 如果是签名区,初始化签名配置
if (material.type === 'signature-zone' || material.type === 'seal-zone') {
element.signature = {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: material.type === 'seal-zone',
required: true,
};
}
if (typeof index === 'number') {
templateConfig.value.elements.splice(index, 0, element);
} else {
templateConfig.value.elements.push(element);
}
activeId.value = id;
recordSnapshot();
};
// 删除元素
const deleteElement = (id: string) => {
const index = templateConfig.value.elements.findIndex((e) => e.id === id);
if (index !== -1) {
templateConfig.value.elements.splice(index, 1);
if (activeId.value === id) {
activeId.value = null;
}
recordSnapshot();
}
};
// 复制元素
const copyElement = (id: string) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (!element) return;
const newId = uuidv4();
const newElement: ContractElement = {
...JSON.parse(JSON.stringify(element)),
id: newId,
};
const index = templateConfig.value.elements.findIndex((e) => e.id === id);
templateConfig.value.elements.splice(index + 1, 0, newElement);
activeId.value = newId;
recordSnapshot();
};
// 复制到剪贴板
const copyToClipboard = (id: string) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (element) {
clipboard.value = JSON.parse(JSON.stringify(element));
}
};
// 从剪贴板粘贴
const pasteFromClipboard = () => {
if (!clipboard.value) return false;
const newId = uuidv4();
const newElement: ContractElement = {
...JSON.parse(JSON.stringify(clipboard.value)),
id: newId,
};
templateConfig.value.elements.push(newElement);
activeId.value = newId;
recordSnapshot();
return true;
};
// 检查剪贴板是否有内容
const hasClipboard = computed(() => clipboard.value !== null);
// 更新元素属性
const updateElementProps = (id: string, props: Record<string, any>) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (element) {
element.props = { ...element.props, ...props };
// 如果是变量占位符,且选择了变量,自动添加到模板变量列表
if (element.type === 'variable' && props.variableCode) {
const varConfig = predefinedVariables.find(
(v) => v.code === props.variableCode,
);
if (varConfig) {
const exists = templateConfig.value.variables.find(
(v) => v.code === varConfig.code,
);
if (!exists) {
templateConfig.value.variables.push({ ...varConfig });
}
}
}
}
};
// 更新元素
const updateElement = (id: string, updates: Partial<ContractElement>) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (element) {
Object.assign(element, updates);
recordSnapshot();
}
};
// 移动元素
const moveElement = (id: string, direction: 'down' | 'up') => {
const elements = templateConfig.value.elements;
const index = elements.findIndex((e) => e.id === id);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= elements.length) return;
[elements[index], elements[newIndex]] = [
elements[newIndex]!,
elements[index]!,
];
recordSnapshot();
};
// 设置拖拽状态
const setDragging = (val: boolean) => {
isDragging.value = val;
};
// 设置预览模式
const setPreview = (val: boolean) => {
isPreview.value = val;
};
// 清空画布
const clearCanvas = () => {
templateConfig.value.elements = [];
activeId.value = null;
recordSnapshot();
};
// 添加变量
const addVariable = (variable: VariableConfig) => {
const exists = templateConfig.value.variables.find(
(v) => v.code === variable.code,
);
if (!exists) {
templateConfig.value.variables.push(variable);
recordSnapshot();
}
};
// 删除变量
const removeVariable = (code: string) => {
const index = templateConfig.value.variables.findIndex(
(v) => v.code === code,
);
if (index !== -1) {
templateConfig.value.variables.splice(index, 1);
recordSnapshot();
}
};
// 更新模板基础配置
const updateTemplateConfig = (config: Partial<ContractTemplateConfig>) => {
Object.assign(templateConfig.value, config);
};
// 导出配置
const exportConfig = () => {
return JSON.stringify(templateConfig.value, null, 2);
};
// 导入配置
const importConfig = (json: string) => {
try {
const config = JSON.parse(json);
templateConfig.value = config;
activeId.value = null;
recordSnapshot();
return true;
} catch {
return false;
}
};
// 克隆元素(用于拖拽)
const cloneElement = (material: ContractMaterial): ContractElement => {
const id = uuidv4();
const element: ContractElement = {
id,
type: material.type,
props: JSON.parse(JSON.stringify(material.defaultProps)),
};
if (material.type === 'table') {
element.tableColumns = [
{ key: 'col1', title: '列1', width: 100, align: 'center' },
{ key: 'col2', title: '列2', width: 100, align: 'center' },
{ key: 'col3', title: '列3', width: 100, align: 'center' },
];
element.tableData = [{ col1: '', col2: '', col3: '' }];
}
if (material.type === 'signature-zone' || material.type === 'seal-zone') {
element.signature = {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: material.type === 'seal-zone',
required: true,
};
}
return element;
};
return {
activeId,
activeElement,
templateConfig,
isDragging,
isPreview,
canUndo,
canRedo,
hasClipboard,
setActive,
addElement,
deleteElement,
copyElement,
copyToClipboard,
pasteFromClipboard,
updateElementProps,
updateElement,
moveElement,
setDragging,
setPreview,
clearCanvas,
addVariable,
removeVariable,
updateTemplateConfig,
exportConfig,
importConfig,
cloneElement,
undo,
redo,
recordSnapshot,
};
});
@@ -0,0 +1,181 @@
import type { ContractTemplateConfig, PartyType } from './contractDesignStore';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
// 签署数据
export interface SignatureData {
elementId: string; // 对应的元素 ID
partyType: PartyType; // 签署方类型
partyLabel: string; // 签署方标签
signatureImage: string; // 签名图片(base64
signedAt: string; // 签署时间
signedBy?: string; // 签署人
}
// 变量填写数据
export interface VariableData {
code: string;
value: string;
}
// 合同签署状态
export interface ContractSignState {
contractId: string;
templateConfig: ContractTemplateConfig | null;
signatures: SignatureData[];
variables: VariableData[];
status: 'completed' | 'draft' | 'signing';
startedAt?: string;
completedAt?: string;
}
export const useContractSignStore = defineStore('contract-sign', () => {
// 合同模板配置
const templateConfig = ref<ContractTemplateConfig | null>(null);
// 签署数据
const signatures = ref<SignatureData[]>([]);
// 变量数据
const variables = ref<VariableData[]>([]);
// 签署状态
const status = ref<'completed' | 'draft' | 'signing'>('draft');
// 加载合同模板
const loadTemplate = (config: ContractTemplateConfig) => {
templateConfig.value = JSON.parse(JSON.stringify(config));
signatures.value = [];
variables.value = [];
status.value = 'signing';
};
// 获取元素的签名数据
const getSignature = (elementId: string): SignatureData | undefined => {
return signatures.value.find((s) => s.elementId === elementId);
};
// 添加或更新签名
const setSignature = (data: Omit<SignatureData, 'signedAt'>) => {
const index = signatures.value.findIndex(
(s) => s.elementId === data.elementId,
);
const signatureData: SignatureData = {
...data,
signedAt: new Date().toISOString(),
};
if (index === -1) {
signatures.value.push(signatureData);
} else {
signatures.value[index] = signatureData;
}
};
// 移除签名
const removeSignature = (elementId: string) => {
const index = signatures.value.findIndex((s) => s.elementId === elementId);
if (index !== -1) {
signatures.value.splice(index, 1);
}
};
// 获取变量值
const getVariable = (code: string): string => {
const variable = variables.value.find((v) => v.code === code);
return variable?.value || '';
};
// 设置变量值
const setVariable = (code: string, value: string) => {
const index = variables.value.findIndex((v) => v.code === code);
if (index === -1) {
variables.value.push({ code, value });
} else {
variables.value[index]!.value = value;
}
};
// 获取所有需要签署的元素
const signatureElements = computed(() => {
if (!templateConfig.value) return [];
return templateConfig.value.elements.filter(
(e) => e.type === 'signature-zone' || e.type === 'seal-zone',
);
});
// 获取所有必须签署的元素
const requiredSignatureElements = computed(() => {
return signatureElements.value.filter((e) => e.signature?.required);
});
// 检查是否所有必须签署的元素都已签署
const allRequiredSigned = computed(() => {
return requiredSignatureElements.value.every((e) =>
signatures.value.some((s) => s.elementId === e.id),
);
});
// 获取签署进度
const signProgress = computed(() => {
const total = requiredSignatureElements.value.length;
if (total === 0) return 100;
const signed = requiredSignatureElements.value.filter((e) =>
signatures.value.some((s) => s.elementId === e.id),
).length;
return Math.round((signed / total) * 100);
});
// 完成签署
const completeSign = () => {
if (!allRequiredSigned.value) {
return false;
}
status.value = 'completed';
return true;
};
// 重置
const reset = () => {
templateConfig.value = null;
signatures.value = [];
variables.value = [];
status.value = 'draft';
};
// 导出签署后的合同数据
const exportSignedContract = () => {
return {
template: templateConfig.value,
signatures: signatures.value,
variables: variables.value,
status: status.value,
completedAt:
status.value === 'completed' ? new Date().toISOString() : undefined,
};
};
return {
templateConfig,
signatures,
variables,
status,
signatureElements,
requiredSignatureElements,
allRequiredSigned,
signProgress,
loadTemplate,
getSignature,
setSignature,
removeSignature,
getVariable,
setVariable,
completeSign,
reset,
exportSignedContract,
};
});
@@ -0,0 +1,350 @@
import type { ContractTemplateConfig } from '../store/contractDesignStore';
// 示例合同模板 - 简单采购合同
export const samplePurchaseContract: ContractTemplateConfig = {
id: 'sample-purchase-001',
name: '采购合同模板',
code: 'PURCHASE_CONTRACT',
category: '采购类',
description: '标准采购合同模板,适用于一般商品采购场景',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '采购合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'contract-no',
type: 'paragraph',
props: {
content: '合同编号:{{contract_no}}',
align: 'right',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'divider-1',
type: 'divider',
props: {
style: 'solid',
color: 'var(--el-border-color)',
margin: 16,
},
},
{
id: 'party-info',
type: 'paragraph',
props: {
content:
'甲方(采购方):{{party_a_name}}\n地址:{{party_a_address}}\n联系电话:{{party_a_phone}}\n\n乙方(供应方):{{party_b_name}}\n地址:{{party_b_address}}\n联系电话:{{party_b_phone}}',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'divider-2',
type: 'divider',
props: {
style: 'dashed',
color: 'var(--el-border-color)',
margin: 16,
},
},
{
id: 'intro',
type: 'paragraph',
props: {
content:
'甲乙双方经友好协商,就甲方向乙方采购相关产品事宜,达成如下协议:',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-1',
type: 'title',
props: {
content: '第一条 采购内容',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-1-content',
type: 'paragraph',
props: {
content:
'甲方向乙方采购以下产品(详见附件清单),合同总金额为人民币 {{contract_amount}} 元整。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-2',
type: 'title',
props: {
content: '第二条 交付方式',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-2-content',
type: 'paragraph',
props: {
content:
'乙方应于合同签订后30个工作日内完成产品交付。交付地点为甲方指定地址。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-3',
type: 'title',
props: {
content: '第三条 付款方式',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-3-content',
type: 'paragraph',
props: {
content: '甲方应于产品验收合格后15个工作日内,向乙方支付全部合同款项。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-4',
type: 'title',
props: {
content: '第四条 违约责任',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-4-content',
type: 'paragraph',
props: {
content:
'任何一方违反本合同约定,应向对方支付合同总金额10%的违约金,并赔偿由此造成的全部损失。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-5',
type: 'title',
props: {
content: '第五条 争议解决',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-5-content',
type: 'paragraph',
props: {
content:
'本合同在履行过程中发生的争议,由双方协商解决;协商不成的,任何一方均可向合同签订地人民法院提起诉讼。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-6',
type: 'title',
props: {
content: '第六条 其他',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-6-content',
type: 'paragraph',
props: {
content:
'本合同一式两份,甲乙双方各执一份,具有同等法律效力。本合同自双方签字盖章之日起生效。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'divider-3',
type: 'divider',
props: {
style: 'solid',
color: 'var(--el-border-color)',
margin: 24,
},
},
{
id: 'sign-section-title',
type: 'paragraph',
props: {
content: '(以下为签署区)',
align: 'center',
fontSize: 12,
lineHeight: 1.5,
indent: 0,
},
},
{
id: 'sign-party-a',
type: 'paragraph',
props: {
content: '甲方(盖章):',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'signature-a',
type: 'signature-zone',
props: {
width: 200,
height: 80,
label: '签名',
showBorder: true,
},
signature: {
partyType: 'party_a',
partyLabel: '甲方签名',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'date-a',
type: 'date-zone',
props: {
format: 'YYYY年MM月DD日',
label: '签署日期',
showUnderline: true,
},
},
{
id: 'sign-party-b',
type: 'paragraph',
props: {
content: '乙方(盖章):',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'signature-b',
type: 'signature-zone',
props: {
width: 200,
height: 80,
label: '签名',
showBorder: true,
},
signature: {
partyType: 'party_b',
partyLabel: '乙方签名',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'date-b',
type: 'date-zone',
props: {
format: 'YYYY年MM月DD日',
label: '签署日期',
showUnderline: true,
},
},
],
variables: [
{ code: 'party_a_name', name: '甲方名称', type: 'text', required: true },
{
code: 'party_a_address',
name: '甲方地址',
type: 'text',
required: false,
},
{ code: 'party_a_phone', name: '甲方电话', type: 'text', required: false },
{ code: 'party_b_name', name: '乙方名称', type: 'text', required: true },
{
code: 'party_b_address',
name: '乙方地址',
type: 'text',
required: false,
},
{ code: 'party_b_phone', name: '乙方电话', type: 'text', required: false },
{ code: 'contract_no', name: '合同编号', type: 'text', required: true },
{
code: 'contract_amount',
name: '合同金额',
type: 'money',
required: true,
format: '¥#,##0.00',
},
],
parties: [
{ type: 'party_a', label: '甲方(采购方)', required: true },
{ type: 'party_b', label: '乙方(供应方)', required: true },
],
};
// 导出所有模板
export const contractTemplates = [
{
id: 'sample-purchase',
name: '采购合同',
description: '标准采购合同模板',
icon: 'FileText',
template: samplePurchaseContract,
},
];
@@ -0,0 +1,283 @@
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
export interface ExportPdfOptions {
filename?: string;
pageSize?: 'A4' | 'A5' | 'Letter';
pageMargin?: { bottom: number; left: number; right: number; top: number };
quality?: number;
}
// 页面尺寸(单位:mm
const PAGE_SIZES_MM = {
A4: { width: 210, height: 297 },
A5: { width: 148, height: 210 },
Letter: { width: 216, height: 279 },
};
// 页面尺寸(单位:px,96dpi)
const PAGE_SIZES_PX = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
/**
* 将多个页面元素导出为 PDF
* @param pages 页面元素数组
* @param options 导出选项
*/
export async function exportPagesToPdf(
pages: HTMLElement[],
options: ExportPdfOptions = {},
): Promise<void> {
const {
filename = `contract-${Date.now()}.pdf`,
pageSize = 'A4',
quality = 4, // 提高默认分辨率,3 = 3倍缩放
} = options;
const pageConfig = PAGE_SIZES_MM[pageSize];
// 创建 PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: [pageConfig.width, pageConfig.height],
});
// 逐页渲染
for (const [i, page] of pages.entries()) {
// 导出前隐藏不需要的元素(如已签署徽章)
const hiddenElements: HTMLElement[] = [];
page.querySelectorAll('.signed-badge').forEach((el) => {
const htmlEl = el as HTMLElement;
hiddenElements.push(htmlEl);
htmlEl.style.display = 'none';
});
// 导出前移除签名区域的边框和背景样式
const signedElements: { el: HTMLElement; originalStyle: string }[] = [];
page
.querySelectorAll('.signature-display.signed, .seal-display.signed')
.forEach((el) => {
const htmlEl = el as HTMLElement;
signedElements.push({
el: htmlEl,
originalStyle: htmlEl.getAttribute('style') || '',
});
htmlEl.style.border = 'none';
htmlEl.style.background = 'transparent';
htmlEl.style.padding = '0';
});
// 使用 html2canvas 将元素转换为 canvas
const canvas = await html2canvas(page, {
scale: quality,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false,
});
// 导出后恢复隐藏的元素
hiddenElements.forEach((el) => {
el.style.display = '';
});
// 导出后恢复签名区域的样式
signedElements.forEach(({ el, originalStyle }) => {
el.setAttribute('style', originalStyle);
});
// 计算图片尺寸(填满整页)
const imgData = canvas.toDataURL('image/jpeg', 0.95);
if (i > 0) {
pdf.addPage();
}
// 添加图片,填满整页
pdf.addImage(imgData, 'JPEG', 0, 0, pageConfig.width, pageConfig.height);
}
// 下载 PDF
pdf.save(filename);
}
/**
* 将 HTML 元素导出为 PDF
* @param element 要导出的 HTML 元素
* @param options 导出选项
*/
export async function exportToPdf(
element: HTMLElement,
options: ExportPdfOptions = {},
): Promise<void> {
const {
filename = `contract-${Date.now()}.pdf`,
pageSize = 'A4',
pageMargin = { top: 10, right: 10, bottom: 10, left: 10 },
quality = 2,
} = options;
const pageConfig = PAGE_SIZES_MM[pageSize];
// 导出前隐藏不需要的元素(如已签署徽章)
const hiddenElements: HTMLElement[] = [];
element.querySelectorAll('.signed-badge').forEach((el) => {
const htmlEl = el as HTMLElement;
hiddenElements.push(htmlEl);
htmlEl.style.display = 'none';
});
// 导出前移除签名区域的边框和背景样式
const signedElements: { el: HTMLElement; originalStyle: string }[] = [];
element
.querySelectorAll('.signature-display.signed, .seal-display.signed')
.forEach((el) => {
const htmlEl = el as HTMLElement;
signedElements.push({
el: htmlEl,
originalStyle: htmlEl.getAttribute('style') || '',
});
htmlEl.style.border = 'none';
htmlEl.style.background = 'transparent';
htmlEl.style.padding = '0';
});
// 使用 html2canvas 将元素转换为 canvas
const canvas = await html2canvas(element, {
scale: quality,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false,
});
// 导出后恢复隐藏的元素
hiddenElements.forEach((el) => {
el.style.display = '';
});
// 导出后恢复签名区域的样式
signedElements.forEach(({ el, originalStyle }) => {
el.setAttribute('style', originalStyle);
});
// 创建 PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: [pageConfig.width, pageConfig.height],
});
// 计算可用区域(减去边距)
const contentWidth = pageConfig.width - pageMargin.left - pageMargin.right;
const contentHeight = pageConfig.height - pageMargin.top - pageMargin.bottom;
// 计算图片尺寸(按宽度缩放)
const imgWidth = contentWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 将 canvas 转换为图片数据
const imgData = canvas.toDataURL('image/jpeg', 0.95);
// 如果内容高度小于等于一页可用高度,直接添加
if (imgHeight <= contentHeight) {
pdf.addImage(
imgData,
'JPEG',
pageMargin.left,
pageMargin.top,
imgWidth,
imgHeight,
);
} else {
// 需要分页
let remainingHeight = imgHeight;
let sourceY = 0;
let pageIndex = 0;
while (remainingHeight > 0) {
if (pageIndex > 0) {
pdf.addPage();
}
// 计算当前页显示的内容高度
const currentPageContentHeight = Math.min(remainingHeight, contentHeight);
// 计算源图片中对应的像素高度
const sourceHeight =
(currentPageContentHeight / imgHeight) * canvas.height;
// 创建临时 canvas 来裁剪当前页的内容
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.width;
tempCanvas.height = sourceHeight;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
tempCtx.drawImage(
canvas,
0,
sourceY,
canvas.width,
sourceHeight, // 源区域
0,
0,
canvas.width,
sourceHeight, // 目标区域
);
const pageImgData = tempCanvas.toDataURL('image/jpeg', 0.95);
pdf.addImage(
pageImgData,
'JPEG',
pageMargin.left,
pageMargin.top,
imgWidth,
currentPageContentHeight,
);
}
sourceY += sourceHeight;
remainingHeight -= contentHeight;
pageIndex++;
}
}
// 下载 PDF
pdf.save(filename);
}
/**
* 将合同渲染器导出为 PDF
* @param containerSelector 容器选择器
* @param options 导出选项
*/
export async function exportContractToPdf(
containerSelector: string,
options: ExportPdfOptions = {},
): Promise<boolean> {
const container = document.querySelector(containerSelector) as HTMLElement;
if (!container) {
console.error('Container not found:', containerSelector);
return false;
}
// 查找合同纸张元素
const paper = container.querySelector('.contract-paper') as HTMLElement;
if (!paper) {
console.error('Contract paper not found');
return false;
}
try {
await exportToPdf(paper, options);
return true;
} catch (error) {
console.error('Export PDF failed:', error);
return false;
}
}
@@ -0,0 +1 @@
export { default as DatabaseTreeNodeIcon } from './index.vue';
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import type { TreeNodeType } from '#/utils/database-tree/types';
import { DatabaseTypeIcon } from '#/components/database-type-icon';
import { getNodeIcon, getNodeIconClass } from '#/utils/database-tree/icons';
defineOptions({ name: 'DatabaseTreeNodeIcon' });
const props = withDefaults(
defineProps<{
dbType?: string;
size?: number;
type: string | TreeNodeType;
}>(),
{
dbType: '',
size: 16,
},
);
</script>
<template>
<DatabaseTypeIcon
v-if="type === 'connection'"
:db-type="dbType"
:size="size"
/>
<component
v-else
:is="getNodeIcon(type)"
:size="size"
:class="getNodeIconClass(type)"
/>
</template>
@@ -0,0 +1 @@
export { default as DatabaseTreeNodeLabel } from './index.vue';
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import type { TreeNodeType } from '#/utils/database-tree/types';
import { computed } from 'vue';
import { $t } from '@vben/locales';
import { ElTag } from 'element-plus';
defineOptions({ name: 'DatabaseTreeNodeLabel' });
const props = defineProps<{
dbName?: string;
isSystem?: boolean;
label: string;
type: string | TreeNodeType;
}>();
const showSystemTag = computed(
() =>
props.type === 'connection' &&
(props.isSystem === true || props.dbName === 'default'),
);
</script>
<template>
<div class="flex items-center">
<span class="text-sm">{{ label }}</span>
<ElTag v-if="showSystemTag" class="ml-1" size="small" type="warning">
{{ $t('database-manager.systemDatabase') }}
</ElTag>
</div>
</template>
@@ -0,0 +1 @@
export { default as DatabaseTypeIcon } from './index.vue';
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getDatabaseTypeIcon } from '#/assets/svg';
defineOptions({ name: 'DatabaseTypeIcon' });
const props = withDefaults(
defineProps<{
dbType?: string;
size?: number;
}>(),
{
dbType: '',
size: 16,
},
);
const iconSrc = computed(() => getDatabaseTypeIcon(props.dbType));
</script>
<template>
<img
:src="iconSrc"
:width="size"
:height="size"
class="inline-block shrink-0 object-contain"
alt=""
/>
</template>
@@ -0,0 +1,177 @@
<script lang="ts" setup>
import type { Dept } from '#/api/core/dept';
import { ref, watch } from 'vue';
import { Building2 } from '@vben/icons';
import { ElPopover, ElSkeleton, ElTag } from 'element-plus';
import { getDeptDetailApi } from '#/api/core/dept';
defineOptions({ name: 'DeptTag' });
const props = defineProps<{
deptId?: string;
showIcon?: boolean;
}>();
const detail = ref<Dept | null>(null);
const loading = ref(false);
const hasLoaded = ref(false);
// 缓存:避免重复请求
const cache = new Map<string, Dept>();
async function loadDetail() {
if (hasLoaded.value || !props.deptId) return;
const cached = cache.get(props.deptId);
if (cached) {
detail.value = cached;
hasLoaded.value = true;
return;
}
loading.value = true;
try {
const res = await getDeptDetailApi(props.deptId);
detail.value = res;
cache.set(props.deptId, res);
hasLoaded.value = true;
} catch (error) {
console.error('Failed to load dept detail:', error);
} finally {
loading.value = false;
}
}
watch(
() => props.deptId,
() => {
hasLoaded.value = false;
detail.value = null;
loadDetail();
},
{ immediate: true },
);
</script>
<template>
<ElPopover
v-if="deptId"
trigger="hover"
placement="top"
:width="240"
:show-arrow="true"
>
<template #reference>
<ElTag
v-if="detail"
size="small"
class="dept-tag"
:disable-transitions="true"
>
<Building2 v-if="showIcon" class="mr-1 h-3 w-3" />
{{ detail.name }}
</ElTag>
<span v-else class="tag-skeleton"></span>
</template>
<div v-if="loading" class="py-2">
<ElSkeleton :rows="3" animated />
</div>
<div v-else-if="detail" class="dept-detail">
<div class="mb-2 flex items-center gap-2">
<Building2 class="h-4 w-4 text-[var(--el-color-primary)]" />
<span class="text-sm font-medium text-[var(--el-text-color-primary)]">
{{ detail.name }}
</span>
</div>
<div class="detail-grid">
<div v-if="detail.code" class="detail-row">
<span class="detail-label">编码</span>
<span class="detail-value">{{ detail.code }}</span>
</div>
<div v-if="detail.dept_type_display" class="detail-row">
<span class="detail-label">类型</span>
<span class="detail-value">{{ detail.dept_type_display }}</span>
</div>
<div v-if="detail.lead_name" class="detail-row">
<span class="detail-label">负责人</span>
<span class="detail-value">{{ detail.lead_name }}</span>
</div>
<div v-if="detail.phone" class="detail-row">
<span class="detail-label">电话</span>
<span class="detail-value">{{ detail.phone }}</span>
</div>
<div v-if="detail.email" class="detail-row">
<span class="detail-label">邮箱</span>
<span class="detail-value">{{ detail.email }}</span>
</div>
<div v-if="detail.description" class="detail-row">
<span class="detail-label">描述</span>
<span class="detail-value">{{ detail.description }}</span>
</div>
</div>
</div>
</ElPopover>
<span v-else class="text-xs text-[var(--el-text-color-placeholder)]">-</span>
</template>
<style scoped>
.dept-tag {
cursor: pointer;
}
.tag-skeleton {
display: inline-block;
width: 56px;
height: 20px;
vertical-align: middle;
background: linear-gradient(
90deg,
var(--el-fill-color-light) 25%,
var(--el-fill-color) 50%,
var(--el-fill-color-light) 75%
);
background-size: 200% 100%;
border-radius: 4px;
animation: tag-shimmer 1.5s infinite;
}
@keyframes tag-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.detail-grid {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
}
.detail-label {
flex-shrink: 0;
width: 42px;
color: var(--el-text-color-secondary);
}
.detail-value {
flex: 1;
min-width: 0;
color: var(--el-text-color-primary);
word-break: break-all;
}
</style>
@@ -0,0 +1,45 @@
import type { Component } from 'vue';
import { defineComponent, h } from 'vue';
export type DesignEditorData = Record<string, any>;
export type AppDesignData = Record<string, any>;
export type DashboardBasicInfoData = Record<string, any>;
export type DashboardDesignData = Record<string, any>;
export type DashboardPublishData = Record<string, any>;
export type SystemSummaryData = Record<string, any>;
export interface FormEditorProps {
readonly?: boolean;
schema?: Record<string, any>;
}
export interface PageEditorProps {
readonly?: boolean;
schema?: Record<string, any>;
}
export const FORM_EDITOR_LIGHTWEIGHT = true;
function createLitePanel(name: string): Component {
return defineComponent({
name,
setup(_, { slots }) {
return () => h('div', { class: 'form-editor-lite-placeholder' }, slots.default?.());
},
});
}
export const BasicInfoEditor = createLitePanel('BasicInfoEditor');
export const PublishInfoEditor = createLitePanel('PublishInfoEditor');
export const PageBasicInfoEditor = createLitePanel('PageBasicInfoEditor');
export const PagePublishInfoEditor = createLitePanel('PagePublishInfoEditor');
export const FormEditorContent = createLitePanel('FormEditorContent');
export const PageEditorContent = createLitePanel('PageEditorContent');
export const DesignEditorPanel = createLitePanel('DesignEditorPanel');
export const AppDesignPanel = createLitePanel('AppDesignPanel');
export const AppSettingsPanel = createLitePanel('AppSettingsPanel');
export const DashboardBasicInfoConfirmPanel = createLitePanel('DashboardBasicInfoConfirmPanel');
export const DashboardDesignConfirmPanel = createLitePanel('DashboardDesignConfirmPanel');
export const DashboardPublishConfirmPanel = createLitePanel('DashboardPublishConfirmPanel');
export const SystemSummaryConfirmPanel = createLitePanel('SystemSummaryConfirmPanel');

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