refactor: restore zq admin design baseline

This commit is contained in:
2026-06-22 07:58:57 +08:00
parent 17031cc3c9
commit 0de9c085d5
564 changed files with 154513 additions and 209 deletions
+26 -53
View File
@@ -95,6 +95,8 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
media/
!web/apps/web-ele/src/components/screen-design/components/ConfigPanel/media/
!web/apps/web-ele/src/components/screen-design/components/ConfigPanel/media/MediaConfig.vue
staticfiles/
# FastAPI / Backend
@@ -179,15 +181,14 @@ dmypy.json
/go-view/
node_modules
# ai-agent-admin: exclude legacy generated bundles and removed heavy modules
# ai-agent-admin: exclude generated bundles and unused product entries.
# Keep design/runtime source used by AI workflow panels tracked; lightness is
# enforced by route/menu filtering and lazy imports, not by missing source.
.ops-backups/
docker/uniapp/
docker/web/index/*
!docker/web/index/.gitkeep
uniapp-zq/
web/apps/web-ele/src/components/screen-design/
web/apps/web-ele/src/components/form-design/
web/apps/web-ele/src/components/contract-design/
web/apps/web-ele/src/components/import-export-manager/
web/apps/web-ele/src/components/wiki/
web/apps/web-ele/src/components/zq-draw/
@@ -201,6 +202,26 @@ web/apps/web-ele/src/views/online-dev/
web/apps/web-ele/src/views/online-dev/*
!web/apps/web-ele/src/views/online-dev/page-render/
!web/apps/web-ele/src/views/online-dev/page-render/**
!web/apps/web-ele/src/views/online-dev/form-manager/
web/apps/web-ele/src/views/online-dev/form-manager/*
!web/apps/web-ele/src/views/online-dev/form-manager/composables/
web/apps/web-ele/src/views/online-dev/form-manager/composables/*
!web/apps/web-ele/src/views/online-dev/form-manager/composables/useFormData.ts
!web/apps/web-ele/src/views/online-dev/form-manager/modules/
web/apps/web-ele/src/views/online-dev/form-manager/modules/*
!web/apps/web-ele/src/views/online-dev/form-manager/modules/ListTypePicker.vue
!web/apps/web-ele/src/views/online-dev/form-manager/modules/data-source-config.vue
!web/apps/web-ele/src/views/online-dev/form-manager/modules/list-design.vue
!web/apps/web-ele/src/views/online-dev/form-manager/utils/
web/apps/web-ele/src/views/online-dev/form-manager/utils/*
!web/apps/web-ele/src/views/online-dev/form-manager/utils/formValidator.ts
!web/apps/web-ele/src/views/online-dev/form-render/
web/apps/web-ele/src/views/online-dev/form-render/*
!web/apps/web-ele/src/views/online-dev/form-render/components/
!web/apps/web-ele/src/views/online-dev/form-render/components/**
!web/apps/web-ele/src/views/online-dev/form-render/utils/
web/apps/web-ele/src/views/online-dev/form-render/utils/*
!web/apps/web-ele/src/views/online-dev/form-render/utils/list-filter-fields.ts
web/apps/web-ele/src/views/dashboard/
!web/apps/web-ele/src/views/dashboard/
web/apps/web-ele/src/views/dashboard/*
@@ -210,61 +231,13 @@ web/apps/web-ele/src/views/dashboard/*
!web/apps/web-ele/src/views/dashboard/workspace/**
web/apps/web-ele/src/views/demos/
web/apps/web-ele/src/views/zq-smart-table/
web/apps/web-ele/src/api/online-dev/
!web/apps/web-ele/src/api/online-dev/
web/apps/web-ele/src/api/online-dev/*
!web/apps/web-ele/src/api/online-dev/page-manager.ts
web/apps/web-ele/src/components/dashboard-design/
!web/apps/web-ele/src/components/dashboard-design/
web/apps/web-ele/src/components/dashboard-design/*
!web/apps/web-ele/src/components/dashboard-design/DashboardRenderer.vue
!web/apps/web-ele/src/components/dashboard-design/index.ts
!web/apps/web-ele/src/components/dashboard-design/runtime.ts
!web/apps/web-ele/src/components/dashboard-design/types.ts
!web/apps/web-ele/src/components/dashboard-design/components/
web/apps/web-ele/src/components/dashboard-design/components/*
!web/apps/web-ele/src/components/dashboard-design/components/WidgetRenderer.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/
web/apps/web-ele/src/components/dashboard-design/components/widgets/*
!web/apps/web-ele/src/components/dashboard-design/components/widgets/AnnouncementList.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/ApprovalCenter.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/CalendarWidget.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/ClockWidget.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/CountdownWidget.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/DataTable.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/FilterDate.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/FilterDateRange.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/FilterInput.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/FilterSelect.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/IframeWidget.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/ImageCarousel.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/ImageWidget.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/MyApps.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/NoticeList.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/ProgressCard.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/QuickLinks.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/RankingList.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/ServerMonitor.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/StatCard.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/TodoList.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/VideoPlayer.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/WeatherWidget.vue
!web/apps/web-ele/src/components/dashboard-design/components/widgets/WelcomeCard.vue
!web/apps/web-ele/src/components/dashboard-design/utils/
web/apps/web-ele/src/components/dashboard-design/utils/*
!web/apps/web-ele/src/components/dashboard-design/utils/dataFetcher.ts
web/apps/web-ele/src/components/document-designer/
web/apps/web-ele/src/components/workflow/designer/
web/docs/src/public/guide/
web/packages/zq-univer/
web/apps/web-ele/src/locales/langs/*/dashboard-design.json*
!web/apps/web-ele/src/locales/langs/*/dashboard-design.json
web/apps/web-ele/src/locales/langs/*/*.json_new
web/apps/web-ele/src/locales/langs/*/*.json_sync
web/apps/web-ele/src/locales/langs/*/contract-design.json
web/apps/web-ele/src/locales/langs/*/*.json.tmp
web/apps/web-ele/src/locales/langs/*/draw.json
web/apps/web-ele/src/locales/langs/*/form-design.json
web/apps/web-ele/src/locales/langs/*/screen-design.json
web/apps/web-ele/src/locales/langs/*/whiteboard.json
web/apps/web-ele/src/locales/langs/*/wiki.json
web/apps/web-ele/src/locales/langs/*/zq-editor.json
+89 -6
View File
@@ -16,9 +16,9 @@
},
"type": "module",
"scripts": {
"build": "vite build --mode production",
"build:analyze": "vite build --mode analyze",
"dev": "vite --mode development",
"build": "pnpm vite build --mode production",
"build:analyze": "pnpm vite build --mode analyze",
"dev": "pnpm vite --mode development",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit --skipLibCheck"
},
@@ -26,8 +26,52 @@
"#/*": "./src/*"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.4",
"@element-plus/icons-vue": "^2.3.2",
"@monaco-editor/loader": "^1.7.0",
"@tanstack/vue-virtual": "^3.13.18",
"@tiptap/core": "^3.20.4",
"@tiptap/extension-bubble-menu": "^3.20.4",
"@tiptap/extension-character-count": "^3.20.4",
"@tiptap/extension-code-block-lowlight": "^3.20.4",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-drag-handle": "^3.20.4",
"@tiptap/extension-floating-menu": "^3.20.4",
"@tiptap/extension-highlight": "^3.20.4",
"@tiptap/extension-image": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-mathematics": "^3.20.4",
"@tiptap/extension-mention": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/extension-table": "^3.20.4",
"@tiptap/extension-table-cell": "^3.20.4",
"@tiptap/extension-table-header": "^3.20.4",
"@tiptap/extension-table-row": "^3.20.4",
"@tiptap/extension-task-item": "^3.20.4",
"@tiptap/extension-task-list": "^3.20.4",
"@tiptap/extension-text-align": "^3.20.4",
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@tiptap/suggestion": "^3.20.4",
"@tiptap/vue-3": "^3.20.4",
"@types/d3-geo": "^3.1.0",
"@types/three": "^0.182.0",
"@vben-core/menu-ui": "workspace:*",
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",
@@ -36,8 +80,8 @@
"@vben/icons": "workspace:*",
"@vben/layouts": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/preferences": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/preferences": "workspace:*",
"@vben/request": "workspace:*",
"@vben/stores": "workspace:*",
"@vben/styles": "workspace:*",
@@ -47,17 +91,56 @@
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.0",
"@vue-flow/minimap": "^1.5.4",
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"@vue-office/pdf": "^2.0.10",
"@vue-office/pptx": "^1.0.1",
"@vueuse/core": "catalog:",
"@zq/univer": "workspace:*",
"codemirror": "^6.0.2",
"cron-parser": "^4.9.0",
"d3-geo": "^3.1.1",
"dayjs": "catalog:",
"echarts": "catalog:",
"echarts-liquidfill": "^3.1.0",
"element-plus": "catalog:",
"fabric": "^7.2.0",
"fractional-indexing": "^3.2.0",
"grid-layout-plus": "^1.1.1",
"gsap": "^3.14.2",
"html-docx-js-typescript": "^0.1.5",
"html2canvas": "^1.4.1",
"image-blob-reduce": "^4.1.0",
"jsbarcode": "^3.12.3",
"jspdf": "^3.0.4",
"katex": "^0.16.40",
"lowlight": "^3.3.0",
"monaco-editor": "^0.55.1",
"nanoid": "^5.1.7",
"pako": "^2.1.0",
"perfect-freehand": "^1.2.3",
"pinia": "catalog:",
"points-on-curve": "^1.0.1",
"qrcode": "^1.5.4",
"roughjs": "^4.6.6",
"three": "^0.182.0",
"tiptap-extension-global-drag-handle": "^0.1.18",
"tiptap-markdown": "^0.9.0",
"uuid": "^13.0.0",
"vue": "catalog:",
"vuedraggable": "^4.1.0",
"vue-router": "catalog:"
"vue-advanced-cropper": "^2.8.9",
"vue-echarts": "^8.0.1",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "catalog:",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/jsbarcode": "^3.11.4",
"@types/katex": "^0.16.8",
"@types/pako": "^2.0.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^11.0.0",
"@univerjs/vite-plugin": "0.5.1",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"unplugin-element-plus": "catalog:"
}
@@ -0,0 +1,479 @@
import { requestClient } from '#/api/request';
/**
* 合同管理 API
*/
// ============ 类型定义 ============
/** 模板状态 */
export type ContractTemplateStatus = 'disabled' | 'draft' | 'published';
/** 合同状态 */
export type ContractInstanceStatus =
| 'canceled'
| 'completed'
| 'draft'
| 'expired'
| 'pending'
| 'signing';
/** 合同模板 */
export interface ContractTemplate {
id: string;
name: string;
code: string;
category: string;
description: string;
status: ContractTemplateStatus;
version: number;
template_config: Record<string, any>;
thumbnail: string;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 模板列表项 */
export interface ContractTemplateListItem {
id: string;
name: string;
code: string;
category: string;
description: string;
status: ContractTemplateStatus;
version: number;
thumbnail: string;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 创建模板请求 */
export interface ContractTemplateCreateInput {
name: string;
code: string;
category?: string;
description?: string;
template_config?: Record<string, any>;
}
/** 更新模板请求 */
export interface ContractTemplateUpdateInput {
name?: string;
category?: string;
description?: string;
template_config?: Record<string, any>;
thumbnail?: string;
}
/** 合同实例 */
export interface ContractInstance {
id: string;
contract_no: string;
title: string;
status: ContractInstanceStatus;
template_id: string;
template_name: string;
template_code: string;
creator_id: string;
creator_name: string;
contract_config: Record<string, any>;
variable_data: Record<string, any>;
signature_data: Record<string, string>;
created_at: string;
completed_at?: string;
expired_at?: string;
pdf_file: string;
}
/** 合同列表项 */
export interface ContractInstanceListItem {
id: string;
contract_no: string;
title: string;
status: ContractInstanceStatus;
template_name: string;
creator_name: string;
created_at: string;
completed_at?: string;
}
/** 创建合同请求 */
export interface ContractInstanceCreateInput {
template_id: string;
title: string;
contract_no?: string;
variable_data?: Record<string, any>;
}
/** 更新合同请求 */
export interface ContractInstanceUpdateInput {
title?: string;
variable_data?: Record<string, any>;
signature_data?: Record<string, string>;
}
/** 签署记录 */
export interface ContractSignature {
id: string;
element_id: string;
party_type: string;
party_label: string;
sign_type: string;
signer_id?: string;
signer_name: string;
signature_image: string;
status: string;
signed_at?: string;
}
/** 操作日志 */
export interface ContractLog {
id: string;
action: string;
operator_id: string;
operator_name: string;
comment: string;
sys_create_datetime: string;
}
/** 分页响应 */
interface PaginatedResponse<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}
// ============ 模板 API ============
const BASE_URL = '/api/online-dev/contract';
/**
* 获取模板列表
*/
export async function getTemplateListApi(params?: {
category?: string;
code?: string;
name?: string;
page?: number;
pageSize?: number;
status?: ContractTemplateStatus;
}) {
return requestClient.get<PaginatedResponse<ContractTemplateListItem>>(
`${BASE_URL}/template/list`,
{ params },
);
}
/**
* 获取模板分类
*/
export async function getTemplateCategoriesApi() {
return requestClient.get<string[]>(`${BASE_URL}/template/categories`);
}
/**
* 获取模板详情
*/
export async function getTemplateDetailApi(id: string) {
return requestClient.get<ContractTemplate>(`${BASE_URL}/template/${id}`);
}
/**
* 根据编码获取模板
*/
export async function getTemplateByCodeApi(code: string) {
return requestClient.get<ContractTemplate>(
`${BASE_URL}/template/code/${code}`,
);
}
/**
* 创建模板
*/
export async function createTemplateApi(data: ContractTemplateCreateInput) {
return requestClient.post<ContractTemplate>(`${BASE_URL}/template`, data);
}
/**
* 更新模板
*/
export async function updateTemplateApi(
id: string,
data: ContractTemplateUpdateInput,
) {
return requestClient.put<ContractTemplate>(
`${BASE_URL}/template/${id}`,
data,
);
}
/**
* 删除模板
*/
export async function deleteTemplateApi(id: string) {
return requestClient.delete(`${BASE_URL}/template/${id}`);
}
/**
* 批量删除模板
*/
export async function batchDeleteTemplatesApi(ids: string[]) {
return requestClient.delete<{ count: number }>(`${BASE_URL}/template/batch`, {
params: { ids },
});
}
/**
* 发布模板
*/
export async function publishTemplateApi(id: string) {
return requestClient.post<ContractTemplate>(
`${BASE_URL}/template/${id}/publish`,
);
}
/**
* 停用模板
*/
export async function disableTemplateApi(id: string) {
return requestClient.post<ContractTemplate>(
`${BASE_URL}/template/${id}/disable`,
);
}
/**
* 复制模板
*/
export async function copyTemplateApi(
id: string,
newCode: string,
newName?: string,
) {
return requestClient.post<ContractTemplate>(
`${BASE_URL}/template/${id}/copy`,
null,
{
params: { new_code: newCode, new_name: newName },
},
);
}
// ============ 合同实例 API ============
/**
* 生成合同编号
*/
export async function generateContractNoApi() {
return requestClient.get<{ contract_no: string }>(
`${BASE_URL}/instance/generate-no`,
);
}
/**
* 检查合同编号唯一性
*/
export async function checkContractNoApi(
contractNo: string,
excludeId?: string,
) {
return requestClient.get<{ exists: boolean; valid: boolean }>(
`${BASE_URL}/instance/check-no`,
{ params: { contract_no: contractNo, exclude_id: excludeId } },
);
}
/**
* 获取合同列表
*/
export async function getInstanceListApi(params?: {
contract_no?: string;
page?: number;
pageSize?: number;
status?: ContractInstanceStatus;
template_id?: string;
title?: string;
}) {
return requestClient.get<PaginatedResponse<ContractInstanceListItem>>(
`${BASE_URL}/instance/list`,
{ params },
);
}
/**
* 获取我的合同
*/
export async function getMyInstancesApi(params?: {
page?: number;
pageSize?: number;
status?: ContractInstanceStatus;
title?: string;
}) {
return requestClient.get<PaginatedResponse<ContractInstanceListItem>>(
`${BASE_URL}/instance/my`,
{ params },
);
}
/**
* 获取合同详情
*/
export async function getInstanceDetailApi(id: string) {
return requestClient.get<ContractInstance>(`${BASE_URL}/instance/${id}`);
}
/**
* 创建合同
*/
export async function createInstanceApi(data: ContractInstanceCreateInput) {
return requestClient.post<ContractInstance>(`${BASE_URL}/instance`, data);
}
/**
* 更新合同
*/
export async function updateInstanceApi(
id: string,
data: ContractInstanceUpdateInput,
) {
return requestClient.put<ContractInstance>(
`${BASE_URL}/instance/${id}`,
data,
);
}
/**
* 删除合同
*/
export async function deleteInstanceApi(id: string) {
return requestClient.delete(`${BASE_URL}/instance/${id}`);
}
/**
* 提交合同
*/
export async function submitInstanceApi(id: string) {
return requestClient.post<ContractInstance>(
`${BASE_URL}/instance/${id}/submit`,
);
}
/**
* 完成合同
*/
export async function completeInstanceApi(id: string) {
return requestClient.post<ContractInstance>(
`${BASE_URL}/instance/${id}/complete`,
);
}
/**
* 取消合同
*/
export async function cancelInstanceApi(id: string, comment?: string) {
return requestClient.post(`${BASE_URL}/instance/${id}/cancel`, null, {
params: { comment },
});
}
/**
* 获取合同日志
*/
export async function getInstanceLogsApi(id: string) {
return requestClient.get<ContractLog[]>(`${BASE_URL}/instance/${id}/logs`);
}
// ============ 签署 API ============
/**
* 签署合同
*/
export async function signInstanceApi(
id: string,
data: {
element_id: string;
signature_image: string;
signer_name?: string;
},
) {
return requestClient.post<ContractSignature>(
`${BASE_URL}/instance/${id}/sign`,
data,
);
}
/**
* 获取签署记录
*/
export async function getInstanceSignaturesApi(id: string) {
return requestClient.get<ContractSignature[]>(
`${BASE_URL}/instance/${id}/signatures`,
);
}
// ============ 手机签署 API ============
/** 手机签署令牌 */
export interface MobileSignToken {
token: string;
sign_url: string;
expired_at: string;
qrcode_data: string;
}
/** 移动端合同信息 */
export interface MobileContractInfo {
id: string;
contract_no: string;
title: string;
status: ContractInstanceStatus;
contract_config: Record<string, any>;
variable_data: Record<string, any>;
signature_data: Record<string, string>;
party_type: string;
signer_name: string;
template_name: string;
}
/**
* 生成手机签署二维码
*/
export async function createMobileSignTokenApi(
id: string,
data?: {
expire_minutes?: number;
party_type?: string;
signer_name?: string;
},
) {
return requestClient.post<MobileSignToken>(
`${BASE_URL}/instance/${id}/mobile-sign-token`,
data || {},
);
}
/**
* 获取移动端合同信息(无需登录)
*/
export async function getMobileContractApi(token: string) {
return requestClient.get<MobileContractInfo>(
`${BASE_URL}/mobile/contract/${token}`,
);
}
/**
* 移动端签署(无需登录)
*/
export async function mobileSignApi(
token: string,
data: {
element_id: string;
signature_image: string;
signer_name?: string;
},
) {
return requestClient.post<ContractSignature>(
`${BASE_URL}/mobile/sign/${token}`,
data,
);
}
@@ -0,0 +1,679 @@
/**
* 文档生成 API
*/
import { requestClient } from '#/api/request';
// ==================== 类型定义 ====================
export interface PageMargin {
top: number;
right: number;
bottom: number;
left: number;
}
export interface SealPosition {
seal_type: string;
position_type: 'fixed' | 'relative';
x: number;
y: number;
page: number;
width: number;
height: number;
field_name?: string;
}
export interface CalculationField {
name: string;
label?: string;
formula: string;
format?: 'chinese' | 'money' | 'number' | 'percent';
decimal_places?: number;
}
export interface AggregationField {
name: string;
label?: string;
source: string;
field: string;
function: 'avg' | 'count' | 'max' | 'min' | 'sum';
format?: 'chinese' | 'money' | 'number' | 'percent';
decimal_places?: number;
}
export interface CalculationRules {
fields: CalculationField[];
aggregations: AggregationField[];
}
export interface DocumentTemplate {
id: string;
application_id?: string;
name: string;
code: string;
category: string;
description?: string;
form_code?: string;
workflow_code?: string;
template_type: 'designer' | 'html';
template_content?: string;
template_css?: string;
page_size: string;
page_orientation: string;
page_margin?: PageMargin;
watermark_enabled: boolean;
watermark_text?: string;
watermark_type: string;
watermark_image_id?: string;
watermark_opacity: number;
watermark_angle: number;
seal_enabled: boolean;
seal_positions?: SealPosition[];
header_enabled: boolean;
header_template?: string;
footer_enabled: boolean;
footer_template?: string;
show_page_number: boolean;
calculation_rules?: CalculationRules;
status: string;
is_builtin: boolean;
version: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface DocumentTemplateListItem {
id: string;
name: string;
code: string;
category: string;
description?: string;
form_code?: string;
workflow_code?: string;
status: string;
is_builtin: boolean;
version: number;
sys_create_datetime?: string;
}
export interface DocumentTemplateCreateInput {
application_id?: string;
name: string;
code: string;
category?: string;
description?: string;
form_code?: string;
workflow_code?: string;
template_type?: string;
template_content?: string;
template_css?: string;
page_size?: string;
page_orientation?: string;
page_margin?: PageMargin;
watermark_enabled?: boolean;
watermark_text?: string;
watermark_type?: string;
watermark_image_id?: string;
watermark_opacity?: number;
watermark_angle?: number;
seal_enabled?: boolean;
seal_positions?: SealPosition[];
header_enabled?: boolean;
header_template?: string;
footer_enabled?: boolean;
footer_template?: string;
show_page_number?: boolean;
calculation_rules?: CalculationRules;
}
export interface DocumentTemplateUpdateInput {
name?: string;
category?: string;
description?: string;
form_code?: string;
workflow_code?: string;
template_type?: string;
template_content?: string;
template_css?: string;
page_size?: string;
page_orientation?: string;
page_margin?: PageMargin;
watermark_enabled?: boolean;
watermark_text?: string;
watermark_type?: string;
watermark_image_id?: string;
watermark_opacity?: number;
watermark_angle?: number;
seal_enabled?: boolean;
seal_positions?: SealPosition[];
header_enabled?: boolean;
header_template?: string;
footer_enabled?: boolean;
footer_template?: string;
show_page_number?: boolean;
calculation_rules?: CalculationRules;
status?: string;
}
export interface GeneratedDocument {
id: string;
template_id: string;
template_code?: string;
template_name?: string;
form_code?: string;
form_data_id?: string;
workflow_code?: string;
instance_id?: string;
document_name: string;
document_no?: string;
file_id: string;
file_size: number;
page_count: number;
generate_type: string;
generator_id?: string;
generator_name?: string;
status: string;
download_count: number;
sealed: boolean;
seal_info?: Record<string, any>;
sys_create_datetime?: string;
}
export interface GenerateDocumentInput {
template_id: string;
form_data_id?: string;
instance_id?: string;
document_name?: string;
seal_ids?: string[];
signature_file_ids?: Record<string, string>;
}
export interface BatchGenerateDocumentInput {
template_id: string;
form_data_ids: string[];
}
export interface PreviewDocumentInput {
template_id?: string;
template_content?: string;
calculation_rules?: CalculationRules;
form_data_id?: string;
instance_id?: string;
test_data?: Record<string, any>;
}
export interface ElectronicSeal {
id: string;
name: string;
seal_type: string;
description?: string;
seal_image_id: string;
seal_image_url?: string;
owner_type: string;
owner_ids?: string[];
scope: string;
allowed_template_ids?: string[];
width: number;
height: number;
status: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface ElectronicSealCreateInput {
name: string;
seal_type: string;
description?: string;
seal_image_id: string;
owner_type?: string;
owner_ids?: string[];
scope?: string;
allowed_template_ids?: string[];
width?: number;
height?: number;
}
export interface ElectronicSealUpdateInput {
name?: string;
description?: string;
seal_image_id?: string;
owner_type?: string;
owner_ids?: string[];
scope?: string;
allowed_template_ids?: string[];
width?: number;
height?: number;
status?: string;
}
export interface TemplateCategory {
value: string;
label: string;
count: number;
}
// ==================== 模板管理 API ====================
/**
* 获取模板列表
*/
export function getTemplateListApi(params: {
applicationId?: string;
category?: string;
formCode?: string;
isBuiltin?: boolean;
keyword?: string;
page?: number;
pageSize?: number;
status?: string;
workflowCode?: string;
}) {
return requestClient.get<{
items: DocumentTemplateListItem[];
total: number;
}>('/api/online_dev/document-generator/templates', { params });
}
/**
* 获取模板分类
*/
export function getTemplateCategoriesApi(applicationId?: string) {
return requestClient.get<TemplateCategory[]>(
'/api/online_dev/document-generator/templates/categories',
{ params: { applicationId } },
);
}
/**
* 获取内置模板
*/
export function getBuiltinTemplatesApi() {
return requestClient.get<DocumentTemplateListItem[]>(
'/api/online_dev/document-generator/templates/builtin',
);
}
/**
* 根据表单编码获取绑定的已发布单据模板
*/
export function getTemplatesByFormCodeApi(formCode: string) {
return requestClient.get<DocumentTemplateListItem[]>(
`/api/online_dev/document-generator/templates/by-form/${formCode}`,
);
}
/**
* 检查表单是否绑定了单据模板
*/
export function checkFormHasTemplatesApi(formCode: string) {
return requestClient.get<{ hasTemplates: boolean }>(
`/api/online_dev/document-generator/templates/check-form/${formCode}`,
);
}
/**
* 创建模板
*/
export function createTemplateApi(data: DocumentTemplateCreateInput) {
return requestClient.post<DocumentTemplate>(
'/api/online_dev/document-generator/templates',
data,
);
}
/**
* 获取模板详情
*/
export function getTemplateDetailApi(templateId: string) {
return requestClient.get<DocumentTemplate>(
`/api/online_dev/document-generator/templates/${templateId}`,
);
}
/**
* 根据编码获取模板
*/
export function getTemplateByCodeApi(code: string) {
return requestClient.get<DocumentTemplate>(
`/api/online_dev/document-generator/templates/code/${code}`,
);
}
/**
* 更新模板
*/
export function updateTemplateApi(
templateId: string,
data: DocumentTemplateUpdateInput,
) {
return requestClient.put<DocumentTemplate>(
`/api/online_dev/document-generator/templates/${templateId}`,
data,
);
}
/**
* 删除模板
*/
export function deleteTemplateApi(templateId: string) {
return requestClient.delete(
`/api/online_dev/document-generator/templates/${templateId}`,
);
}
/**
* 发布模板
*/
export function publishTemplateApi(templateId: string) {
return requestClient.post<DocumentTemplate>(
`/api/online_dev/document-generator/templates/${templateId}/publish`,
);
}
/**
* 取消发布模板
*/
export function unpublishTemplateApi(templateId: string) {
return requestClient.post<DocumentTemplate>(
`/api/online_dev/document-generator/templates/${templateId}/unpublish`,
);
}
/**
* 复制模板
*/
export function copyTemplateApi(
templateId: string,
newCode: string,
newName: string,
) {
return requestClient.post<DocumentTemplate>(
`/api/online_dev/document-generator/templates/${templateId}/copy`,
null,
{ params: { newCode, newName } },
);
}
/** 导入单据模板请求 */
export interface DocumentTemplateImportInput {
application_id?: string;
name: string;
code: string;
category?: string;
description?: string;
form_code?: string;
workflow_code?: string;
template_type?: string;
template_content?: string;
template_css?: string;
page_size?: string;
page_orientation?: string;
page_margin?: PageMargin;
watermark_enabled?: boolean;
watermark_text?: string;
watermark_type?: string;
watermark_image_id?: string;
watermark_opacity?: number;
watermark_angle?: number;
seal_enabled?: boolean;
seal_positions?: SealPosition[];
header_enabled?: boolean;
header_template?: string;
footer_enabled?: boolean;
footer_template?: string;
show_page_number?: boolean;
calculation_rules?: CalculationRules;
}
export interface DocumentTemplateImportCheckInput {
code: string;
}
export interface DocumentTemplateImportCheckResult {
code_exists: boolean;
can_import: boolean;
}
/** 导出单据模板配置 */
export function exportTemplateConfigApi(templateId: string) {
return requestClient.get<Blob>(
`/api/online_dev/document-generator/templates/${templateId}/export`,
{ responseType: 'blob' },
);
}
/** 导入预检查 */
export function checkImportTemplateApi(data: DocumentTemplateImportCheckInput) {
return requestClient.post<DocumentTemplateImportCheckResult>(
'/api/online_dev/document-generator/templates/import/check',
data,
);
}
/** 导入单据模板配置 */
export function importTemplateConfigApi(data: DocumentTemplateImportInput) {
return requestClient.post<DocumentTemplate>(
'/api/online_dev/document-generator/templates/import',
data,
);
}
// ==================== 文档生成 API ====================
/**
* 生成文档
*/
export function generateDocumentApi(data: GenerateDocumentInput) {
return requestClient.post<GeneratedDocument>(
'/api/online_dev/document-generator/generate',
data,
);
}
/**
* 预览文档
*/
export function previewDocumentApi(data: PreviewDocumentInput) {
return requestClient.post(
'/api/online_dev/document-generator/preview',
data,
{
responseType: 'blob',
},
);
}
/**
* 预览HTML
*/
export function previewHtmlApi(data: PreviewDocumentInput) {
return requestClient.post<{ html: string }>(
'/api/online_dev/document-generator/preview-html',
data,
);
}
/**
* 批量生成文档
*/
export function batchGenerateDocumentApi(data: BatchGenerateDocumentInput) {
return requestClient.post<{
data: { fail: number; success: number };
message: string;
}>('/api/online_dev/document-generator/batch-generate', data);
}
/**
* 获取文档列表
*/
export function getDocumentListApi(params: {
formCode?: string;
formDataId?: string;
instanceId?: string;
keyword?: string;
page?: number;
pageSize?: number;
templateId?: string;
}) {
return requestClient.get<{
items: GeneratedDocument[];
total: number;
}>('/api/online_dev/document-generator/documents', { params });
}
/**
* 获取文档详情
*/
export function getDocumentDetailApi(documentId: string) {
return requestClient.get<GeneratedDocument>(
`/api/online_dev/document-generator/documents/${documentId}`,
);
}
/**
* 下载文档
*/
export function downloadDocumentApi(documentId: string) {
return requestClient.get(
`/api/online_dev/document-generator/documents/${documentId}/download`,
{
responseType: 'blob',
},
);
}
/**
* 删除文档
*/
export function deleteDocumentApi(documentId: string) {
return requestClient.delete(
`/api/online_dev/document-generator/documents/${documentId}`,
);
}
/**
* 重新生成文档
*/
export function regenerateDocumentApi(documentId: string) {
return requestClient.post<GeneratedDocument>(
`/api/online_dev/document-generator/documents/${documentId}/regenerate`,
);
}
// ==================== 电子签章 API ====================
/**
* 获取签章列表
*/
export function getSealListApi(params: {
keyword?: string;
page?: number;
pageSize?: number;
sealType?: string;
status?: string;
}) {
return requestClient.get<{
items: ElectronicSeal[];
total: number;
}>('/api/online_dev/document-generator/seals', { params });
}
/**
* 获取可用签章
*/
export function getAvailableSealsApi(templateId?: string) {
return requestClient.get<ElectronicSeal[]>(
'/api/online_dev/document-generator/seals/available',
{ params: { templateId } },
);
}
/**
* 创建签章
*/
export function createSealApi(data: ElectronicSealCreateInput) {
return requestClient.post<ElectronicSeal>(
'/api/online_dev/document-generator/seals',
data,
);
}
/**
* 获取签章详情
*/
export function getSealDetailApi(sealId: string) {
return requestClient.get<ElectronicSeal>(
`/api/online_dev/document-generator/seals/${sealId}`,
);
}
/**
* 更新签章
*/
export function updateSealApi(sealId: string, data: ElectronicSealUpdateInput) {
return requestClient.put<ElectronicSeal>(
`/api/online_dev/document-generator/seals/${sealId}`,
data,
);
}
/**
* 删除签章
*/
export function deleteSealApi(sealId: string) {
return requestClient.delete(
`/api/online_dev/document-generator/seals/${sealId}`,
);
}
// ==================== 常量 ====================
export const TEMPLATE_CATEGORIES = [
{ value: 'leave', label: '请假单' },
{ value: 'expense', label: '报销单' },
{ value: 'purchase', label: '采购单' },
{ value: 'contract', label: '合同' },
{ value: 'certificate', label: '证明' },
{ value: 'other', label: '其他' },
];
export const TEMPLATE_STATUS = [
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '已发布' },
];
export const PAGE_SIZES = [
{ value: 'A4', label: 'A4 (210mm × 297mm)' },
{ value: 'A3', label: 'A3 (297mm × 420mm)' },
{ value: 'Letter', label: 'Letter (216mm × 279mm)' },
{ value: 'Legal', label: 'Legal (216mm × 356mm)' },
];
export const PAGE_ORIENTATIONS = [
{ value: 'portrait', label: '纵向' },
{ value: 'landscape', label: '横向' },
];
export const SEAL_TYPES = [
{ value: 'company', label: '公司公章' },
{ value: 'department', label: '部门章' },
{ value: 'personal', label: '个人章' },
{ value: 'contract', label: '合同专用章' },
{ value: 'finance', label: '财务专用章' },
];
// ==================== 字体 ====================
export interface FontOption {
label: string;
value: string;
}
/**
* 获取服务器支持的字体列表
*/
export function getAvailableFontsApi() {
return requestClient.get<FontOption[]>(
'/api/online_dev/document-generator/fonts',
);
}
@@ -0,0 +1,193 @@
import { requestClient } from '#/api/request';
/**
* 电子签章 API
*/
// ============ 类型定义 ============
/** 签章类型 */
export type SealType =
| 'company'
| 'contract'
| 'department'
| 'finance'
| 'personal';
/** 签章状态 */
export type SealStatus = 'active' | 'disabled';
/** 所有者类型 */
export type OwnerType = 'all' | 'dept' | 'role' | 'user';
/** 使用范围 */
export type ScopeType = 'all' | 'specific';
/** 电子签章 */
export interface ElectronicSeal {
id: string;
name: string;
seal_type: SealType;
description?: string;
seal_image_id: string;
seal_image_url?: string;
owner_type: OwnerType;
owner_ids?: string[];
scope: ScopeType;
allowed_template_ids?: string[];
width: number;
height: number;
status: SealStatus;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
/** 创建签章请求 */
export interface ElectronicSealCreateInput {
name: string;
seal_type: SealType;
description?: string;
seal_image_id: string;
owner_type?: OwnerType;
owner_ids?: string[];
scope?: ScopeType;
allowed_template_ids?: string[];
width?: number;
height?: number;
}
/** 更新签章请求 */
export interface ElectronicSealUpdateInput {
name?: string;
description?: string;
seal_image_id?: string;
owner_type?: OwnerType;
owner_ids?: string[];
scope?: ScopeType;
allowed_template_ids?: string[];
width?: number;
height?: number;
status?: SealStatus;
}
/** 列表查询参数 */
export interface SealListParams {
page?: number;
pageSize?: number;
sealType?: SealType;
status?: SealStatus;
keyword?: string;
}
/** 签章使用记录 */
export interface SealUsageLog {
id: string;
seal_id: string;
seal_name?: string;
document_id: string;
document_name?: string;
user_id: string;
user_name?: string;
position_x: number;
position_y: number;
page_number: number;
sys_create_datetime?: string;
}
/** 分页响应 */
interface PaginatedResponse<T> {
items: T[];
total: number;
}
// ============ API 函数 ============
/**
* 获取签章列表(分页)
*/
export async function getSealListApi(params?: SealListParams) {
return requestClient.get<PaginatedResponse<ElectronicSeal>>(
'/api/online_dev/electronic-seal',
{ params },
);
}
/**
* 获取可用签章列表
*/
export async function getAvailableSealsApi(templateId?: string) {
return requestClient.get<ElectronicSeal[]>(
'/api/online_dev/electronic-seal/available',
{ params: { templateId } },
);
}
/**
* 获取签章详情
*/
export async function getSealDetailApi(sealId: string) {
return requestClient.get<ElectronicSeal>(
`/api/online_dev/electronic-seal/${sealId}`,
);
}
/**
* 创建签章
*/
export async function createSealApi(data: ElectronicSealCreateInput) {
return requestClient.post<ElectronicSeal>(
'/api/online_dev/electronic-seal',
data,
);
}
/**
* 更新签章
*/
export async function updateSealApi(
sealId: string,
data: ElectronicSealUpdateInput,
) {
return requestClient.put<ElectronicSeal>(
`/api/online_dev/electronic-seal/${sealId}`,
data,
);
}
/**
* 删除签章
*/
export async function deleteSealApi(sealId: string) {
return requestClient.delete(`/api/online_dev/electronic-seal/${sealId}`);
}
/**
* 启用签章
*/
export async function enableSealApi(sealId: string) {
return requestClient.post<ElectronicSeal>(
`/api/online_dev/electronic-seal/${sealId}/enable`,
);
}
/**
* 禁用签章
*/
export async function disableSealApi(sealId: string) {
return requestClient.post<ElectronicSeal>(
`/api/online_dev/electronic-seal/${sealId}/disable`,
);
}
/**
* 获取签章使用记录
*/
export async function getSealUsageLogsApi(
sealId: string,
params?: { page?: number; pageSize?: number },
) {
return requestClient.get<PaginatedResponse<SealUsageLog>>(
`/api/online_dev/electronic-seal/${sealId}/logs`,
{ params },
);
}
@@ -0,0 +1,846 @@
import { requestClient } from '#/api/request';
/**
* 表单数据操作 API
* 动态操作表单数据,支持主表和子表的 CRUD、导入导出
*/
// ============ 类型定义 ============
/** 表单数据创建请求 */
export interface FormDataCreateInput {
main: Record<string, any>;
sub_tables?: Record<string, Record<string, any>[]>;
}
/** 表单数据更新请求 */
export interface FormDataUpdateInput {
main: Record<string, any>;
sub_tables?: Record<string, Record<string, any>[]>;
}
/** 表单数据列表响应 */
export interface FormDataListResponse {
items: Record<string, any>[];
total: number;
page: number;
page_size: number;
}
/** 表单数据详情(含子表) */
export interface FormDataDetail {
main: Record<string, any>;
sub_tables: Record<string, Record<string, any>[]>;
}
/** 列表查询参数 */
export interface FormDataListParams {
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: 'asc' | 'desc';
[key: string]: any; // 动态过滤条件
}
/** 游标分页查询参数 */
export interface CursorPaginationParams {
cursor?: string;
cursorDirection?: 'next' | 'prev';
pageSize?: number;
[key: string]: any;
}
/** 游标分页列表响应 */
export interface CursorPaginationResponse {
items: Record<string, any>[];
has_more: boolean;
next_cursor: null | string;
prev_cursor: null | string;
page_size: number;
}
/** 导入错误详情 */
export interface ImportError {
row: number;
field: null | string;
error: string;
type: 'header' | 'option' | 'required' | 'system' | 'type';
}
/** 导入结果 */
export interface ImportResult {
success: number;
fail: number;
message: string;
errors: Array<{ row: number; error: string }>;
validated?: boolean;
will_insert?: number;
will_update?: number;
action?: string;
}
/** 导出任务状态 */
export type ExportTaskStatus =
| 'cancelled'
| 'completed'
| 'expired'
| 'failed'
| 'pending'
| 'processing';
/** 创建导出任务响应 */
export interface CreateExportTaskResponse {
task_id: string;
status: ExportTaskStatus;
message: string;
}
/** 导出任务状态响应 */
export interface ExportTaskStatusResponse {
task_id: string;
status: ExportTaskStatus;
progress: number;
total_count: number;
processed_count: number;
file_size: null | number;
error_message: null | string;
created_at: null | string;
started_at: null | string;
completed_at: null | string;
expires_at: null | string;
}
// ============ 权限 API ============
/** 表单操作权限 */
export interface FormPermissions {
view: boolean;
add: boolean;
edit: boolean;
delete: boolean;
export: boolean;
import: boolean;
}
/** 字段权限配置 */
export interface FieldPermission {
permission_type?: 'hidden' | 'masked' | 'read' | 'write';
permission?: 'hidden' | 'masked' | 'read' | 'write';
mask_rule?: 'email' | 'id_card' | 'name' | 'phone' | null;
}
/** 字段权限映射 */
export type FieldPermissions = Record<string, FieldPermission>;
/**
* 获取当前用户对表单的操作权限
*/
export async function getFormPermissionsApi(
formCode: string,
): Promise<FormPermissions> {
return requestClient.get<FormPermissions>(
`/api/online_dev/form-data/${formCode}/permissions`,
);
}
/**
* 获取当前用户对表单的字段权限
*/
export async function getFieldPermissionsApi(
formCode: string,
): Promise<FieldPermissions> {
return requestClient.get<FieldPermissions>(
`/api/online_dev/form-data/${formCode}/field-permissions`,
);
}
// ============ 表单数据 CRUD API ============
/**
* 查询表单数据列表
*/
export async function getFormDataListApi(
formCode: string,
params?: FormDataListParams,
) {
return requestClient.get<FormDataListResponse>(
`/api/online_dev/form-data/${formCode}/list`,
{ params },
);
}
/**
* 游标分页查询表单数据列表(跳过 COUNT,适用于大数据量)
*/
export async function getFormDataListCursorApi(
formCode: string,
params?: CursorPaginationParams,
) {
return requestClient.get<CursorPaginationResponse>(
`/api/online_dev/form-data/${formCode}/list/cursor`,
{ params },
);
}
/** 树形子节点查询参数 */
export interface TreeChildrenParams {
parentId?: string;
parentField?: string;
}
/** 树形节点数据 */
export interface TreeNodeData extends Record<string, any> {
id: string;
has_children: boolean;
}
/**
* 获取树形数据子节点(懒加载)
* @param formCode 表单编码
* @param params 查询参数
* @returns 子节点列表
*/
export async function getTreeChildrenApi(
formCode: string,
params?: TreeChildrenParams,
) {
return requestClient.get<TreeNodeData[]>(
`/api/online_dev/form-data/${formCode}/tree/children`,
{ params },
);
}
/**
* 获取表单数据详情(含子表)
*/
export async function getFormDataDetailApi(formCode: string, pk: string) {
return requestClient.get<FormDataDetail>(
`/api/online_dev/form-data/${formCode}/detail/${pk}`,
);
}
/** 字段唯一值响应 */
export interface FieldValuesResponse {
items: { count: number; label: string; value: any }[];
total: number;
hasMore: boolean;
}
/** 字段唯一值查询参数 */
export interface FieldValuesParams {
page?: number;
pageSize?: number;
search?: string;
}
/**
* 获取字段唯一值列表(用于过滤选项)
*/
export async function getFieldValuesApi(
formCode: string,
fieldName: string,
params?: FieldValuesParams,
) {
return requestClient.get<FieldValuesResponse>(
`/api/online_dev/form-data/${formCode}/field-values/${fieldName}`,
{ params },
);
}
/** 唯一性检查参数 */
export interface CheckUniqueParams {
field: string;
value: string;
excludeId?: string;
}
/** 唯一性检查响应 */
export interface CheckUniqueResponse {
unique: boolean;
}
/**
* 检查字段值唯一性
*/
export async function checkFormDataUniqueApi(
formCode: string,
params: CheckUniqueParams,
) {
return requestClient.get<CheckUniqueResponse>(
`/api/online_dev/form-data/${formCode}/check-unique`,
{ params },
);
}
/**
* 新增表单数据
*/
export async function createFormDataApi(
formCode: string,
data: FormDataCreateInput,
) {
return requestClient.post<Record<string, any>>(
`/api/online_dev/form-data/${formCode}`,
data,
);
}
/**
* 更新表单数据
*/
export async function updateFormDataApi(
formCode: string,
pk: string,
data: FormDataUpdateInput,
) {
return requestClient.put<Record<string, any>>(
`/api/online_dev/form-data/${formCode}/${pk}`,
data,
);
}
/**
* 删除表单数据
*/
export async function deleteFormDataApi(formCode: string, pk: string) {
return requestClient.delete<{ success: boolean }>(
`/api/online_dev/form-data/${formCode}/${pk}`,
);
}
/**
* 批量删除表单数据
*/
export async function batchDeleteFormDataApi(formCode: string, ids: string[]) {
return requestClient.delete<{ count: number }>(
`/api/online_dev/form-data/${formCode}/batch/delete`,
{ params: { ids } },
);
}
// ============ 导入导出 API ============
/**
* 导出表单数据为 Excel
*/
export async function exportFormDataExcelApi(
formCode: string,
options?: {
ids?: string[];
includeSubTables?: boolean;
},
) {
return requestClient.download<Blob>(
`/api/online_dev/form-data/${formCode}/export/excel`,
{ params: options },
);
}
/**
* 获取导入模板
*/
export async function getImportTemplateApi(formCode: string) {
return requestClient.download<Blob>(
`/api/online_dev/form-data/${formCode}/import/template`,
);
}
/** 导入选项 */
export interface ImportOptions {
mode?: 'append' | 'overwrite'; // 导入模式:追加或覆盖
dataHandling?: 'insert_only' | 'update_only' | 'upsert'; // 数据处理方式
matchField?: string; // 更新模式下的匹配字段
validateOnly?: boolean; // 是否仅验证数据
}
/**
* 从 Excel 导入表单数据
*/
export async function importFormDataExcelApi(
formCode: string,
file: File,
options?: ImportOptions,
) {
const formData = new FormData();
formData.append('file', file);
if (options?.mode) {
formData.append('mode', options.mode);
}
if (options?.dataHandling) {
formData.append('data_handling', options.dataHandling);
}
if (options?.matchField) {
formData.append('match_field', options.matchField);
}
if (options?.validateOnly !== undefined) {
formData.append('validate_only', String(options.validateOnly));
}
return requestClient.post<ImportResult>(
`/api/online_dev/form-data/${formCode}/import/excel`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
}
/** 导入进度回调参数 */
export interface ImportProgressData {
processed: number;
total: number;
percent: number;
stage: 'importing' | 'parsing';
success: number;
fail: number;
}
/** SSE 导入完成回调参数 */
export interface ImportCompletedData {
success: number;
fail: number;
errors: Array<{ row: number; error: string }>;
message: string;
}
/** 验证进度回调参数 */
export interface ValidateProgressData {
processed: number;
total: number;
percent: number;
stage: string;
}
/** SSE 验证完成回调参数 */
export interface ValidateCompletedData {
success: number;
fail: number;
errors: Array<{ row: number; error: string }>;
message: string;
validated: boolean;
will_insert: number;
will_update: number;
action: string;
}
/**
* SSE 方式验证导入数据(带实时进度)
* 返回 abort 函数用于中断请求
*/
export function validateFormDataSSE(
formCode: string,
file: File,
options: Omit<ImportOptions, 'validateOnly'>,
callbacks: {
onProgress?: (data: ValidateProgressData) => void;
onCompleted?: (data: ValidateCompletedData) => void;
onError?: (message: string) => void;
},
): { abort: () => void; promise: Promise<void> } {
const controller = new AbortController();
const formData = new FormData();
formData.append('file', file);
if (options.mode) formData.append('mode', options.mode);
if (options.dataHandling) formData.append('data_handling', options.dataHandling);
if (options.matchField) formData.append('match_field', options.matchField);
let sseBuffer = '';
let pendingEvent = '';
let pendingData = '';
function handleSSEEvent(event: string, dataStr: string) {
try {
const parsed = JSON.parse(dataStr);
switch (event) {
case 'progress': {
callbacks.onProgress?.(parsed);
break;
}
case 'completed': {
callbacks.onCompleted?.(parsed);
break;
}
case 'error': {
callbacks.onError?.(parsed.message || 'Validate failed');
break;
}
}
} catch {
// ignore parse errors
}
}
function flushPending() {
if (pendingEvent && pendingData) {
handleSSEEvent(pendingEvent, pendingData);
}
pendingEvent = '';
pendingData = '';
}
function parseSseLines(text: string) {
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('event: ')) {
if (pendingEvent && pendingData) flushPending();
pendingEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
pendingData = line.slice(6);
} else if (line.trim() === '') {
flushPending();
}
}
}
const promise = requestClient.postSSE(
`/api/online_dev/form-data/${formCode}/import/validate/sse`,
formData,
{
signal: controller.signal,
onMessage(content: string) {
sseBuffer += content;
const lastDoubleNewline = sseBuffer.lastIndexOf('\n\n');
if (lastDoubleNewline !== -1) {
const completePart = sseBuffer.slice(0, lastDoubleNewline + 2);
sseBuffer = sseBuffer.slice(lastDoubleNewline + 2);
parseSseLines(completePart);
}
},
onEnd() {
if (sseBuffer.trim()) {
parseSseLines(sseBuffer);
flushPending();
}
sseBuffer = '';
},
},
);
return { abort: () => controller.abort(), promise };
}
/**
* SSE 方式导入表单数据(带实时进度)
* 返回 abort 函数用于中断请求
*/
export function importFormDataSSE(
formCode: string,
file: File,
options: Omit<ImportOptions, 'validateOnly'>,
callbacks: {
onProgress?: (data: ImportProgressData) => void;
onCompleted?: (data: ImportCompletedData) => void;
onError?: (message: string) => void;
},
): { abort: () => void; promise: Promise<void> } {
const controller = new AbortController();
const formData = new FormData();
formData.append('file', file);
if (options.mode) formData.append('mode', options.mode);
if (options.dataHandling) formData.append('data_handling', options.dataHandling);
if (options.matchField) formData.append('match_field', options.matchField);
let sseBuffer = '';
let pendingEvent = '';
let pendingData = '';
function handleSSEEvent(event: string, dataStr: string) {
try {
const parsed = JSON.parse(dataStr);
switch (event) {
case 'progress': {
callbacks.onProgress?.(parsed);
break;
}
case 'completed': {
callbacks.onCompleted?.(parsed);
break;
}
case 'error': {
callbacks.onError?.(parsed.message || 'Import failed');
break;
}
}
} catch {
// ignore parse errors
}
}
function flushPending() {
if (pendingEvent && pendingData) {
handleSSEEvent(pendingEvent, pendingData);
}
pendingEvent = '';
pendingData = '';
}
function parseSseLines(text: string) {
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('event: ')) {
if (pendingEvent && pendingData) flushPending();
pendingEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
pendingData = line.slice(6);
} else if (line.trim() === '') {
flushPending();
}
}
}
const promise = requestClient.postSSE(
`/api/online_dev/form-data/${formCode}/import/sse`,
formData,
{
signal: controller.signal,
onMessage(content: string) {
sseBuffer += content;
const lastDoubleNewline = sseBuffer.lastIndexOf('\n\n');
if (lastDoubleNewline !== -1) {
const completePart = sseBuffer.slice(0, lastDoubleNewline + 2);
sseBuffer = sseBuffer.slice(lastDoubleNewline + 2);
parseSseLines(completePart);
}
},
onEnd() {
if (sseBuffer.trim()) {
parseSseLines(sseBuffer);
flushPending();
}
sseBuffer = '';
},
},
);
return { abort: () => controller.abort(), promise };
}
// ============ 配置 ============
export interface ImportExportConfig {
maxRows: number;
serverMemoryGB: number;
}
/**
* 获取导入导出配置(最大行数等)
*/
export function getImportExportConfigApi(): Promise<ImportExportConfig> {
return requestClient.get('/api/online_dev/form-data/import-export/config');
}
// ============ 辅助函数 ============
/**
* 下载 Blob 文件
*/
export function downloadBlob(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
/**
* 导出并下载表单数据
*/
export async function downloadFormDataExcel(
formCode: string,
options?: {
filename?: string;
ids?: string[];
includeSubTables?: boolean;
},
) {
const blob = await exportFormDataExcelApi(formCode, {
ids: options?.ids,
includeSubTables: options?.includeSubTables,
});
downloadBlob(blob, options?.filename || `${formCode}_data.xlsx`);
}
/**
* 下载导入模板
*/
export async function downloadImportTemplate(
formCode: string,
filename?: string,
) {
const blob = await getImportTemplateApi(formCode);
downloadBlob(blob, filename || `${formCode}_import_template.xlsx`);
}
// ============ 异步导出任务 API ============
/**
* 创建异步导出任务
*/
export async function createExportTaskApi(
formCode: string,
options?: {
ids?: string[];
includeSubTables?: boolean;
},
) {
return requestClient.post<CreateExportTaskResponse>(
`/api/online_dev/form-data/${formCode}/export/task`,
null,
{ params: options },
);
}
/**
* 获取导出任务状态
*/
export async function getExportTaskStatusApi(formCode: string, taskId: string) {
return requestClient.get<ExportTaskStatusResponse>(
`/api/online_dev/form-data/${formCode}/export/task/${taskId}/status`,
);
}
/**
* 下载导出任务文件
*/
export async function downloadExportTaskFileApi(
formCode: string,
taskId: string,
) {
return requestClient.download<Blob>(
`/api/online_dev/form-data/${formCode}/export/task/${taskId}/download`,
);
}
/**
* 取消导出任务
*/
export async function cancelExportTaskApi(formCode: string, taskId: string) {
return requestClient.post<{ message: string; success: boolean }>(
`/api/online_dev/form-data/${formCode}/export/task/${taskId}/cancel`,
);
}
/**
* 轮询导出任务状态直到完成或失败
* 支持页面离开时自动停止轮询
*/
export function pollExportTask(
formCode: string,
taskId: string,
options?: {
filename?: string;
onProgress?: (
progress: number,
status: ExportTaskStatus,
details?: ExportTaskStatusResponse,
) => void;
pollInterval?: number;
},
): { promise: Promise<void>; stop: () => void } {
const { onProgress, pollInterval = 3000 } = options || {}; // 默认3秒
let stopped = false;
let timeoutId: null | ReturnType<typeof setTimeout> = null;
const stop = () => {
stopped = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
const promise = new Promise<void>((resolve, reject) => {
const poll = async () => {
if (stopped) {
return; // 已停止,不再轮询
}
try {
console.log(`[轮询] 查询任务状态: ${taskId}`);
const status = await getExportTaskStatusApi(formCode, taskId);
console.log(`[轮询] 任务状态:`, status);
if (stopped) return; // 请求期间可能被停止
onProgress?.(status.progress, status.status, status);
switch (status.status) {
case 'cancelled': {
reject(new Error('导出任务已取消'));
break;
}
case 'completed': {
// 下载文件
const blob = await downloadExportTaskFileApi(formCode, taskId);
downloadBlob(blob, options?.filename || `${formCode}_data.xlsx`);
resolve();
break;
}
case 'expired': {
reject(new Error('导出任务已过期'));
break;
}
case 'failed': {
reject(new Error(status.error_message || '导出失败'));
break;
}
default: {
if (!stopped) {
// 继续轮询
timeoutId = setTimeout(poll, pollInterval);
}
}
}
} catch (error) {
if (!stopped) {
reject(error);
}
}
};
poll();
});
return { promise, stop };
}
/**
* 异步导出并下载(带轮询)
*/
export async function asyncExportFormData(
formCode: string,
options?: {
filename?: string;
ids?: string[];
includeSubTables?: boolean;
onProgress?: (
progress: number,
status: ExportTaskStatus,
details?: ExportTaskStatusResponse,
) => void;
pollInterval?: number;
},
): Promise<{ stop: () => void }> {
// 1. 创建任务
const { task_id } = await createExportTaskApi(formCode, {
ids: options?.ids,
includeSubTables: options?.includeSubTables,
});
// 2. 轮询状态
const { promise, stop } = pollExportTask(formCode, task_id, options);
await promise;
return { stop };
}
@@ -0,0 +1,404 @@
import { requestClient } from '#/api/request';
import { resolveFormListApplicationIdParam } from '#/utils/form-list-application-id';
/**
* 表单管理 API
* 表单元数据的 CRUD、发布、复制、导入导出
*/
// ============ 类型定义 ============
/** 子表配置 */
export interface FormSubTable {
id?: string;
table_name: string;
table_schema?: string;
table_database?: string;
alias?: string;
foreign_key: string;
related_field?: string;
relation_type?: string;
sort?: number;
}
/** 表单类型 */
export type FormType = 'normal' | 'workflow';
/** 表单元数据 */
export interface FormMeta {
id: string;
application_id?: string;
name: string;
code: string;
form_type: FormType;
description: string;
status: string;
version: number;
db_config: string;
main_table: string;
main_table_schema: string;
main_table_database: string;
form_config: Record<string, any>;
list_config: Record<string, any>;
sort: number;
show_in_mobile: boolean;
globally_visible?: boolean;
icon: string;
icon_bg_color: string;
sys_create_datetime: string;
sys_update_datetime: string;
sub_tables?: FormSubTable[];
}
/** 表单列表项 */
export interface FormMetaListItem {
id: string;
application_id?: string;
application_name: string;
application_code?: string;
name: string;
code: string;
form_type: FormType;
description: string;
status: string;
version: number;
main_table: string;
sort: number;
show_in_mobile?: boolean;
globally_visible?: boolean;
icon?: string;
icon_bg_color?: string;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 创建表单请求 */
export interface FormMetaCreateInput {
application_id?: string;
name: string;
code: string;
form_type?: FormType;
description?: string;
sort?: number;
show_in_mobile?: boolean;
globally_visible?: boolean;
icon?: string;
icon_bg_color?: string;
db_config: string;
main_table: string;
main_table_schema?: string;
main_table_database?: string;
form_config?: Record<string, any>;
list_config?: Record<string, any>;
sub_tables?: FormSubTable[];
}
/** 更新表单请求 */
export interface FormMetaUpdateInput {
name?: string;
form_type?: FormType;
description?: string;
sort?: number;
show_in_mobile?: boolean;
globally_visible?: boolean;
icon?: string;
icon_bg_color?: string;
form_config?: Record<string, any>;
list_config?: Record<string, any>;
sub_tables?: FormSubTable[];
}
/** 表 DDL 信息 */
export interface TableDDL {
main_table: string;
sub_tables: Record<string, string>;
}
/** 表重命名映射 */
export interface TableRenameMapping {
original_name: string;
new_name: string;
new_schema?: string;
}
/** 导入表单请求 */
export interface FormImportInput {
application_id?: string;
name: string;
code: string;
form_type?: FormType;
description?: string;
db_config: string;
main_table: string;
main_table_schema?: string;
main_table_database?: string;
form_config?: Record<string, any>;
list_config?: Record<string, any>;
show_in_mobile?: boolean;
globally_visible?: boolean;
sub_tables?: FormSubTable[];
table_ddl?: TableDDL;
auto_create_tables?: boolean;
/** 目标 Schema 不存在时自动创建(PostgreSQL / SQL Server */
create_schema_if_not_exists?: boolean;
table_rename_mappings?: TableRenameMapping[];
/** 导出时源库类型(用于跨方言自动建表校验) */
export_db_type?: string;
}
/** 导入预检查请求 */
export interface FormImportCheckInput {
code: string;
db_config: string;
main_table: string;
main_table_schema?: string;
main_table_database?: string;
sub_tables?: FormSubTable[];
table_ddl?: TableDDL;
}
/** 单表检查结果 */
export interface TableCheckResult {
table_name: string;
schema_name: string;
exists: boolean;
has_ddl: boolean;
}
/** 导入预检查结果 */
export interface FormImportCheckResult {
code_exists: boolean;
main_table_check: TableCheckResult;
sub_table_checks: TableCheckResult[];
can_import: boolean;
available_schemas: string[];
/** 目标连接数据库类型 */
target_db_type?: string;
}
/** 列表查询参数 */
export interface FormListParams {
page?: number;
pageSize?: number;
applicationId?: string;
name?: string;
code?: string;
form_type?: FormType;
status?: string;
/** 是否包含其他应用全局可见的表单(选择器场景) */
includeGloballyVisible?: boolean;
}
/** 发布配置 */
export interface FormPublishInput {
/** 菜单名称 */
menu_name: string;
/** 上级菜单ID */
menu_parent_id?: string;
/** 菜单图标 */
menu_icon?: string;
/** 菜单排序 */
menu_order?: number;
}
/** 分页响应 */
interface FormPaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// ============ 表单元数据 API ============
/**
* 获取表单列表(分页)
*/
export async function getFormListApi(params?: FormListParams) {
const applicationId = resolveFormListApplicationIdParam(params?.applicationId);
return requestClient.get<FormPaginatedResponse<FormMetaListItem>>(
'/api/online_dev/form/list',
{ params: { ...params, applicationId } },
);
}
/**
* 获取分类列表
*/
export async function getFormCategoriesApi() {
return requestClient.get<string[]>('/api/online_dev/form/categories');
}
/**
* 获取表单详情
*/
export async function getFormDetailApi(formId: string) {
return requestClient.get<FormMeta>(`/api/online_dev/form/${formId}`);
}
/**
* 根据编码获取表单
*/
export async function getFormByCodeApi(code: string) {
return requestClient.get<FormMeta>(`/api/online_dev/form/code/${code}`);
}
/**
* 创建表单
*/
export async function createFormApi(data: FormMetaCreateInput) {
return requestClient.post<FormMeta>('/api/online_dev/form', data);
}
/**
* 更新表单
*/
export async function updateFormApi(formId: string, data: FormMetaUpdateInput) {
return requestClient.put<FormMeta>(`/api/online_dev/form/${formId}`, data);
}
/** 表单物理表校验 */
export interface FormValidateTablesInput {
db_config: string;
main_table: string;
main_table_schema?: string;
main_table_database?: string;
sub_tables?: FormSubTable[];
}
export interface FormValidateTablesResult {
valid: boolean;
connection_ok: boolean;
connection_message: string;
db_config: string;
main_table_exists: boolean;
sub_table_checks: Array<{
table_name: string;
schema_name: string;
exists: boolean;
has_ddl?: boolean;
}>;
}
export async function validateFormTablesApi(data: FormValidateTablesInput) {
return requestClient.post<FormValidateTablesResult>(
'/api/online_dev/form/validate-tables',
data,
);
}
/**
* 删除表单
*/
export async function deleteFormApi(formId: string) {
return requestClient.delete<FormMeta>(`/api/online_dev/form/${formId}`);
}
/**
* 批量删除表单
*/
export async function batchDeleteFormApi(ids: string[]) {
return requestClient.delete<{ count: number }>(
'/api/online_dev/form/batch/delete',
{
params: { ids },
},
);
}
/**
* 发布表单
*/
export async function publishFormApi(formId: string, data: FormPublishInput) {
return requestClient.post<FormMeta>(
`/api/online_dev/form/${formId}/publish`,
data,
);
}
/**
* 取消发布表单
*/
export async function unpublishFormApi(formId: string) {
return requestClient.post<FormMeta>(
`/api/online_dev/form/${formId}/unpublish`,
);
}
/**
* 复制表单
*/
export async function copyFormApi(
formId: string,
newCode: string,
newName?: string,
) {
return requestClient.post<FormMeta>(
`/api/online_dev/form/${formId}/copy`,
null,
{
params: { new_code: newCode, new_name: newName },
},
);
}
/**
* 导出表单配置(返回 JSON 文件)
*/
export async function exportFormConfigApi(formId: string) {
return requestClient.get<Blob>(`/api/online_dev/form/${formId}/export`, {
responseType: 'blob',
});
}
/**
* 批量导出表单配置
*/
export async function batchExportFormConfigApi(ids: string[]) {
return requestClient.post<Blob>('/api/online_dev/form/batch/export', ids, {
responseType: 'blob',
});
}
/**
* 导入预检查
*/
export async function checkImportFormApi(data: FormImportCheckInput) {
return requestClient.post<FormImportCheckResult>(
'/api/online_dev/form/import/check',
data,
);
}
/**
* 导入表单配置
*/
export async function importFormConfigApi(data: FormImportInput) {
return requestClient.post<FormMeta>('/api/online_dev/form/import', data);
}
/** 已发布表单简单信息 */
export interface PublishedFormSimple {
code: string;
name: string;
mainTable: string;
application_id?: string;
application_name?: string;
fields: Array<{
field: string;
label: string;
type: string;
}>;
}
/**
* 获取已发布表单简单列表(用于下拉选择)
*/
export async function getPublishedFormsSimpleApi(applicationId?: string) {
const resolvedApplicationId =
resolveFormListApplicationIdParam(applicationId);
return requestClient.get<PublishedFormSimple[]>(
'/api/online_dev/form/published/simple',
{ params: { applicationId: resolvedApplicationId } },
);
}
@@ -0,0 +1,433 @@
import { requestClient } from '#/api/request';
/** 报表模板 */
export interface ReportTemplate {
id: string;
application_id?: string;
name: string;
code: string;
category: string;
description: string;
status: string;
allow_export: boolean;
allow_print: boolean;
allow_watermark: boolean;
watermark_config: Record<string, any>;
sort: number;
active_version_id?: string;
sys_create_datetime: string;
sys_update_datetime: string;
}
export interface ReportTemplateListItem {
id: string;
application_id?: string;
application_name?: string;
application_code?: string;
name: string;
code: string;
category: string;
description: string;
status: string;
has_release_menu?: boolean;
sort: number;
sys_create_datetime: string;
sys_update_datetime: string;
}
export interface ReportVersion {
id: string;
template_id: string;
version: number;
state: number;
snapshot: Record<string, any>;
cells: Record<string, any>;
query_list: any[];
sort_list: any[];
column_list: any[];
fence_list: any[];
convert_config: Record<string, any>;
datasets: ReportDatasetItem[];
sys_create_datetime: string;
sys_update_datetime: string;
}
export interface ReportDatasetItem {
id?: string;
data_source_id: string;
data_source_code?: string;
data_source_name?: string;
alias: string;
field_mapping?: Record<string, any>;
convert_config?: Record<string, any>;
sort?: number;
}
export interface ReportTemplateCreateInput {
application_id?: string;
name: string;
code: string;
category?: string;
description?: string;
sort?: number;
}
export interface ReportTemplateUpdateInput {
name?: string;
category?: string;
description?: string;
sort?: number;
allow_export?: boolean;
allow_print?: boolean;
allow_watermark?: boolean;
watermark_config?: Record<string, any>;
}
export interface ReportSaveInput {
id: string;
versionId?: string;
type?: number;
snapshot?: string | Record<string, any>;
cells?: string | Record<string, any>;
queryList?: any[];
sortList?: any[];
columnList?: any[];
fenceList?: any[];
convertConfig?: any;
dataSetList?: ReportDatasetItem[];
}
export interface ReportSaveResult {
template_id: string;
version_id: string;
state: number;
}
export interface ReportListParams {
page?: number;
pageSize?: number;
applicationId?: string;
name?: string;
code?: string;
category?: string;
status?: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
}
export async function getReportListApi(params?: ReportListParams) {
return requestClient.get<PaginatedResponse<ReportTemplateListItem>>(
'/api/online_dev/report/list',
{ params },
);
}
export async function getReportCategoriesApi(applicationId?: string) {
return requestClient.get<string[]>('/api/online_dev/report/categories', {
params: { applicationId },
});
}
export async function getReportDetailApi(templateId: string) {
return requestClient.get<ReportTemplate>(
`/api/online_dev/report/${templateId}`,
);
}
export async function getReportByCodeApi(code: string) {
return requestClient.get<ReportTemplate>(
`/api/online_dev/report/code/${code}`,
);
}
export async function createReportApi(data: ReportTemplateCreateInput) {
return requestClient.post<ReportTemplate>('/api/online_dev/report', data);
}
export async function updateReportApi(
templateId: string,
data: ReportTemplateUpdateInput,
) {
return requestClient.put<ReportTemplate>(
`/api/online_dev/report/${templateId}`,
data,
);
}
export async function deleteReportApi(templateId: string) {
return requestClient.delete<ReportTemplate>(
`/api/online_dev/report/${templateId}`,
);
}
export async function getReportVersionsApi(templateId: string) {
return requestClient.get<
Array<{
id: string;
template_id: string;
version: number;
state: number;
sys_create_datetime: string;
sys_update_datetime: string;
}>
>(`/api/online_dev/report/${templateId}/versions`);
}
export async function getReportVersionApi(versionId: string) {
return requestClient.get<ReportVersion>(
`/api/online_dev/report/version/${versionId}`,
);
}
export async function saveReportVersionApi(data: ReportSaveInput) {
return requestClient.post<ReportSaveResult>(
'/api/online_dev/report/save',
data,
);
}
export async function copyReportApi(
templateId: string,
newCode: string,
newName?: string,
) {
return requestClient.post<ReportTemplate>(
`/api/online_dev/report/${templateId}/copy`,
null,
{ params: { newCode, newName } },
);
}
export async function copyReportVersionApi(versionId: string) {
return requestClient.post<ReportSaveResult>(
`/api/online_dev/report/version/${versionId}/copy`,
);
}
export async function deleteReportVersionApi(versionId: string) {
return requestClient.delete<{ success: boolean }>(
`/api/online_dev/report/version/${versionId}`,
);
}
export interface ReportReleaseMenuInfo {
published: boolean;
menu: {
id: string;
title: string;
path: string;
parent_id?: string;
icon?: string;
order?: number;
} | null;
}
export async function getReportReleaseMenuApi(templateId: string) {
return requestClient.get<ReportReleaseMenuInfo>(
`/api/online_dev/report/${templateId}/release-menu`,
);
}
export interface ReportSelectorCategory {
category: string;
children: Array<{
id: string;
name: string;
code: string;
status: string;
}>;
}
export async function getReportSelectorApi(applicationId?: string) {
return requestClient.get<ReportSelectorCategory[]>(
'/api/online_dev/report/selector',
{ params: { applicationId } },
);
}
export async function exportReportPdfApi(
templateId: string,
params?: Record<string, any>,
) {
return requestClient.post<Blob>(
'/api/online_dev/report/data/export-pdf/template',
{ params: params || {} },
{
params: { templateId },
responseType: 'blob',
},
);
}
export async function previewReportVersionApi(
versionId: string,
params?: Record<string, any>,
) {
return requestClient.post<Record<string, any>>(
`/api/online_dev/report/data/${versionId}/preview`,
{ params: params || {} },
);
}
export async function previewReportTemplateApi(
templateId: string,
params?: Record<string, any>,
) {
return requestClient.post<Record<string, any>>(
'/api/online_dev/report/data/preview-template',
{ params: params || {} },
{ params: { templateId } },
);
}
export async function previewReportDesignApi(
versionId: string,
params?: Record<string, any>,
draft?: {
snapshot?: string;
cells?: string;
queryList?: any[];
sortList?: any[];
columnList?: any[];
fenceList?: any[];
convertConfig?: any[];
},
) {
return requestClient.post<Record<string, any>>(
`/api/online_dev/report/data/${versionId}/preview-design`,
{
params: params || {},
...(draft || {}),
},
);
}
export async function getReportQueryListApi(templateId: string) {
return requestClient.get<{ queryList: any[] }>(
`/api/online_dev/report/data/query-list/${templateId}`,
);
}
export async function getReportQueryListByCodeApi(code: string) {
return requestClient.get<{
queryList: any[];
templateId: string;
fullName?: string;
}>(`/api/online_dev/report/data/query-list/code/${code}`);
}
export async function checkImportReportApi(code: string) {
return requestClient.post<{ code_exists: boolean; can_import: boolean }>(
'/api/online_dev/report/import/check',
{ code },
);
}
export interface ReportImportInput {
schema_version?: number;
application_id?: string;
name: string;
code: string;
category?: string;
description?: string;
sort?: number;
allow_export?: boolean;
allow_print?: boolean;
allow_watermark?: boolean;
watermark_config?: Record<string, any>;
version?: Record<string, any>;
datasets?: ReportDatasetItem[];
}
export async function exportReportConfigApi(templateId: string) {
return requestClient.get<Blob>(`/api/online_dev/report/${templateId}/export`, {
responseType: 'blob',
});
}
export async function importReportConfigApi(data: ReportImportInput) {
return requestClient.post<ReportTemplate>('/api/online_dev/report/import', data);
}
export async function uploadReportFileApi(file: File) {
const form = new FormData();
form.append('file', file);
return requestClient.post<{ name: string; url: string }>(
'/api/online_dev/report/data/upload/file',
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
}
export async function downloadReportImageApi(
imgValue: string,
imgType: 'BASE64' | 'URL' = 'URL',
) {
return requestClient.post<{ name: string; url: string }>(
'/api/online_dev/report/data/downImg',
{ imgValue, imgType },
);
}
export interface ReportPublishInput {
menu_name: string;
menu_parent_id?: string;
menu_icon?: string;
menu_order?: number;
}
export async function publishReportApi(
templateId: string,
data: ReportPublishInput,
) {
return requestClient.post<ReportTemplate>(
`/api/online_dev/report/${templateId}/publish`,
data,
);
}
export async function unpublishReportApi(templateId: string) {
return requestClient.post<ReportTemplate>(
`/api/online_dev/report/${templateId}/unpublish`,
);
}
export async function exportReportDesignExcelApi(
versionId: string,
params?: Record<string, any>,
draft?: {
snapshot?: string;
cells?: string;
queryList?: any[];
sortList?: any[];
columnList?: any[];
fenceList?: any[];
convertConfig?: any[];
},
) {
return requestClient.post<Blob>(
`/api/online_dev/report/data/${versionId}/export-excel-design`,
{
params: params || {},
...(draft || {}),
},
{ responseType: 'blob' },
);
}
export async function exportReportExcelApi(
templateId: string,
params?: Record<string, any>,
) {
return requestClient.post<Blob>(
'/api/online_dev/report/data/export-excel/template',
{ params: params || {} },
{
params: { templateId },
responseType: 'blob',
},
);
}
@@ -0,0 +1,435 @@
import { requestClient } from '#/api/request';
/**
* 大屏设计器 API
* 大屏项目的 CRUD、发布、复制、导入导出
*/
// ============ 类型定义 ============
/** 大屏项目 */
export interface ScreenProject {
id: string;
application_id?: string;
name: string;
code: string;
description: string;
status: string;
version: number;
thumbnail: string;
screen_config: Record<string, any>;
sort: number;
access_password: string;
published_at: null | string;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 大屏项目列表项 */
export interface ScreenProjectListItem {
id: string;
application_id?: string;
name: string;
code: string;
description: string;
status: string;
version: number;
thumbnail: string;
sort: number;
access_password: string;
published_at: null | string;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 创建大屏项目请求 */
export interface ScreenProjectCreateInput {
application_id?: string;
name: string;
code: string;
description?: string;
sort?: number;
screen_config?: Record<string, any>;
thumbnail?: string;
}
/** 更新大屏项目请求 */
export interface ScreenProjectUpdateInput {
name?: string;
description?: string;
sort?: number;
screen_config?: Record<string, any>;
thumbnail?: string;
}
/** 导入大屏请求 */
export interface ScreenImportInput {
application_id?: string;
name: string;
code: string;
description?: string;
screen_config?: Record<string, any>;
}
/** 导入预检查请求 */
export interface ScreenImportCheckInput {
code: string;
}
/** 导入预检查结果 */
export interface ScreenImportCheckResult {
code_exists: boolean;
can_import: boolean;
}
/** 列表查询参数 */
export interface ScreenListParams {
page?: number;
pageSize?: number;
name?: string;
code?: string;
status?: string;
applicationId?: string;
}
/** 发布配置 */
export interface ScreenPublishInput {
/** 访问密码 */
access_password?: string;
}
/** 设置密码请求 */
export interface ScreenSetPasswordInput {
/** 访问密码,空字符串表示清除密码 */
access_password: string;
}
/** 分页响应 */
interface ScreenPaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// ============ 大屏项目 API ============
/**
* 获取大屏项目列表(分页)
*/
export async function getScreenListApi(params?: ScreenListParams) {
return requestClient.get<ScreenPaginatedResponse<ScreenProjectListItem>>(
'/api/online_dev/screen/list',
{ params },
);
}
/**
* 获取大屏项目详情
*/
export async function getScreenDetailApi(projectId: string) {
return requestClient.get<ScreenProject>(
`/api/online_dev/screen/${projectId}`,
);
}
/**
* 根据编码获取大屏项目
*/
export async function getScreenByCodeApi(code: string) {
return requestClient.get<ScreenProject>(
`/api/online_dev/screen/code/${code}`,
);
}
/**
* 创建大屏项目
*/
export async function createScreenApi(data: ScreenProjectCreateInput) {
return requestClient.post<ScreenProject>('/api/online_dev/screen', data);
}
/**
* 更新大屏项目
*/
export async function updateScreenApi(
projectId: string,
data: ScreenProjectUpdateInput,
) {
return requestClient.put<ScreenProject>(
`/api/online_dev/screen/${projectId}`,
data,
);
}
/**
* 删除大屏项目
*/
export async function deleteScreenApi(projectId: string) {
return requestClient.delete<ScreenProject>(
`/api/online_dev/screen/${projectId}`,
);
}
/**
* 批量删除大屏项目
*/
export async function batchDeleteScreenApi(ids: string[]) {
return requestClient.delete<{ count: number }>(
'/api/online_dev/screen/batch',
{
params: { ids },
},
);
}
/**
* 发布大屏
*/
export async function publishScreenApi(
projectId: string,
data: ScreenPublishInput,
) {
return requestClient.post<ScreenProject>(
`/api/online_dev/screen/${projectId}/publish`,
data,
);
}
/**
* 取消发布大屏
*/
export async function unpublishScreenApi(projectId: string) {
return requestClient.post<ScreenProject>(
`/api/online_dev/screen/${projectId}/unpublish`,
);
}
/**
* 设置访问密码
*/
export async function setScreenPasswordApi(
projectId: string,
data: ScreenSetPasswordInput,
) {
return requestClient.post<ScreenProject>(
`/api/online_dev/screen/${projectId}/set-password`,
data,
);
}
/**
* 验证访问密码
*/
export async function verifyScreenPasswordApi(
projectId: string,
password: string,
) {
return requestClient.post<{ valid: boolean }>(
`/api/online_dev/screen/${projectId}/verify-password`,
{ access_password: password },
);
}
/**
* 复制大屏项目
*/
export async function copyScreenApi(
projectId: string,
newCode: string,
newName?: string,
) {
return requestClient.post<ScreenProject>(
`/api/online_dev/screen/${projectId}/copy`,
null,
{
params: { newCode, newName },
},
);
}
/**
* 导出大屏配置(返回 JSON 文件)
*/
export async function exportScreenConfigApi(projectId: string) {
return requestClient.get<Blob>(`/api/online_dev/screen/${projectId}/export`, {
responseType: 'blob',
});
}
/**
* 导入预检查
*/
export async function checkImportScreenApi(data: ScreenImportCheckInput) {
return requestClient.post<ScreenImportCheckResult>(
'/api/online_dev/screen/import/check',
data,
);
}
/**
* 导入大屏配置
*/
export async function importScreenConfigApi(data: ScreenImportInput) {
return requestClient.post<ScreenProject>(
'/api/online_dev/screen/import',
data,
);
}
// ============ 素材分类类型定义 ============
/** 素材分类 */
export interface MaterialCategory {
id: string;
name: string;
code: string;
icon: string;
sort: number;
material_count: number;
}
/** 创建素材分类请求 */
export interface MaterialCategoryCreateInput {
name: string;
code: string;
icon?: string;
sort?: number;
}
/** 更新素材分类请求 */
export interface MaterialCategoryUpdateInput {
name?: string;
icon?: string;
sort?: number;
}
// ============ 素材类型定义 ============
/** 素材 */
export interface Material {
id: string;
name: string;
category_id: string;
category_name: string;
file_id: null | string;
file_url: string;
file_type: string;
file_size: number;
sort: number;
sys_create_datetime: string;
}
/** 创建素材请求 */
export interface MaterialCreateInput {
category_id?: string;
name: string;
file_id: string;
}
/** 更新素材请求 */
export interface MaterialUpdateInput {
name?: string;
category_id?: string;
sort?: number;
}
// ============ 素材分类 API ============
/**
* 获取素材分类列表
*/
export async function getMaterialCategoriesApi() {
return requestClient.get<MaterialCategory[]>(
'/api/online_dev/screen/material/categories',
);
}
/**
* 创建素材分类
*/
export async function createMaterialCategoryApi(
data: MaterialCategoryCreateInput,
) {
return requestClient.post<MaterialCategory>(
'/api/online_dev/screen/material/categories',
data,
);
}
/**
* 更新素材分类
*/
export async function updateMaterialCategoryApi(
categoryId: string,
data: MaterialCategoryUpdateInput,
) {
return requestClient.put<MaterialCategory>(
`/api/online_dev/screen/material/categories/${categoryId}`,
data,
);
}
/**
* 删除素材分类
*/
export async function deleteMaterialCategoryApi(categoryId: string) {
return requestClient.delete<{ success: boolean }>(
`/api/online_dev/screen/material/categories/${categoryId}`,
);
}
// ============ 素材 API ============
/**
* 获取素材列表
*/
export async function getMaterialsApi(categoryId?: string) {
return requestClient.get<Material[]>('/api/online_dev/screen/material/list', {
params: categoryId ? { category_id: categoryId } : undefined,
});
}
/**
* 创建素材
*/
export async function createMaterialApi(data: MaterialCreateInput) {
return requestClient.post<Material>(
'/api/online_dev/screen/material/create',
data,
);
}
/**
* 更新素材
*/
export async function updateMaterialApi(
materialId: string,
data: MaterialUpdateInput,
) {
return requestClient.put<Material>(
`/api/online_dev/screen/material/${materialId}`,
data,
);
}
/**
* 删除素材
*/
export async function deleteMaterialApi(materialId: string) {
return requestClient.delete<{ success: boolean }>(
`/api/online_dev/screen/material/${materialId}`,
);
}
/**
* 批量删除素材
*/
export async function batchDeleteMaterialsApi(ids: string[]) {
return requestClient.delete<{ count: number }>(
'/api/online_dev/screen/material/batch',
{
params: { ids },
},
);
}
@@ -0,0 +1,775 @@
import { requestClient } from '#/api/request';
import { resolveFormListApplicationIdParam } from '#/utils/form-list-application-id';
/**
* 工作流管理 API
*/
// ============ 类型定义 ============
/** 流程状态 */
export type WorkflowStatus = 'disabled' | 'draft' | 'published';
/** 流程类型 */
export type WorkflowType =
| 'admin'
| 'application'
| 'approval'
| 'business'
| 'finance'
| 'hr'
| 'other';
/** 流程元数据 */
export interface WorkflowMeta {
id: string;
application_id?: string;
name: string;
code: string;
workflow_type: WorkflowType;
icon: string;
icon_bg_color: string;
category: string;
description: string;
form_code: string;
form_name: string;
document_template_codes: string[];
status: WorkflowStatus;
version: number;
sort: number;
flow_definition: Record<string, any>;
sys_create_datetime: string;
}
/** 流程列表项 */
export interface WorkflowListItem {
id: string;
application_id?: string;
application_name?: string;
name: string;
code: string;
workflow_type: WorkflowType;
icon: string;
icon_bg_color: string;
category: string;
description: string;
form_code: string;
form_name: string;
status: WorkflowStatus;
version: number;
sort: number;
sys_create_datetime: string;
}
/** 创建流程请求 */
export interface WorkflowCreateInput {
application_id?: string;
name: string;
code: string;
workflow_type?: WorkflowType;
icon?: string;
icon_bg_color?: string;
category?: string;
description?: string;
form_code: string;
document_template_codes?: string[];
flow_definition?: Record<string, any>;
sort?: number;
}
/** 更新流程请求 */
export interface WorkflowUpdateInput {
name?: string;
workflow_type?: WorkflowType;
icon?: string;
icon_bg_color?: string;
category?: string;
description?: string;
form_code?: string;
document_template_codes?: string[];
flow_definition?: Record<string, any>;
sort?: number;
}
/** 列表查询参数 */
export interface WorkflowListParams {
page?: number;
pageSize?: number;
applicationId?: string;
allApps?: boolean;
name?: string;
code?: string;
workflowType?: WorkflowType;
category?: string;
status?: WorkflowStatus;
form_code?: string;
}
/** 分页响应 */
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// ============ API 接口 ============
const BASE_URL = '/api/online_dev/workflow';
/**
* 获取流程列表(分页)
*/
export async function getWorkflowListApi(params?: WorkflowListParams) {
return requestClient.get<PaginatedResponse<WorkflowListItem>>(
`${BASE_URL}/list`,
{ params },
);
}
/**
* 获取分类列表
*/
export async function getWorkflowCategoriesApi() {
return requestClient.get<string[]>(`${BASE_URL}/categories`);
}
/**
* 获取流程详情
*/
export async function getWorkflowDetailApi(id: string) {
return requestClient.get<WorkflowMeta>(`${BASE_URL}/${id}`);
}
/**
* 根据编码获取流程
*/
export async function getWorkflowByCodeApi(code: string) {
return requestClient.get<WorkflowMeta>(`${BASE_URL}/code/${code}`);
}
/**
* 创建流程
*/
export async function createWorkflowApi(data: WorkflowCreateInput) {
return requestClient.post<WorkflowMeta>(BASE_URL, data);
}
/**
* 更新流程
*/
export async function updateWorkflowApi(id: string, data: WorkflowUpdateInput) {
return requestClient.put<WorkflowMeta>(`${BASE_URL}/${id}`, data);
}
/**
* 删除流程
*/
export async function deleteWorkflowApi(id: string) {
return requestClient.delete(`${BASE_URL}/${id}`);
}
/**
* 批量删除流程
*/
export async function batchDeleteWorkflowApi(ids: string[]) {
return requestClient.delete<{ count: number }>(`${BASE_URL}/batch/delete`, {
params: { ids },
});
}
/**
* 发布流程
*/
export async function publishWorkflowApi(id: string) {
return requestClient.post<WorkflowMeta>(`${BASE_URL}/${id}/publish`);
}
/**
* 停用流程
*/
export async function disableWorkflowApi(id: string) {
return requestClient.post<WorkflowMeta>(`${BASE_URL}/${id}/disable`);
}
/**
* 复制流程
*/
export async function copyWorkflowApi(
id: string,
newCode: string,
newName?: string,
) {
return requestClient.post<WorkflowMeta>(`${BASE_URL}/${id}/copy`, null, {
params: { new_code: newCode, new_name: newName },
});
}
/** 导入流程请求 */
export interface WorkflowImportInput {
application_id?: string;
name: string;
code: string;
workflow_type?: WorkflowType;
icon?: string;
icon_bg_color?: string;
category?: string;
description?: string;
form_code: string;
document_template_codes?: string[];
flow_definition?: Record<string, any>;
sort?: number;
}
export interface WorkflowImportCheckInput {
code: string;
}
export interface WorkflowImportCheckResult {
code_exists: boolean;
can_import: boolean;
}
/** 导出流程配置 */
export async function exportWorkflowConfigApi(id: string) {
return requestClient.get<Blob>(`${BASE_URL}/${id}/export`, {
responseType: 'blob',
});
}
/** 导入预检查 */
export async function checkImportWorkflowApi(data: WorkflowImportCheckInput) {
return requestClient.post<WorkflowImportCheckResult>(
`${BASE_URL}/import/check`,
data,
);
}
/** 导入流程配置 */
export async function importWorkflowConfigApi(data: WorkflowImportInput) {
return requestClient.post<WorkflowMeta>(`${BASE_URL}/import`, data);
}
/** 获取可关联表单列表参数 */
export interface AvailableFormsParams {
formType?: 'normal' | 'workflow';
/** 所属应用 ID;子应用传当前应用 ID,主应用不传(仅主应用表单) */
applicationId?: string;
}
/**
* 获取可关联的表单列表
* @param params 表单类型与应用过滤
*/
export async function getAvailableFormsApi(
params: AvailableFormsParams = {},
) {
const { formType = 'workflow', applicationId } = params;
const resolvedApplicationId = resolveFormListApplicationIdParam(applicationId);
return requestClient.get<
Array<{
application_id?: string;
application_name?: string;
code: string;
name: string;
}>
>(
`${BASE_URL}/available-forms`,
{
params: {
form_type: formType,
applicationId: resolvedApplicationId,
},
},
);
}
/**
* 获取表单关联的已发布流程
*/
export async function getWorkflowsByFormApi(formCode: string) {
return requestClient.get<WorkflowListItem[]>(
`${BASE_URL}/by-form/${formCode}`,
);
}
// ============ 流程实例 API ============
/** 流程实例 */
export interface WorkflowInstance {
id: string;
instance_no: string;
title: string;
status: 'approved' | 'cancelled' | 'pending' | 'rejected';
workflow_id?: string;
workflow_name: string;
workflow_code?: string;
form_code?: string;
form_data_id?: string;
current_node_id?: string;
current_node_name: string;
initiator_id?: string;
initiator_name: string;
started_at: string;
completed_at?: string;
}
/** 流程实例列表项 */
export interface WorkflowInstanceListItem {
id: string;
instance_no: string;
title: string;
status: string;
workflow_name: string;
application_name?: string;
current_node_name: string;
initiator_name: string;
started_at: string;
completed_at?: string;
has_document_templates?: boolean;
}
/** 发起流程参数 */
export interface StartWorkflowInput {
workflow_code: string;
title: string;
form_data: Record<string, any>;
}
/**
* 发起流程
*/
export async function startWorkflowApi(data: StartWorkflowInput) {
return requestClient.post<WorkflowInstance>(
`${BASE_URL}/instance/start`,
data,
);
}
/**
* 获取我发起的流程列表
*/
export async function getMyInitiatedApi(params?: {
page?: number;
pageSize?: number;
status?: string;
title?: string;
workflow_name?: string;
}) {
return requestClient.get<PaginatedResponse<WorkflowInstanceListItem>>(
`${BASE_URL}/instance/my-initiated`,
{ params },
);
}
/**
* 获取全部流程实例列表(需 workflow:instance:list 权限)
*/
export async function getInstanceListApi(params?: {
page?: number;
pageSize?: number;
status?: string;
title?: string;
workflow_name?: string;
instance_no?: string;
initiator_name?: string;
workflow_id?: string;
application_id?: string;
}) {
return requestClient.get<PaginatedResponse<WorkflowInstanceListItem>>(
`${BASE_URL}/instance/list`,
{ params },
);
}
/**
* 获取流程实例详情
*/
export async function getInstanceDetailApi(instanceId: string) {
return requestClient.get<WorkflowInstance>(
`${BASE_URL}/instance/${instanceId}`,
);
}
/**
* 获取流程实例的表单数据
*/
export async function getInstanceFormDataApi(instanceId: string) {
return requestClient.get<Record<string, any>>(
`${BASE_URL}/instance/${instanceId}/form-data`,
);
}
/**
* 取消流程
*/
export async function cancelInstanceApi(instanceId: string) {
return requestClient.post(`${BASE_URL}/instance/${instanceId}/cancel`);
}
/**
* 催办
*/
export async function urgeInstanceApi(instanceId: string) {
return requestClient.post(`${BASE_URL}/instance/${instanceId}/urge`);
}
/**
* 获取流程日志
*/
export async function getInstanceLogsApi(instanceId: string) {
return requestClient.get<WorkflowLog[]>(
`${BASE_URL}/instance/${instanceId}/logs`,
);
}
/**
* 获取流程实例当前待办任务
*/
export async function getInstancePendingTasksApi(instanceId: string) {
return requestClient.get<WorkflowTaskListItem[]>(
`${BASE_URL}/instance/${instanceId}/pending-tasks`,
);
}
// ============ 流程进度 API ============
/** 处理人信息 */
export interface ProgressHandler {
user_id: string;
user_name: string;
status: string; // pending/approved/rejected/transferred/delegated/waiting
action: string; // approve/reject/transfer/delegate/add_sign
comment: string;
handled_at: string;
}
/** 额外操作(加签、转交等) */
export interface ProgressExtraAction {
type: string; // add_sign/reduce_sign/transfer/delegate/return
from_user_id: string;
from_user_name: string;
to_user_id: string;
to_user_name: string;
sign_type: string; // before/after/parallel
status: string;
time: string;
comment: string;
}
/** 进度节点 */
export interface ProgressNode {
id: string;
name: string;
type: string; // start/approval/handle/copy/condition/parallel/delay/notify/service/subflow/end
status: string; // completed/active/pending/skipped/rejected
completed_at: string;
handlers: ProgressHandler[];
extra_actions: ProgressExtraAction[];
condition_result?: string;
branches?: any[];
delay_until?: string;
}
/** 驳回记录 */
export interface ProgressReturn {
from_node_id: string;
from_node_name: string;
to_node_id: string;
to_node_name: string;
operator_name: string;
reason: string;
time: string;
}
/** 流程进度 */
export interface FlowProgress {
instance_id: string;
instance_status: string;
current_node_id: string;
nodes: ProgressNode[];
returns: ProgressReturn[];
}
/**
* 获取流程实例执行进度
*/
export async function getInstanceProgressApi(instanceId: string) {
return requestClient.get<FlowProgress>(
`${BASE_URL}/instance/${instanceId}/progress`,
);
}
/** 流程实例关联的单据 */
export interface InstanceDocument {
id: string;
template_id: string;
template_code?: string;
template_name?: string;
document_name: string;
document_no?: string;
file_id: string;
file_size: number;
page_count: number;
generate_type: string;
generator_id?: string;
generator_name?: string;
sys_create_datetime?: string;
}
/**
* 获取流程实例关联的单据
*/
export async function getInstanceDocumentsApi(instanceId: string) {
return requestClient.get<InstanceDocument[]>(
`${BASE_URL}/instance/${instanceId}/documents`,
);
}
/**
* 生成流程实例单据
*/
export async function generateInstanceDocumentsApi(instanceId: string) {
return requestClient.post<{ count: number; message: string }>(
`${BASE_URL}/instance/${instanceId}/generate-documents`,
);
}
/** 流程日志 */
export interface WorkflowLog {
id: string;
node_id: string;
node_name: string;
action: string;
operator_id: string;
operator_name: string;
comment: string;
extra_data?: {
[key: string]: any;
signature_file_id?: string;
};
sys_create_datetime: string;
}
// ============ 任务 API ============
/** 任务 */
export interface WorkflowTask {
id: string;
instance_id: string;
instance_no: string;
instance_title: string;
node_id: string;
node_name: string;
task_type: 'approval' | 'copy';
status: 'approved' | 'pending' | 'read' | 'rejected' | 'transferred';
assignee_id: string;
assignee_name: string;
comment: string;
handled_at?: string;
created_at: string;
workflow_name: string;
initiator_name: string;
started_at?: string;
}
/** 任务列表项 */
export interface WorkflowTaskListItem {
id: string;
instance_id: string;
instance_no: string;
instance_title: string;
instance_status?: string;
node_name: string;
task_type: string;
status: string;
assignee_id?: string;
assignee_name?: string;
workflow_name: string;
initiator_name: string;
started_at?: string;
created_at: string;
sys_create_datetime?: string;
has_document_templates?: boolean;
// 超时相关字段
timeout_at?: string;
timeout_action?: string;
is_timeout?: boolean;
}
/**
* 获取我的待办任务
*/
export async function getMyPendingTasksApi(params?: {
instance_title?: string;
page?: number;
pageSize?: number;
workflow_name?: string;
}) {
return requestClient.get<PaginatedResponse<WorkflowTaskListItem>>(
`${BASE_URL}/task/pending`,
{ params },
);
}
/**
* 获取我的已办任务
*/
export async function getMyHandledTasksApi(params?: {
instance_title?: string;
page?: number;
pageSize?: number;
workflow_name?: string;
}) {
return requestClient.get<PaginatedResponse<WorkflowTaskListItem>>(
`${BASE_URL}/task/handled`,
{ params },
);
}
/**
* 获取抄送给我的任务
*/
export async function getMyCopyTasksApi(params?: {
instance_title?: string;
page?: number;
pageSize?: number;
workflow_name?: string;
}) {
return requestClient.get<PaginatedResponse<WorkflowTaskListItem>>(
`${BASE_URL}/task/cc`,
{ params },
);
}
/**
* 获取抄送给我的任务(别名)
*/
export const getCopyToMeApi = getMyCopyTasksApi;
/**
* 获取待办任务数量
*/
export async function getPendingCountApi() {
return requestClient.get<{ count: number }>(`${BASE_URL}/task/pending/count`);
}
/** 操作权限 */
export interface ActionPermission {
action:
| 'add_sign'
| 'approve'
| 'delegate'
| 'reduce_sign'
| 'reject'
| 'return'
| 'transfer';
enabled: boolean;
label: string;
}
/** 任务详情(包含表单数据) */
/** 驳回记录 */
export interface ReturnLog {
operator_name: string;
comment: string;
created_at: string;
}
export interface WorkflowTaskDetail extends WorkflowTask {
form_code: string;
form_config: any;
form_data: Record<string, any>;
form_permissions: Array<{
access: 'editable' | 'hidden' | 'readonly';
field: string;
}>;
action_permissions: ActionPermission[];
require_signature?: boolean;
last_return_log?: null | ReturnLog;
}
/**
* 获取任务详情
*/
export async function getTaskDetailApi(taskId: string) {
return requestClient.get<WorkflowTaskDetail>(`${BASE_URL}/task/${taskId}`);
}
/**
* 审批任务
*/
export async function approveTaskApi(
taskId: string,
data: {
action: 'approve' | 'reject' | 'return';
comment?: string;
form_data?: Record<string, any>;
return_to?: string; // 驳回目标: 'initiator' 或节点ID
signature?: string; // 签名数据(base64格式)
},
) {
return requestClient.post(`${BASE_URL}/task/${taskId}/approve`, data);
}
/**
* 办理任务
*/
export async function handleTaskApi(
taskId: string,
data: { comment?: string; form_data?: Record<string, any> },
) {
return requestClient.post(`${BASE_URL}/task/${taskId}/handle`, data);
}
/**
* 转交任务
*/
export async function transferTaskApi(
taskId: string,
data: { comment?: string; to_user_id: string },
) {
return requestClient.post(`${BASE_URL}/task/${taskId}/transfer`, data);
}
/**
* 委派任务
*/
export async function delegateTaskApi(
taskId: string,
data: { comment?: string; to_user_id: string },
) {
return requestClient.post(`${BASE_URL}/task/${taskId}/delegate`, data);
}
/**
* 加签任务
* @param sign_type - before: 前加签, after: 后加签, parallel: 并行加签
*/
export async function addSignTaskApi(
taskId: string,
data: {
comment?: string;
sign_type: 'after' | 'before' | 'parallel';
to_user_ids: string[];
},
) {
return requestClient.post(`${BASE_URL}/task/${taskId}/add-sign`, data);
}
/**
* 标记抄送已读
*/
export async function markCopyReadApi(taskId: string) {
return requestClient.post(`${BASE_URL}/task/${taskId}/read`);
}
/**
* 修改并重新提交(驳回后)
*/
export async function reviseTaskApi(
taskId: string,
data: { comment?: string; form_data: Record<string, any> },
) {
return requestClient.post(`${BASE_URL}/task/${taskId}/revise`, data);
}
@@ -16,7 +16,7 @@ import type {
* - agent-debug: 智能体编辑器调试模式
* - agent-chat: 智能体正式对话模式(支持历史、反馈)
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onUnmounted, ref, watch } from 'vue';
import { Settings2, X } from '@vben/icons';
@@ -31,19 +31,32 @@ import {
import { AiWorkingAnimation } from '#/components/ai-loading';
import { ChatBox } from '#/components/ChatBox';
import {
AppDesignPanel,
AppSettingsPanel,
DashboardBasicInfoConfirmPanel,
DashboardDesignConfirmPanel,
DashboardPublishConfirmPanel,
DesignEditorPanel,
SystemSummaryConfirmPanel,
} from '#/components/form-editor';
import { useChatApi } from './composables/useChatApi';
import { useEventHandler } from './composables/useEventHandler';
const AppDesignPanel = defineAsyncComponent(() =>
import('#/components/form-editor/AppDesignPanel.vue'),
);
const AppSettingsPanel = defineAsyncComponent(() =>
import('#/components/form-editor/AppSettingsPanel.vue'),
);
const DashboardBasicInfoConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DashboardBasicInfoConfirmPanel.vue'),
);
const DashboardDesignConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DashboardDesignConfirmPanel.vue'),
);
const DashboardPublishConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DashboardPublishConfirmPanel.vue'),
);
const DesignEditorPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DesignEditorPanel.vue'),
);
const SystemSummaryConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/SystemSummaryConfirmPanel.vue'),
);
// ==================== Props ====================
const props = withDefaults(defineProps<AiChatPanelProps>(), {
mode: 'workflow',
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,614 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
Code,
Copy,
Download,
Eye,
LayoutDashboard,
Redo2,
Trash2,
Undo2,
Upload,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElButtonGroup,
ElEmpty,
ElScrollbar,
ElTooltip,
} from 'element-plus';
import { GridItem, GridLayout } from 'grid-layout-plus';
import {
getWidgetMaterials,
useDashboardDesignStore,
} from '../store/dashboardDesignStore';
import WidgetRenderer from './WidgetRenderer.vue';
defineProps<{
showSave?: boolean;
}>();
const emit = defineEmits<{
(e: 'preview'): void;
(e: 'viewCode'): void;
(e: 'clear'): void;
(e: 'import'): void;
(e: 'export'): void;
(e: 'save'): void;
}>();
const store = useDashboardDesignStore();
// 布局数据 - 直接从 store 获取,不使用 computed setter 避免循环
const layout = computed(() =>
store.dashboardConfig.widgets.map((w) => ({
i: w.i,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
minW: w.minW,
minH: w.minH,
maxW: w.maxW,
maxH: w.maxH,
})),
);
// 拖拽相关
const isDragOver = ref(false);
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
isDragOver.value = true;
};
const handleDragLeave = () => {
isDragOver.value = false;
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
isDragOver.value = false;
// 从 dataTransfer 获取组件类型
const widgetType = e.dataTransfer?.getData('widget-type');
if (widgetType) {
const material = getWidgetMaterials().find((m) => m.type === widgetType);
if (material) {
// 计算拖放位置对应的网格坐标
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
// 计算列宽(考虑 margin
const margin = store.dashboardConfig.margin;
const colWidth =
(rect.width - margin[0] * (store.dashboardConfig.columns + 1)) /
store.dashboardConfig.columns;
const rowHeight = store.dashboardConfig.rowHeight;
// 转换为网格坐标
const x = Math.max(
0,
Math.min(
Math.floor(offsetX / (colWidth + margin[0])),
store.dashboardConfig.columns - material.defaultW,
),
);
const y = Math.max(0, Math.floor(offsetY / (rowHeight + margin[1])));
store.addWidget(material, { x, y });
}
}
};
// 布局变化
const handleLayoutUpdated = (newLayout: any[]) => {
store.updateLayout(newLayout);
};
// 选中 widget
const handleWidgetClick = (id: string, e: MouseEvent) => {
e.stopPropagation();
store.setActive(id);
};
// 取消选中
const handleCanvasClick = () => {
store.setActive(null);
};
// 删除 widget
const handleDelete = (id: string, e: MouseEvent) => {
e.stopPropagation();
store.deleteWidget(id);
};
// 复制 widget
const handleCopy = (id: string, e: MouseEvent) => {
e.stopPropagation();
store.copyWidget(id);
};
// 获取 widget 配置
const getWidget = (i: string) => {
return store.dashboardConfig.widgets.find((w) => w.i === i);
};
// 获取 widget 材料信息
const getWidgetMaterial = (type: string) => {
return getWidgetMaterials().find((m) => m.type === type);
};
// 获取动画延迟(交错入场效果)
const getAnimationDelay = (i: string) => {
const index = store.dashboardConfig.widgets.findIndex((w) => w.i === i);
return index * 80; // 每个组件延迟 80ms
};
// 右键菜单
const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const contextMenuWidgetId = ref<null | string>(null);
const handleContextMenu = (id: string, e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
store.setActive(id);
contextMenuWidgetId.value = id;
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
contextMenuVisible.value = true;
};
const closeContextMenu = () => {
contextMenuVisible.value = false;
contextMenuWidgetId.value = null;
};
// 右键菜单操作
const handleContextMenuAction = (action: string) => {
if (!contextMenuWidgetId.value) return;
switch (action) {
case 'copy': {
store.copyWidget(contextMenuWidgetId.value);
break;
}
case 'delete': {
store.deleteWidget(contextMenuWidgetId.value);
break;
}
}
closeContextMenu();
};
// 键盘快捷键
const handleKeyDown = (e: KeyboardEvent) => {
// 如果焦点在输入框中,不处理快捷键
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
// Delete 删除选中组件
if ((e.key === 'Delete' || e.key === 'Backspace') && store.activeId) {
e.preventDefault();
store.deleteWidget(store.activeId);
}
// Ctrl+Z 撤销
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
store.undo();
}
// Ctrl+Y 或 Ctrl+Shift+Z 重做
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'y' || (e.key === 'z' && e.shiftKey))
) {
e.preventDefault();
store.redo();
}
// Ctrl+C 复制
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && store.activeId) {
e.preventDefault();
store.copyWidget(store.activeId);
}
// Escape 取消选中
if (e.key === 'Escape') {
store.setActive(null);
closeContextMenu();
}
};
// 点击其他地方关闭右键菜单
const handleDocumentClick = () => {
closeContextMenu();
};
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('click', handleDocumentClick);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
<div class="design-canvas flex h-full w-full flex-col gap-3">
<!-- Header -->
<div class="canvas-header flex items-center justify-between">
<div class="flex items-center gap-2">
<LayoutDashboard class="h-4 w-4" />
<span class="font-medium">{{
$t('dashboard-design.canvas.title')
}}</span>
<span class="text-muted-foreground text-xs">
{{
$t('dashboard-design.widgetCount', {
count: store.dashboardConfig.widgets.length,
})
}}
</span>
</div>
<div class="flex items-center gap-1">
<!-- 撤销/重做 -->
<ElButtonGroup size="small">
<ElTooltip
:content="$t('dashboard-design.canvas.undo') || '撤销'"
placement="bottom"
>
<ElButton :disabled="!store.canUndo" @click="store.undo">
<Undo2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('dashboard-design.canvas.redo') || '重做'"
placement="bottom"
>
<ElButton :disabled="!store.canRedo" @click="store.redo">
<Redo2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
</ElButtonGroup>
<div class="bg-border mx-1 h-4 w-px"></div>
<!-- 操作按钮 -->
<ElTooltip :content="$t('dashboard-design.preview')" placement="bottom">
<ElButton size="small" @click="emit('preview')">
<Eye class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('dashboard-design.jsonPreview')"
placement="bottom"
>
<ElButton size="small" @click="emit('viewCode')">
<Code class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip :content="$t('dashboard-design.clear')" placement="bottom">
<ElButton size="small" @click="emit('clear')">
<Trash2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<div class="bg-border mx-1 h-4 w-px"></div>
<!-- 导入导出 -->
<ElTooltip :content="$t('dashboard-design.import')" placement="bottom">
<ElButton size="small" @click="emit('import')">
<Upload class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip :content="$t('dashboard-design.export')" placement="bottom">
<ElButton size="small" @click="emit('export')">
<Download class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-hidden">
<ElScrollbar class="h-full">
<div
class="canvas-container relative h-full"
:class="{ 'drag-over': isDragOver }"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="handleCanvasClick"
>
<ElEmpty
v-if="store.dashboardConfig.widgets.length === 0"
:description="$t('dashboard-design.dragTip')"
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
/>
<GridLayout
v-else
:layout="layout"
:col-num="store.dashboardConfig.columns"
:row-height="store.dashboardConfig.rowHeight"
:margin="store.dashboardConfig.margin"
:is-draggable="true"
:is-resizable="true"
:vertical-compact="true"
:use-css-transforms="true"
@layout-updated="handleLayoutUpdated"
>
<GridItem
v-for="item in layout"
:key="item.i"
:i="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
:min-w="item.minW"
:min-h="item.minH"
:max-w="item.maxW"
:max-h="item.maxH"
class="widget-item"
:class="{ active: store.activeId === item.i }"
@click="handleWidgetClick(item.i, $event)"
@contextmenu="handleContextMenu(item.i, $event)"
>
<div class="widget-wrapper h-full w-full">
<!-- 操作按钮 -->
<div v-if="store.activeId === item.i" class="widget-actions">
<button
class="action-btn"
:title="$t('dashboard-design.copy')"
@click="handleCopy(item.i, $event)"
>
<Copy class="h-3.5 w-3.5" />
</button>
<button
class="action-btn delete"
:title="$t('dashboard-design.delete')"
@click="handleDelete(item.i, $event)"
>
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
<!-- Widget 内容 -->
<WidgetRenderer
v-if="getWidget(item.i)"
:widget="getWidget(item.i)!"
:material="getWidgetMaterial(getWidget(item.i)!.type)"
:is-design-mode="true"
:animation-delay="getAnimationDelay(item.i)"
/>
</div>
</GridItem>
</GridLayout>
</div>
</ElScrollbar>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="contextMenuVisible"
class="context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<div class="context-menu-item" @click="handleContextMenuAction('copy')">
<Copy class="h-4 w-4" />
<span>{{ $t('dashboard-design.copy') }}</span>
<span class="shortcut">Ctrl+C</span>
</div>
<div
class="context-menu-item delete"
@click="handleContextMenuAction('delete')"
>
<Trash2 class="h-4 w-4" />
<span>{{ $t('dashboard-design.delete') }}</span>
<span class="shortcut">Delete</span>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.design-canvas {
overflow: hidden;
background-color: var(--el-bg-color-page);
border-radius: 8px;
}
.canvas-header {
flex-shrink: 0;
padding: 12px 16px;
background-color: var(--el-bg-color);
border-radius: 8px;
}
:deep(.el-scrollbar__wrap) {
height: 100%;
}
:deep(.el-scrollbar__view) {
height: 100%;
}
.canvas-container {
background-color: var(--el-color-primary-light-9);
background-image: radial-gradient(
circle,
var(--el-border-color-lighter) 1px,
transparent 1px
);
background-size: 20px 20px;
border-radius: 8px;
transition: background-color 0.2s;
}
.canvas-container.drag-over {
background-color: var(--el-color-primary-light-9);
}
.widget-item {
overflow: hidden;
background: var(--el-bg-color);
border: 2px solid transparent;
border-radius: 8px;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.widget-item:hover {
border-color: var(--el-color-primary-light-5);
}
.widget-item.active {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
}
.widget-wrapper {
position: relative;
}
.widget-actions {
position: absolute;
top: -32px;
right: 0;
z-index: 10;
display: flex;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
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;
}
.action-btn:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.action-btn.delete:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-color: var(--el-color-danger);
}
/* Grid Layout 样式覆盖 */
:deep(.vue-grid-item) {
touch-action: none;
}
:deep(.vue-grid-item.vue-grid-placeholder) {
background: var(--el-color-primary-light-7) !important;
border: 2px dashed var(--el-color-primary) !important;
border-radius: 8px;
}
:deep(.vue-resizable-handle) {
right: 4px;
bottom: 4px;
width: 16px;
height: 16px;
background: none;
}
:deep(.vue-resizable-handle::after) {
position: absolute;
right: 4px;
bottom: 4px;
width: 8px;
height: 8px;
content: '';
border-right: 2px solid var(--el-border-color);
border-bottom: 2px solid var(--el-border-color);
}
.widget-item.active :deep(.vue-resizable-handle::after) {
border-color: var(--el-color-primary);
}
/* 右键菜单样式 */
.context-menu {
position: fixed;
z-index: 9999;
min-width: 160px;
padding: 4px 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.context-menu-item {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
transition: all 0.15s;
}
.context-menu-item:hover {
color: var(--el-color-primary);
background: var(--el-fill-color-light);
}
.context-menu-item.delete:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
.context-menu-item .shortcut {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.context-menu-divider {
height: 1px;
margin: 4px 0;
background: var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElColorPicker,
ElRadioButton,
ElRadioGroup,
} from 'element-plus';
type ColorMode = 'gradient' | 'solid';
type GradientDirection =
| 'to bottom'
| 'to bottom left'
| 'to bottom right'
| 'to left'
| 'to right'
| 'to top'
| 'to top left'
| 'to top right';
interface GradientPreset {
label: string;
value: string;
}
const props = withDefaults(
defineProps<{
modelValue?: string;
showAlpha?: boolean;
}>(),
{
modelValue: '',
showAlpha: true,
},
);
const emit = defineEmits<{
(e: 'change', value: string): void;
(e: 'update:modelValue', value: string): void;
}>();
// 内置纯色预设(半透明,兼容 dark/light 模式)
const solidPresets = [
'rgba(99, 102, 241, 0.35)',
'rgba(59, 130, 246, 0.35)',
'rgba(14, 165, 233, 0.35)',
'rgba(20, 184, 166, 0.35)',
'rgba(34, 197, 94, 0.35)',
'rgba(132, 204, 22, 0.35)',
'rgba(245, 158, 11, 0.35)',
'rgba(249, 115, 22, 0.35)',
'rgba(239, 68, 68, 0.35)',
'rgba(236, 72, 153, 0.35)',
'rgba(168, 85, 247, 0.35)',
'rgba(107, 114, 128, 0.3)',
];
// 内置渐变色预设
const gradientPresets = computed<GradientPreset[]>(() => [
{
label: $t('dashboard-design.gradient.presets.warmSunrise'),
value:
'linear-gradient(135deg, rgba(245,247,250,0.45) 0%, rgba(195,207,226,0.45) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.oceanBreeze'),
value:
'linear-gradient(135deg, rgba(224,195,252,0.4) 0%, rgba(142,197,252,0.4) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.freshMint'),
value:
'linear-gradient(135deg, rgba(212,252,121,0.35) 0%, rgba(150,230,161,0.35) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.peachGlow'),
value:
'linear-gradient(135deg, rgba(255,236,210,0.45) 0%, rgba(252,182,159,0.4) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.lavenderDream'),
value:
'linear-gradient(135deg, rgba(161,140,209,0.35) 0%, rgba(251,194,235,0.35) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.skyBlue'),
value:
'linear-gradient(135deg, rgba(137,247,254,0.3) 0%, rgba(102,166,255,0.35) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.roseWater'),
value:
'linear-gradient(135deg, rgba(254,207,239,0.4) 0%, rgba(255,154,158,0.35) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.softGrass'),
value:
'linear-gradient(135deg, rgba(193,223,196,0.4) 0%, rgba(222,236,221,0.4) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.winterNymph'),
value:
'linear-gradient(135deg, rgba(161,196,253,0.35) 0%, rgba(194,233,251,0.35) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.cottonCandy'),
value:
'linear-gradient(135deg, rgba(243,231,233,0.4) 0%, rgba(227,238,255,0.4) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.sunnyMorning'),
value:
'linear-gradient(135deg, rgba(246,211,101,0.35) 0%, rgba(253,160,133,0.35) 100%)',
},
{
label: $t('dashboard-design.gradient.presets.crystalClear'),
value:
'linear-gradient(135deg, rgba(253,252,251,0.4) 0%, rgba(226,209,195,0.4) 100%)',
},
]);
const directionOptions = computed(() => [
{ label: '↓', value: 'to bottom' },
{ label: '→', value: 'to right' },
{ label: '↗', value: 'to top right' },
{ label: '↘', value: 'to bottom right' },
{ label: '↑', value: 'to top' },
{ label: '←', value: 'to left' },
{ label: '↖', value: 'to top left' },
{ label: '↙', value: 'to bottom left' },
]);
// 状态
const colorMode = ref<ColorMode>('solid');
const solidColor = ref('');
const gradientColor1 = ref('#f5f7fa');
const gradientColor2 = ref('#c3cfe2');
const gradientDirection = ref<GradientDirection>('to bottom right');
// 解析当前值
function parseValue(val: string) {
if (!val) {
colorMode.value = 'solid';
solidColor.value = '';
return;
}
const gradientMatch = val.match(
/linear-gradient\(\s*(to\s[^,]+|[\d.]+deg)\s*,\s*(#[\da-fA-F]{3,8}|rgba?\([^)]+\)|\w+)\s+\d+%\s*,\s*(#[\da-fA-F]{3,8}|rgba?\([^)]+\)|\w+)\s+\d+%\s*\)/,
);
if (gradientMatch) {
colorMode.value = 'gradient';
gradientDirection.value =
(gradientMatch[1]?.trim() as GradientDirection) || 'to bottom right';
gradientColor1.value = gradientMatch[2]?.trim() || '#f5f7fa';
gradientColor2.value = gradientMatch[3]?.trim() || '#c3cfe2';
} else {
colorMode.value = 'solid';
solidColor.value = val;
}
}
// 初始化
parseValue(props.modelValue);
// 监听外部值变化
watch(
() => props.modelValue,
(val) => {
parseValue(val || '');
},
);
// 生成渐变色值
function buildGradientValue(): string {
return `linear-gradient(${gradientDirection.value}, ${gradientColor1.value} 0%, ${gradientColor2.value} 100%)`;
}
// 发出更新
function emitUpdate(val: string) {
emit('update:modelValue', val);
emit('change', val);
}
// 纯色变化
function handleSolidChange(val: null | string) {
solidColor.value = val || '';
emitUpdate(solidColor.value);
}
// 渐变色参数变化
function handleGradientChange() {
const val = buildGradientValue();
emitUpdate(val);
}
// 切换模式
function handleModeChange(mode: ColorMode) {
colorMode.value = mode;
if (mode === 'solid') {
emitUpdate(solidColor.value);
} else {
handleGradientChange();
}
}
// 选择预设
function handlePresetClick(preset: GradientPreset) {
parseValue(preset.value);
emitUpdate(preset.value);
}
// 清除
function handleClear() {
solidColor.value = '';
emitUpdate('');
}
// 预览色块样式
const previewStyle = computed(() => {
const val = props.modelValue;
if (!val) return { backgroundColor: 'var(--el-fill-color-lighter)' };
if (val.includes('gradient')) return { background: val };
return { backgroundColor: val };
});
</script>
<template>
<div class="gradient-color-picker w-full">
<!-- 模式切换 -->
<ElRadioGroup
:model-value="colorMode"
size="small"
class="mb-2 w-full"
@change="
(val: string | number | boolean | undefined) =>
handleModeChange(val as ColorMode)
"
>
<ElRadioButton value="solid">
{{ $t('dashboard-design.gradient.solid') }}
</ElRadioButton>
<ElRadioButton value="gradient">
{{ $t('dashboard-design.gradient.gradient') }}
</ElRadioButton>
</ElRadioGroup>
<!-- 纯色模式 -->
<div v-if="colorMode === 'solid'" class="space-y-2">
<div class="flex items-center gap-2">
<ElColorPicker
:model-value="solidColor"
:show-alpha="showAlpha"
@change="handleSolidChange"
/>
<span class="text-muted-foreground flex-1 truncate text-xs">
{{ solidColor || $t('dashboard-design.gradient.noColor') }}
</span>
<ElButton v-if="solidColor" size="small" text @click="handleClear">
{{ $t('dashboard-design.attribute.reset') }}
</ElButton>
</div>
<!-- 纯色预设 -->
<div class="grid grid-cols-6 gap-1.5">
<button
v-for="(color, index) in solidPresets"
:key="index"
type="button"
class="h-6 w-full cursor-pointer rounded border transition-all hover:scale-110"
:class="
solidColor === color
? 'border-primary ring-primary/30 ring-2'
: 'border-gray-200 dark:border-gray-600'
"
:style="{ backgroundColor: color }"
@click="handleSolidChange(color)"
></button>
</div>
</div>
<!-- 渐变模式 -->
<div v-else class="space-y-2">
<!-- 预览 -->
<div class="h-8 w-full rounded-md border" :style="previewStyle"></div>
<!-- 两个颜色 -->
<div class="flex items-center gap-2">
<div class="flex flex-1 items-center gap-1">
<ElColorPicker
v-model="gradientColor1"
size="small"
show-alpha
@change="handleGradientChange"
/>
<span class="text-muted-foreground text-xs">
{{ $t('dashboard-design.gradient.startColor') }}
</span>
</div>
<div class="flex flex-1 items-center gap-1">
<ElColorPicker
v-model="gradientColor2"
size="small"
show-alpha
@change="handleGradientChange"
/>
<span class="text-muted-foreground text-xs">
{{ $t('dashboard-design.gradient.endColor') }}
</span>
</div>
</div>
<!-- 方向 -->
<div class="flex items-center gap-2">
<span class="text-muted-foreground whitespace-nowrap text-xs">
{{ $t('dashboard-design.gradient.direction') }}
</span>
<div class="direction-grid">
<button
v-for="opt in directionOptions"
:key="opt.value"
type="button"
class="direction-btn"
:class="{ active: gradientDirection === opt.value }"
:title="opt.value"
@click="
gradientDirection = opt.value as GradientDirection;
handleGradientChange();
"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- 预设渐变 -->
<div>
<div class="text-muted-foreground mb-1 text-xs">
{{ $t('dashboard-design.gradient.presetTitle') }}
</div>
<div class="preset-grid">
<button
v-for="preset in gradientPresets"
:key="preset.value"
type="button"
class="preset-item"
:style="{ background: preset.value }"
:title="preset.label"
@click="handlePresetClick(preset)"
></button>
</div>
</div>
<!-- 清除 -->
<div class="flex justify-end">
<ElButton size="small" text @click="handleClear">
{{ $t('dashboard-design.attribute.reset') }}
</ElButton>
</div>
</div>
</div>
</template>
<style scoped>
.direction-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
}
.direction-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 12px;
cursor: pointer;
background: var(--el-fill-color-lighter);
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s;
}
.direction-btn:hover {
background: var(--el-fill-color);
}
.direction-btn.active {
background: var(--el-color-primary-light-8);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.preset-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.preset-item {
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
cursor: pointer;
transition: all 0.2s;
}
.preset-item:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
</style>
@@ -0,0 +1,308 @@
<script setup lang="ts">
import type { WidgetMaterial } from '../store/dashboardDesignStore';
import { computed, ref } from 'vue';
import {
Activity,
AreaChart,
Award,
BarChart2,
Bell,
Calendar,
CalendarDays,
CalendarRange,
CandlestickChart,
CheckSquare,
ChevronsLeft,
ChevronsRight,
CircleDot,
Clock,
CloudSun,
CreditCard,
Filter,
Gauge,
GitBranch,
Globe,
Grid,
Image,
Images,
LayoutDashboard,
LayoutGrid,
LayoutList,
LineChart,
ListChecks,
ListFilter,
Megaphone,
Monitor,
PieChart,
Radar,
ScatterChart,
Search,
Smile,
Stamp,
Table,
Timer,
TrendingUp,
Video,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElScrollbar } from 'element-plus';
import {
getWidgetMaterials,
useDashboardDesignStore,
} from '../store/dashboardDesignStore';
const store = useDashboardDesignStore();
// 面板是否折叠
const isPanelCollapsed = ref(false);
// 当前激活的分类
const activeCategory = ref('chart');
// 图标映射
const iconMap: Record<string, any> = {
CreditCard,
Activity,
TrendingUp,
BarChart2,
PieChart,
Gauge,
AreaChart,
Radar,
Filter,
ScatterChart,
CircleDot,
CandlestickChart,
GitBranch,
CheckSquare,
Bell,
Award,
Grid,
Smile,
Calendar,
Timer,
Clock,
CloudSun,
Images,
Table,
Globe,
Video,
Image,
Megaphone,
ListChecks,
Stamp,
LayoutGrid,
Monitor,
Search,
ListFilter,
CalendarDays,
CalendarRange,
};
// 分类配置(带图标)
const categories = [
{
key: 'filter',
label: $t('dashboard-design.material.category.filter'),
icon: ListFilter,
},
{
key: 'chart',
label: $t('dashboard-design.material.category.chart'),
icon: LineChart,
},
{
key: 'widget',
label: $t('dashboard-design.material.category.common'),
icon: LayoutDashboard,
},
{
key: 'list',
label: $t('dashboard-design.material.category.other'),
icon: LayoutList,
},
];
// 按分类分组 - 使用函数调用以支持动态语言切换
const groupedMaterials = computed(() => {
const materials = getWidgetMaterials();
const groups: Record<string, WidgetMaterial[]> = {};
for (const cat of categories) {
groups[cat.key] = materials.filter((m) => m.category === cat.key);
}
return groups;
});
// 当前分类的组件列表
const currentMaterials = computed(() => {
return groupedMaterials.value[activeCategory.value] || [];
});
// 处理拖拽开始
const handleDragStart = (e: DragEvent, material: WidgetMaterial) => {
store.setDragging(true);
if (e.dataTransfer) {
e.dataTransfer.setData('widget-type', material.type);
e.dataTransfer.effectAllowed = 'copy';
}
};
// 处理拖拽结束
const handleDragEnd = () => {
store.setDragging(false);
};
// 点击添加
const handleClick = (material: WidgetMaterial) => {
store.addWidget(material);
};
</script>
<template>
<div
class="material-panel flex h-full"
:style="{ width: isPanelCollapsed ? '56px' : '262px' }"
>
<!-- 左侧导航栏 -->
<div class="flex w-14 flex-col bg-gray-50 dark:bg-gray-800">
<!-- 导航按钮 -->
<button
v-for="cat in categories"
:key="cat.key"
class="nav-btn"
:class="{ active: activeCategory === cat.key && !isPanelCollapsed }"
@click="
activeCategory = cat.key;
isPanelCollapsed = false;
"
>
<component :is="cat.icon" class="h-4 w-4" />
<span>{{ cat.label }}</span>
</button>
<!-- 占位将折叠按钮推到底部 -->
<div class="flex-1"></div>
<!-- 折叠按钮 -->
<button
class="collapse-btn"
@click="isPanelCollapsed = !isPanelCollapsed"
>
<ChevronsLeft v-if="isPanelCollapsed" class="h-4 w-4" />
<ChevronsRight v-else class="h-4 w-4" />
</button>
</div>
<!-- 右侧内容区域 -->
<div
v-show="!isPanelCollapsed"
class="flex flex-1 flex-col overflow-hidden"
>
<ElScrollbar class="flex-1">
<div class="p-3">
<div class="grid grid-cols-2 gap-2">
<div
v-for="material in currentMaterials"
:key="material.type"
class="material-item flex cursor-move flex-col items-center gap-1 rounded-md border border-transparent p-3 transition-all"
draggable="true"
@dragstart="(e: DragEvent) => handleDragStart(e, material)"
@dragend="handleDragEnd"
@click="handleClick(material)"
>
<component
:is="iconMap[material.icon]"
class="text-muted-foreground h-6 w-6"
/>
<span class="text-muted-foreground text-xs">{{
material.title
}}</span>
</div>
</div>
</div>
</ElScrollbar>
</div>
</div>
</template>
<style scoped>
.material-panel {
overflow: hidden;
background-color: var(--el-bg-color);
border-radius: 8px;
transition: width 0.2s ease;
}
.material-item {
background-color: var(--el-fill-color-lighter);
}
.material-item:hover {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.material-item:active {
transform: scale(0.95);
}
/* 折叠按钮 */
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 8px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: none;
border-top: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
}
.collapse-btn:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
}
/* 左侧导航按钮 */
.nav-btn {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
padding: 12px 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: none;
transition: all 0.2s;
}
.nav-btn:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
}
.nav-btn.active {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
.nav-btn.active::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2px;
content: '';
background-color: var(--el-color-primary);
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Maximize2, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElDialog, ElScrollbar } from 'element-plus';
import { GridItem, GridLayout } from 'grid-layout-plus';
import { useDashboardDesignStore } from '../store/dashboardDesignStore';
import WidgetRenderer from './WidgetRenderer.vue';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
}>();
const store = useDashboardDesignStore();
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 布局数据(只读)
const layout = computed(() =>
store.dashboardConfig.widgets.map((w) => ({
i: w.i,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
})),
);
// 获取 widget 配置
const getWidget = (i: string) => {
return store.dashboardConfig.widgets.find((w) => w.i === i);
};
// 获取动画延迟(交错入场效果)
const getAnimationDelay = (i: string) => {
const index = store.dashboardConfig.widgets.findIndex((w) => w.i === i);
return index * 80; // 每个组件延迟 80ms
};
const handleClose = () => {
dialogVisible.value = false;
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('dashboard-design.preview')"
width="90%"
top="5vh"
:close-on-click-modal="false"
destroy-on-close
class="preview-dialog"
>
<template #header>
<div class="flex items-center gap-2">
<Maximize2 class="h-5 w-5" />
<span class="text-lg font-medium"
>{{ $t('dashboard-design.title') }} -
{{ $t('dashboard-design.preview') }}</span
>
<span class="text-muted-foreground ml-2 text-sm">
{{ store.dashboardConfig.name }}
</span>
</div>
</template>
<ElScrollbar class="preview-content" style="height: 75vh">
<div
class="preview-container p-4"
:style="
store.dashboardConfig.backgroundColor?.includes('gradient')
? { background: store.dashboardConfig.backgroundColor }
: { backgroundColor: store.dashboardConfig.backgroundColor || '' }
"
>
<GridLayout
v-if="layout.length > 0"
:layout="layout"
:col-num="store.dashboardConfig.columns"
:row-height="store.dashboardConfig.rowHeight"
:margin="store.dashboardConfig.margin"
:is-draggable="false"
:is-resizable="false"
:vertical-compact="true"
:use-css-transforms="true"
:style="
store.dashboardConfig.showOuterMargin
? {}
: {
marginLeft: `-${store.dashboardConfig.margin[0]}px`,
marginRight: `-${store.dashboardConfig.margin[0]}px`,
marginTop: `-${store.dashboardConfig.margin[1]}px`,
width: `calc(100% + ${store.dashboardConfig.margin[0] * 2}px)`,
}
"
>
<GridItem
v-for="item in layout"
:key="item.i"
:i="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
class="preview-widget"
>
<WidgetRenderer
v-if="getWidget(item.i)"
:widget="getWidget(item.i)!"
:is-design-mode="false"
:animation-delay="getAnimationDelay(item.i)"
/>
</GridItem>
</GridLayout>
<div v-else class="flex h-64 items-center justify-center text-gray-400">
{{ $t('dashboard-design.noWidgetsTip') }}
</div>
</div>
</ElScrollbar>
<template #footer>
<ElButton @click="handleClose">
<X class="mr-1 h-4 w-4" />
{{ $t('common.close') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.preview-dialog :deep(.el-dialog__body) {
padding: 0;
}
.preview-container {
min-height: 100%;
background-color: var(--el-bg-color-page);
}
.preview-widget {
overflow: hidden;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
xAxisData,
seriesData,
smooth,
stack,
showLegend,
legendPosition,
colors,
xAxisName,
yAxisName,
axisNameLocation,
gridLeft,
gridRight,
gridTop,
gridBottom,
} = props.widget.props;
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0 }),
...(legendPosition === 'bottom' && { bottom: 0 }),
...(legendPosition === 'left' && { left: 0, orient: 'vertical' }),
...(legendPosition === 'right' && { right: 0, orient: 'vertical' }),
}
: { show: false };
// 坐标轴名称位置
const nameLocation = axisNameLocation || 'end';
// 计算 grid 边距
let finalBottom = gridBottom ?? 3;
let finalTop = gridTop ?? 10;
if (showLegend && legendPosition === 'bottom') finalBottom += 12;
if (showLegend && legendPosition === 'top') finalTop += 5;
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: legendConfig,
grid: {
left: `${gridLeft ?? 3}%`,
right: `${gridRight ?? 4}%`,
bottom: `${finalBottom}%`,
top: `${finalTop}%`,
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xAxisData || [],
name: xAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 25 : 5,
},
yAxis: {
type: 'value',
name: yAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 40 : 5,
},
series: (seriesData || []).map((s: any) => ({
name: s.name,
type: 'line',
smooth: smooth ?? true,
stack: stack ? 'Total' : undefined,
areaStyle: {
opacity: 0.6,
},
emphasis: {
focus: 'series',
},
data: s.data,
})),
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-area flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
xAxisData,
seriesData,
horizontal,
stack,
barWidth,
barRadius,
showBackground,
showLegend,
legendPosition,
colors,
xAxisName,
yAxisName,
axisNameLocation,
gridLeft,
gridRight,
gridTop,
gridBottom,
} = props.widget.props;
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0 }),
...(legendPosition === 'bottom' && { bottom: 0 }),
...(legendPosition === 'left' && { left: 0, orient: 'vertical' }),
...(legendPosition === 'right' && { right: 0, orient: 'vertical' }),
}
: { show: false };
// 坐标轴名称位置
const nameLocation = axisNameLocation || 'end';
// 计算 grid 边距
let finalBottom = gridBottom ?? 3;
let finalTop = gridTop ?? 10;
if (showLegend && legendPosition === 'bottom') finalBottom += 12;
if (showLegend && legendPosition === 'top') finalTop += 5;
const baseOption: Record<string, any> = {
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
legend: legendConfig,
grid: {
left: `${gridLeft ?? 3}%`,
right: `${gridRight ?? 4}%`,
bottom: `${finalBottom}%`,
top: `${finalTop}%`,
containLabel: true,
},
series: (seriesData || []).map((s: any) => ({
name: s.name,
type: 'bar',
data: s.data,
stack: stack ? 'total' : undefined,
barWidth: barWidth === 'auto' ? undefined : barWidth,
showBackground,
backgroundStyle: showBackground
? { color: 'rgba(180, 180, 180, 0.2)' }
: undefined,
itemStyle: {
borderRadius: barRadius || 0,
},
})),
};
if (horizontal) {
baseOption.xAxis = {
type: 'value',
name: yAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 25 : 5,
};
baseOption.yAxis = {
type: 'category',
data: xAxisData || [],
name: xAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 40 : 5,
};
} else {
baseOption.xAxis = {
type: 'category',
data: xAxisData || [],
name: xAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 25 : 5,
};
baseOption.yAxis = {
type: 'value',
name: yAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 40 : 5,
};
}
renderEcharts(baseOption);
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-bar flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const { seriesData, sort, orient, showLegend, legendPosition, colors } =
props.widget.props;
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0 }),
...(legendPosition === 'bottom' && { bottom: 0 }),
...(legendPosition === 'left' && { left: 0, orient: 'vertical' }),
...(legendPosition === 'right' && { right: 0, orient: 'vertical' }),
}
: { show: false };
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'item',
// 漏斗图中 {d}% 含义不直观,这里直接显示名称 + 数值
formatter: '{b}: {c}',
},
legend: legendConfig,
series: [
{
type: 'funnel',
left: '10%',
top: showLegend && legendPosition === 'top' ? 40 : 20,
bottom: showLegend && legendPosition === 'bottom' ? 40 : 20,
width: '80%',
min: 0,
max: 100,
minSize: '0%',
maxSize: '100%',
sort: sort || 'descending',
orient: orient || 'vertical',
gap: 2,
label: {
show: true,
position: 'inside',
// 在图形内部直接显示 名称: 数值
formatter: '{b}: {c}',
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: 'solid',
},
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
},
emphasis: {
label: {
fontSize: 16,
},
},
data: seriesData || [],
},
],
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-funnel flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
value,
min,
max,
unit,
splitNumber,
showProgress,
colorStops,
pointerWidth,
pointerLength,
} = props.widget.props;
// 颜色渐变配置
const axisLineColor =
colorStops && colorStops.length > 0
? colorStops.map((stop: any) => [stop.offset, stop.color])
: [
[0.3, '#67e0e3'],
[0.7, '#37a2da'],
[1, '#fd666d'],
];
renderEcharts({
series: [
{
type: 'gauge',
min: min ?? 0,
max: max ?? 100,
splitNumber: splitNumber ?? 10,
progress: {
show: showProgress ?? true,
width: 18,
},
axisLine: {
lineStyle: {
width: 18,
color: axisLineColor,
},
},
axisTick: {
show: true,
splitNumber: 5,
length: 6,
lineStyle: {
color: 'auto',
width: 1,
},
},
splitLine: {
length: 12,
lineStyle: {
width: 2,
color: 'auto',
},
},
axisLabel: {
distance: 25,
color: '#999',
fontSize: 12,
},
pointer: {
width: pointerWidth || 6,
length: pointerLength || '60%',
itemStyle: {
color: 'auto',
},
},
anchor: {
show: true,
showAbove: true,
size: 18,
itemStyle: {
borderWidth: 6,
borderColor: '#999',
},
},
title: {
show: false,
},
detail: {
valueAnimation: true,
fontSize: 28,
fontWeight: 'bold',
offsetCenter: [0, '70%'],
formatter: `{value}${unit || '%'}`,
color: 'inherit',
},
data: [
{
value: value ?? 0,
},
],
},
],
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-gauge flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
xAxisData,
yAxisData,
seriesData,
minValue,
maxValue,
gridLeft,
gridRight,
gridTop,
gridBottom,
} = props.widget.props;
renderEcharts({
tooltip: {
position: 'top',
formatter: (params: any) => {
const x = xAxisData?.[params.value[0]] || params.value[0];
const y = yAxisData?.[params.value[1]] || params.value[1];
return `${x} ${y}<br/>${$t('dashboard-design.widgets.chart.visits')}: ${params.value[2]}`;
},
},
grid: {
left: `${gridLeft ?? 3}%`,
right: `${gridRight ?? 15}%`,
bottom: `${gridBottom ?? 3}%`,
top: `${gridTop ?? 10}%`,
containLabel: true,
},
xAxis: {
type: 'category',
data: xAxisData || [],
splitArea: {
show: true,
},
},
yAxis: {
type: 'category',
data: yAxisData || [],
splitArea: {
show: true,
},
},
visualMap: {
min: minValue ?? 0,
max: maxValue ?? 100,
calculable: true,
orient: 'vertical',
right: '2%',
top: 'center',
inRange: {
color: ['#f0f9e8', '#bae4bc', '#7bccc4', '#43a2ca', '#0868ac'],
},
},
series: [
{
type: 'heatmap',
data: seriesData || [],
label: {
show: true,
fontSize: 10,
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-heatmap flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// 计算移动平均线
const calculateMA = (data: number[][], dayCount: number) => {
const result: (number | string)[] = [];
for (let i = 0; i < data.length; i++) {
if (i < dayCount - 1) {
result.push('-');
continue;
}
let sum = 0;
for (let j = 0; j < dayCount; j++) {
sum += data[i - j]?.[1] ?? 0; // 收盘价
}
result.push(+(sum / dayCount).toFixed(2));
}
return result;
};
const renderChart = () => {
const {
xAxisData,
seriesData,
showMA5,
showMA10,
gridLeft,
gridRight,
gridTop,
gridBottom,
} = props.widget.props;
const series: any[] = [
{
name: 'K线',
type: 'candlestick',
data: seriesData || [],
itemStyle: {
color: '#ec0000',
color0: '#00da3c',
borderColor: '#ec0000',
borderColor0: '#00da3c',
},
},
];
// 添加 MA5
if (showMA5 && seriesData?.length >= 5) {
series.push({
name: 'MA5',
type: 'line',
data: calculateMA(seriesData, 5),
smooth: true,
lineStyle: {
opacity: 0.8,
width: 1,
},
symbol: 'none',
});
}
// 添加 MA10
if (showMA10 && seriesData?.length >= 10) {
series.push({
name: 'MA10',
type: 'line',
data: calculateMA(seriesData, 10),
smooth: true,
lineStyle: {
opacity: 0.8,
width: 1,
},
symbol: 'none',
});
}
renderEcharts({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
formatter: (params: any) => {
const kline = params.find((p: any) => p.seriesName === 'K线');
if (!kline) return '';
const [open, close, low, high] = kline.data;
let html = `${kline.axisValue}<br/>`;
html += `开盘: ${open}<br/>`;
html += `收盘: ${close}<br/>`;
html += `最低: ${low}<br/>`;
html += `最高: ${high}<br/>`;
params.forEach((p: any) => {
if (p.seriesName !== 'K线' && p.data !== '-') {
html += `${p.seriesName}: ${p.data}<br/>`;
}
});
return html;
},
},
legend: {
show: true,
top: 0,
data: ['K线', ...(showMA5 ? ['MA5'] : []), ...(showMA10 ? ['MA10'] : [])],
},
grid: {
left: `${gridLeft ?? 3}%`,
right: `${gridRight ?? 3}%`,
bottom: `${gridBottom ?? 3}%`,
top: `${gridTop ?? 15}%`,
containLabel: true,
},
xAxis: {
type: 'category',
data: xAxisData || [],
boundaryGap: true,
axisLine: { onZero: false },
splitLine: { show: false },
},
yAxis: {
type: 'value',
scale: true,
splitArea: {
show: true,
},
},
series,
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-kline flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
xAxisData,
seriesData,
smooth,
showArea,
showSymbol,
symbolSize,
lineWidth,
showLegend,
legendPosition,
colors,
xAxisName,
yAxisName,
axisNameLocation,
gridLeft,
gridRight,
gridTop,
gridBottom,
} = props.widget.props;
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0 }),
...(legendPosition === 'bottom' && { bottom: 0 }),
...(legendPosition === 'left' && { left: 0, orient: 'vertical' }),
...(legendPosition === 'right' && { right: 0, orient: 'vertical' }),
}
: { show: false };
// 坐标轴名称位置
const nameLocation = axisNameLocation || 'end';
// 计算 grid 边距
let finalBottom = gridBottom ?? 3;
let finalTop = gridTop ?? 10;
if (showLegend && legendPosition === 'bottom') finalBottom += 12;
if (showLegend && legendPosition === 'top') finalTop += 5;
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'axis',
},
legend: legendConfig,
grid: {
left: `${gridLeft ?? 3}%`,
right: `${gridRight ?? 4}%`,
bottom: `${finalBottom}%`,
top: `${finalTop}%`,
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xAxisData || [],
name: xAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 25 : 5,
},
yAxis: {
type: 'value',
name: yAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 40 : 5,
},
series: (seriesData || []).map((s: any) => ({
name: s.name,
type: 'line',
smooth: smooth ?? true,
showSymbol: showSymbol ?? true,
symbolSize: symbolSize || 6,
lineStyle: {
width: lineWidth || 2,
},
areaStyle: showArea ? { opacity: 0.3 } : undefined,
data: s.data,
})),
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-line flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
seriesData,
pieType,
radius,
showLabel,
labelPosition,
showLegend,
legendPosition,
colors,
} = props.widget.props;
// 计算半径
let pieRadius: [string, string] = ['0%', '70%'];
if (pieType === 'ring') {
pieRadius = ['40%', '70%'];
} else if (pieType === 'rose') {
pieRadius = ['20%', '70%'];
} else if (radius && Array.isArray(radius) && radius.length === 2) {
pieRadius = [radius[0], radius[1]];
}
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0, left: 'center' }),
...(legendPosition === 'bottom' && { bottom: 0, left: 'center' }),
...(legendPosition === 'left' && {
left: 0,
top: 'center',
orient: 'vertical',
}),
...(legendPosition === 'right' && {
right: 0,
top: 'center',
orient: 'vertical',
}),
}
: { show: false };
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
},
legend: legendConfig,
series: [
{
name: props.widget.props.title,
type: 'pie',
radius: pieRadius,
roseType: pieType === 'rose' ? 'radius' : undefined,
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 4,
},
label: {
show: showLabel ?? true,
position: labelPosition === 'inside' ? 'inside' : 'outside',
formatter: labelPosition === 'inside' ? '{d}%' : '{b}: {d}%',
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
labelLine: {
show: showLabel && labelPosition !== 'inside',
},
data: seriesData || [],
},
],
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-pie flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const { indicators, seriesData, shape, showLegend, legendPosition, colors } =
props.widget.props;
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0 }),
...(legendPosition === 'bottom' && { bottom: 0 }),
...(legendPosition === 'left' && { left: 0, orient: 'vertical' }),
...(legendPosition === 'right' && { right: 0, orient: 'vertical' }),
}
: { show: false };
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858'],
tooltip: {
trigger: 'item',
},
legend: legendConfig,
radar: {
shape: shape || 'polygon',
indicator: indicators || [],
center: ['50%', '55%'],
radius: '65%',
},
series: [
{
type: 'radar',
data: (seriesData || []).map((s: any) => ({
name: s.name,
value: s.value,
areaStyle: {
opacity: 0.2,
},
})),
},
],
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-radar flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const { seriesData, colors } = props.widget.props;
const colorList = colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666'];
// 生成多个环形进度
const series = (seriesData || []).flatMap((item: any, index: number) => {
const radius = 90 - index * 25;
const percent = (item.value / (item.max || 100)) * 100;
return [
// 背景环
{
type: 'pie',
radius: [`${radius - 8}%`, `${radius}%`],
center: ['50%', '50%'],
silent: true,
label: { show: false },
data: [
{
value: 100,
itemStyle: {
color: 'rgba(128, 128, 128, 0.1)',
},
},
],
},
// 进度环
{
type: 'pie',
radius: [`${radius - 8}%`, `${radius}%`],
center: ['50%', '50%'],
startAngle: 90,
label: {
show: index === 0,
position: 'center',
formatter: () => {
return `{value|${item.value}}{unit|%}\n{name|${item.name}}`;
},
rich: {
value: {
fontSize: 28,
fontWeight: 'bold',
color: colorList[index % colorList.length],
},
unit: {
fontSize: 14,
color: '#999',
},
name: {
fontSize: 12,
color: '#666',
padding: [5, 0, 0, 0],
},
},
},
data: [
{
value: percent,
name: item.name,
itemStyle: {
color: colorList[index % colorList.length],
borderRadius: 10,
},
},
{
value: 100 - percent,
itemStyle: {
color: 'transparent',
},
},
],
},
];
});
renderEcharts({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
if (params.value === 100 || params.itemStyle?.color === 'transparent')
return '';
const item = seriesData?.find((s: any) => s.name === params.name);
return item ? `${item.name}: ${item.value}/${item.max || 100}` : '';
},
},
legend: {
show: true,
bottom: 0,
data: (seriesData || []).map((s: any) => s.name),
},
series,
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-ring flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const { nodes, links, colors } = props.widget.props;
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
formatter: (params: any) => {
if (params.dataType === 'node') {
return params.name;
}
return `${params.data.source}${params.data.target}: ${params.data.value}`;
},
},
series: [
{
type: 'sankey',
emphasis: {
focus: 'adjacency',
},
left: '5%',
right: '15%',
top: '10%',
bottom: '10%',
nodeWidth: 20,
nodeGap: 12,
lineStyle: {
color: 'gradient',
curveness: 0.5,
},
label: {
position: 'right',
fontSize: 12,
},
data: nodes || [],
links: links || [],
},
],
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-sankey flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
widget: DashboardWidget;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const {
seriesData,
symbolSize,
showLegend,
legendPosition,
colors,
xAxisName,
yAxisName,
axisNameLocation,
gridLeft,
gridRight,
gridTop,
gridBottom,
} = props.widget.props;
// 图例位置配置
const legendConfig: any = showLegend
? {
show: true,
...(legendPosition === 'top' && { top: 0 }),
...(legendPosition === 'bottom' && { bottom: 0 }),
...(legendPosition === 'left' && { left: 0, orient: 'vertical' }),
...(legendPosition === 'right' && { right: 0, orient: 'vertical' }),
}
: { show: false };
// 坐标轴名称位置
const nameLocation = axisNameLocation || 'end';
// 计算 grid 边距
let finalBottom = gridBottom ?? 10;
let finalTop = gridTop ?? 10;
if (showLegend && legendPosition === 'bottom') finalBottom += 10;
if (showLegend && legendPosition === 'top') finalTop += 5;
renderEcharts({
color: colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${params.seriesName}<br/>${xAxisName || 'X'}: ${params.value[0]}<br/>${yAxisName || 'Y'}: ${params.value[1]}`;
},
},
legend: legendConfig,
grid: {
left: `${gridLeft ?? 10}%`,
right: `${gridRight ?? 5}%`,
bottom: `${finalBottom}%`,
top: `${finalTop}%`,
containLabel: true,
},
xAxis: {
type: 'value',
name: xAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 25 : 5,
splitLine: {
lineStyle: {
type: 'dashed',
},
},
},
yAxis: {
type: 'value',
name: yAxisName || '',
nameLocation,
nameGap: nameLocation === 'middle' ? 40 : 5,
splitLine: {
lineStyle: {
type: 'dashed',
},
},
},
series: (seriesData || []).map((s: any) => ({
name: s.name,
type: 'scatter',
symbolSize: symbolSize || 10,
data: s.data,
emphasis: {
focus: 'series',
label: {
show: true,
formatter: (param: any) => param.data[0],
position: 'top',
},
},
})),
});
};
onMounted(renderChart);
watch(() => props.widget.props, renderChart, { deep: true });
</script>
<template>
<div class="chart-scatter flex h-full flex-col p-3">
<div class="text-muted-foreground mb-2 text-sm font-medium">
{{ widget.props.title }}
</div>
<div class="min-h-0 flex-1">
<EchartsUI ref="chartRef" class="h-full w-full" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed } from 'vue';
import { ListChecks } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty } from 'element-plus';
import FormDataList from '#/views/online-dev/form-render/components/FormDataList.vue';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const formCode = computed(() => props.widget.props.formCode || '');
</script>
<template>
<div class="form-render-widget flex h-full flex-col">
<div v-if="widget.props.title" class="widget-header">
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<div v-if="isDesignMode" class="design-placeholder">
<ListChecks class="h-8 w-8 text-gray-400" />
<div class="mt-2 text-sm text-gray-500">
{{ $t('dashboard-design.widgets.formRender.placeholder') }}
</div>
<div class="mt-1 text-xs text-gray-400">
{{ formCode || $t('dashboard-design.widgets.formRender.noFormCode') }}
</div>
</div>
<template v-else>
<div v-if="!formCode" class="empty-state">
<ElEmpty
:description="$t('dashboard-design.widgets.formRender.noFormCode')"
/>
</div>
<div v-else class="min-h-0 flex-1 overflow-hidden">
<FormDataList :form-code="formCode" />
</div>
</template>
</div>
</template>
<style scoped>
.widget-header {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.design-placeholder,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
}
.design-placeholder {
background: var(--el-fill-color-lighter);
}
</style>
@@ -0,0 +1,321 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { Code, RotateCw, Upload } from '@vben/icons';
import { $t } from '@vben/locales';
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 { useDashboardDesignStore } from './store/dashboardDesignStore';
const props = defineProps<{
// 初始配置 JSON
initialConfig?: string;
}>();
const emit = defineEmits<{
(e: 'save', config: string): void;
}>();
const store = useDashboardDesignStore();
// 预览弹窗
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.dashboardConfig.widgets.length === 0) {
ElMessage.warning($t('dashboard-design.noWidgetsTip'));
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('dashboard-design.saveSuccess'));
};
// 清空画布
const handleClear = async () => {
if (store.dashboardConfig.widgets.length === 0) {
return;
}
try {
await ElMessageBox.confirm($t('dashboard-design.clearConfirm'), $t('common.tips'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
});
store.clearCanvas();
ElMessage.success($t('dashboard-design.clearSuccess'));
} 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 = `dashboard-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success($t('dashboard-design.exportSuccess'));
};
// 打开导入弹窗
const handleOpenImport = () => {
importContent.value = '';
importVisible.value = true;
};
// 确认导入
const handleImport = () => {
if (!importContent.value.trim()) {
ElMessage.warning($t('dashboard-design.importEmpty'));
return;
}
const success = store.importConfig(importContent.value);
if (success) {
importVisible.value = false;
ElMessage.success($t('dashboard-design.importSuccess'));
} else {
ElMessage.error($t('dashboard-design.importError'));
}
};
// 复制代码
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(codeContent.value);
ElMessage.success($t('dashboard-design.copySuccess'));
} catch {
ElMessage.error($t('dashboard-design.copyError'));
}
};
// 键盘快捷键处理
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('dashboard-design.undoSuccess'));
}
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('dashboard-design.redoSuccess'));
}
return;
}
// Ctrl+C 复制
if (ctrlKey && e.key === 'c') {
if (store.activeId) {
e.preventDefault();
store.copyToClipboard(store.activeId);
ElMessage.success($t('dashboard-design.copyWidgetSuccess'));
}
return;
}
// Ctrl+V 粘贴
if (ctrlKey && e.key === 'v') {
if (store.hasClipboard) {
e.preventDefault();
store.pasteFromClipboard();
ElMessage.success($t('dashboard-design.pasteWidgetSuccess'));
}
return;
}
// Delete 或 Backspace 删除
if (e.key === 'Delete' || e.key === 'Backspace') {
if (store.activeId) {
e.preventDefault();
store.deleteWidget(store.activeId);
ElMessage.success($t('dashboard-design.deleteWidgetSuccess'));
}
return;
}
// Ctrl+S 保存
if (ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
}
};
// 注册/注销键盘事件
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<template>
<div class="dashboard-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('dashboard-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('dashboard-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('dashboard-design.copyCode') }} </ElButton>
<ElButton type="primary" @click="codeVisible = false"> {{ $t('common.close') }} </ElButton>
</template>
</ElDialog>
<!-- 导入弹窗 -->
<ElDialog
v-model="importVisible"
:title="$t('dashboard-design.importTitle')"
width="600px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Upload class="h-5 w-5" />
<span>{{ $t('dashboard-design.importTitle') }}</span>
</div>
</template>
<ElInput
v-model="importContent"
type="textarea"
:rows="15"
:placeholder="$t('dashboard-design.importPlaceholder')"
/>
<template #footer>
<ElButton @click="importVisible = false">{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" @click="handleImport">
<RotateCw class="mr-1 h-4 w-4" />
{{ $t('dashboard-design.import') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.dashboard-design {
height: 100%;
}
.code-container {
max-height: 500px;
overflow: auto;
}
.code-container pre {
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElScrollbar, ElTag, ElTooltip } from 'element-plus';
import { ZqDialog } from '#/components/zq-dialog';
import { CodeEditor } from '../../zq-form/code-editor';
interface FieldInfo {
field: string;
label: string;
type?: string;
group?: string;
insertCode?: string;
}
const props = defineProps<{
currentField?: string;
fields: FieldInfo[];
modelValue: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'confirm', value: string): void;
}>();
const visible = ref(false);
const localCode = ref('');
const open = () => {
localCode.value = props.modelValue || '';
visible.value = true;
};
const handleConfirm = () => {
emit('update:modelValue', localCode.value);
emit('confirm', localCode.value);
visible.value = false;
};
watch(
() => props.modelValue,
(val) => {
if (visible.value) {
localCode.value = val || '';
}
},
);
const insertFieldRef = (f: FieldInfo) => {
localCode.value += f.insertCode || `model.${f.field}`;
};
const fieldGroups = computed(() => {
const groups: Array<{ fields: FieldInfo[]; group: string }> = [];
const map = new Map<string, FieldInfo[]>();
for (const f of props.fields) {
const g = f.group || '';
if (!map.has(g)) {
map.set(g, []);
}
map.get(g)!.push(f);
}
for (const [group, fields] of map) {
groups.push({ group, fields });
}
return groups;
});
const apiDocs = computed(() => [
{
title: $t('form-design.buttonScript.sectionVariables'),
items: [
{
name: 'model',
desc: $t('form-design.buttonScript.varModelDesc'),
},
{
name: '$root',
desc: $t('form-design.buttonScript.varRootDesc'),
},
{
name: '$result',
desc: $t('form-design.buttonScript.varResultDesc'),
},
],
},
{
title: $t('form-design.buttonScript.sectionMethods'),
items: [
{
name: '$setValue(field, value)',
desc: $t('form-design.buttonScript.methodSetValue'),
},
{
name: '$setValues({ field1: val1, ... })',
desc: $t('form-design.buttonScript.methodSetValues'),
},
{
name: '$getValue(field)',
desc: $t('form-design.buttonScript.methodGetValue'),
},
{
name: '$getSubTable(field)',
desc: $t('form-design.buttonScript.methodGetSubTable'),
},
{
name: '$setSubTable(field, rows)',
desc: $t('form-design.buttonScript.methodSetSubTable'),
},
{
name: '$addRow(field, row)',
desc: $t('form-design.buttonScript.methodAddRow'),
},
{
name: '$removeRow(field, index)',
desc: $t('form-design.buttonScript.methodRemoveRow'),
},
{
name: '$updateRow(field, index, data)',
desc: $t('form-design.buttonScript.methodUpdateRow'),
},
{
name: '$clearSubTable(field)',
desc: $t('form-design.buttonScript.methodClearSubTable'),
},
{
name: '$show(field)',
desc: $t('form-design.buttonScript.methodShowDesc'),
},
{
name: '$hide(field)',
desc: $t('form-design.buttonScript.methodHideDesc'),
},
{
name: '$disable(field)',
desc: $t('form-design.buttonScript.methodDisableDesc'),
},
{
name: '$enable(field)',
desc: $t('form-design.buttonScript.methodEnableDesc'),
},
],
},
{
title: $t('form-design.buttonScript.sectionExamples'),
items: [
{
name: $t('form-design.buttonScript.exSetField'),
desc: `$setValue('status', '已审核');\n$setValue('approve_time', new Date().toLocaleString());`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exBatchSet'),
desc: `$setValues({\n status: '已提交',\n submit_time: new Date().toLocaleString(),\n submitter: model.current_user\n});`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exMainGetSub'),
desc: `// 主表从子表汇总\nconst rows = $getSubTable('items');\nconst total = rows.reduce((s, r) => s + (Number(r.amount) || 0), 0);\n$setValue('total_amount', total);`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exMainSetSub'),
desc: `// 主表向子表批量赋值\nconst rows = $getSubTable('items');\nrows.forEach((r, i) => {\n $updateRow('items', i, { ...r, status: model.batch_status });\n});`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exAddRow'),
desc: `// 添加一行到子表\n$addRow('items', {\n name: '新商品',\n price: 0,\n quantity: 1\n});`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exClearAndFill'),
desc: `// 清空子表后重新填充\n$clearSubTable('items');\nconst sources = $getSubTable('source_items');\nsources.forEach(s => {\n $addRow('items', { name: s.name, price: s.price, quantity: 1 });\n});`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exCondition'),
desc: `// 条件判断赋值\nif (model.total_amount > 10000) {\n $setValue('discount', 0.9);\n $setValue('level', 'VIP');\n} else {\n $setValue('discount', 1);\n $setValue('level', '普通');\n}`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exDataSourceResult'),
desc: `// 数据源返回数据使用($result\n// $result 为数据源返回的原始数据\nif (Array.isArray($result)) {\n $clearSubTable('items');\n $result.forEach(row => {\n $addRow('items', { name: row.name, price: row.price });\n });\n} else {\n $setValues($result);\n}`,
isCode: true,
},
{
name: $t('form-design.buttonScript.exShowHideDisable'),
desc: `// 点击按钮后控制字段显示/隐藏/禁用\n$show('approve_section');\n$disable('name');\n$disable('amount');\n$hide('draft_tip');`,
isCode: true,
},
],
},
]);
defineExpose({ open });
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('form-design.buttonScript.title')"
width="1200px"
content-height="600px"
@confirm="handleConfirm"
>
<div class="flex h-[600px] gap-0 overflow-hidden">
<!-- 左侧说明面板 -->
<div
class="my-2 flex w-[380px] flex-shrink-0 flex-col overflow-hidden rounded-[8px] border border-[var(--el-border-color-lighter)]"
>
<ElScrollbar class="flex-1">
<div class="p-3">
<!-- 可用字段分组展示 -->
<div class="mb-3">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-regular)]"
>
{{ $t('form-design.buttonScript.sectionFields') }}
</div>
<template v-for="group in fieldGroups" :key="group.group">
<div
v-if="group.group"
class="mb-1 mt-2 text-xs text-[var(--el-text-color-secondary)]"
>
{{ group.group }}
</div>
<div class="flex flex-wrap gap-1">
<ElTooltip
v-for="f in group.fields"
:key="f.field"
:content="`${f.insertCode || `model.${f.field}`} (${f.type || 'text'})`"
placement="top"
>
<ElTag
size="small"
class="cursor-pointer"
:type="
f.field === currentField
? 'warning'
: f.type === 'sub-table'
? 'danger'
: undefined
"
@click="insertFieldRef(f)"
>
{{ f.label }}
</ElTag>
</ElTooltip>
</div>
</template>
<div class="mt-1 text-xs text-[var(--el-text-color-placeholder)]">
{{ $t('form-design.buttonScript.clickToInsert') }}
</div>
</div>
<!-- API 文档 -->
<template v-for="section in apiDocs" :key="section.title">
<div class="mb-3">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-regular)]"
>
{{ section.title }}
</div>
<div class="space-y-2">
<div
v-for="item in section.items"
:key="item.name"
class="rounded bg-[var(--el-fill-color-light)] px-2 py-1.5"
>
<div
class="text-xs font-medium text-[var(--el-text-color-primary)]"
>
{{ item.name }}
</div>
<div
v-if="(item as any).isCode"
class="mt-0.5 whitespace-pre-wrap font-mono text-xs text-[var(--el-text-color-secondary)]"
>
{{ item.desc }}
</div>
<div
v-else
class="mt-0.5 text-xs text-[var(--el-text-color-secondary)]"
>
{{ item.desc }}
</div>
</div>
</div>
</div>
</template>
<!-- 注意事项 -->
<div class="mb-3">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-regular)]"
>
{{ $t('form-design.buttonScript.sectionNotes') }}
</div>
<ul
class="list-inside list-disc space-y-1 text-xs text-[var(--el-text-color-secondary)]"
>
<li>{{ $t('form-design.buttonScript.noteNoReturn') }}</li>
<li>{{ $t('form-design.buttonScript.noteAsync') }}</li>
<li>{{ $t('form-design.buttonScript.noteError') }}</li>
</ul>
</div>
</div>
</ElScrollbar>
</div>
<!-- 右侧代码编辑器 -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- <div class="flex items-center justify-between border-b border-[var(&#45;&#45;el-border-color-lighter)] px-3 py-2">-->
<!-- <span class="text-xs text-[var(&#45;&#45;el-text-color-secondary)]">-->
<!-- {{ $t('form-design.buttonScript.editorTitle') }}-->
<!-- </span>-->
<!-- </div>-->
<div class="flex-1 overflow-auto p-2">
<CodeEditor
v-model="localCode"
language="javascript"
:line-numbers="false"
:fold-gutter="false"
line-wrapping
height="100%"
min-height="100%"
:placeholder="$t('form-design.attribute.buttonScriptPlaceholder')"
/>
</div>
</div>
</div>
</ZqDialog>
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { FormItemSchema } from '../store/formDesignStore';
import { $t } from '@vben/locales';
import { ElFormItem, ElInput, ElOption, ElSelect, ElSwitch } from 'element-plus';
defineProps<{
activeItem: FormItemSchema;
availableFields: Array<{ label: string; value: string }>;
sectionKey?: string;
}>();
</script>
<template>
<ElFormItem :label="$t('form-design.qrcode.dataSource')">
<ElSelect v-model="activeItem.props.dataSource">
<ElOption
value="static"
:label="$t('form-design.qrcode.dataSourceStatic')"
/>
<ElOption
value="field"
:label="$t('form-design.qrcode.dataSourceField')"
/>
<ElOption
value="formula"
:label="$t('form-design.qrcode.dataSourceFormula')"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="activeItem.props.dataSource === 'static'"
:label="$t('form-design.qrcode.placeholder')"
>
<ElInput
v-model="activeItem.defaultValue"
type="textarea"
:rows="3"
:placeholder="$t('form-design.qrcode.placeholder')"
/>
</ElFormItem>
<ElFormItem
v-if="activeItem.props.dataSource === 'field'"
:label="$t('form-design.qrcode.boundField')"
>
<ElSelect v-model="activeItem.props.boundField" filterable clearable>
<ElOption
v-for="field in availableFields"
:key="field.value"
:value="field.value"
:label="field.label"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="activeItem.props.dataSource === 'formula'"
:label="$t('form-design.qrcode.formula')"
>
<ElInput
v-model="activeItem.props.formula"
type="textarea"
:rows="2"
:placeholder="$t('form-design.qrcode.formulaPlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('form-design.qrcode.showContent')">
<ElSwitch v-model="activeItem.props.showContent" />
</ElFormItem>
<ElFormItem :label="$t('form-design.qrcode.enableDownload')">
<ElSwitch v-model="activeItem.props.enableDownload" />
</ElFormItem>
<ElFormItem :label="$t('form-design.qrcode.enableCopy')">
<ElSwitch v-model="activeItem.props.enableCopy" />
</ElFormItem>
</template>
@@ -0,0 +1,353 @@
<script setup lang="ts">
import { ref } from 'vue';
import { $t } from '@vben/locales';
import { ElButton, ElDialog, ElInput, ElMessage } from 'element-plus';
const visible = ref(false);
const code = ref('');
const open = (formConf: any) => {
code.value = generateVueCode(formConf);
visible.value = true;
};
const handleCopy = () => {
navigator.clipboard.writeText(code.value);
ElMessage.success($t('form-design.message.copySuccess'));
};
// 代码生成逻辑
function generateVueCode(conf: any) {
const { template, script } = generateParts(conf);
return `<template>
<div class="form-container p-4">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="${conf.labelWidth}px"
label-position="${conf.labelPosition}"
size="${conf.size}"
>
${template}
<el-form-item>
<el-button type="primary" @click="handleSubmit">${$t('common.submit') || '提交'}</el-button>
<el-button @click="handleReset">${$t('common.reset') || '重置'}</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
${script}
<\/script>
`;
}
function generateParts(conf: any) {
let template = '';
const fields: string[] = [];
const rules: string[] = [];
// 递归生成模板
function traverse(items: any[], indent = 6) {
let html = '';
const space = ' '.repeat(indent);
items.forEach((item) => {
// 生成显隐控制
let vIf = '';
if (item.showCondition) {
// 替换 model. 为 formData.
const condition = item.showCondition.replaceAll('model.', 'formData.');
vIf = ` v-if="${condition}"`;
}
switch (item.type) {
case 'collapse': {
// 折叠面板
const accordion = item.props.accordion ? ' accordion' : '';
html += `${space}<el-collapse model-value="1"${accordion}${vIf}>\n`;
item.items.forEach((subItem: any) => {
html += `${space} <el-collapse-item title="${subItem.title}" name="${subItem.name}">\n`;
html += traverse(subItem.children, indent + 4);
html += `${space} </el-collapse-item>\n`;
});
html += `${space}</el-collapse>\n`;
break;
}
case 'divider': {
// 分割线
const props = Object.entries(item.props)
.map(([key, val]) => {
if (val === 'solid' || val === 'horizontal' || val === 'center')
return ''; // 默认值不输出
return `${key}="${val}"`;
})
.filter(Boolean)
.join(' ');
const content =
item.label === $t('form-design.material.components.divider')
? ''
: item.label;
html += content
? `${space}<el-divider ${props}${vIf}>${content}</el-divider>\n`
: `${space}<el-divider ${props}${vIf} />\n`;
break;
}
case 'grid': {
html += `${space}<el-row :gutter="${item.props.gutter}"${vIf}>\n`;
item.columns.forEach((col: any) => {
html += `${space} <el-col :span="${col.span}">\n`;
html += traverse(col.children, indent + 4);
html += `${space} </el-col>\n`;
});
html += `${space}</el-row>\n`;
break;
}
case 'tabs': {
// 标签页
const type = item.props.type ? ` type="${item.props.type}"` : '';
const tabPosition =
item.props.tabPosition === 'top'
? ''
: ` tab-position="${item.props.tabPosition}"`;
html += `${space}<el-tabs model-value="1"${type}${tabPosition}${vIf}>\n`;
item.items.forEach((subItem: any) => {
html += `${space} <el-tab-pane label="${subItem.label}" name="${subItem.name}">\n`;
html += traverse(subItem.children, indent + 4);
html += `${space} </el-tab-pane>\n`;
});
html += `${space}</el-tabs>\n`;
break;
}
default: {
// 收集字段和规则
const defaultVal = getItemDefaultValue(item);
fields.push(`${item.field}: ${JSON.stringify(defaultVal)}`);
const itemRules = [];
if (item.props.required) {
const trigger = ['input', 'input-number', 'textarea'].includes(
item.type,
)
? 'blur'
: 'change';
const requiredMsg =
$t('form-design.attribute.requiredTip') || '不能为空';
itemRules.push(
`{ required: true, message: '${item.label}${requiredMsg}', trigger: '${trigger}' }`,
);
}
if (item.regList && item.regList.length > 0) {
item.regList.forEach((reg: any) => {
if (reg.pattern && reg.message) {
// 直接使用用户输入的正则字符串,假设用户输入的是合法的正则字面量或字符串
// 如果是字符串形式 "/^...$/",我们直接作为正则字面量输出
// 简单判断:如果是以 / 开头和结尾,视为字面量,否则作为字符串处理(不太常见)
// 为了保险,我们假设用户输入的是如 "/^...$/" 格式
itemRules.push(
`{ pattern: ${reg.pattern}, message: '${reg.message}', trigger: 'blur' }`,
);
}
});
}
if (itemRules.length > 0) {
rules.push(`${item.field}: [${itemRules.join(', ')}]`);
}
// 生成组件 HTML
// 将 v-if 加在 el-form-item 上
html += `${space}<el-form-item label="${item.label}" prop="${item.field}"${vIf}>\n`;
html += `${space} ${generateComponentTag(item)}\n`;
html += `${space}</el-form-item>\n`;
}
}
});
return html;
}
template = traverse(conf.items);
// 生成 script
const script = `
const formRef = ref();
const formData = reactive({
${fields.join(',\n ')}
});
const rules = reactive({
${rules.join(',\n ')}
});
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
console.log('submit!', formData);
ElMessage.success('${$t('form-design.message.saveSuccess')}');
} catch (error) {
console.log('error submit!', error);
}
};
const handleReset = () => {
if (!formRef.value) return;
formRef.value.resetFields();
};
`;
return { template, script };
}
function getItemDefaultValue(item: any) {
if (item.type === 'checkbox') return [];
if (['input-number', 'rate', 'slider'].includes(item.type)) return 0;
if (item.type === 'switch') return false;
return null;
}
function generateComponentTag(item: any) {
// 默认值为 true 的属性列表,如果值为 false 需要显式输出
const defaultsTrue = new Set(['controls', 'editable']);
const props = Object.entries(item.props)
.filter(([key, val]) => {
if (key === 'required') return false; // 已处理
if (val === null || val === undefined) return false;
if (val === '' && key !== 'activeText' && key !== 'inactiveText')
return false; // 空字符串通常不输出,除了开关文案
// 如果值是 false,且默认不是 true,则忽略(即默认 false 的属性不输出)
if (val === false && !defaultsTrue.has(key)) return false;
// 如果值是 true,且默认是 true,则忽略(即默认 true 的属性不输出)
if (val === true && defaultsTrue.has(key)) return false;
return true;
})
.map(([key, val]) => {
if (val === true) return key;
if (val === false) return `:${key}="false"`;
if (typeof val === 'string') return `${key}="${val}"`;
return `:${key}="${val}"`;
})
.join(' ');
const tagMap: Record<string, string> = {
input: 'el-input',
textarea: 'el-input',
select: 'el-select',
radio: 'el-radio-group',
checkbox: 'el-checkbox-group',
date: 'el-date-picker',
'input-number': 'el-input-number',
time: 'el-time-picker',
switch: 'el-switch',
slider: 'el-slider',
rate: 'el-rate',
color: 'el-color-picker',
'rich-text': 'RichTextEditor',
};
const tagName = tagMap[item.type] || 'el-input';
let content = '';
let extraProps = '';
if (item.type === 'textarea') {
extraProps = ' type="textarea"';
}
// 富文本组件
if (item.type === 'rich-text') {
return `<RichTextEditor v-model="formData.${item.field}" ${props} />`;
}
// 处理选项子组件
if (['checkbox', 'radio', 'select'].includes(item.type) && item.options) {
switch (item.type) {
case 'checkbox': {
content = `\n ${item.options
.map(
(opt: any) =>
`<el-checkbox :label="${typeof opt.value === 'string' ? `'${opt.value}'` : opt.value}">${opt.label}</el-checkbox>`,
)
.join('\n ')}\n `;
break;
}
case 'radio': {
content = `\n ${item.options
.map(
(opt: any) =>
`<el-radio :label="${typeof opt.value === 'string' ? `'${opt.value}'` : opt.value}">${opt.label}</el-radio>`,
)
.join('\n ')}\n `;
break;
}
case 'select': {
content = `\n ${item.options
.map(
(opt: any) =>
`<el-option label="${opt.label}" :value="${typeof opt.value === 'string' ? `'${opt.value}'` : opt.value}" />`,
)
.join('\n ')}\n `;
break;
}
// No default
}
}
return content
? `<${tagName} v-model="formData.${item.field}" ${props}${extraProps}>${content}</${tagName}>`
: `<${tagName} v-model="formData.${item.field}" ${props}${extraProps} />`;
}
defineExpose({ open });
</script>
<template>
<ElDialog
v-model="visible"
:title="$t('form-design.viewCode')"
width="800px"
append-to-body
destroy-on-close
>
<div class="relative">
<ElInput
v-model="code"
type="textarea"
:rows="20"
readonly
class="font-mono text-xs"
/>
<ElButton
type="primary"
size="small"
class="absolute right-2 top-2"
@click="handleCopy"
>
{{ $t('form-design.copy') }}
</ElButton>
</div>
<template #footer>
<ElButton @click="visible = false">{{ $t('common.close') }}</ElButton>
</template>
</ElDialog>
</template>
@@ -0,0 +1,474 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus } from '@vben/icons';
import { $t } from '@vben/locales';
import { Delete } from '@element-plus/icons-vue';
import {
ElButton,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
interface ConditionRule {
field: string;
operator: string;
value: any;
valueType?: 'string' | 'number' | 'boolean';
}
interface Props {
modelValue?: string;
fields: Array<{ label: string; value: string; type?: string }>;
mode?: 'simple' | 'advanced';
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
mode: 'simple',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const currentMode = ref<'simple' | 'advanced'>(props.mode);
const logic = ref<'AND' | 'OR'>('AND');
const rules = ref<ConditionRule[]>([]);
const advancedExpression = ref('');
const isUpdatingFromInternal = ref(false); // 标记是否是内部更新,防止循环
const operators = [
{ label: $t('form-design.attribute.conditionBuilder.operators.equals'), value: '===' },
{ label: $t('form-design.attribute.conditionBuilder.operators.notEquals'), value: '!==' },
{ label: $t('form-design.attribute.conditionBuilder.operators.greaterThan'), value: '>' },
{ label: $t('form-design.attribute.conditionBuilder.operators.greaterThanOrEqual'), value: '>=' },
{ label: $t('form-design.attribute.conditionBuilder.operators.lessThan'), value: '<' },
{ label: $t('form-design.attribute.conditionBuilder.operators.lessThanOrEqual'), value: '<=' },
{ label: $t('form-design.attribute.conditionBuilder.operators.contains'), value: 'includes' },
{ label: $t('form-design.attribute.conditionBuilder.operators.notContains'), value: '!includes' },
{ label: $t('form-design.attribute.conditionBuilder.operators.isEmpty'), value: 'empty' },
{ label: $t('form-design.attribute.conditionBuilder.operators.isNotEmpty'), value: '!empty' },
];
const booleanOptions = [
{ label: $t('common.yes'), value: true },
{ label: $t('common.no'), value: false },
];
// 初始化
watch(
() => props.modelValue,
(val) => {
// 如果是内部更新触发的,跳过解析,避免覆盖用户输入
if (isUpdatingFromInternal.value) {
isUpdatingFromInternal.value = false;
return;
}
if (!val) {
// 如果没有值且是简单模式,初始化一个空规则
if (currentMode.value === 'simple' && rules.value.length === 0) {
rules.value = [{
field: '',
operator: '===',
value: '',
}];
} else {
rules.value = [];
}
advancedExpression.value = '';
return;
}
// 尝试解析表达式
advancedExpression.value = val;
// 简单表达式解析(仅支持单个条件或简单的 && / || 组合)
if (currentMode.value === 'simple') {
parseExpression(val);
}
},
{ immediate: true },
);
// 判断字段名是否需要使用括号访问(包含非标识符字符)
function needsBracketAccess(field: string): boolean {
return !/^\w+$/.test(field);
}
// 生成字段引用表达式
function fieldRef(field: string): string {
return needsBracketAccess(field) ? `model['${field}']` : `model.${field}`;
}
// 解析表达式为规则(简单解析)
function parseExpression(expr: string) {
if (!expr) {
rules.value = [];
return;
}
// 检测逻辑运算符
if (expr.includes(' && ')) {
logic.value = 'AND';
} else if (expr.includes(' || ')) {
logic.value = 'OR';
}
// 简单解析(仅支持基本格式)
const parts = expr.split(logic.value === 'AND' ? ' && ' : ' || ');
rules.value = parts
.map((part) => {
const trimmed = part.trim();
// 匹配 model['field'] operator value 或 model.field operator value
const match = trimmed.match(
/model(?:\.(\w+)|\['([^']+)'])\s*(===|!==|>=|<=|>|<)\s*(.+)/,
);
if (match) {
const fieldName = match[1] || match[2];
const operator = match[3];
let value: any = match[4]?.trim();
if (!fieldName || !operator || value === undefined) return null;
// 转换布尔值
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
}
// 去除引号(字符串)
else if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1);
}
// 转换数字
else if (!Number.isNaN(Number(value))) {
value = Number(value);
}
return {
field: fieldName,
operator,
value,
};
}
return null;
})
.filter(Boolean) as ConditionRule[];
if (rules.value.length === 0) {
rules.value = [{ field: '', operator: '===', value: '' }];
}
}
// 生成表达式
function generateExpression() {
if (currentMode.value === 'advanced') {
return advancedExpression.value;
}
if (rules.value.length === 0) {
return '';
}
// 过滤出完整的规则(有字段、操作符,且需要值的情况下有值)
const completeRules = rules.value.filter((rule) => {
if (!rule.field || !rule.operator) return false;
// 为空/不为空操作符不需要值
if (['empty', '!empty'].includes(rule.operator)) return true;
// 其他操作符需要值(注意:false、0 都是有效值)
if (rule.value === null || rule.value === undefined || rule.value === '') {
return false;
}
return true;
});
// 如果没有完整的规则,但有部分规则,不更新(保持原值)
if (completeRules.length === 0 && rules.value.length > 0) {
return props.modelValue || '';
}
const expressions = completeRules
.map((rule) => {
const ref = fieldRef(rule.field);
// 处理特殊操作符
if (rule.operator === 'empty') {
return `!${ref} || ${ref} === ''`;
}
if (rule.operator === '!empty') {
return `${ref} && ${ref} !== ''`;
}
if (rule.operator === 'includes') {
const val =
typeof rule.value === 'string' ? `'${rule.value}'` : rule.value;
return `${ref} && ${ref}.includes(${val})`;
}
if (rule.operator === '!includes') {
const val =
typeof rule.value === 'string' ? `'${rule.value}'` : rule.value;
return `${ref} && !${ref}.includes(${val})`;
}
// 普通比较
let val = rule.value;
if (typeof val === 'string') {
val = `'${val}'`;
} else if (typeof val === 'boolean') {
val = val.toString();
}
return `${ref} ${rule.operator} ${val}`;
});
if (expressions.length === 0) {
return '';
}
const connector = logic.value === 'AND' ? ' && ' : ' || ';
return expressions.join(connector);
}
// 添加规则
function addRule() {
rules.value.push({
field: '',
operator: '===',
value: '',
});
}
// 删除规则
function removeRule(index: number) {
rules.value.splice(index, 1);
updateExpression();
}
// 更新表达式
function updateExpression() {
const expr = generateExpression();
isUpdatingFromInternal.value = true;
emit('update:modelValue', expr);
}
// 切换模式
function toggleMode() {
if (currentMode.value === 'simple') {
currentMode.value = 'advanced';
advancedExpression.value = generateExpression();
} else {
currentMode.value = 'simple';
parseExpression(advancedExpression.value);
}
}
// 获取字段类型
function getFieldType(fieldName: string): string {
const field = props.fields.find((f) => f.value === fieldName);
if (!field?.type) return 'string';
const componentType = field.type;
// 根据组件类型映射到数据类型
if (componentType === 'input-number' || componentType === 'slider' || componentType === 'rate') {
return 'number';
}
if (componentType === 'switch') {
return 'boolean';
}
return 'string';
}
// 监听规则变化 - 使用防抖避免频繁更新
let updateTimer: ReturnType<typeof setTimeout> | null = null;
watch([rules, logic], () => {
if (updateTimer) {
clearTimeout(updateTimer);
}
// 延迟更新,避免输入过程中频繁触发
updateTimer = setTimeout(() => {
updateExpression();
}, 300);
}, { deep: true });
watch(advancedExpression, (val) => {
if (currentMode.value === 'advanced') {
isUpdatingFromInternal.value = true;
emit('update:modelValue', val);
}
});
// 获取值输入类型
const getValueInputType = (rule: ConditionRule) => {
if (['empty', '!empty'].includes(rule.operator)) {
return 'none';
}
const fieldType = getFieldType(rule.field);
if (
fieldType.includes('int') ||
fieldType.includes('number') ||
fieldType.includes('decimal')
) {
return 'number';
}
if (fieldType.includes('bool')) {
return 'boolean';
}
return 'string';
};
</script>
<template>
<div class="condition-builder">
<!-- 模式切换 -->
<div class="mb-2 flex items-center justify-between pb-2 text-[11px] text-[var(--el-text-color-regular)]">
<span>
{{ currentMode === 'simple' ? $t('form-design.attribute.conditionBuilder.visualMode') : $t('form-design.attribute.conditionBuilder.advancedMode') }}
</span>
<ElButton
type="primary"
link
style="font-size: smaller"
@click="toggleMode"
>
{{ currentMode === 'simple' ? $t('form-design.attribute.conditionBuilder.switchToAdvanced') : $t('form-design.attribute.conditionBuilder.switchToVisual') }}
</ElButton>
</div>
<!-- 简单模式 -->
<div v-if="currentMode === 'simple'" class="space-y-3">
<!-- 逻辑关系 -->
<div v-if="rules.length > 1" class="flex items-center gap-2">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t('form-design.attribute.conditionBuilder.satisfyCondition') }}
</span>
<ElSelect v-model="logic" size="small" style="width: 100px">
<ElOption :label="$t('form-design.attribute.conditionBuilder.all')" value="AND" />
<ElOption :label="$t('form-design.attribute.conditionBuilder.any')" value="OR" />
</ElSelect>
</div>
<!-- 规则列表 -->
<div
v-for="(rule, index) in rules"
:key="index"
class="flex items-start gap-2"
>
<!-- 字段选择 -->
<ElSelect
v-model="rule.field"
:placeholder="$t('form-design.attribute.conditionBuilder.selectField')"
filterable
size="small"
style="width: 85px"
>
<ElOption
v-for="field in fields"
:key="field.value"
:label="field.label"
:value="field.value"
/>
</ElSelect>
<!-- 操作符 -->
<ElSelect
v-model="rule.operator"
:placeholder="$t('form-design.attribute.conditionBuilder.operator')"
size="small"
class="flex-1"
>
<ElOption
v-for="op in operators"
:key="op.value"
:label="op.label"
:value="op.value"
/>
</ElSelect>
<!-- 值输入 -->
<template v-if="getValueInputType(rule) === 'number'">
<ElInputNumber
v-model="rule.value"
:placeholder="$t('form-design.attribute.conditionBuilder.value')"
size="small"
controls-position="right"
class="flex-1"
/>
</template>
<template v-else-if="getValueInputType(rule) === 'boolean'">
<ElSelect
v-model="rule.value"
:placeholder="$t('form-design.attribute.conditionBuilder.value')"
size="small"
class="flex-1"
>
<ElOption
v-for="opt in booleanOptions"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</template>
<template v-else-if="getValueInputType(rule) === 'string'">
<ElInput
v-model="rule.value"
:placeholder="$t('form-design.attribute.conditionBuilder.value')"
size="small"
class="flex-1"
/>
</template>
<!-- 删除按钮 -->
<ElButton
link
type="danger"
size="small"
:icon="Delete"
@click="removeRule(index)"
/>
</div>
<!-- 添加规则按钮 -->
<ElButton link type="primary" size="small" :icon="Plus" @click="addRule">
{{ $t('form-design.attribute.conditionBuilder.addCondition') }}
</ElButton>
<!-- 预览表达式 -->
<div
v-if="rules.length > 0"
class="mt-3 rounded bg-[var(--el-fill-color-light)] p-2 text-xs text-[var(--el-text-color-secondary)]"
>
<div class="mb-1 font-bold">{{ $t('form-design.attribute.conditionBuilder.generatedExpression') }}</div>
<code class="break-all">{{ generateExpression() || $t('form-design.attribute.conditionBuilder.empty') }}</code>
</div>
</div>
<!-- 高级模式 -->
<div v-else>
<ElInput
v-model="advancedExpression"
type="textarea"
:rows="4"
:placeholder="$t('form-design.attribute.conditionPlaceholder')"
/>
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('form-design.attribute.conditionTip') }}
</div>
</div>
</div>
</template>
<style scoped>
.condition-builder {
width: 100%;
}
</style>
@@ -0,0 +1,759 @@
<script setup lang="ts">
import { $t } from '@vben/locales';
import { onMounted, onUnmounted, ref } from 'vue';
import { CircleHelp, PanelLeft } from '@vben/icons';
import { ZqDialog } from '#/components/zq-dialog';
import {
Back,
Collection,
Delete,
Document,
Download,
Memo,
Monitor,
Plus,
ReadingLamp,
Right,
Upload,
} from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElIcon,
ElInput,
ElMessage,
ElMessageBox,
ElScrollbar,
ElTag,
ElTooltip,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import draggable from 'vuedraggable';
import { useFormDesignStore } from '../store/formDesignStore';
import CodeModal from './CodeModal.vue';
import FormItemWrapper from './FormItemWrapper.vue';
import MobileFormPreview from './MobileFormPreview.vue';
import PreviewModal from './PreviewModal.vue';
const props = withDefaults(
defineProps<{
toolbars?: string[];
}>(),
{
toolbars: () => [
'undo',
'redo',
'import',
'preview',
'clear',
'json',
'saveTemplate',
],
},
);
const store = useFormDesignStore();
const {
formConf,
activeId,
isDragging,
previewMode,
leftPanelCollapsed,
rightPanelCollapsed,
} = storeToRefs(store);
const previewRef = ref();
const codeModalRef = ref();
const importVisible = ref(false);
const importJson = ref('');
const jsonVisible = ref(false);
const saveTemplateVisible = ref(false);
const templateName = ref('');
const handleSelect = (item: any) => store.setActive(item.id);
const handleDelete = (id: string) => store.deleteItem(id);
const handleCanvasClick = () => store.setActive(null);
const handleAdd = (evt: any) => {
const newIndex = evt.newIndex;
const newItem = formConf.value.items[newIndex];
if (newItem) {
store.setActive(newItem.id);
}
};
const handleImport = () => {
importJson.value = '';
importVisible.value = true;
};
const handleViewJson = () => {
jsonVisible.value = true;
};
const handleSaveTemplate = () => {
if (formConf.value.items.length === 0) {
ElMessage.warning($t('form-design.material.noComponents'));
return;
}
templateName.value = '';
saveTemplateVisible.value = true;
};
const confirmSaveTemplate = () => {
if (!templateName.value) {
ElMessage.warning($t('form-design.message.importEmpty'));
return;
}
store.addTemplate({
title: templateName.value,
icon: 'Document',
items: JSON.parse(JSON.stringify(formConf.value.items)),
});
saveTemplateVisible.value = false;
ElMessage.success($t('form-design.message.saveSuccess'));
};
const handleGenerateCode = () => {
codeModalRef.value?.open(formConf.value);
};
const confirmImport = () => {
try {
const parsed = JSON.parse(importJson.value);
if (typeof parsed !== 'object' || !parsed.items) {
ElMessage.error($t('form-design.message.importError'));
return;
}
// 简单的校验通过
formConf.value = parsed;
store.setActive(null);
importVisible.value = false;
ElMessage.success($t('form-design.message.importSuccess'));
} catch {
ElMessage.error($t('form-design.message.importError'));
}
};
const handleClear = () => {
ElMessageBox.confirm($t('form-design.message.clearConfirm'), $t('common.tips'), {
type: 'warning',
}).then(() => {
formConf.value.items = [];
store.setActive(null);
});
};
const handlePreview = () => {
previewRef.value?.open();
};
const handleSave = () => {
ElMessage.success($t('form-design.message.exportSuccess'));
};
// 快捷键处理
const handleKeydown = (event: KeyboardEvent) => {
// 如果焦点在输入框中,不处理快捷键
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const ctrlKey = isMac ? event.metaKey : event.ctrlKey;
// Delete: 删除选中组件
if (event.key === 'Delete' || event.key === 'Backspace') {
if (activeId.value) {
event.preventDefault();
store.deleteSelected();
}
return;
}
// Ctrl+Z: 撤销
if (ctrlKey && event.key === 'z' && !event.shiftKey) {
event.preventDefault();
store.undo();
return;
}
// Ctrl+Y 或 Ctrl+Shift+Z: 重做
if ((ctrlKey && event.key === 'y') || (ctrlKey && event.shiftKey && event.key === 'z')) {
event.preventDefault();
store.redo();
return;
}
// Ctrl+C: 复制
if (ctrlKey && event.key === 'c') {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
return;
}
if (activeId.value) {
event.preventDefault();
store.copyToClipboard();
ElMessage.success($t('form-design.message.copySuccess'));
}
return;
}
// Ctrl+V: 粘贴
if (ctrlKey && event.key === 'v') {
event.preventDefault();
store.pasteFromClipboard();
return;
}
// ↑: 上移组件
if (event.key === 'ArrowUp' && activeId.value) {
event.preventDefault();
store.moveItem(activeId.value, 'up');
return;
}
// ↓: 下移组件
if (event.key === 'ArrowDown' && activeId.value) {
event.preventDefault();
store.moveItem(activeId.value, 'down');
return;
}
// Escape: 取消选择
if (event.key === 'Escape') {
store.clearSelection();
}
};
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
const mobileHelpVisible = ref(false);
</script>
<template>
<div
class="design-canvas flex h-full flex-col bg-[var(--el-fill-color-light)]"
>
<div
class="canvas-toolbar flex items-center gap-2 rounded border-b border-[var(--el-border-color)] bg-[var(--el-bg-color)] px-2 py-2"
>
<ElTooltip
:content="
leftPanelCollapsed
? $t('form-design.expandLeftPanel')
: $t('form-design.collapseLeftPanel')
"
placement="bottom"
>
<ElButton size="small" link @click="store.toggleLeftPanel">
<PanelLeft
class="h-4 w-4 transition-transform duration-300"
:class="leftPanelCollapsed ? 'rotate-180' : ''"
/>
</ElButton>
</ElTooltip>
<div class="flex min-w-0 flex-1 items-center justify-between gap-2 px-1">
<div class="flex items-center space-x-2">
<ElTooltip
:content="$t('form-design.undo')"
placement="bottom"
v-if="toolbars.includes('undo')"
>
<ElButton
size="small"
link
:disabled="!store.canUndo"
@click="store.undo"
>
<ElIcon :size="16"><Back /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.redo')"
placement="bottom"
v-if="toolbars.includes('redo')"
>
<ElButton
size="small"
link
:disabled="!store.canRedo"
@click="store.redo"
>
<ElIcon :size="16"><Right /></ElIcon>
</ElButton>
</ElTooltip>
<!-- PC / 移动端切换移到左侧 -->
<ElTooltip
:content="previewMode === 'pc' ? $t('form-design.mobilePreview') : $t('form-design.pcPreview')"
placement="bottom"
>
<ElButton size="small" link @click="store.togglePreviewMode">
<ElIcon :size="16">
<Monitor v-if="previewMode === 'mobile'" />
<!-- 手机轮廓 SVG -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/>
<line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
</ElIcon>
</ElButton>
</ElTooltip>
<template v-if="previewMode === 'mobile'">
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)]">{{ $t('form-design.mobilePreviewTip') }}</span>
<ElTooltip :content="$t('form-design.mobileHelpLearnMore')" placement="bottom">
<ElButton size="small" link class="ml-1" @click="mobileHelpVisible = true">
<CircleHelp class="size-4 text-[var(--el-color-primary)]" />
</ElButton>
</ElTooltip>
</template>
</div>
<div class="flex items-center space-x-1">
<ElTooltip
:content="$t('form-design.import')"
placement="bottom"
v-if="toolbars.includes('import')"
>
<ElButton size="small" text @click="handleImport">
<ElIcon :size="16"><Upload /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.viewCode')"
placement="bottom"
v-if="toolbars.includes('json')"
>
<ElButton size="small" text @click="handleViewJson">
<ElIcon :size="16"><Memo /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.attribute.layout.addPanel')"
placement="bottom"
v-if="toolbars.includes('saveTemplate')"
>
<ElButton size="small" text @click="handleSaveTemplate">
<ElIcon :size="16"><Collection /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.viewCode')"
placement="bottom"
v-if="toolbars.includes('code')"
>
<ElButton size="small" text @click="handleGenerateCode">
<ElIcon :size="16"><Document /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.previewLabel')"
placement="bottom"
v-if="toolbars.includes('preview')"
>
<ElButton size="small" text @click="handlePreview">
<ElIcon :size="16"><ReadingLamp /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.save')"
placement="bottom"
v-if="toolbars.includes('save')"
>
<ElButton size="small" text @click="handleSave">
<ElIcon :size="16"><Download /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('form-design.clear')"
placement="bottom"
v-if="toolbars.includes('clear')"
>
<ElButton size="small" text @click="handleClear">
<ElIcon :size="16"><Delete /></ElIcon>
</ElButton>
</ElTooltip>
</div>
</div>
<ElTooltip
:content="
rightPanelCollapsed
? $t('form-design.expandRightPanel')
: $t('form-design.collapseRightPanel')
"
placement="bottom"
>
<ElButton size="small" link @click="store.toggleRightPanel">
<PanelLeft
class="h-4 w-4 transition-transform duration-300"
:class="rightPanelCollapsed ? '' : 'rotate-180'"
/>
</ElButton>
</ElTooltip>
</div>
<ElScrollbar class="canvas-body flex-1" view-class="h-full">
<div
class="flex min-h-full justify-center"
:class="previewMode === 'mobile' ? 'items-start py-8' : ''"
@click.self="handleCanvasClick"
>
<!-- 移动端手机外框 -->
<div
v-if="previewMode === 'mobile'"
class="mobile-phone-frame relative flex-shrink-0"
>
<!-- 手机顶部刘海 -->
<div class="mobile-phone-notch" />
<!-- 手机顶部状态栏 -->
<div class="mobile-phone-statusbar flex items-center justify-between px-5">
<span class="text-xs font-semibold">9:41</span>
<div class="flex items-center gap-1.5">
<!-- 信号格 -->
<svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor">
<rect x="0" y="8" width="3" height="4" rx="0.5"/>
<rect x="4.5" y="5" width="3" height="7" rx="0.5"/>
<rect x="9" y="2" width="3" height="10" rx="0.5"/>
<rect x="13.5" y="0" width="2.5" height="12" rx="0.5"/>
</svg>
<!-- WiFi -->
<svg width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/>
</svg>
<!-- 电池 -->
<svg width="22" height="12" viewBox="0 0 22 12" fill="currentColor">
<rect x="0" y="1" width="18" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.2"/>
<rect x="18.5" y="3.5" width="2" height="5" rx="1"/>
<rect x="1.5" y="2.5" width="13" height="7" rx="1"/>
</svg>
</div>
</div>
<!-- 表单内容区overflow-y-auto 实现真实滚动 -->
<div class="mobile-phone-body">
<!-- 移动端仅预览不支持拖拽 -->
<MobileFormPreview v-if="formConf.items.length > 0" :items="formConf.items" />
<div
v-else
class="flex flex-col items-center justify-center py-16"
>
<div
class="flex h-[180px] w-[180px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-8"
:class="'border-[var(--el-border-color)] text-[var(--el-text-color-placeholder)]'"
>
<ElIcon :size="28" class="mb-2"><Plus /></ElIcon>
<span class="text-xs">{{ $t('dashboard-design.dragTip') }}</span>
</div>
</div>
</div>
<!-- 手机底部安全区 -->
<div class="mobile-phone-home" />
</div>
<!-- PC 模式原有画布 -->
<div v-else class="canvas-wrapper min-h-full w-full rounded-b p-8">
<div
:style="{
padding: `${formConf.formPaddingTop ?? formConf.formPadding ?? 20}px ${formConf.formPaddingRight ?? formConf.formPadding ?? 20}px ${formConf.formPaddingBottom ?? formConf.formPadding ?? 20}px ${formConf.formPaddingLeft ?? formConf.formPadding ?? 20}px`,
margin: `${formConf.formMarginTop ?? formConf.formMargin ?? 0}px ${formConf.formMarginRight ?? formConf.formMargin ?? 0}px ${formConf.formMarginBottom ?? formConf.formMargin ?? 0}px ${formConf.formMarginLeft ?? formConf.formMargin ?? 0}px`,
width: formConf.formWidth || '100%',
maxWidth: formConf.formMaxWidth || 'none',
backgroundColor: formConf.formBackground || 'var(--el-bg-color)',
border: formConf.formBorder ? '1px solid var(--el-border-color)' : 'none',
borderRadius: formConf.formBorder ? `${formConf.formBorderRadius || 4}px` : '0',
boxShadow: formConf.formShadow ? '0 2px 12px 0 rgba(0, 0, 0, 0.1)' : 'none',
minHeight: '100%',
}"
>
<ElForm
:label-width="`${formConf.labelWidth}px`"
:label-position="formConf.labelPosition"
:size="formConf.size"
:disabled="formConf.disabled || false"
:style="{
'--el-form-item-margin-bottom': `${formConf.itemSpacing || 18}px`,
}"
class="relative h-full"
>
<draggable
v-model="formConf.items"
group="form-design"
item-key="id"
class="canvas-area relative h-full min-h-[200px]"
ghost-class="ghost"
:animation="200"
@add="handleAdd"
>
<template #item="{ element }">
<FormItemWrapper
:schema="element"
:active="activeId === element.id"
:preview-mode="previewMode"
@click="handleSelect(element)"
@delete="handleDelete"
/>
</template>
</draggable>
<div
v-if="formConf.items.length === 0"
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center"
>
<div
class="flex h-[300px] w-[300px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors duration-300"
:class="[
isDragging
? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]'
: 'border-[var(--el-border-color)] text-[var(--el-text-color-placeholder)]',
]"
>
<ElIcon :size="40" class="mb-2"><Plus /></ElIcon>
<span>{{
isDragging ? $t('form-design.message.clearConfirm') : $t('dashboard-design.dragTip')
}}</span>
</div>
</div>
</ElForm>
</div>
</div>
</div>
</ElScrollbar>
<!-- 预览弹窗 -->
<PreviewModal ref="previewRef" :conf="formConf" />
<!-- 代码生成弹窗 -->
<CodeModal ref="codeModalRef" />
<!-- 导入弹窗 -->
<ElDialog
v-model="importVisible"
:title="$t('form-design.message.importTitle')"
width="600px"
append-to-body
>
<div class="mb-2 text-xs text-gray-500">
{{ $t('form-design.message.importPlaceholder') }}
</div>
<ElInput
v-model="importJson"
type="textarea"
:rows="10"
:placeholder="$t('form-design.message.importPlaceholder')"
/>
<template #footer>
<ElButton @click="importVisible = false">{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" @click="confirmImport">{{ $t('form-design.import') }}</ElButton>
</template>
</ElDialog>
<!-- 查看 JSON 弹窗 -->
<ElDialog
v-model="jsonVisible"
:title="$t('form-design.viewCode')"
width="600px"
append-to-body
>
<ElInput
:model-value="JSON.stringify(formConf, null, 2)"
type="textarea"
:rows="15"
readonly
/>
<template #footer>
<ElButton type="primary" @click="jsonVisible = false">{{ $t('common.close') }}</ElButton>
</template>
</ElDialog>
<!-- 保存模板弹窗 -->
<ElDialog
v-model="saveTemplateVisible"
:title="$t('form-design.templateLabel')"
width="400px"
append-to-body
>
<div class="mb-2 text-xs text-gray-500">
{{ $t('form-design.template.applyConfirm') }}
</div>
<ElInput
v-model="templateName"
:placeholder="$t('form-design.attribute.nodeLabel')"
@keyup.enter="confirmSaveTemplate"
/>
<template #footer>
<ElButton @click="saveTemplateVisible = false">{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" @click="confirmSaveTemplate">{{ $t('common.ok') }}</ElButton>
</template>
</ElDialog>
<!-- 手机端渲染差异帮助弹窗 -->
<ZqDialog
v-model="mobileHelpVisible"
:title="$t('form-design.mobileHelpTitle')"
width="820px"
:show-footer="false"
>
<div class="space-y-4 text-sm">
<!-- 提示横幅 -->
<div class="rounded-lg bg-[var(--el-color-primary-light-9)] px-4 py-3 text-[var(--el-color-primary)]">
{{ $t('form-design.mobileHelp.previewOnlyTip') }}
</div>
<!-- 差异列表 -->
<div
v-for="item in (([
{ title: $t('form-design.mobileHelp.layoutTitle'), desc: $t('form-design.mobileHelp.layoutDesc'), tag: 'warning' },
{ title: $t('form-design.mobileHelp.selectTitle'), desc: $t('form-design.mobileHelp.selectDesc'), tag: 'info' },
{ title: $t('form-design.mobileHelp.dateTitle'), desc: $t('form-design.mobileHelp.dateDesc'), tag: 'info' },
{ title: $t('form-design.mobileHelp.numberTitle'), desc: $t('form-design.mobileHelp.numberDesc'), tag: 'info' },
{ title: $t('form-design.mobileHelp.radioCheckboxTitle'), desc: $t('form-design.mobileHelp.radioCheckboxDesc'), tag: 'info' },
{ title: $t('form-design.mobileHelp.richTextTitle'), desc: $t('form-design.mobileHelp.richTextDesc'), tag: 'warning' },
{ title: $t('form-design.mobileHelp.layoutDecorTitle'), desc: $t('form-design.mobileHelp.layoutDecorDesc'), tag: 'danger' },
{ title: $t('form-design.mobileHelp.subTableTitle'), desc: $t('form-design.mobileHelp.subTableDesc'), tag: 'info' },
{ title: $t('form-design.mobileHelp.selectorTitle'), desc: $t('form-design.mobileHelp.selectorDesc'), tag: 'success' },
{ title: $t('form-design.mobileHelp.tabsStepsTitle'), desc: $t('form-design.mobileHelp.tabsStepsDesc'), tag: 'success' },
] as Array<{ title: string; desc: string; tag: 'warning' | 'info' | 'danger' | 'success' }>))"
:key="item.title"
class="flex flex-col gap-3 rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="flex-shrink-0 pt-0.5">
<ElTag :type="item.tag" size="small" effect="light">{{ item.title }}</ElTag>
</div>
<div class="text-[var(--el-text-color-regular)] leading-relaxed">{{ item.desc }}</div>
</div>
</div>
</ZqDialog>
</div>
</template>
<style scoped>
.ghost {
position: relative;
height: 4px;
overflow: hidden;
background: var(--el-color-primary-light-9);
border-top: 2px solid var(--el-color-primary);
}
.ghost::after {
display: block;
content: '';
background: var(--el-bg-color);
}
/* iPhone 手机外框 */
.mobile-phone-frame {
width: 393px;
background: var(--el-bg-color);
border-radius: 54px;
border: 10px solid #1c1c1e;
box-shadow:
0 0 0 1px #3a3a3c,
0 30px 80px rgba(0, 0, 0, 0.4),
inset 0 0 0 1px #3a3a3c;
display: flex;
flex-direction: column;
overflow: hidden;
height: calc(100vh - 120px);
max-height: 820px;
min-height: 600px;
flex-shrink: 0;
position: relative;
}
/* 刘海 */
.mobile-phone-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 126px;
height: 34px;
background: #1c1c1e;
border-radius: 0 0 20px 20px;
z-index: 10;
}
.mobile-phone-statusbar {
background: var(--el-bg-color);
font-size: 12px;
color: var(--el-text-color-primary);
flex-shrink: 0;
height: 44px;
padding-top: 10px;
}
/* 内容区:真实 overflow-y-auto 滚动,隐藏滚动条 */
.mobile-phone-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: var(--el-fill-color-light);
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.mobile-phone-body::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.mobile-phone-home {
height: 34px;
background: var(--el-bg-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mobile-phone-home::after {
content: '';
width: 134px;
height: 5px;
background: #1c1c1e;
border-radius: 3px;
}
/* 移动端拖拽区域 */
.mobile-drop-zone {
min-height: 200px;
width: 100%;
}
.mobile-drop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
/* 移动端模式下栅格强制单列 */
.mobile-phone-body :deep(.el-row) {
display: block !important;
}
.mobile-phone-body :deep(.el-col) {
width: 100% !important;
max-width: 100% !important;
flex: 0 0 100% !important;
}
</style>
@@ -0,0 +1,826 @@
<script setup lang="ts">
import type { TableConfig, TableField } from '../store/formDesignStore';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import {
ArrowDown,
ArrowRight,
Calendar,
Document,
Edit,
Grid,
Odometer,
Open,
} from '@element-plus/icons-vue';
import { ElIcon, ElScrollbar, ElTag } from 'element-plus';
import { storeToRefs } from 'pinia';
import draggable from 'vuedraggable';
import { useFormDesignStore } from '../store/formDesignStore';
const props = defineProps<{
searchKeyword?: string;
}>();
const store = useFormDesignStore();
const { dataSource, formConf } = storeToRefs(store);
// 折叠状态管理
const activeGroups = ref(['main']); // 默认展开主表
const toggleGroup = (group: string) => {
const index = activeGroups.value.indexOf(group);
if (index === -1) {
activeGroups.value.push(group);
} else {
activeGroups.value.splice(index, 1);
}
};
// 后端自动处理的系统字段(禁止拖拽,标注"自动处理")
const AUTO_HANDLED_FIELDS = new Set([
'sys_create_datetime',
'sys_creator_id',
'sys_dept_id',
'sys_modifier_id',
'sys_update_datetime',
]);
// 判断字段是否为自动处理字段
const isAutoHandledField = (fieldName: string) => {
return AUTO_HANDLED_FIELDS.has(fieldName);
};
// 字段过滤函数
const filterFields = (fields: TableField[]) => {
if (!props.searchKeyword) return fields;
const keyword = props.searchKeyword.toLowerCase();
return fields.filter(
(field) =>
field.name.toLowerCase().includes(keyword) ||
(field.comment && field.comment.toLowerCase().includes(keyword)),
);
};
// 计算已使用的主表字段集合(不在子表单内的字段)
const usedMainTableFields = computed(() => {
const fields = new Set<string>();
// 递归查找字段,但不进入子表单内部
const findFields = (items: any[]) => {
items.forEach((item) => {
// 子表单的 field 是表名,不是主表字段,跳过
if (item.type === 'sub-table') {
// 不进入子表单内部
return;
}
if (item.field) {
fields.add(item.field);
}
if (item.children) findFields(item.children);
if (item.columns) {
item.columns.forEach((col: any) => {
if (col.children) findFields(col.children);
});
}
if (item.items) {
item.items.forEach((subItem: any) => {
if (subItem.children) findFields(subItem.children);
});
}
});
};
findFields(formConf.value.items);
return fields;
});
// 计算已使用的从表字段集合(按表名分组)
const usedSubTableFields = computed(() => {
const tableFieldsMap = new Map<string, Set<string>>();
// 递归查找子表单及其内部字段
const findSubTableFields = (items: any[]) => {
items.forEach((item) => {
if (item.type === 'sub-table' && item.field) {
const tableName = item.field;
if (!tableFieldsMap.has(tableName)) {
tableFieldsMap.set(tableName, new Set());
}
// 收集子表单内部的字段
if (item.children) {
item.children.forEach((child: any) => {
if (child.field) {
tableFieldsMap.get(tableName)!.add(child.field);
}
});
}
}
// 继续递归查找嵌套的子表单
if (item.columns) {
item.columns.forEach((col: any) => {
if (col.children) findSubTableFields(col.children);
});
}
if (item.items) {
item.items.forEach((subItem: any) => {
if (subItem.children) findSubTableFields(subItem.children);
});
}
if (item.children && item.type !== 'sub-table') {
findSubTableFields(item.children);
}
});
};
findSubTableFields(formConf.value.items);
return tableFieldsMap;
});
// 计算已使用的从表集合(通过子表单的 field 属性判断)
const usedSubTables = computed(() => {
const tables = new Set<string>();
const findSubTables = (items: any[]) => {
items.forEach((item) => {
if (item.type === 'sub-table' && item.field) {
tables.add(item.field);
}
if (item.children) findSubTables(item.children);
if (item.columns) {
item.columns.forEach((col: any) => {
if (col.children) findSubTables(col.children);
});
}
if (item.items) {
item.items.forEach((subItem: any) => {
if (subItem.children) findSubTables(subItem.children);
});
}
});
};
findSubTables(formConf.value.items);
return tables;
});
// 判断字段是否已使用(区分主表和从表)
const isFieldUsed = (fieldName: string, tableName?: string) => {
if (tableName) {
// 从表字段:检查对应子表单内是否已使用
const tableFields = usedSubTableFields.value.get(tableName);
return tableFields ? tableFields.has(fieldName) : false;
} else {
// 主表字段:检查主表单中是否已使用
return usedMainTableFields.value.has(fieldName);
}
};
// 判断从表是否已使用
const isSubTableUsed = (tableName: string) =>
usedSubTables.value.has(tableName);
// 拖拽事件处理
const onDragStart = () => store.setDragging(true);
const onDragEnd = () => store.setDragging(false);
// 点击添加字段到画布(仅主表字段可点击添加)
const handleClickAdd = (field: TableField, isSubTable = false) => {
// 从表字段不允许点击添加,只能拖入子表单
if (isSubTable) return;
if (isFieldUsed(field.name)) return;
const component = generateComponentFromField(field);
store.addComponent(component);
};
// 点击添加从表(子表单)到画布
const handleClickAddSubTable = (table: TableConfig) => {
if (isSubTableUsed(table.tableName)) return;
const component = generateSubTableFromTable(table);
store.addComponent(component);
};
// 根据字段类型映射组件类型和图标
const getComponentTypeInfo = (fieldType: string) => {
const type = fieldType.toLowerCase();
if (
type.includes('int') ||
type.includes('decimal') ||
type.includes('numeric') ||
type.includes('float') ||
type.includes('double')
) {
return { type: 'input-number', icon: Odometer, label: $t('form-design.material.components.number') };
}
if (
type.includes('datetime') ||
type.includes('timestamp') ||
type.includes('date')
) {
return { type: 'date', icon: Calendar, label: $t('form-design.material.components.date') };
}
if (type.includes('text') || type.includes('long') || type.includes('blob')) {
return { type: 'textarea', icon: Document, label: $t('form-design.material.components.textarea') };
}
if (type.includes('bool') || type.includes('tinyint(1)')) {
return { type: 'switch', icon: Open, label: $t('form-design.material.components.switch') };
}
if (type.includes('char') || type.includes('string')) {
return { type: 'input', icon: Edit, label: $t('form-design.material.components.input') };
}
// 默认
return { type: 'input', icon: Edit, label: $t('form-design.material.components.input') };
};
// 生成组件配置
// 判断字段是否为系统字段(主键、ID、外键)
const isSystemField = (field: TableField, foreignKey?: string) => {
return (
field.isPrimaryKey ||
field.name.toLowerCase() === 'id' ||
(foreignKey && field.name === foreignKey)
);
};
const generateComponentFromField = (field: TableField, foreignKey?: string) => {
const { type } = getComponentTypeInfo(field.type);
// 判断是否为系统字段(主键、ID、外键),系统字段默认禁用
const isSystem = isSystemField(field, foreignKey);
// 根据数据库约束生成校验规则
const regList: { message: string; pattern: string }[] = [];
// 字符串类型的最大长度校验
if (field.maxLength && field.maxLength > 0) {
// 使用正则限制最大长度
regList.push({
pattern: `^.{0,${field.maxLength}}$`,
message: $t('form-design.attribute.maxLenError', { max: field.maxLength }),
});
}
// 整数类型校验
const fieldType = field.type.toLowerCase();
if (fieldType.includes('int') && !fieldType.includes('point')) {
regList.push({
pattern: String.raw`^-?\d+$`,
message: $t('form-design.attribute.integerError'),
});
}
// 数值类型精度校验
if (field.precision && field.scale !== undefined && field.scale > 0) {
const integerPart = field.precision - field.scale;
regList.push({
pattern: `^-?\\d{0,${integerPart}}(\\.\\d{0,${field.scale}})?$`,
message: $t('form-design.attribute.numberPrecisionError', {
integer: integerPart,
scale: field.scale,
}),
});
}
const baseComponent: any = {
type,
label: field.comment || field.name, // 优先使用注释作为标签
field: field.name, // 自动绑定字段名
props: {
placeholder: isSystem
? $t('form-design.attribute.systemAutoGenerate')
: (['cascader', 'checkbox', 'color', 'cron-selector', 'date', 'dept-selector', 'file-selector', 'form-selector', 'image-selector', 'post-selector', 'radio', 'rate', 'region-selector', 'role-selector', 'select', 'slider', 'table-selector', 'time', 'tree-select', 'user-selector'].includes(type)
? $t('common.selectPlaceholder') + (field.comment || field.name)
: $t('common.placeholder') + (field.comment || field.name)),
width: '100%',
disabled: isSystem, // 系统字段默认禁用
// NOT NULL -> required: true,但系统字段不需要必填
required: !field.nullable && !field.isPrimaryKey && !isSystem,
},
// 添加正则校验规则
regList: regList.length > 0 ? regList : undefined,
// 标记是否为系统字段
isSystemField: isSystem,
};
// 根据类型补充特定属性
switch (type) {
case 'date': {
Object.assign(baseComponent.props, {
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
editable: true,
readonly: false,
});
break;
}
case 'input': {
// input 默认显示字数限制
baseComponent.props.showWordLimit = true;
// input 添加 maxlength 属性
if (field.maxLength) {
baseComponent.props.maxlength = field.maxLength;
}
break;
}
case 'input-number': {
Object.assign(baseComponent.props, {
controls: true,
// 如果有精度信息,设置精度
precision: field.scale,
});
break;
}
case 'switch': {
Object.assign(baseComponent.props, { width: 40 });
break;
}
case 'textarea': {
Object.assign(baseComponent.props, { type: 'textarea', rows: 3 });
// textarea 默认显示字数限制
baseComponent.props.showWordLimit = true;
// textarea 添加 maxlength 属性
if (field.maxLength) {
baseComponent.props.maxlength = field.maxLength;
}
break;
}
// No default
}
return store.cloneComponent(baseComponent);
};
// 系统字段列表(不添加到子表单中)
const SYSTEM_FIELDS = new Set([
'sys_create_datetime',
'sys_creator_id',
'sys_dept_id',
'sys_modifier_id',
'sys_update_datetime',
]);
// 根据从表配置生成子表单组件
const generateSubTableFromTable = (table: TableConfig) => {
// 过滤掉系统字段,只保留业务字段(包括主键和外键,但设为禁用)
const businessFields = table.fields.filter(
(field) => !SYSTEM_FIELDS.has(field.name),
);
// 生成子表单的子组件
const children = businessFields.map((field) => {
// 判断是否为系统字段
const isSystem = isSystemField(field, table.foreignKey);
const { type } = getComponentTypeInfo(field.type);
// 根据数据库约束生成校验规则
const regList: { message: string; pattern: string }[] = [];
// 字符串类型的最大长度校验
if (field.maxLength && field.maxLength > 0) {
regList.push({
pattern: `^.{0,${field.maxLength}}$`,
message: $t('form-design.attribute.maxLenError', { max: field.maxLength }),
});
}
// 整数类型校验
const fieldType = field.type.toLowerCase();
if (fieldType.includes('int') && !fieldType.includes('point')) {
regList.push({
pattern: String.raw`^-?\d+$`,
message: $t('form-design.attribute.integerError'),
});
}
// 数值类型精度校验
if (field.precision && field.scale !== undefined && field.scale > 0) {
const integerPart = field.precision - field.scale;
regList.push({
pattern: `^-?\\d{0,${integerPart}}(\\.\\d{0,${field.scale}})?$`,
message: $t('form-design.attribute.numberPrecisionError', {
integer: integerPart,
scale: field.scale,
}),
});
}
const component: any = {
type,
label: field.comment || field.name,
field: field.name,
props: {
placeholder: isSystem
? $t('form-design.attribute.systemAutoGenerate')
: $t('common.placeholder') + (field.comment || field.name),
width: '100%',
disabled: isSystem, // 系统字段默认禁用
required: !field.nullable && !isSystem, // 系统字段不需要必填
},
regList: regList.length > 0 ? regList : undefined,
isSystemField: isSystem, // 标记是否为系统字段
};
// 根据类型补充特定属性
if (type === 'input-number' && field.scale !== undefined) {
component.props.precision = field.scale;
}
if (type === 'input' || type === 'textarea') {
// 默认显示字数限制
component.props.showWordLimit = true;
if (field.maxLength) {
component.props.maxlength = field.maxLength;
}
}
return component;
});
const subTableComponent = {
type: 'sub-table',
label: table.alias || table.tableName,
field: table.tableName, // 使用表名作为字段标识
props: {
showIndex: true,
summary: false,
addable: true,
deletable: true,
displayMode: 'table',
},
children,
};
return store.cloneComponent(subTableComponent);
};
// Clone 函数:包装 generateComponentFromField
const cloneField = (field: TableField) => {
return generateComponentFromField(field);
};
// Clone 函数:包装 generateSubTableFromTable
const cloneSubTable = (table: TableConfig) => {
return generateSubTableFromTable(table);
};
// 包装主表字段(带过滤,但保留主键和ID字段)
const filteredMainTableFields = computed(() => {
const fields = dataSource.value.mainTable?.fields || [];
// 不再过滤主键和ID字段,让它们可以被添加(但会被设为禁用)
return filterFields(fields);
});
// 包装从表列表(带过滤,但保留主键、ID和外键字段)
const filteredSubTables = computed(() => {
if (!dataSource.value.subTables) return [];
return dataSource.value.subTables
.map((subTable) => {
// 不再过滤主键、ID和外键字段,让它们可以被添加(但会被设为禁用)
const filteredFields = filterFields(subTable.fields);
// 如果从表名字匹配,或者有字段匹配,则显示该从表
const subTableMatch =
props.searchKeyword &&
subTable.tableName
.toLowerCase()
.includes(props.searchKeyword.toLowerCase());
if (!props.searchKeyword || subTableMatch || filteredFields.length > 0) {
return {
...subTable,
// 如果是表名匹配,显示所有字段;否则只显示匹配的字段
fields:
props.searchKeyword && subTableMatch
? subTable.fields
: filteredFields,
};
}
return null;
})
.filter(Boolean) as TableConfig[];
});
</script>
<template>
<div class="h-full">
<ElScrollbar class="h-full">
<div class="px-4 pb-4">
<!-- 主表字段 -->
<div 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('main')"
>
<span class="font-bold">{{ $t('form-design.attribute.mainTableFields') }} ({{
dataSource.mainTable?.tableName || $t('form-design.attribute.notConfigured')
}})</span>
<ElIcon class="h-4 w-4">
<ArrowDown v-if="activeGroups.includes('main')" />
<ArrowRight v-else />
</ElIcon>
</div>
<div v-show="activeGroups.includes('main')">
<div
v-if="!dataSource.mainTable"
class="rounded bg-[var(--el-fill-color-light)] py-4 text-center text-xs text-[var(--el-text-color-placeholder)]"
>
{{ $t('form-design.attribute.configDataSourceTip') }}
</div>
<div
v-else-if="filteredMainTableFields.length === 0"
class="rounded bg-[var(--el-fill-color-light)] py-2 text-center text-xs text-[var(--el-text-color-placeholder)]"
>
{{ $t('form-design.material.noFields') }}
</div>
<draggable
v-else
:list="filteredMainTableFields"
:group="{ name: 'form-design', pull: 'clone', put: false }"
:sort="false"
:clone="cloneField"
item-key="name"
class="grid grid-cols-1 gap-2"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="component-item flex cursor-move items-center justify-between rounded border border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] px-3 py-2 transition-colors hover:border-[var(--el-color-primary)] hover:text-[var(--el-color-primary)]"
:class="{
'cursor-not-allowed opacity-50 hover:!border-[var(--el-border-color)] hover:!text-inherit':
isFieldUsed(element.name) || isAutoHandledField(element.name),
}"
@click.stop="!isAutoHandledField(element.name) && handleClickAdd(element)"
>
<div class="flex items-center gap-2 overflow-hidden">
<ElIcon class="flex-shrink-0 text-sm">
<component
:is="getComponentTypeInfo(element.type).icon"
/>
</ElIcon>
<div class="flex flex-col overflow-hidden">
<span
class="truncate text-xs font-medium"
:title="element.name"
>
{{ element.name }}
</span>
<span
class="truncate text-[10px] text-[var(--el-text-color-secondary)]"
:title="element.comment"
>
{{ element.comment || '-' }}
</span>
</div>
</div>
<div class="flex items-center gap-1">
<ElTag
v-if="isAutoHandledField(element.name)"
size="small"
type="warning"
effect="light"
class="h-4 px-1 py-0 text-[10px]"
>
{{ $t('form-design.attribute.autoHandled') }}
</ElTag>
<ElTag
v-else-if="isFieldUsed(element.name)"
size="small"
type="info"
effect="light"
class="h-4 px-1 py-0 text-[10px]"
>
{{ $t('form-design.attribute.added') }}
</ElTag>
<template v-else>
<ElTag
v-if="element.isPrimaryKey"
size="small"
type="warning"
effect="plain"
class="h-4 px-1 py-0 text-[10px]"
>
PK
</ElTag>
<ElTag
v-else-if="element.name.toLowerCase() === 'id'"
size="small"
type="warning"
effect="plain"
<!-- class="h-4 px-1 py-0 text-[10px]" --
>
>
ID
</ElTag>
<span
v-else
class="text-[10px] text-[var(--el-text-color-placeholder)]"
>{{ element.type }}</span>
</template>
</div>
</div>
</template>
</draggable>
</div>
</div>
<!-- 从表字段 (遍历每个从表) -->
<div
v-for="subTable in filteredSubTables"
:key="subTable.id"
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(`sub_${subTable.id}`)"
>
<span class="font-bold">{{ $t('form-design.attribute.subTableLabel') }}: {{ subTable.tableName }}</span>
<div class="flex items-center gap-2">
<ElTag size="small" effect="plain" class="origin-right scale-90">
{{ $t('form-design.attribute.subTableLabel') }}
</ElTag>
<ElIcon class="h-4 w-4">
<ArrowDown v-if="activeGroups.includes(`sub_${subTable.id}`)" />
<ArrowRight v-else />
</ElIcon>
</div>
</div>
<div v-show="activeGroups.includes(`sub_${subTable.id}`)">
<!-- 从表整体拖拽卡片 -->
<draggable
:list="[subTable]"
:group="{ name: 'form-design', pull: 'clone', put: false }"
:sort="false"
:clone="cloneSubTable"
item-key="id"
class="mb-2"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="sub-table-card flex cursor-move items-center justify-between rounded border-2 border-dashed border-[var(--el-color-primary-light-5)] bg-[var(--el-color-primary-light-9)] px-3 py-2 transition-colors hover:border-[var(--el-color-primary)] hover:bg-[var(--el-color-primary-light-8)]"
:class="{
'cursor-not-allowed opacity-50 hover:!border-[var(--el-color-primary-light-5)] hover:!bg-[var(--el-color-primary-light-9)]':
isSubTableUsed(element.tableName),
}"
@click.stop="handleClickAddSubTable(element)"
>
<div class="flex items-center gap-2">
<ElIcon class="text-[var(--el-color-primary)]">
<Grid />
</ElIcon>
<div class="flex flex-col">
<span
class="text-xs font-medium text-[var(--el-color-primary)]"
>
{{ $t('form-design.attribute.addSubTableWhole') }}
</span>
<span
class="text-[10px] text-[var(--el-text-color-secondary)]"
>
{{ $t('form-design.attribute.fieldCount', { count: element.fields.length }) }}
</span>
</div>
</div>
<ElTag
v-if="isSubTableUsed(element.tableName)"
size="small"
type="info"
effect="light"
class="h-4 px-1 py-0 text-[10px]"
>
{{ $t('form-design.attribute.added') }}
</ElTag>
</div>
</template>
</draggable>
<!-- 从表字段提示 -->
<div
v-if="!isSubTableUsed(subTable.tableName)"
class="mb-2 rounded bg-[var(--el-color-warning-light-9)] px-2 py-1.5 text-[10px] text-[var(--el-color-warning)]"
>
{{ $t('form-design.attribute.addSubTableFirstTip') }}
</div>
<!-- 从表单个字段列表 -->
<draggable
:list="subTable.fields"
:group="{ name: 'sub-table-fields', pull: 'clone', put: false }"
:sort="false"
:clone="cloneField"
item-key="name"
class="grid grid-cols-1 gap-2"
:class="{ 'opacity-60': !isSubTableUsed(subTable.tableName) }"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="component-item flex cursor-move items-center justify-between rounded border border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] px-3 py-2 transition-colors hover:border-[var(--el-color-primary)] hover:text-[var(--el-color-primary)]"
:class="{
'cursor-not-allowed opacity-50 hover:!border-[var(--el-border-color)] hover:!text-inherit':
isFieldUsed(element.name, subTable.tableName),
}"
@click.stop="handleClickAdd(element, true)"
>
<div class="flex items-center gap-2 overflow-hidden">
<ElIcon class="flex-shrink-0 text-sm">
<component
:is="getComponentTypeInfo(element.type).icon"
/>
</ElIcon>
<div class="flex flex-col overflow-hidden">
<span
class="truncate text-xs font-medium"
:title="element.name"
>
{{ element.name }}
</span>
<span
class="truncate text-[10px] text-[var(--el-text-color-secondary)]"
:title="element.comment"
>
{{ element.comment || '-' }}
</span>
</div>
</div>
<div class="flex items-center gap-1">
<ElTag
v-if="isFieldUsed(element.name, subTable.tableName)"
size="small"
type="info"
effect="light"
class="h-4 px-1 py-0 text-[10px]"
>
{{ $t('form-design.attribute.added') }}
</ElTag>
<template v-else>
<ElTag
v-if="element.isPrimaryKey"
size="small"
type="warning"
effect="plain"
class="h-4 px-1 py-0 text-[10px]"
>
PK
</ElTag>
<ElTag
v-else-if="element.name.toLowerCase() === 'id'"
size="small"
type="warning"
effect="plain"
class="h-4 px-1 py-0 text-[10px]"
>
ID
</ElTag>
<ElTag
v-else-if="element.name === subTable.foreignKey"
size="small"
type="danger"
effect="plain"
class="h-4 px-1 py-0 text-[10px]"
>
FK
</ElTag>
<span
v-else
class="text-[10px] text-[var(--el-text-color-placeholder)]"
>{{ element.type }}</span>
</template>
</div>
</div>
</template>
</draggable>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
<style scoped>
.component-item {
/* 优化拖拽时的样式 */
user-select: none;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,753 @@
<script setup lang="ts">
import type {
FormLifecycleActionType,
FormLifecycleEvent,
FormLifecycleHook,
FormLifecycleMode,
} from '../store/formDesignStore';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { Delete, Plus, Rank } from '@element-plus/icons-vue';
import {
ElButton,
ElCheckbox,
ElCheckboxGroup,
ElCollapse,
ElCollapseItem,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTag,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import draggable from 'vuedraggable';
import { v4 as uuidv4 } from 'uuid';
import {
getWorkflowDetailApi,
getWorkflowListApi,
type WorkflowListItem,
} from '#/api/ai-platform/ai-platform';
import {
getAllDataSourceApi,
getDataSourceByCodeApi,
type DataSourceSimple,
} from '#/api/core/data-source';
import { useAppContextStore } from '#/store/app-context';
import { CodeEditor } from '../../zq-form/code-editor';
import { useFormDesignStore } from '../store/formDesignStore';
import ButtonScriptEditorDialog from './ButtonScriptEditorDialog.vue';
const props = defineProps<{
allFormFields: { field: string; label: string }[];
}>();
const store = useFormDesignStore();
const { formConf } = storeToRefs(store);
const appContextStore = useAppContextStore();
const lifecycleHooks = computed({
get: () => formConf.value.lifecycleHooks || [],
set: (val: FormLifecycleHook[]) => {
formConf.value.lifecycleHooks = val;
},
});
const EVENT_OPTIONS: { label: string; value: FormLifecycleEvent }[] = [
{ label: $t('form-design.formLifecycle.event.beforeLoad'), value: 'beforeLoad' },
{
label: $t('form-design.formLifecycle.event.afterLoadSuccess'),
value: 'afterLoadSuccess',
},
{
label: $t('form-design.formLifecycle.event.afterLoadFail'),
value: 'afterLoadFail',
},
{ label: $t('form-design.formLifecycle.event.beforeSubmit'), value: 'beforeSubmit' },
{
label: $t('form-design.formLifecycle.event.afterSubmitSuccess'),
value: 'afterSubmitSuccess',
},
{
label: $t('form-design.formLifecycle.event.afterSubmitFail'),
value: 'afterSubmitFail',
},
];
const ACTION_OPTIONS: { label: string; value: FormLifecycleActionType }[] = [
{ label: $t('form-design.formLifecycle.action.script'), value: 'script' },
{ label: $t('form-design.formLifecycle.action.dataSource'), value: 'dataSource' },
{ label: $t('form-design.formLifecycle.action.redirect'), value: 'redirect' },
{ label: $t('form-design.formLifecycle.action.message'), value: 'message' },
{ label: $t('form-design.formLifecycle.action.workflow'), value: 'workflow' },
];
const MODE_OPTIONS: { label: string; value: FormLifecycleMode }[] = [
{ label: $t('form-design.formLifecycle.mode.add'), value: 'add' },
{ label: $t('form-design.formLifecycle.mode.edit'), value: 'edit' },
{ label: $t('form-design.formLifecycle.mode.view'), value: 'view' },
];
const MESSAGE_TYPE_OPTIONS = [
{ label: $t('form-design.formLifecycle.messageType.success'), value: 'success' },
{ label: $t('form-design.formLifecycle.messageType.warning'), value: 'warning' },
{ label: $t('form-design.formLifecycle.messageType.info'), value: 'info' },
{ label: $t('form-design.formLifecycle.messageType.error'), value: 'error' },
];
const WORKFLOW_TYPES = ['data_process', 'automation'] as const;
const dataSourceList = ref<DataSourceSimple[]>([]);
const loadingDataSource = ref(false);
const workflowList = ref<WorkflowListItem[]>([]);
const loadingWorkflowList = ref(false);
const scriptEditorRef = ref<InstanceType<typeof ButtonScriptEditorDialog>>();
const editingHook = ref<FormLifecycleHook | null>(null);
const editingScriptField = ref<'callbackScript' | 'script'>('script');
const scriptDialogFields = computed(() =>
props.allFormFields.map((f) => ({ field: f.field, label: f.label })),
);
function createDefaultHook(sort: number): FormLifecycleHook {
return {
id: uuidv4(),
enabled: true,
event: 'afterSubmitSuccess',
modes: ['add', 'edit'],
actionType: 'message',
actionConfig: {
message: '',
messageType: 'success',
},
blockOnError: false,
sort,
};
}
function addHook() {
const hooks = [...lifecycleHooks.value];
hooks.push(createDefaultHook(hooks.length));
lifecycleHooks.value = hooks;
}
function removeHook(id: string) {
lifecycleHooks.value = lifecycleHooks.value.filter((h) => h.id !== id);
updateSort();
}
function updateSort() {
lifecycleHooks.value.forEach((hook, index) => {
hook.sort = index;
});
}
function onDragEnd() {
updateSort();
}
function ensureActionConfig(hook: FormLifecycleHook) {
if (!hook.actionConfig) {
hook.actionConfig = {};
}
}
async function loadDataSourceList() {
if (dataSourceList.value.length > 0) return;
loadingDataSource.value = true;
try {
dataSourceList.value =
(await getAllDataSourceApi(appContextStore.currentApp?.id)) || [];
} catch (error) {
console.error('Load data source list error:', error);
dataSourceList.value = [];
} finally {
loadingDataSource.value = false;
}
}
async function handleDataSourceChange(hook: FormLifecycleHook, code: string) {
ensureActionConfig(hook);
if (!code) {
hook.actionConfig.dataSourceParams = [];
return;
}
try {
const data = await getDataSourceByCodeApi(code);
const paramDefs = data.params || [];
const existingParams = hook.actionConfig.dataSourceParams || [];
const newParams: any[] = [];
for (const paramDef of paramDefs) {
const existing = existingParams.find((p) => p.name === paramDef.name);
if (existing) {
newParams.push({
...existing,
label: paramDef.label || paramDef.name,
type: paramDef.type || 'string',
required: paramDef.required || false,
default: paramDef.default,
});
} else {
newParams.push({
name: paramDef.name,
label: paramDef.label || paramDef.name,
type: paramDef.type || 'string',
required: paramDef.required || false,
default: paramDef.default,
valueSource: 'fixed',
fixedValue: undefined,
});
}
}
hook.actionConfig.dataSourceParams = newParams;
} catch (error) {
console.error('Load data source detail error:', error);
hook.actionConfig.dataSourceParams = [];
}
}
function isWorkflowAllowed(workflow: {
status?: string;
workflow_type?: string;
}) {
return (
workflow.status === 'published' &&
WORKFLOW_TYPES.includes(
workflow.workflow_type as (typeof WORKFLOW_TYPES)[number],
)
);
}
function getWorkflowStartVariables(workflow: {
definition?: any;
input_variables?: any[];
published_definition?: any;
}) {
const definition =
workflow.published_definition?.nodes?.length
? workflow.published_definition
: workflow.definition;
const startNode = definition?.nodes?.find(
(node: any) => node.type === 'start',
);
return startNode?.data?.variables || workflow.input_variables || [];
}
async function loadWorkflowList() {
if (workflowList.value.length > 0) return;
loadingWorkflowList.value = true;
try {
const res = await getWorkflowListApi({
page: 1,
pageSize: 200,
status: 'published',
applicationId: appContextStore.currentApp?.id,
});
workflowList.value = (res.items || []).filter((wf) =>
isWorkflowAllowed(wf),
);
} catch (error) {
console.error('Load workflow list error:', error);
workflowList.value = [];
} finally {
loadingWorkflowList.value = false;
}
}
async function handleWorkflowChange(hook: FormLifecycleHook, workflowId: string) {
ensureActionConfig(hook);
if (!workflowId) {
hook.actionConfig.workflowInputs = [];
return;
}
try {
const workflow = await getWorkflowDetailApi(workflowId);
if (!isWorkflowAllowed(workflow)) {
ElMessage.warning($t('form-design.attribute.buttonWorkflowTypeNotAllowed'));
hook.actionConfig.workflowId = '';
hook.actionConfig.workflowInputs = [];
return;
}
const variables = getWorkflowStartVariables(workflow);
const existingParams = hook.actionConfig.workflowInputs || [];
const newParams: any[] = [];
for (const variableDef of variables) {
const varName = variableDef.variable || variableDef.name;
if (!varName) continue;
const existing = existingParams.find((p) => p.name === varName);
if (existing) {
newParams.push({
...existing,
label: variableDef.label || varName,
type: variableDef.type || 'string',
required: variableDef.required || false,
default: variableDef.default_value,
});
} else {
newParams.push({
name: varName,
label: variableDef.label || varName,
type: variableDef.type || 'string',
required: variableDef.required || false,
default: variableDef.default_value,
valueSource: 'fixed',
fixedValue: undefined,
sourceField: undefined,
});
}
}
hook.actionConfig.workflowInputs = newParams;
} catch (error) {
console.error('Load workflow detail error:', error);
hook.actionConfig.workflowInputs = [];
}
}
function openScriptEditor(hook: FormLifecycleHook, field: 'callbackScript' | 'script') {
editingHook.value = hook;
editingScriptField.value = field;
scriptEditorRef.value?.open();
}
function onScriptUpdate(val: string) {
if (!editingHook.value) return;
ensureActionConfig(editingHook.value);
if (editingScriptField.value === 'callbackScript') {
editingHook.value.actionConfig.callbackScript = val;
} else {
editingHook.value.actionConfig.script = val;
}
}
const editingScriptValue = computed(() => {
if (!editingHook.value) return '';
return editingScriptField.value === 'callbackScript'
? editingHook.value.actionConfig.callbackScript || ''
: editingHook.value.actionConfig.script || '';
});
function getHookSummary(hook: FormLifecycleHook) {
const eventLabel =
EVENT_OPTIONS.find((o) => o.value === hook.event)?.label || hook.event;
const actionLabel =
ACTION_OPTIONS.find((o) => o.value === hook.actionType)?.label ||
hook.actionType;
return `${eventLabel} · ${actionLabel}`;
}
function isBlockingEvent(event: FormLifecycleEvent) {
return event === 'beforeLoad' || event === 'beforeSubmit';
}
function onActionTypeChange(hook: FormLifecycleHook, type: FormLifecycleActionType) {
ensureActionConfig(hook);
if (type === 'workflow' && hook.actionConfig.async === undefined) {
hook.actionConfig.async = true;
}
}
</script>
<template>
<div class="form-lifecycle-hooks">
<div class="mb-2 flex justify-end">
<ElButton type="primary" link size="small" :icon="Plus" @click="addHook">
{{ $t('form-design.formLifecycle.addHook') }}
</ElButton>
</div>
<div
v-if="lifecycleHooks.length === 0"
class="rounded border border-dashed border-[var(--el-border-color)] p-4 text-center text-xs text-[var(--el-text-color-secondary)]"
>
{{ $t('form-design.formLifecycle.empty') }}
</div>
<draggable
v-else
v-model="lifecycleHooks"
item-key="id"
handle=".hook-drag-handle"
@end="onDragEnd"
>
<template #item="{ element: hook }">
<ElCollapse class="lifecycle-collapse mb-2">
<ElCollapseItem :name="hook.id">
<template #title>
<div class="flex w-full items-center gap-2 pr-2">
<ElIcon
class="hook-drag-handle cursor-move text-[var(--el-text-color-secondary)]"
>
<Rank />
</ElIcon>
<ElSwitch v-model="hook.enabled" size="small" @click.stop />
<span
class="flex-1 truncate text-xs text-[var(--el-text-color-regular)]"
>
{{ getHookSummary(hook) }}
</span>
<ElButton
type="danger"
link
size="small"
:icon="Delete"
@click.stop="removeHook(hook.id)"
/>
</div>
</template>
<ElFormItem :label="$t('form-design.formLifecycle.eventLabel')">
<ElSelect v-model="hook.event" class="w-full">
<ElOption
v-for="opt in EVENT_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('form-design.formLifecycle.modeLabel')">
<ElCheckboxGroup v-model="hook.modes">
<ElCheckbox
v-for="opt in MODE_OPTIONS"
:key="opt.value"
:label="opt.value"
size="small"
>
{{ opt.label }}
</ElCheckbox>
</ElCheckboxGroup>
</ElFormItem>
<ElFormItem :label="$t('form-design.formLifecycle.actionTypeLabel')">
<ElSelect
v-model="hook.actionType"
class="w-full"
@change="(type: FormLifecycleActionType) => onActionTypeChange(hook, type)"
>
<ElOption
v-for="opt in ACTION_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="isBlockingEvent(hook.event)"
:label="$t('form-design.formLifecycle.blockOnError')"
>
<ElSwitch v-model="hook.blockOnError" />
<div class="mt-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('form-design.formLifecycle.blockOnErrorTip') }}
</div>
</ElFormItem>
<!-- script -->
<template v-if="hook.actionType === 'script'">
<ElFormItem :label="$t('form-design.formLifecycle.script')">
<div
class="w-full cursor-pointer"
@dblclick="openScriptEditor(hook, 'script')"
>
<CodeEditor
:model-value="hook.actionConfig.script || ''"
language="javascript"
:line-numbers="false"
readonly
min-height="60px"
max-height="120px"
:placeholder="$t('form-design.formLifecycle.scriptPlaceholder')"
/>
</div>
<div class="mt-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('form-design.formLifecycle.scriptTip') }}
</div>
</ElFormItem>
</template>
<!-- dataSource -->
<template v-if="hook.actionType === 'dataSource'">
<ElFormItem :label="$t('form-design.attribute.dataSource')">
<ElSelect
v-model="hook.actionConfig.dataSourceCode"
filterable
clearable
class="w-full"
:loading="loadingDataSource"
@focus="loadDataSourceList"
@change="(code: string) => handleDataSourceChange(hook, code)"
>
<ElOption
v-for="ds in dataSourceList"
:key="ds.code"
:label="`${ds.name} (${ds.code})`"
:value="ds.code"
/>
</ElSelect>
</ElFormItem>
<template v-if="hook.actionConfig.dataSourceParams?.length">
<div
class="mb-2 mt-2 pt-2 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ $t('form-design.attribute.paramConfig') }}
</div>
<div
v-for="param in hook.actionConfig.dataSourceParams"
:key="param.name"
class="mb-3 rounded p-2"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-[var(--el-text-color-regular)]">
{{ param.name }}
<span
v-if="param.label && param.label !== param.name"
class="text-xs text-[var(--el-text-color-secondary)]"
>
({{ param.label }})
</span>
</span>
<ElTag size="small" type="info">{{ param.type || 'string' }}</ElTag>
</div>
<ElFormItem
:label="$t('form-design.attribute.paramValueSource')"
class="mb-2"
>
<ElSelect v-model="param.valueSource" size="small" class="w-full">
<ElOption
:label="$t('form-design.attribute.paramSource.fixed')"
value="fixed"
/>
<ElOption
:label="$t('form-design.attribute.paramSource.field')"
value="field"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-show="param.valueSource === 'fixed'"
:label="$t('form-design.attribute.paramFixedValue')"
>
<ElInput v-model="param.fixedValue" size="small" />
</ElFormItem>
<ElFormItem
v-show="param.valueSource === 'field'"
:label="$t('form-design.attribute.paramSourceField')"
>
<ElSelect
v-model="param.sourceField"
size="small"
filterable
clearable
class="w-full"
>
<ElOption
v-for="field in allFormFields"
:key="field.field"
:label="`${field.label} (${field.field})`"
:value="field.field"
/>
</ElSelect>
</ElFormItem>
</div>
</template>
<ElFormItem :label="$t('form-design.formLifecycle.callbackScript')">
<div
class="w-full cursor-pointer rounded border border-[var(--el-border-color)]"
@dblclick="openScriptEditor(hook, 'callbackScript')"
>
<CodeEditor
:model-value="hook.actionConfig.callbackScript || ''"
language="javascript"
:line-numbers="false"
readonly
min-height="60px"
max-height="120px"
:placeholder="$t('form-design.formLifecycle.callbackScriptPlaceholder')"
/>
</div>
</ElFormItem>
</template>
<!-- redirect -->
<template v-if="hook.actionType === 'redirect'">
<ElFormItem :label="$t('form-design.formLifecycle.redirectUrl')">
<ElInput
v-model="hook.actionConfig.url"
:placeholder="$t('form-design.formLifecycle.redirectUrlPlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('form-design.formLifecycle.openInNewTab')">
<ElSwitch v-model="hook.actionConfig.openInNewTab" />
</ElFormItem>
</template>
<!-- message -->
<template v-if="hook.actionType === 'message'">
<ElFormItem :label="$t('form-design.formLifecycle.message')">
<ElInput
v-model="hook.actionConfig.message"
type="textarea"
:rows="2"
:placeholder="$t('form-design.formLifecycle.messagePlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('form-design.formLifecycle.messageTypeLabel')">
<ElSelect v-model="hook.actionConfig.messageType" class="w-full">
<ElOption
v-for="opt in MESSAGE_TYPE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
</template>
<!-- workflow -->
<template v-if="hook.actionType === 'workflow'">
<ElFormItem :label="$t('form-design.attribute.buttonWorkflow')">
<ElSelect
v-model="hook.actionConfig.workflowId"
filterable
clearable
class="w-full"
:loading="loadingWorkflowList"
@focus="loadWorkflowList"
@change="(id: string) => handleWorkflowChange(hook, id)"
>
<ElOption
v-for="wf in workflowList"
:key="wf.id"
:label="`${wf.name} (${wf.code})`"
:value="wf.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('form-design.formLifecycle.workflowAsync')">
<ElSwitch v-model="hook.actionConfig.async" />
<div class="mt-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('form-design.formLifecycle.workflowAsyncTip') }}
</div>
</ElFormItem>
<template v-if="hook.actionConfig.workflowInputs?.length">
<div
class="mb-2 mt-2 pt-2 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ $t('form-design.attribute.buttonWorkflowInputMapping') }}
</div>
<div
v-for="param in hook.actionConfig.workflowInputs"
:key="param.name"
class="mb-3 rounded border border-[var(--el-border-color)] p-2"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-[var(--el-text-color-regular)]">
{{ param.name }}
<span
v-if="param.label && param.label !== param.name"
class="text-xs text-[var(--el-text-color-secondary)]"
>
({{ param.label }})
</span>
</span>
<ElTag size="small" type="info">{{ param.type || 'string' }}</ElTag>
</div>
<ElFormItem
:label="$t('form-design.attribute.paramValueSource')"
class="mb-2"
>
<ElSelect v-model="param.valueSource" size="small" class="w-full">
<ElOption
:label="$t('form-design.attribute.paramSource.fixed')"
value="fixed"
/>
<ElOption
:label="$t('form-design.attribute.paramSource.field')"
value="field"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-show="param.valueSource === 'fixed'"
:label="$t('form-design.attribute.paramFixedValue')"
>
<ElInput v-model="param.fixedValue" size="small" />
</ElFormItem>
<ElFormItem
v-show="param.valueSource === 'field'"
:label="$t('form-design.attribute.paramSourceField')"
>
<ElSelect
v-model="param.sourceField"
size="small"
filterable
clearable
class="w-full"
>
<ElOption
v-for="field in allFormFields"
:key="field.field"
:label="`${field.label} (${field.field})`"
:value="field.field"
/>
</ElSelect>
</ElFormItem>
</div>
</template>
</template>
</ElCollapseItem>
</ElCollapse>
</template>
</draggable>
<ButtonScriptEditorDialog
ref="scriptEditorRef"
:model-value="editingScriptValue"
:fields="scriptDialogFields"
@update:model-value="onScriptUpdate"
/>
</div>
</template>
<style scoped>
.lifecycle-collapse {
border: none;
}
.lifecycle-collapse :deep(.el-collapse-item__header) {
min-height: 36px;
height: auto;
padding-top: 4px;
padding-bottom: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
background-color: transparent;
border-bottom: none;
}
.lifecycle-collapse :deep(.el-collapse-item__wrap) {
border-bottom: none;
}
.lifecycle-collapse :deep(.el-collapse-item__content) {
padding-bottom: 8px;
font-size: 12px;
}
.lifecycle-collapse :deep(.el-form-item__label) {
font-size: 12px;
color: var(--el-text-color-regular);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { $t } from '@vben/locales';
import { nextTick, ref, watch } from 'vue';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElTree,
} from 'element-plus';
const props = defineProps<{
data: any[];
modelValue: boolean;
}>();
const emit = defineEmits(['update:modelValue', 'confirm']);
const visible = ref(false);
const treeData = ref<any[]>([]);
const currentNode = ref<any>(null);
const treeRef = ref<InstanceType<typeof ElTree>>();
// 生成唯一ID,用于树组件追踪
const generateId = () => `_${Math.random().toString(36).slice(2, 11)}`;
// 递归处理数据,确保有 id 和 children
const processData = (items: any[]): any[] => {
return items.map((item) => ({
...item,
id: item.id || generateId(),
children: item.children ? processData(item.children) : [],
}));
};
// 递归清理数据,移除临时 id
const cleanData = (items: any[]): any[] => {
return items.map((item) => {
const { id, ...rest } = item;
const newItem: any = { ...rest };
if (newItem.children && newItem.children.length > 0) {
newItem.children = cleanData(newItem.children);
} else {
delete newItem.children;
}
return newItem;
});
};
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
// 深拷贝并处理数据
treeData.value = processData(
JSON.parse(JSON.stringify(props.data || [])),
);
currentNode.value = null;
}
},
);
watch(
() => visible.value,
(val) => {
emit('update:modelValue', val);
},
);
const handleNodeClick = (data: any) => {
currentNode.value = data;
};
const addRootNode = () => {
const newNode = {
id: generateId(),
label: $t('form-design.attribute.addOption'),
value: 'new_option',
children: [],
};
treeData.value.push(newNode);
currentNode.value = newNode;
nextTick(() => {
treeRef.value?.setCurrentKey(newNode.id);
});
};
const addChildNode = () => {
if (!currentNode.value) return;
const newNode = {
id: generateId(),
label: $t('form-design.attribute.addChild'),
value: 'new_child',
children: [],
};
if (!currentNode.value.children) {
currentNode.value.children = [];
}
currentNode.value.children.push(newNode);
// 自动展开父节点
/* treeRef.value?.store.nodesMap[currentNode.value.id].expanded = true; */
};
const removeNode = () => {
if (!currentNode.value) return;
treeRef.value?.remove(currentNode.value);
currentNode.value = null;
};
const allowDrop = () => {
// 可以添加限制逻辑,目前允许任意拖拽
return true;
};
const handleConfirm = () => {
const result = cleanData(treeData.value);
emit('confirm', result);
visible.value = false;
};
</script>
<template>
<ElDialog
v-model="visible"
:title="$t('form-design.attribute.dataSource')"
width="800px"
:close-on-click-modal="false"
append-to-body
>
<div class="flex h-[500px] rounded border border-[var(--el-border-color)]">
<!-- 左侧树形结构 -->
<div class="flex w-1/2 flex-col border-r border-[var(--el-border-color)]">
<div
class="flex gap-2 border-b border-[var(--el-border-color-lighter)] p-2"
>
<ElButton type="primary" size="small" @click="addRootNode">
{{ $t('form-design.attribute.addRoot') }}
</ElButton>
<ElButton size="small" :disabled="!currentNode" @click="addChildNode">
{{ $t('form-design.attribute.addChild') }}
</ElButton>
<ElButton
type="danger"
size="small"
:disabled="!currentNode"
@click="removeNode"
>
{{ $t('form-design.delete') }}
</ElButton>
</div>
<div class="flex-1 overflow-y-auto p-2">
<ElTree
ref="treeRef"
:data="treeData"
node-key="id"
default-expand-all
draggable
:allow-drop="allowDrop"
highlight-current
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ data }">
<span class="custom-tree-node text-sm">
<span>{{ data.label }}</span>
<span
class="ml-2 text-xs text-[var(--el-text-color-secondary)]"
v-if="data.value"
>({{ data.value }})</span
>
</span>
</template>
</ElTree>
</div>
</div>
<!-- 右侧节点编辑 -->
<div class="flex w-1/2 flex-col bg-[var(--el-fill-color-light)]">
<div class="p-4" v-if="currentNode">
<div
class="mb-4 border-b pb-2 font-bold text-[var(--el-text-color-primary)]"
>
{{ $t('form-design.attribute.nodeProps') }}
</div>
<ElForm label-position="top" size="small">
<ElFormItem :label="$t('form-design.attribute.nodeLabel')">
<ElInput v-model="currentNode.label" :placeholder="$t('common.placeholder')" />
</ElFormItem>
<ElFormItem :label="$t('form-design.attribute.nodeValue')">
<ElInput v-model="currentNode.value" :placeholder="$t('common.placeholder')" />
</ElFormItem>
</ElForm>
</div>
<div
v-else
class="flex flex-1 items-center justify-center text-sm text-[var(--el-text-color-placeholder)]"
>
{{ $t('form-design.attribute.selectNodeTip') }}
</div>
</div>
</div>
<div class="mt-4">
<ElCollapse>
<ElCollapseItem :title="$t('form-design.attribute.viewJson')" name="json">
<ElInput
:model-value="JSON.stringify(treeData, null, 2)"
type="textarea"
:rows="6"
readonly
/>
</ElCollapseItem>
</ElCollapse>
</div>
<template #footer>
<span class="dialog-footer">
<ElButton @click="visible = false">{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" @click="handleConfirm">{{ $t('common.ok') }}</ElButton>
</span>
</template>
</ElDialog>
</template>
<style scoped>
:deep(.el-tree-node__content) {
height: 32px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { $t } from '@vben/locales';
import { reactive, ref, watch } from 'vue';
import { ElForm, ElMessage } from 'element-plus';
import { ZqDialog } from '#/components/zq-dialog';
import { applyItemDefaultValue } from '../utils/defaultValueResolver';
import { getSubTableInitialRowCount } from '../utils/subTableHelpers';
import PreviewItem from './PreviewItem.vue';
const props = defineProps<{
conf: any;
}>();
const visible = ref(false);
const formData = reactive<any>({});
const formRef = ref();
// 递归初始化数据
function initFormData(items: any[]) {
items.forEach((item: any) => {
if (item.type === 'grid') {
item.columns.forEach((col: any) => {
initFormData(col.children);
});
} else if (item.field) {
if (applyItemDefaultValue(formData, item, item.options)) {
// 已应用配置的默认值
} else if (item.type === 'checkbox') {
formData[item.field] = [];
} else if (['input-number', 'rate', 'slider'].includes(item.type)) {
formData[item.field] = 0;
} else if (item.type === 'switch') {
formData[item.field] = false;
} else if (item.type === 'sub-table') {
formData[item.field] = [];
const min = getSubTableInitialRowCount(item.props);
for (let i = 0; i < min; i++) {
const newRow: any = { _id: `${Date.now()}_${Math.random()}` };
if (item.children) {
item.children.forEach((col: any) => {
newRow[col.field] = null;
});
}
formData[item.field].push(newRow);
}
} else {
formData[item.field] = null;
}
}
});
}
// 初始化数据
watch(
() => props.conf,
(newConf) => {
if (!newConf || !newConf.items) return;
// 重置
for (const key in formData) delete formData[key];
initFormData(newConf.items);
},
{ deep: true },
);
const open = () => {
visible.value = true;
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
ElMessage.success($t('form-design.message.validateSuccess'));
console.log($t('form-design.preview.submitData'), formData);
} catch {
ElMessage.error($t('form-design.message.validateError'));
}
};
defineExpose({ open });
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('form-design.previewLabel')"
width="800px"
max-height="60vh"
:confirm-text="$t('form-design.preview.submit')"
@confirm="handleSubmit"
>
<div
:style="{
padding: `${conf.formPaddingTop ?? conf.formPadding ?? 20}px ${conf.formPaddingRight ?? conf.formPadding ?? 20}px ${conf.formPaddingBottom ?? conf.formPadding ?? 20}px ${conf.formPaddingLeft ?? conf.formPadding ?? 20}px`,
margin: `${conf.formMarginTop ?? conf.formMargin ?? 0}px ${conf.formMarginRight ?? conf.formMargin ?? 0}px ${conf.formMarginBottom ?? conf.formMargin ?? 0}px ${conf.formMarginLeft ?? conf.formMargin ?? 0}px`,
width: conf.formWidth || '100%',
maxWidth: conf.formMaxWidth || 'none',
backgroundColor: conf.formBackground || 'transparent',
border: conf.formBorder ? '1px solid var(--el-border-color)' : 'none',
borderRadius: conf.formBorder ? `${conf.formBorderRadius || 4}px` : '0',
boxShadow: conf.formShadow ? '0 2px 12px 0 rgba(0, 0, 0, 0.1)' : 'none',
}"
>
<ElForm
ref="formRef"
:model="formData"
:label-width="`${conf.labelWidth}px`"
:label-position="conf.labelPosition"
:size="conf.size"
:disabled="conf.disabled || false"
:style="{
'--el-form-item-margin-bottom': `${conf.itemSpacing || 18}px`,
}"
>
<PreviewItem
v-for="item in conf.items"
:key="item.id"
:item="item"
:model-value="formData"
/>
</ElForm>
</div>
<div class="bg-accent mt-4 rounded p-4">
<div class="text-muted-foreground mb-2 text-sm font-bold">
{{ $t('form-design.preview.formData') }} (v-model):
</div>
<pre class="text-muted-foreground overflow-auto text-xs">{{
formData
}}</pre>
</div>
</ZqDialog>
</template>
@@ -0,0 +1,329 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElScrollbar, ElTag, ElTooltip } from 'element-plus';
import { ZqDialog } from '#/components/zq-dialog';
import { CodeEditor } from '../../zq-form/code-editor';
interface FieldInfo {
field: string;
label: string;
type?: string;
group?: string;
insertCode?: string;
}
const props = defineProps<{
modelValue: string;
fields: FieldInfo[];
currentField?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'confirm', value: string): void;
}>();
const visible = ref(false);
const localCode = ref('');
const open = () => {
localCode.value = props.modelValue || '';
visible.value = true;
};
const handleConfirm = () => {
emit('update:modelValue', localCode.value);
emit('confirm', localCode.value);
visible.value = false;
};
watch(
() => props.modelValue,
(val) => {
if (visible.value) {
localCode.value = val || '';
}
},
);
const insertFieldRef = (f: FieldInfo) => {
localCode.value += f.insertCode || `model.${f.field}`;
};
// 按 group 分组字段
const fieldGroups = computed(() => {
const groups: Array<{ group: string; fields: FieldInfo[] }> = [];
const map = new Map<string, FieldInfo[]>();
for (const f of props.fields) {
const g = f.group || '';
if (!map.has(g)) {
map.set(g, []);
}
map.get(g)!.push(f);
}
for (const [group, fields] of map) {
groups.push({ group, fields });
}
return groups;
});
const apiDocs = computed(() => [
{
title: $t('form-design.scriptEditor.sectionVariables'),
items: [
{
name: 'model',
desc: $t('form-design.scriptEditor.varModelDesc'),
},
{
name: 'field',
desc: $t('form-design.scriptEditor.varFieldDesc'),
},
{
name: 'value',
desc: $t('form-design.scriptEditor.varValueDesc'),
},
{
name: '$options',
desc: $t('form-design.scriptEditor.varOptionsDesc'),
},
{
name: 'model.xxx_selectedItem',
desc: $t('form-design.scriptEditor.varSelectedItemDesc'),
},
{
name: '$root',
desc: $t('form-design.scriptEditor.varRootDesc'),
},
{
name: '$rows',
desc: $t('form-design.scriptEditor.varRowsDesc'),
},
],
},
{
title: $t('form-design.scriptEditor.sectionMethods'),
items: [
{
name: '$show(field)',
desc: $t('form-design.scriptEditor.methodShowDesc'),
},
{
name: '$hide(field)',
desc: $t('form-design.scriptEditor.methodHideDesc'),
},
{
name: '$disable(field)',
desc: $t('form-design.scriptEditor.methodDisableDesc'),
},
{
name: '$enable(field)',
desc: $t('form-design.scriptEditor.methodEnableDesc'),
},
],
},
{
title: $t('form-design.scriptEditor.sectionExamples'),
items: [
{
name: $t('form-design.scriptEditor.exConcat'),
desc: "return model.field_a + ' ' + model.field_b;",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exCondition'),
desc: "return model.amount > 1000 ? '大额' : '普通';",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exOptionLabel'),
desc: "return $options.find(o => o.value === model.status)?.label || '';",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exFormat'),
desc: "return model.price ? '¥' + Number(model.price).toFixed(2) : '';",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exSelectorField'),
desc: "return model.customer_selectedItem?.company_name || '';",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exSubGetRoot'),
desc: "return $root.company_name;",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exRootGetSub'),
desc: "return $root.items?.reduce((s, r) => s + (r.amount || 0), 0);",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exShowHide'),
desc: "// 根据类型字段控制其他字段显隐\nif (model.type === 'company') {\n $show('company_name');\n $hide('person_name');\n} else {\n $hide('company_name');\n $show('person_name');\n}",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exDisableEnable'),
desc: "// 根据状态禁用/启用字段\nif (model.status === 'approved') {\n $disable('amount');\n $disable('description');\n} else {\n $enable('amount');\n $enable('description');\n}",
isCode: true,
},
],
},
{
title: $t('form-design.scriptEditor.sectionAggregate'),
items: [
{
name: $t('form-design.scriptEditor.exSum'),
desc: "// 子表 amount 字段求和\nreturn model.items?.reduce((s, r) => s + (Number(r.amount) || 0), 0) || 0;",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exAvg'),
desc: "// 子表 amount 字段平均值\nconst rows = model.items || [];\nif (!rows.length) return 0;\nreturn rows.reduce((s, r) => s + (Number(r.amount) || 0), 0) / rows.length;",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exMax'),
desc: "// 子表 amount 字段最大值\nconst vals = (model.items || []).map(r => Number(r.amount) || 0);\nreturn vals.length ? Math.max(...vals) : 0;",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exMin'),
desc: "// 子表 amount 字段最小值\nconst vals = (model.items || []).map(r => Number(r.amount) || 0);\nreturn vals.length ? Math.min(...vals) : 0;",
isCode: true,
},
{
name: $t('form-design.scriptEditor.exCount'),
desc: "// 子表行数\nreturn (model.items || []).length;",
isCode: true,
},
],
},
]);
defineExpose({ open });
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('form-design.scriptEditor.title')"
width="1200px"
content-height="600px"
@confirm="handleConfirm"
>
<div class="flex h-[600px] gap-0 overflow-hidden">
<!-- 左侧说明面板 -->
<div class="flex w-[380px] flex-shrink-0 my-2 flex-col overflow-hidden border rounded-[8px] border-[var(--el-border-color-lighter)]">
<ElScrollbar class="flex-1">
<div class="p-3">
<!-- 可用字段分组展示 -->
<div class="mb-3">
<div class="mb-2 text-xs font-bold text-[var(--el-text-color-regular)]">
{{ $t('form-design.scriptEditor.sectionFields') }}
</div>
<template v-for="group in fieldGroups" :key="group.group">
<div v-if="group.group" class="mb-1 mt-2 text-xs text-[var(--el-text-color-secondary)]">
{{ group.group }}
</div>
<div class="flex flex-wrap gap-1">
<ElTooltip
v-for="f in group.fields"
:key="f.field"
:content="`${f.insertCode || ('model.' + f.field)} (${f.type || 'text'})`"
placement="top"
>
<ElTag
size="small"
class="cursor-pointer"
:type="f.field === currentField ? 'warning' : (f.type === 'sub-table' ? 'danger' : undefined)"
@click="insertFieldRef(f)"
>
{{ f.label }}
</ElTag>
</ElTooltip>
</div>
</template>
<div class="mt-1 text-xs text-[var(--el-text-color-placeholder)]">
{{ $t('form-design.scriptEditor.clickToInsert') }}
</div>
</div>
<!-- API 文档 -->
<template v-for="section in apiDocs" :key="section.title">
<div class="mb-3">
<div class="mb-2 text-xs font-bold text-[var(--el-text-color-regular)]">
{{ section.title }}
</div>
<div class="space-y-2">
<div
v-for="item in section.items"
:key="item.name"
class="rounded bg-[var(--el-fill-color-light)] px-2 py-1.5"
>
<div class="text-xs font-medium text-[var(--el-text-color-primary)]">
{{ item.name }}
</div>
<div
v-if="(item as any).isCode"
class="mt-0.5 font-mono text-xs text-[var(--el-text-color-secondary)]"
>
{{ item.desc }}
</div>
<div v-else class="mt-0.5 text-xs text-[var(--el-text-color-secondary)]">
{{ item.desc }}
</div>
</div>
</div>
</div>
</template>
<!-- 注意事项 -->
<div class="mb-3">
<div class="mb-2 text-xs font-bold text-[var(--el-text-color-regular)]">
{{ $t('form-design.scriptEditor.sectionNotes') }}
</div>
<ul class="list-inside list-disc space-y-1 text-xs text-[var(--el-text-color-secondary)]">
<li>{{ $t('form-design.scriptEditor.noteReturn') }}</li>
<li>{{ $t('form-design.scriptEditor.noteNull') }}</li>
<li>{{ $t('form-design.scriptEditor.noteError') }}</li>
<li>{{ $t('form-design.scriptEditor.notePriority') }}</li>
</ul>
</div>
</div>
</ElScrollbar>
</div>
<!-- 右侧代码编辑器 -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- <div class="flex items-center justify-between border-b border-[var(--el-border-color-lighter)] px-3 py-2">
<span class="text-xs text-[var(--el-text-color-secondary)]">
{{ $t('form-design.scriptEditor.editorTitle') }}
<span v-if="currentField" class="ml-1 font-medium text-[var(--el-color-primary)]">
{{ currentField }}
</span>
</span>
</div> -->
<div class="flex-1 overflow-auto p-2">
<CodeEditor
v-model="localCode"
language="javascript"
:line-numbers="false"
:fold-gutter="false"
line-wrapping
height="100%"
min-height="100%"
:placeholder="$t('form-design.attribute.customScriptPlaceholder')"
/>
</div>
</div>
</div>
</ZqDialog>
</template>
@@ -0,0 +1,35 @@
import { v4 as uuidv4 } from 'uuid';
/** 按钮 / 下拉菜单项共用的 action 默认配置 */
export function createDefaultButtonActionProps() {
return {
actionType: 'none',
actionUrl: '',
actionScript: '',
actionDataSourceCode: '',
actionDataSourceParams: [] as any[],
actionWorkflowId: '',
actionWorkflowInputs: [] as any[],
actionTemplateIds: [] as string[],
actionAutoRefresh: true,
};
}
/** 下拉按钮菜单项默认配置 */
export function createDefaultDropdownMenuItem(label: string) {
return {
id: uuidv4().replaceAll('-', ''),
label,
divided: false,
disabled: false,
...createDefaultButtonActionProps(),
};
}
/** 将菜单项包装为 executeButtonAction 可识别的 item 结构 */
export function wrapMenuItemAsButton(menuItem: Record<string, any>) {
return {
field: menuItem.id,
props: menuItem,
};
}
@@ -0,0 +1,408 @@
import { ref } from 'vue';
import { $t } from '@vben/locales';
import { ElMessage } from 'element-plus';
import {
getWorkflowDetailApi,
runWorkflowApi,
} from '#/api/ai-platform/ai-platform';
import { requestClient } from '#/api/request';
const FIELD_STATES_KEY = '__fieldStates__';
export interface ButtonActionContext {
model: any;
rootModel?: any;
formCode?: string;
editId?: string;
onGenerateDocument?: (config: {
actionAutoRefresh: boolean;
actionTemplateIds?: string[];
}) => void;
setWorkflowLoading?: (loading: boolean) => void;
}
function getFieldStates(root: any) {
if (!root[FIELD_STATES_KEY]) {
root[FIELD_STATES_KEY] = {};
}
return root[FIELD_STATES_KEY];
}
export function createFieldControlHelpers(root: any) {
const states = getFieldStates(root);
const $show = (field: string) => {
if (!states[field]) states[field] = {};
states[field].hidden = false;
};
const $hide = (field: string) => {
if (!states[field]) states[field] = {};
states[field].hidden = true;
};
const $disable = (field: string) => {
if (!states[field]) states[field] = {};
states[field].disabled = true;
};
const $enable = (field: string) => {
if (!states[field]) states[field] = {};
states[field].disabled = false;
};
return { $show, $hide, $disable, $enable };
}
export function createButtonScriptHelpers(rootData: any) {
const $setValue = (field: string, value: any) => {
if (field.includes('.')) {
const parts = field.split('.');
const table = parts[0] as string;
const subField = parts[1] as string;
if (Array.isArray(rootData[table])) {
rootData[table].forEach((row: any) => {
row[subField] = value;
});
}
} else {
rootData[field] = value;
}
};
const $setValues = (obj: Record<string, any>) => {
for (const [field, value] of Object.entries(obj)) {
$setValue(field, value);
}
};
const $getValue = (field: string) => {
if (field.includes('.')) {
const parts = field.split('.');
const table = parts[0] as string;
const subField = parts[1] as string;
if (Array.isArray(rootData[table])) {
return rootData[table].map((row: any) => row[subField]);
}
return undefined;
}
return rootData[field];
};
const $getSubTable = (field: string) => rootData[field] || [];
const $setSubTable = (field: string, rows: any[]) => {
rootData[field] = rows;
};
const $addRow = (field: string, row: any) => {
if (!Array.isArray(rootData[field])) {
rootData[field] = [];
}
rootData[field].push({ _id: `${Date.now()}_${Math.random()}`, ...row });
};
const $removeRow = (field: string, index: number) => {
if (Array.isArray(rootData[field])) {
rootData[field].splice(index, 1);
}
};
const $updateRow = (field: string, index: number, data: any) => {
if (Array.isArray(rootData[field]) && rootData[field][index]) {
Object.assign(rootData[field][index], data);
}
};
const $clearSubTable = (field: string) => {
rootData[field] = [];
};
return {
$setValue,
$setValues,
$getValue,
$getSubTable,
$setSubTable,
$addRow,
$removeRow,
$updateRow,
$clearSubTable,
};
}
function createSafeProxy(obj: any) {
if (!obj || typeof obj !== 'object') return obj;
return new Proxy(obj, {
get(target, prop) {
if (typeof prop === 'symbol') return target[prop];
const val = target[prop];
if (val === null) return '';
return val;
},
});
}
export function evaluateWorkflowInputScript(
script: string,
rootData: any,
model: any,
): any {
if (!script?.trim()) return '';
try {
const safeModel = createSafeProxy(model);
const safeRoot = createSafeProxy(rootData);
const helpers = createButtonScriptHelpers(rootData);
let scriptBody = script.trim();
const hasReturn =
scriptBody.includes('return ') || scriptBody.includes('return;');
const hasStatement = /\b(if|for|while|switch|try|const|let|var)\b/.test(
scriptBody,
);
if (!hasReturn && !hasStatement) {
scriptBody = `return (${scriptBody})`;
}
const fn = new Function('model', '$root', '$getValue', scriptBody);
return fn(safeModel, safeRoot, helpers.$getValue);
} catch (error) {
console.warn('Workflow input script error:', error);
return '';
}
}
async function executeButtonDataSource(item: any, context: ButtonActionContext) {
const rootData = context.rootModel || context.model;
const model = context.model;
const dsCode = item.props.actionDataSourceCode;
const dsParams = item.props.actionDataSourceParams || [];
const params: Record<string, any> = {};
for (const p of dsParams) {
if (p.valueSource === 'fixed') {
params[p.name] = p.fixedValue ?? p.default ?? '';
} else if (p.valueSource === 'field' && p.sourceField) {
params[p.name] = rootData[p.sourceField] ?? '';
}
}
try {
const response = await requestClient.get(
`/api/core/data-source/execute/${dsCode}`,
{ params },
);
const $result = response;
if (item.props.actionScript) {
const helpers = createButtonScriptHelpers(rootData);
const fieldCtrl = createFieldControlHelpers(rootData);
const fn = new Function(
'model',
'$root',
'$result',
'$setValue',
'$setValues',
'$getValue',
'$getSubTable',
'$setSubTable',
'$addRow',
'$removeRow',
'$updateRow',
'$clearSubTable',
'$show',
'$hide',
'$disable',
'$enable',
item.props.actionScript,
);
fn(
model,
rootData,
$result,
helpers.$setValue,
helpers.$setValues,
helpers.$getValue,
helpers.$getSubTable,
helpers.$setSubTable,
helpers.$addRow,
helpers.$removeRow,
helpers.$updateRow,
helpers.$clearSubTable,
fieldCtrl.$show,
fieldCtrl.$hide,
fieldCtrl.$disable,
fieldCtrl.$enable,
);
}
} catch (error) {
console.warn(`Button data source error for ${item.field}:`, error);
}
}
async function executeButtonWorkflow(item: any, context: ButtonActionContext) {
const workflowId = item.props?.actionWorkflowId;
if (!workflowId) {
ElMessage.warning($t('form-design.attribute.buttonWorkflowNotSelected'));
return;
}
const rootData = context.rootModel || context.model;
const inputs: Record<string, any> = {};
const mappings = item.props?.actionWorkflowInputs || [];
for (const mapping of mappings) {
if (mapping.valueSource === 'field' && mapping.sourceField) {
inputs[mapping.name] = rootData[mapping.sourceField] ?? '';
} else if (mapping.valueSource === 'script' && mapping.scriptValue) {
inputs[mapping.name] = evaluateWorkflowInputScript(
mapping.scriptValue,
rootData,
context.model,
);
} else {
inputs[mapping.name] = mapping.fixedValue ?? mapping.default ?? '';
}
}
try {
context.setWorkflowLoading?.(true);
const workflow = await getWorkflowDetailApi(workflowId);
if (
workflow.status !== 'published' ||
!['data_process', 'automation'].includes(workflow.workflow_type)
) {
ElMessage.warning(
$t('form-design.attribute.buttonWorkflowTypeNotAllowed'),
);
return;
}
const run = await runWorkflowApi(workflowId, inputs);
if (run.status === 'failed') {
ElMessage.error(
run.error_message || $t('form-design.attribute.buttonWorkflowFailed'),
);
return;
}
ElMessage.success($t('form-design.attribute.buttonWorkflowSuccess'));
} catch (error: any) {
ElMessage.error(
error?.message || $t('form-design.attribute.buttonWorkflowFailed'),
);
} finally {
context.setWorkflowLoading?.(false);
}
}
function handleGenerateDocumentClick(item: any, context: ButtonActionContext) {
if (!context.formCode) {
ElMessage.warning(
$t('form-design.attribute.buttonGenerateDocumentNoFormCode'),
);
return;
}
if (!context.editId) {
ElMessage.warning(
$t('form-design.attribute.buttonGenerateDocumentNeedSave'),
);
return;
}
const templateIds = item.props?.actionTemplateIds;
context.onGenerateDocument?.({
actionTemplateIds:
Array.isArray(templateIds) && templateIds.length > 0
? templateIds
: undefined,
actionAutoRefresh: item.props?.actionAutoRefresh !== false,
});
}
/** 执行按钮或下拉菜单项的 action */
export async function executeButtonAction(
item: any,
context: ButtonActionContext,
) {
const actionType = item.props?.actionType;
if (actionType === 'url' && item.props?.actionUrl) {
window.open(item.props.actionUrl, '_blank');
} else if (actionType === 'script' && item.props?.actionScript) {
try {
const rootData = context.rootModel || context.model;
const model = context.model;
const helpers = createButtonScriptHelpers(rootData);
const fieldCtrl = createFieldControlHelpers(rootData);
const fn = new Function(
'model',
'$root',
'$setValue',
'$setValues',
'$getValue',
'$getSubTable',
'$setSubTable',
'$addRow',
'$removeRow',
'$updateRow',
'$clearSubTable',
'$show',
'$hide',
'$disable',
'$enable',
item.props.actionScript,
);
fn(
model,
rootData,
helpers.$setValue,
helpers.$setValues,
helpers.$getValue,
helpers.$getSubTable,
helpers.$setSubTable,
helpers.$addRow,
helpers.$removeRow,
helpers.$updateRow,
helpers.$clearSubTable,
fieldCtrl.$show,
fieldCtrl.$hide,
fieldCtrl.$disable,
fieldCtrl.$enable,
);
} catch (error) {
console.warn(`Button script error for ${item.field}:`, error);
}
} else if (actionType === 'dataSource' && item.props?.actionDataSourceCode) {
await executeButtonDataSource(item, context);
} else if (actionType === 'generateDocument') {
handleGenerateDocumentClick(item, context);
} else if (actionType === 'workflow') {
await executeButtonWorkflow(item, context);
}
}
/** PreviewItem 内使用的 workflow loading 状态管理 */
export function useButtonWorkflowLoading() {
const buttonWorkflowLoading = ref(false);
const dropdownWorkflowLoadingIds = ref<Set<string>>(new Set());
function setWorkflowLoading(loading: boolean, menuItemId?: string) {
if (menuItemId) {
const next = new Set(dropdownWorkflowLoadingIds.value);
if (loading) {
next.add(menuItemId);
} else {
next.delete(menuItemId);
}
dropdownWorkflowLoadingIds.value = next;
return;
}
buttonWorkflowLoading.value = loading;
}
function isDropdownMenuItemLoading(menuItemId: string) {
return dropdownWorkflowLoadingIds.value.has(menuItemId);
}
return {
buttonWorkflowLoading,
isDropdownMenuItemLoading,
setWorkflowLoading,
};
}
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { TableConfig } from './store/formDesignStore';
import { watch } from 'vue';
import { storeToRefs } from 'pinia';
import AttributePanel from './components/AttributePanel.vue';
import DesignCanvas from './components/DesignCanvas.vue';
import MaterialPanel from './components/MaterialPanel.vue';
import { useFormDesignStore } from './store/formDesignStore';
const LEFT_PANEL_WIDTH = 288;
const RIGHT_PANEL_WIDTH = 306;
const props = defineProps<{
dataSource?: TableConfig[];
/** 当前表单编码,用于按钮「生成单据」动作绑定模板 */
formCode?: string;
/** 表单物理库连接 code(与 FormMeta.db_config 一致) */
formDbConfig?: string;
}>();
const store = useFormDesignStore();
const { leftPanelCollapsed, rightPanelCollapsed } = storeToRefs(store);
// 监听数据源变化,更新 Store
watch(
() => props.dataSource,
(val) => {
if (val) {
store.setDataSource(val);
}
},
{ immediate: true, deep: true },
);
</script>
<template>
<div
class="form-design-container bg-background-deep flex h-full w-full overflow-hidden"
:class="
leftPanelCollapsed && rightPanelCollapsed ? 'gap-0' : 'gap-3'
"
>
<!-- Left: Material Panel -->
<div
class="h-full flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out"
:style="{
width: leftPanelCollapsed ? '0px' : `${LEFT_PANEL_WIDTH}px`,
}"
>
<div class="h-full" :style="{ width: `${LEFT_PANEL_WIDTH}px` }">
<MaterialPanel />
</div>
</div>
<!-- Center: Design Canvas -->
<div class="relative h-full min-w-0 flex-1 overflow-hidden">
<DesignCanvas />
</div>
<!-- Right: Attribute Panel -->
<div
class="h-full flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out"
:style="{
width: rightPanelCollapsed ? '0px' : `${RIGHT_PANEL_WIDTH}px`,
}"
>
<div class="h-full" :style="{ width: `${RIGHT_PANEL_WIDTH}px` }">
<AttributePanel :form-code="formCode" :form-db-config="formDbConfig" />
</div>
</div>
</div>
</template>
<style scoped>
.form-design-container {
/* Ensure container takes full height relative to its parent */
height: 100%;
}
</style>
@@ -0,0 +1,883 @@
import { computed, nextTick, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { defineStore } from 'pinia';
import { v4 as uuidv4 } from 'uuid';
// 数据来源类型
export type DataSourceType =
| 'api'
| 'dataSource'
| 'dependent'
| 'dict'
| 'formData'
| 'static';
// 表单数据过滤条件
export interface FormDataFilter {
targetField: string; // 目标表单的过滤字段(如 customer_id
sourceField: string; // 当前表单的依赖字段(如 customer)
filterType:
| 'eq'
| 'gt'
| 'gte'
| 'in'
| 'like'
| 'lt'
| 'lte'
| 'ne'
| 'not_null' // 过滤类型
| 'null';
}
/** 表单选择器 / 下拉选择(表单数据源)关联自动填充配置 */
export interface ReverseAutoFillConfig {
enabled: boolean;
sourceField: string;
targetField: string;
filterType?: 'eq';
pageSize?: number;
clearWhenEmpty?: boolean;
}
/** @deprecated 使用 ReverseAutoFillConfig */
export type FormSelectorReverseAutoFill = ReverseAutoFillConfig;
/** 表单生命周期事件 */
export type FormLifecycleEvent =
| 'afterLoadFail'
| 'afterLoadSuccess'
| 'afterSubmitFail'
| 'afterSubmitSuccess'
| 'beforeLoad'
| 'beforeSubmit';
/** 表单生命周期适用模式 */
export type FormLifecycleMode = 'add' | 'edit' | 'view';
/** 表单提交生命周期动作类型 */
export type FormLifecycleActionType =
| 'dataSource'
| 'message'
| 'redirect'
| 'script'
| 'workflow';
/** 工作流输入变量映射(与按钮 workflow 映射结构一致) */
export interface FormLifecycleWorkflowInput {
name: string;
label?: string;
type?: string;
required?: boolean;
default?: any;
valueSource: 'field' | 'fixed';
fixedValue?: any;
sourceField?: string;
}
/** 表单提交生命周期动作配置 */
export interface FormLifecycleActionConfig {
script?: string;
dataSourceCode?: string;
dataSourceParams?: DataSourceParamConfig[];
callbackScript?: string;
url?: string;
openInNewTab?: boolean;
message?: string;
messageType?: 'error' | 'info' | 'success' | 'warning';
workflowId?: string;
workflowInputs?: FormLifecycleWorkflowInput[];
/** workflow 专用:true 时不阻塞 UI */
async?: boolean;
}
/** 表单提交生命周期钩子 */
export interface FormLifecycleHook {
id: string;
enabled: boolean;
event: FormLifecycleEvent;
modes: FormLifecycleMode[];
actionType: FormLifecycleActionType;
actionConfig: FormLifecycleActionConfig;
/** beforeSubmit / beforeLoad 专用:脚本/数据源失败时阻止提交或打开 */
blockOnError?: boolean;
sort: number;
}
// 数据源参数配置(用于配置数据源参数的值来源)
export interface DataSourceParamConfig {
name: string; // 参数名
label?: string; // 参数显示名
type?: 'boolean' | 'float' | 'integer' | 'string'; // 参数类型
required?: boolean; // 是否必填
default?: any; // 默认值
// 值来源配置
valueSource: 'fixed' | 'field' | 'search'; // fixed=固定值, field=表单字段, search=搜索输入
fixedValue?: any; // 固定值(valueSource=fixed时使用)
sourceField?: string; // 来源字段名(valueSource=field时使用)
}
// 数据来源配置
export interface DataSourceConfig {
type: DataSourceType;
// 字典模式
dictCode?: string;
// API模式
apiUrl?: string;
apiMethod?: 'GET' | 'POST';
labelField?: string; // 显示字段映射
valueField?: string; // 值字段映射
descField?: string; // 描述字段映射
childrenField?: string; // 子节点字段映射(级联/树形)
apiParams?: Record<string, any>; // 额外参数
// 依赖字段模式
dependField?: string; // 依赖的字段名
dependParamName?: string; // 传递给接口的参数名
// 数据源模式(使用系统配置的数据源)
dataSourceCode?: string; // 数据源编码
dataSourceParams?: DataSourceParamConfig[]; // 数据源参数配置
// 表单数据模式
formCode?: string; // 表单编码
formLabelField?: string; // 显示字段
formValueField?: string; // 值字段
formDescField?: string; // 描述字段
formFilters?: FormDataFilter[]; // 过滤条件列表
formPageSize?: number; // 分页大小(默认100
formEnableSearch?: boolean; // 是否启用远程搜索
formParentField?: string; // 父节点字段(级联组件使用,默认 parent_id)
formLazyLoad?: boolean; // 是否懒加载(级联组件使用,默认 true)
reverseAutoFill?: ReverseAutoFillConfig; // 关联自动填充(select 等表单数据源)
// 对象转选项模式(当API返回单个对象时,将对象属性转为选项)
objectToOptions?: boolean; // 是否启用对象转选项
objectExcludeFields?: string[]; // 排除的字段列表
objectLabelMaxLength?: number; // 标签最大长度(超出截断显示)
}
// 表格选择器列配置
export interface TableSelectorColumn {
field: string;
label: string;
width?: number;
}
// 表格选择器配置
export interface TableSelectorConfig {
dialogTitle?: string; // 弹窗标题
dialogWidth?: string; // 弹窗宽度
columns?: TableSelectorColumn[]; // 显示列配置
searchFields?: string[]; // 搜索字段列表
collapseTags?: boolean; // 多选时是否折叠标签
}
// AI OCR 字段映射配置
export interface OcrFieldMapping {
source: string; // Function返回的字段名
target: string; // 表单字段名
transform?: string; // 转换规则
}
// AI OCR 结构化输出字段定义(与LLM节点SchemaField一致)
export interface OcrSchemaField {
name: string;
type: string;
description: string;
required: boolean;
items?: OcrSchemaField; // array 类型的元素定义
properties?: OcrSchemaField[]; // object 类型的子属性
enum?: string[]; // 枚举值(仅 string 类型)
default?: any; // 默认值
}
// AI 文件识别支持的文件类型
export type AiFileType = 'all' | 'excel' | 'image' | 'pdf' | 'text' | 'word';
// AI OCR 预设模板类型
export type OcrTemplateType =
| 'business_license'
| 'contract'
| 'custom'
| 'id_card'
| 'invoice'
| 'receipt';
// AI OCR 配置
export interface AiOcrConfig {
enabled: boolean; // 是否启用
templateType: OcrTemplateType; // 预设模板类型
acceptFileTypes: AiFileType[]; // 接受的文件类型
outputSchema: OcrSchemaField[]; // 结构化输出字段定义
fieldMapping: OcrFieldMapping[]; // 字段映射
confirmMode: 'auto' | 'manual'; // 填充模式:自动/手动确认
customPrompt?: string; // 自定义提示词
}
export interface FormItemSchema {
id: string;
type: string;
field: string;
label: string;
icon?: string;
props: Record<string, any>;
rules?: any[];
// 新增正则校验列表
regList?: { message: string; pattern: string }[];
// 显隐控制表达式,例如: model.field_abc === '1'
showCondition?: string;
// 隐藏条件表达式,例如: model.field_abc === '1'
hideCondition?: string;
// 是否隐藏(直接隐藏开关)
isHidden?: boolean;
// 是否隐藏Label
hideLabel?: boolean;
// 单组件 Label 对齐方式(覆盖表单全局设置)
labelPosition?: '' | 'left' | 'right' | 'top';
// 默认值
defaultValue?: any;
/** 默认值方式:value-指定选项值,index-按选项顺序(从 1 开始) */
defaultValueMode?: 'index' | 'value';
// 静态选项数据
options?: { children?: any[]; label: string; value: any }[];
// 数据来源配置
dataSource?: DataSourceConfig;
// AI OCR 配置(用于 ai-image-ocr 组件)
aiOcrConfig?: AiOcrConfig;
// 表格选择器配置(用于 table-selector 组件)
tableSelectorConfig?: TableSelectorConfig;
// 表单选择器配置(用于 form-selector 组件)
formSelectorConfig?: {
columns?: any[];
formCode?: string;
formFilters?: FormDataFilter[];
labelField?: string;
reverseAutoFill?: ReverseAutoFillConfig;
searchFields?: any[];
valueField?: string;
};
// 直接子项,用于 sub-table 等容器组件
children?: FormItemSchema[];
columns?: { children: FormItemSchema[]; span: number }[];
// 折叠面板/标签页的子项配置
items?: {
children?: FormItemSchema[];
color?: string;
content?: string;
description?: string;
icon?: string;
label?: string;
name?: string;
timestamp?: string;
title?: string;
type?: string;
}[];
}
// 表字段定义
export interface TableField {
name: string;
type: string;
comment: string;
nullable: boolean;
isPrimaryKey: boolean;
maxLength?: number; // 最大长度 (varchar/char)
precision?: number; // 数值精度
scale?: number; // 小数位数
}
// 表配置定义
export interface TableConfig {
id: string;
type: 'main' | 'sub';
tableName: string;
alias: string;
foreignKey?: string;
relatedField?: string;
relationType?: 'one-to-many' | 'one-to-one';
fields: TableField[];
}
export const useFormDesignStore = defineStore('form-design', () => {
const activeId = ref<null | string>(null);
// 多选支持
const selectedIds = ref<Set<string>>(new Set());
// 剪贴板
const clipboard = ref<FormItemSchema[]>([]);
// 预览模式:pc | mobile
const previewMode = ref<'mobile' | 'pc'>('pc');
const togglePreviewMode = () => {
previewMode.value = previewMode.value === 'pc' ? 'mobile' : 'pc';
};
/** 左侧物料面板 / 右侧属性面板收起状态 */
const leftPanelCollapsed = ref(false);
const rightPanelCollapsed = ref(false);
const toggleLeftPanel = () => {
leftPanelCollapsed.value = !leftPanelCollapsed.value;
};
const toggleRightPanel = () => {
rightPanelCollapsed.value = !rightPanelCollapsed.value;
};
// 数据源配置(从第一步传入)
const dataSource = ref<{
mainTable: null | TableConfig;
subTables: TableConfig[];
}>({
mainTable: null,
subTables: [],
});
const formConf = ref({
labelWidth: 100,
labelPosition: 'top' as 'left' | 'right' | 'top',
size: 'default' as 'default' | 'large' | 'small',
// 表单容器样式
formPadding: 20, // 表单内边距(兼容旧数据)
formPaddingTop: 20,
formPaddingRight: 20,
formPaddingBottom: 20,
formPaddingLeft: 20,
formPaddingLinked: true, // 上下联动、左右联动
formMargin: 0, // 表单外边距(兼容旧数据)
formMarginTop: 0,
formMarginRight: 0,
formMarginBottom: 0,
formMarginLeft: 0,
formMarginLinked: true, // 上下联动、左右联动
formWidth: '100%', // 表单宽度
formMaxWidth: '', // 表单最大宽度
// 表单项间距
itemSpacing: 18, // 表单项之间的间距
// 表单背景
formBackground: '', // 表单背景色
formBorder: false, // 是否显示边框
formBorderRadius: 4, // 边框圆角
formShadow: false, // 是否显示阴影
// 禁用状态
disabled: false, // 全局禁用
items: [] as FormItemSchema[],
lifecycleHooks: [] as FormLifecycleHook[],
});
// 历史记录相关
const history = ref<string[]>([]);
const historyIndex = ref(-1);
const isTimeTravel = ref(false); // 防止 undo/redo 触发 watcher
// 拖拽状态
const isDragging = ref(false);
const setDragging = (val: boolean) => {
isDragging.value = val;
};
// 记录快照
const recordSnapshot = () => {
if (isTimeTravel.value) return;
// 如果当前不在历史记录末尾,先删除后面的记录
if (historyIndex.value < history.value.length - 1) {
history.value.splice(historyIndex.value + 1);
}
history.value.push(JSON.stringify(formConf.value));
historyIndex.value = history.value.length - 1;
// 限制历史记录长度,例如最多 20 步
if (history.value.length > 20) {
history.value.shift();
historyIndex.value--;
}
};
// 初始化记录
// recordSnapshot(); // 不要在定义时立即调用,因为 formConf 还是初始值,可以在 mounted 或首次变化时触发
// 监听 formConf 变化
watch(
formConf,
() => {
recordSnapshot();
},
{ deep: true },
);
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) {
formConf.value = JSON.parse(snapshot);
}
// 恢复选中状态 (简单处理:如果当前选中的组件在 undo 后不存在了,则取消选中)
if (activeId.value) {
// TODO: 检查 activeId 是否存在于新的 formConf 中
}
nextTick(() => {
isTimeTravel.value = false;
});
};
const redo = () => {
if (!canRedo.value) return;
isTimeTravel.value = true;
historyIndex.value++;
const snapshot = history.value[historyIndex.value];
if (snapshot) {
formConf.value = JSON.parse(snapshot);
}
nextTick(() => {
isTimeTravel.value = false;
});
};
function setActive(
id: null | string,
multiSelect = false,
rangeSelect = false,
) {
if (!id) {
activeId.value = null;
selectedIds.value.clear();
return;
}
if (multiSelect) {
// Ctrl+点击:切换选中状态
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
if (activeId.value === id) {
activeId.value =
selectedIds.value.size > 0
? ([...selectedIds.value][0] ?? null)
: null;
}
} else {
selectedIds.value.add(id);
activeId.value = id;
}
} else if (rangeSelect) {
// Shift+点击:范围选择(仅支持顶层 items)
if (activeId.value) {
const ids = formConf.value.items.map((item) => item.id);
const startIdx = ids.indexOf(activeId.value);
const endIdx = ids.indexOf(id);
if (startIdx !== -1 && endIdx !== -1) {
const [from, to] =
startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
for (let i = from; i <= to; i++) {
selectedIds.value.add(ids[i]!);
}
}
}
activeId.value = id;
} else {
// 普通点击:单选
activeId.value = id;
selectedIds.value.clear();
selectedIds.value.add(id);
}
}
function clearSelection() {
selectedIds.value.clear();
activeId.value = null;
}
function isSelected(id: string) {
return selectedIds.value.has(id);
}
// 递归查找组件及其所在列表
function findItemAndList(
id: string,
items: FormItemSchema[] = formConf.value.items,
): null | { index: number; item: FormItemSchema; list: FormItemSchema[] } {
if (!items || !Array.isArray(items)) return null;
const index = items.findIndex((item) => item.id === id);
if (index !== -1) {
return { item: items[index]!, list: items, index };
}
for (const item of items) {
if (item.columns) {
for (const col of item.columns) {
const found = findItemAndList(id, col.children || []);
if (found) return found;
}
}
if (item.items) {
for (const subItem of item.items) {
const found = findItemAndList(id, subItem.children || []);
if (found) return found;
}
}
if (item.children) {
const found = findItemAndList(id, item.children);
if (found) return found;
}
}
return null;
}
// 移动组件(上移/下移)
function moveItem(id: string, direction: 'down' | 'up') {
const found = findItemAndList(id);
if (!found) return;
const { list, index } = found;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= list.length) return;
// 交换位置
const temp = list[index]!;
list[index] = list[newIndex]!;
list[newIndex] = temp;
}
// 复制到剪贴板
function copyToClipboard(ids?: string[]) {
const targetIds = ids || [...selectedIds.value];
if (targetIds.length === 0 && activeId.value) {
targetIds.push(activeId.value);
}
clipboard.value = targetIds
.map((id) => findItemAndList(id)?.item)
.filter((item): item is FormItemSchema => !!item)
.map((item) => cloneComponent(item));
}
// 从剪贴板粘贴
function pasteFromClipboard() {
if (clipboard.value.length === 0) return;
// 粘贴到当前选中组件之后,或者画布末尾
const newItems = clipboard.value.map((item) => cloneComponent(item));
if (activeId.value) {
const found = findItemAndList(activeId.value);
if (found) {
found.list.splice(found.index + 1, 0, ...newItems);
activeId.value = newItems[0]?.id ?? null;
return;
}
}
// 默认添加到画布末尾
formConf.value.items.push(...newItems);
if (newItems.length > 0) {
activeId.value = newItems[0]?.id ?? null;
}
}
// 批量删除
function deleteSelected() {
const idsToDelete = [...selectedIds.value];
if (idsToDelete.length === 0 && activeId.value) {
idsToDelete.push(activeId.value);
}
for (const id of idsToDelete) {
deleteItemFromList(formConf.value.items, id);
}
selectedIds.value.clear();
activeId.value = null;
}
// 更新组件标签
function updateItemLabel(id: string, label: string) {
const found = findItemAndList(id);
if (found) {
found.item.label = label;
}
}
// 递归查找并删除
function deleteItemFromList(items: FormItemSchema[], id: string): boolean {
const index = items.findIndex((item) => item.id === id);
if (index !== -1) {
items.splice(index, 1);
return true;
}
for (const item of items) {
if (item.columns) {
for (const col of item.columns) {
if (deleteItemFromList(col.children, id)) {
return true;
}
}
}
if (item.items) {
for (const subItem of item.items) {
if (subItem.children && deleteItemFromList(subItem.children, id)) {
return true;
}
}
}
if (item.children && deleteItemFromList(item.children, id)) {
return true;
}
}
return false;
}
function deleteItem(id: string) {
if (deleteItemFromList(formConf.value.items, id) && activeId.value === id) {
activeId.value = null;
}
}
function cloneComponent(origin: any) {
const clone = JSON.parse(JSON.stringify(origin));
function generateIds(item: any) {
const id = uuidv4().replaceAll('-', '');
item.id = id;
// 布局组件不需要绑定数据库字段
if (!item.field && !['button-dropdown'].includes(item.type)) {
item.field = `field_${id.slice(0, 8)}`;
}
if (item.columns) {
item.columns.forEach((col: any) => {
col.children = col.children.map((child: any) => generateIds(child));
});
}
if (item.items) {
item.items.forEach((subItem: any) => {
if (item.type === 'button-dropdown') {
subItem.id = uuidv4().replaceAll('-', '');
return;
}
if (subItem.children) {
subItem.children = subItem.children.map((child: any) =>
generateIds(child),
);
}
});
}
if (item.children) {
item.children = item.children.map((child: any) => generateIds(child));
}
return item;
}
return generateIds(clone);
}
// 添加组件到画布末尾
function addComponent(component: any) {
const newItem = cloneComponent(component);
formConf.value.items.push(newItem);
activeId.value = newItem.id;
}
function copyItem(id: string) {
function findAndCopy(items: FormItemSchema[]): boolean {
const index = items.findIndex((item) => item.id === id);
if (index !== -1) {
const original = items[index];
const clone = cloneComponent(original);
items.splice(index + 1, 0, clone);
activeId.value = clone.id;
return true;
}
for (const item of items) {
if (item.columns) {
for (const col of item.columns) {
if (findAndCopy(col.children)) return true;
}
}
if (item.items) {
for (const subItem of item.items) {
if (subItem.children && findAndCopy(subItem.children)) return true;
}
}
if (item.children && findAndCopy(item.children)) return true;
}
return false;
}
findAndCopy(formConf.value.items);
}
const defaultTemplates = [
{
title: $t('form-design.template.login'),
icon: 'User',
items: [
{
type: 'input',
label: $t('form-design.material.components.input'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
width: '100%',
},
field: 'username',
id: 'tpl_login_1',
},
{
type: 'input',
label: $t('form-design.attribute.password'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
type: 'password',
showPassword: true,
width: '100%',
},
field: 'password',
id: 'tpl_login_2',
},
],
},
{
title: $t('form-design.template.register'),
icon: 'Edit',
items: [
{
type: 'input',
label: $t('form-design.material.components.input'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
width: '100%',
},
field: 'username',
id: 'tpl_reg_1',
},
{
type: 'input',
label: $t('form-design.attribute.password'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
type: 'password',
showPassword: true,
width: '100%',
},
field: 'password',
id: 'tpl_reg_2',
},
{
type: 'input',
label: $t('form-design.attribute.password'), // Should probably be confirm password but using existing keys for now
props: {
placeholder: $t('form-design.attribute.placeholder'),
type: 'password',
showPassword: true,
width: '100%',
},
field: 'confirm_password',
id: 'tpl_reg_3',
},
{
type: 'input',
label: $t('form-design.attribute.mobile'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
width: '100%',
},
field: 'mobile',
id: 'tpl_reg_4',
},
],
},
{
title: $t('form-design.template.userInfo'),
icon: 'Document',
items: [
{
type: 'input',
label: $t('form-design.attribute.nodeLabel'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
width: '100%',
},
field: 'name',
id: 'tpl_user_1',
},
{
type: 'radio',
label: $t('form-design.material.components.radio'),
props: { border: true },
options: [
{ label: $t('common.male'), value: 1 },
{ label: $t('common.female'), value: 2 },
],
field: 'gender',
id: 'tpl_user_2',
},
{
type: 'textarea',
label: $t('form-design.material.components.textarea'),
props: {
placeholder: $t('form-design.attribute.placeholder'),
rows: 3,
width: '100%',
},
field: 'bio',
id: 'tpl_user_3',
},
],
},
];
const templates = ref(defaultTemplates);
const addTemplate = (template: any) => {
templates.value.push(template);
};
const setDataSource = (configs: TableConfig[]) => {
dataSource.value = {
mainTable: configs.find((t) => t.type === 'main') || null,
subTables: configs.filter((t) => t.type === 'sub'),
};
};
return {
activeId,
selectedIds,
formConf,
dataSource,
setDataSource,
setActive,
clearSelection,
isSelected,
deleteItem,
deleteSelected,
cloneComponent,
addComponent,
copyItem,
moveItem,
copyToClipboard,
pasteFromClipboard,
updateItemLabel,
undo,
redo,
canUndo,
canRedo,
isDragging,
setDragging,
templates,
addTemplate,
previewMode,
togglePreviewMode,
leftPanelCollapsed,
rightPanelCollapsed,
toggleLeftPanel,
toggleRightPanel,
};
});
@@ -0,0 +1,224 @@
export type DefaultValueMode = 'index' | 'value';
export const INDEX_DEFAULT_VALUE_TYPES = new Set([
'checkbox',
'radio',
'select',
]);
export function supportsIndexDefaultValue(type?: string): boolean {
return !!type && INDEX_DEFAULT_VALUE_TYPES.has(type);
}
export function getDefaultValueMode(item?: {
defaultValueMode?: DefaultValueMode;
}): DefaultValueMode {
return item?.defaultValueMode === 'index' ? 'index' : 'value';
}
/** 未显式标记 index 时,根据序号与选项 value 是否一致做兼容推断 */
export function shouldInferIndexMode(
item: {
defaultValue?: any;
defaultValueMode?: DefaultValueMode;
props?: { multiple?: boolean };
type?: string;
},
options: { value: any }[],
): boolean {
if (getDefaultValueMode(item) === 'index') return true;
if (!supportsIndexDefaultValue(item.type) || !options.length) return false;
if (isMultipleOptionComponent(item)) return false;
if (
item.defaultValue === undefined ||
item.defaultValue === null ||
item.defaultValue === ''
) {
return false;
}
const index = Number(item.defaultValue);
if (!Number.isInteger(index) || index < 1 || index > options.length) {
return false;
}
const hasValueMatch = options.some(
(opt) =>
opt.value === item.defaultValue ||
String(opt.value) === String(item.defaultValue),
);
return !hasValueMatch;
}
function usesIndexDefaultResolution(
item: {
defaultValue?: any;
defaultValueMode?: DefaultValueMode;
props?: { multiple?: boolean };
type?: string;
},
options: { value: any }[],
): boolean {
return (
getDefaultValueMode(item) === 'index' || shouldInferIndexMode(item, options)
);
}
export function isMultipleOptionComponent(item: {
props?: { multiple?: boolean };
type?: string;
}): boolean {
if (item.type === 'checkbox') return true;
return item.type === 'select' && !!item.props?.multiple;
}
function pickOptionValueByIndex(
options: { value: any }[],
index: unknown,
): any | undefined {
const i = Number(index);
if (!Number.isFinite(i) || i < 1) return undefined;
const opt = options[i - 1];
if (!opt) return undefined;
return opt.value;
}
/** 将按顺序配置的 defaultValue1-based)解析为实际选项 value */
export function resolveIndexDefaultValue(
item: {
defaultValue?: any;
props?: { multiple?: boolean };
type?: string;
},
options: { value: any }[],
): any | undefined {
if (
item.defaultValue === undefined ||
item.defaultValue === null ||
item.defaultValue === ''
) {
return undefined;
}
if (!options.length) return undefined;
if (isMultipleOptionComponent(item)) {
const indices = Array.isArray(item.defaultValue)
? item.defaultValue
: [item.defaultValue];
const values = indices
.map((idx) => pickOptionValueByIndex(options, idx))
.filter((v) => v !== undefined);
return values;
}
return pickOptionValueByIndex(options, item.defaultValue);
}
/** 解析组件 defaultValue(含按顺序模式) */
export function resolveItemDefaultValue(
item: {
defaultValue?: any;
defaultValueMode?: DefaultValueMode;
options?: { value: any }[];
props?: { multiple?: boolean };
type?: string;
},
runtimeOptions?: { value: any }[],
): any | undefined {
if (
item.defaultValue === undefined ||
item.defaultValue === null ||
item.defaultValue === ''
) {
return undefined;
}
const options = runtimeOptions ?? item.options ?? [];
if (usesIndexDefaultResolution(item, options)) {
return resolveIndexDefaultValue(item, options);
}
if (item.type === 'checkbox' && !Array.isArray(item.defaultValue)) {
return [item.defaultValue];
}
return item.defaultValue;
}
/** 写入 formData(新增时字段尚未赋值) */
export function applyItemDefaultValue(
formData: Record<string, any>,
item: {
defaultValue?: any;
defaultValueMode?: DefaultValueMode;
field?: string;
options?: { value: any }[];
props?: { multiple?: boolean };
type?: string;
},
runtimeOptions?: { value: any }[],
): boolean {
if (!item.field) return false;
const resolved = resolveItemDefaultValue(item, runtimeOptions);
if (resolved === undefined) return false;
formData[item.field] = resolved;
return true;
}
export function isFieldEmptyForDefault(value: unknown): boolean {
if (value === undefined || value === null || value === '') return true;
return Array.isArray(value) && value.length === 0;
}
/** 当前表单值是否仍为「未解析的序号」(误把 1、2 当作选项 value 写入) */
export function isUnresolvedIndexDefaultValue(
item: {
defaultValue?: any;
props?: { multiple?: boolean };
type?: string;
},
currentValue: unknown,
): boolean {
if (
item.defaultValue === undefined ||
item.defaultValue === null ||
item.defaultValue === ''
) {
return false;
}
if (isMultipleOptionComponent(item)) {
const indices = Array.isArray(item.defaultValue)
? item.defaultValue
: [item.defaultValue];
const current = Array.isArray(currentValue)
? currentValue
: currentValue === undefined || currentValue === null
? []
: [currentValue];
if (current.length !== indices.length) return false;
return current.every((v, i) => Number(v) === Number(indices[i]));
}
return Number(currentValue) === Number(item.defaultValue);
}
/** 是否应根据序号配置写入/修正表单值 */
export function shouldResolveIndexDefault(
item: {
defaultValue?: any;
defaultValueMode?: DefaultValueMode;
props?: { multiple?: boolean };
type?: string;
},
currentValue: unknown,
options: { value: any }[],
): boolean {
if (!usesIndexDefaultResolution(item, options)) return false;
if (!supportsIndexDefaultValue(item.type)) return false;
if (isFieldEmptyForDefault(currentValue)) return true;
return isUnresolvedIndexDefaultValue(item, currentValue);
}
@@ -0,0 +1,19 @@
/** 子表单允许的最小行数(删除下限;必填时至少 1 行) */
export function getSubTableMinRows(props?: {
minRows?: number;
required?: boolean;
}): number {
const minRows = Math.max(0, Number(props?.minRows) || 0);
if (props?.required) {
return Math.max(minRows, 1);
}
return minRows;
}
/** 新增表单时子表单的默认初始化行数 */
export function getSubTableInitialRowCount(props?: {
minRows?: number;
required?: boolean;
}): number {
return getSubTableMinRows(props);
}
@@ -1,93 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElButton, ElDialog, ElInput } from 'element-plus';
const props = defineProps<{
basicInfo?: Record<string, any>;
data?: Record<string, any>;
design?: Record<string, any>;
publishData?: Record<string, any>;
settings?: Record<string, any>;
visible?: boolean;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', value: Record<string, any>): void;
(e: 'update:visible', value: boolean): void;
}>();
const localVisible = computed({
get: () => !!props.visible,
set: (value) => emit('update:visible', value),
});
const payload = computed(
() =>
props.design ||
props.settings ||
props.basicInfo ||
props.publishData ||
props.data ||
{},
);
const title = computed(
() =>
payload.value.title ||
payload.value.name ||
payload.value.type ||
'AI 设计结果',
);
const text = ref('');
watch(
payload,
(value) => {
text.value = JSON.stringify(value, null, 2);
},
{ immediate: true },
);
function parseValue() {
try {
return JSON.parse(text.value || '{}');
} catch {
return { content: text.value };
}
}
function handleClose() {
emit('update:visible', false);
emit('close');
}
function handleConfirm() {
emit('confirm', parseValue());
emit('update:visible', false);
}
</script>
<template>
<ElDialog
v-model="localVisible"
:title="String(title)"
width="720px"
append-to-body
destroy-on-close
@close="handleClose"
>
<ElInput
v-model="text"
type="textarea"
:autosize="{ minRows: 14, maxRows: 24 }"
/>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确认</ElButton>
</template>
</ElDialog>
</template>
@@ -1,27 +1,40 @@
export { default as AppDesignPanel } from './LightConfirmPanel.vue';
export { default as AppSettingsPanel } from './LightConfirmPanel.vue';
export { default as BasicInfoEditor } from './LightConfirmPanel.vue';
export { default as DashboardBasicInfoConfirmPanel } from './LightConfirmPanel.vue';
export { default as DashboardDesignConfirmPanel } from './LightConfirmPanel.vue';
export { default as DashboardPublishConfirmPanel } from './LightConfirmPanel.vue';
export { default as DesignEditorPanel } from './LightConfirmPanel.vue';
export { default as FormEditorContent } from './LightConfirmPanel.vue';
export { default as PageBasicInfoEditor } from './LightConfirmPanel.vue';
export { default as PageEditorContent } from './LightConfirmPanel.vue';
export { default as PagePublishInfoEditor } from './LightConfirmPanel.vue';
export { default as SystemSummaryConfirmPanel } from './LightConfirmPanel.vue';
/**
* 表单编辑器公共组件
* 可用于:表单管理器、工作流设计预览编辑
*/
export type AppDesignData = Record<string, any>;
export type AppSettingsData = Record<string, any>;
export type BasicFormData = Record<string, any>;
export type DashboardBasicInfoData = Record<string, any>;
export type DashboardDesignData = Record<string, any>;
export type DashboardPublishData = Record<string, any>;
export type DesignData = Record<string, any>;
export type DesignType = string;
export type FormBasicData = Record<string, any>;
export type FormEditorData = Record<string, any>;
export type PageBasicInfo = Record<string, any>;
export type PageEditorData = Record<string, any>;
export type PagePublishInfo = Record<string, any>;
export type SystemSummaryData = Record<string, any>;
export { default as BasicInfoEditor } from './BasicInfoEditor.vue';
export type { BasicFormData } from './BasicInfoEditor.vue';
export { default as DesignEditorPanel } from './DesignEditorPanel.vue';
export type { DesignData, DesignType } from './DesignEditorPanel.vue';
export { default as FormEditorContent } from './FormEditorContent.vue';
export type { FormEditorData, BasicFormData as FormBasicData } from './FormEditorContent.vue';
export { default as AppDesignPanel } from './AppDesignPanel.vue';
export type { AppDesignData } from './AppDesignPanel.vue';
export { default as AppSettingsPanel } from './AppSettingsPanel.vue';
export type { AppSettingsData } from './AppSettingsPanel.vue';
export { default as DashboardBasicInfoConfirmPanel } from './DashboardBasicInfoConfirmPanel.vue';
export type { DashboardBasicInfoData } from './DashboardBasicInfoConfirmPanel.vue';
export { default as DashboardDesignConfirmPanel } from './DashboardDesignConfirmPanel.vue';
export type { DashboardDesignData } from './DashboardDesignConfirmPanel.vue';
export { default as PageBasicInfoEditor } from './PageBasicInfoEditor.vue';
export type { PageBasicInfo } from './PageBasicInfoEditor.vue';
export { default as PagePublishInfoEditor } from './PagePublishInfoEditor.vue';
export type { PagePublishInfo } from './PagePublishInfoEditor.vue';
export { default as PageEditorContent } from './PageEditorContent.vue';
export type { PageEditorData } from './PageEditorContent.vue';
export { default as DashboardPublishConfirmPanel } from './DashboardPublishConfirmPanel.vue';
export type { DashboardPublishData } from './DashboardPublishConfirmPanel.vue';
export { default as SystemSummaryConfirmPanel } from './SystemSummaryConfirmPanel.vue';
export type { SystemSummaryData } from './SystemSummaryConfirmPanel.vue';
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
modelValue: {
animated?: boolean;
backgroundColor?: string;
colors?: string[];
dur?: number;
reverse?: boolean;
title?: string;
titleColor?: string;
titleSize?: number;
};
showAnimation?: boolean;
showReverse?: boolean;
showTitle?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: typeof props.modelValue];
}>();
const activeNames = ref(['border-style', 'border-animation', 'border-title']);
const updateField = (field: string, value: any) => {
emit('update:modelValue', { ...props.modelValue, [field]: value });
};
const updateColor = (index: number, value: string) => {
const colors = [...(props.modelValue.colors || ['#4fd2dd', '#235fa7'])];
colors[index] = value;
emit('update:modelValue', { ...props.modelValue, colors });
};
</script>
<template>
<ElCollapse v-model="activeNames">
<ElCollapseItem title="边框样式" name="border-style">
<ElForm label-position="top" size="small">
<ElFormItem label="主色调">
<ElColorPicker
:model-value="modelValue.colors?.[0] || '#4fd2dd'"
show-alpha
@update:model-value="updateColor(0, $event || '#4fd2dd')"
/>
</ElFormItem>
<ElFormItem label="辅助色">
<ElColorPicker
:model-value="modelValue.colors?.[1] || '#235fa7'"
show-alpha
@update:model-value="updateColor(1, $event || '#235fa7')"
/>
</ElFormItem>
<ElFormItem label="背景色">
<ElColorPicker
:model-value="modelValue.backgroundColor || 'transparent'"
show-alpha
@update:model-value="
updateField('backgroundColor', $event || 'transparent')
"
/>
</ElFormItem>
</ElForm>
</ElCollapseItem>
<ElCollapseItem
v-if="showAnimation !== false"
title="动画效果"
name="border-animation"
>
<ElForm label-position="top" size="small">
<ElFormItem label="启用动画">
<ElSwitch
:model-value="modelValue.animated !== false"
@update:model-value="updateField('animated', $event)"
/>
</ElFormItem>
<ElFormItem v-if="modelValue.animated !== false" label="动画速度(秒)">
<ElInputNumber
:model-value="modelValue.dur || 3"
:min="1"
:max="20"
:step="0.5"
controls-position="right"
@update:model-value="updateField('dur', $event || 3)"
/>
</ElFormItem>
<ElFormItem v-if="showReverse" label="反向动画">
<ElSwitch
:model-value="modelValue.reverse || false"
@update:model-value="updateField('reverse', $event)"
/>
</ElFormItem>
</ElForm>
</ElCollapseItem>
<ElCollapseItem v-if="showTitle" title="标题设置" name="border-title">
<ElForm label-position="top" size="small">
<ElFormItem label="标题文字">
<ElInput
:model-value="modelValue.title || ''"
placeholder="请输入标题"
@update:model-value="updateField('title', $event)"
/>
</ElFormItem>
<ElFormItem label="标题颜色">
<ElColorPicker
:model-value="modelValue.titleColor || '#fff'"
@update:model-value="updateField('titleColor', $event || '#fff')"
/>
</ElFormItem>
<ElFormItem label="标题大小">
<ElInputNumber
:model-value="modelValue.titleSize || 16"
:min="12"
:max="36"
controls-position="right"
@update:model-value="updateField('titleSize', $event || 16)"
/>
</ElFormItem>
</ElForm>
</ElCollapseItem>
</ElCollapse>
</template>
@@ -0,0 +1,696 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
Expand,
Fullscreen,
Maximize,
Monitor,
Plus,
Settings,
Trash2,
} from '@vben/icons';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
} from 'element-plus';
import { ImageSelector } from '#/components/zq-form/image-selector';
import { getFileUrl } from '#/composables/useFileUrl';
import { useScreenEditStore } from '../../store';
import { PreviewScaleEnum } from '../../store/types';
import CustomColorDialog from './CustomColorDialog.vue';
interface CustomTheme {
id: string;
name: string;
colors: string[];
}
const screenEditStore = useScreenEditStore();
// 自定义颜色对话框
const customColorDialogVisible = ref(false);
const editingCustomTheme = ref<CustomTheme | null>(null);
// 自定义颜色主题列表
const customThemes = ref<CustomTheme[]>([]);
// 打开添加自定义颜色对话框
const openAddCustomColor = () => {
editingCustomTheme.value = null;
customColorDialogVisible.value = true;
};
// 打开编辑自定义颜色对话框
const openEditCustomColor = (theme: CustomTheme) => {
editingCustomTheme.value = theme;
customColorDialogVisible.value = true;
};
// 保存自定义颜色
const saveCustomTheme = (theme: CustomTheme) => {
const index = customThemes.value.findIndex((t) => t.id === theme.id);
if (index === -1) {
customThemes.value.push(theme);
} else {
customThemes.value[index] = theme;
}
};
// 删除自定义颜色
const deleteCustomTheme = (id: string) => {
const index = customThemes.value.findIndex((t) => t.id === id);
if (index !== -1) {
customThemes.value.splice(index, 1);
}
};
// 画布配置
const canvasConfig = computed(() => screenEditStore.canvasConfig);
// 预览缩放选项
const previewScaleOptions = [
{ label: '自适应', value: PreviewScaleEnum.FIT, icon: Monitor },
{ label: '铺满', value: PreviewScaleEnum.FULL, icon: Maximize },
{ label: '宽度铺满', value: PreviewScaleEnum.WIDTH, icon: Fullscreen },
{ label: '高度铺满', value: PreviewScaleEnum.HEIGHT, icon: Expand },
];
// 主题颜色预设 (与 go-view 保持一致)
const themeColorPresets = [
// go-view 主题
{
name: '明亮',
colors: [
'#4992ff',
'#7cffb2',
'#fddd60',
'#ff6e76',
'#58d9f9',
'#05c091',
'#ff8a45',
'#8d48e3',
'#dd79ff',
],
},
{
name: '暗淡',
colors: [
'#5470c6',
'#91cc75',
'#fac858',
'#ee6666',
'#73c0de',
'#3ba272',
'#fc8452',
'#9a60b4',
'#ea7ccc',
],
},
{
name: '马卡龙',
colors: [
'#2ec7c9',
'#b6a2de',
'#5ab1ef',
'#ffb980',
'#d87a80',
'#8d98b3',
'#e5cf0d',
'#97b552',
'#95706d',
'#dc69aa',
],
},
{
name: '蓝绿',
colors: ['#3fb1e3', '#6be6c1', '#626c91', '#a0a7e6', '#c4ebad', '#96dee8'],
},
{
name: '深紫',
colors: ['#9b8bba', '#e098c7', '#8fd3e8', '#71669e', '#cc70af', '#7cb4cc'],
},
{
name: '复古',
colors: [
'#d87c7c',
'#919e8b',
'#d7ab82',
'#6e7074',
'#61a0a8',
'#efa18d',
'#787464',
'#cc7e63',
'#724e58',
'#4b565b',
],
},
{
name: '粉青',
colors: [
'#fc97af',
'#87f7cf',
'#f7f494',
'#72ccff',
'#f7c5a0',
'#d4a4eb',
'#d2f5a6',
'#76f2f2',
],
},
{
name: '灰粉',
colors: ['#516b91', '#59c4e6', '#edafda', '#93b7e3', '#a5e7f0', '#cbb0e3'],
},
{
name: '青草',
colors: ['#4ea397', '#22c3aa', '#7bd9a5', '#d0648a', '#f58db2', '#f2b3c9'],
},
{
name: '橘红',
colors: ['#893448', '#d95850', '#eb8146', '#ffb248', '#f2d643', '#ebdba4'],
},
{
name: '深色',
colors: [
'#c12e34',
'#e6b600',
'#0098d9',
'#2b821d',
'#005eaa',
'#339ca8',
'#cda819',
'#32a487',
],
},
{
name: '罗马红',
colors: [
'#e01f54',
'#5e4ea5',
'#f5e8c8',
'#b8d2c7',
'#c6b38e',
'#a4d8c2',
'#f3d999',
'#d3758f',
],
},
// 项目独有主题
{
name: '科技蓝',
colors: [
'#00d4ff',
'#00a8e8',
'#0077b6',
'#023e8a',
'#48cae4',
'#90e0ef',
'#00b4d8',
'#0096c7',
],
},
{
name: '渐变紫',
colors: [
'#7f5af0',
'#a78bfa',
'#c4b5fd',
'#e879f9',
'#f0abfc',
'#d946ef',
'#a855f7',
'#8b5cf6',
],
},
{
name: '活力橙',
colors: [
'#ff6b35',
'#f7c59f',
'#efa94a',
'#ff8c42',
'#ffd166',
'#ffb347',
'#ff9f1c',
'#f77f00',
],
},
{
name: '清新绿',
colors: [
'#2ec4b6',
'#8ecae6',
'#219ebc',
'#023047',
'#ffb703',
'#fb8500',
'#3a86ff',
'#06d6a0',
],
},
{
name: '珊瑚粉',
colors: [
'#ff6b6b',
'#f06595',
'#cc5de8',
'#845ef7',
'#5c7cfa',
'#339af0',
'#22b8cf',
'#20c997',
],
},
{
name: '森林',
colors: [
'#2d6a4f',
'#40916c',
'#52b788',
'#74c69d',
'#95d5b2',
'#b7e4c7',
'#1b4332',
'#081c15',
],
},
];
// 当前选中的主题
const selectedTheme = computed(() => {
const currentColors = canvasConfig.value.chartThemeColors;
if (!currentColors || currentColors.length === 0) return '明亮';
// 先检查自定义主题
const customFound = customThemes.value.find(
(t) =>
t.colors.length === currentColors.length &&
t.colors.every((c, i) => c === currentColors[i]),
);
if (customFound) return customFound.name;
// 再检查预设主题
const presetFound = themeColorPresets.find(
(t) =>
t.colors.length === currentColors.length &&
t.colors.every((c, i) => c === currentColors[i]),
);
return presetFound?.name || '明亮';
});
// 选择主题
const selectTheme = (theme: { colors: string[]; name: string }) => {
updateCanvasConfig('chartThemeColors', [...theme.colors]);
};
// 清除背景
const clearBackground = () => {
updateCanvasConfig('backgroundImage', '');
};
// 清除颜色
const clearBackgroundColor = () => {
updateCanvasConfig('background', undefined);
};
// 从图库选择背景图
const handleBackgroundImageSelect = async (
fileId: string | string[] | undefined,
) => {
if (!fileId) return;
const id = Array.isArray(fileId) ? fileId[0] : fileId;
if (id) {
const url = await getFileUrl(id);
updateCanvasConfig('backgroundImage', url);
}
};
// 更新画布配置
const updateCanvasConfig = <K extends keyof typeof canvasConfig.value>(
key: K,
value: (typeof canvasConfig.value)[K],
) => {
screenEditStore.setCanvasConfig(key, value);
};
</script>
<template>
<div class="canvas-config mt-4">
<!-- 尺寸配置 -->
<div class="config-row">
<div class="config-item">
<span class="config-label">宽度</span>
<ElInputNumber
:model-value="canvasConfig.width"
:min="100"
:max="10000"
size="small"
controls-position="right"
@update:model-value="updateCanvasConfig('width', $event || 1920)"
/>
</div>
<div class="config-item">
<span class="config-label">高度</span>
<ElInputNumber
:model-value="canvasConfig.height"
:min="100"
:max="10000"
size="small"
controls-position="right"
@update:model-value="updateCanvasConfig('height', $event || 1080)"
/>
</div>
</div>
<!-- 背景图选择 -->
<div class="config-row-inline">
<ImageSelector
:width="290"
:height="163"
placeholder="请选择背景图片"
source="screen_design"
@change="handleBackgroundImageSelect"
/>
</div>
<!-- 背景颜色 -->
<div class="config-row-inline">
<span class="config-label">背景颜色</span>
<ElColorPicker
:model-value="canvasConfig.background"
show-alpha
@update:model-value="
updateCanvasConfig('background', $event || undefined)
"
/>
</div>
<!-- 背景控制 -->
<div class="config-row-inline">
<span class="config-label">背景控制</span>
<div class="button-group">
<ElButton size="small" @click="clearBackground">清除背景</ElButton>
<ElButton size="small" @click="clearBackgroundColor">清除颜色</ElButton>
</div>
</div>
<!-- 适配方式 -->
<div class="config-row-inline">
<span class="config-label">适配方式</span>
<div class="scale-buttons">
<button
v-for="item in previewScaleOptions"
:key="item.value"
class="scale-btn"
:class="[{ active: canvasConfig.previewScaleType === item.value }]"
:title="item.label"
@click="updateCanvasConfig('previewScaleType', item.value)"
>
<component :is="item.icon" class="h-4 w-4" />
</button>
</div>
</div>
<!-- 主题颜色 -->
<ElCollapse class="theme-collapse">
<ElCollapseItem name="theme">
<template #title>
<div class="theme-title">
<span>默认主题</span>
<Settings class="ml-1 h-4 w-4" />
</div>
</template>
<!-- 自定义颜色 -->
<div class="custom-color-header">
<span>自定义颜色</span>
<button class="add-custom-btn" @click="openAddCustomColor">
<Plus class="h-4 w-4" />
</button>
</div>
<div v-if="customThemes.length > 0" class="theme-colors custom-themes">
<div
v-for="theme in customThemes"
:key="theme.id"
class="theme-item"
:class="[{ active: selectedTheme === theme.name }]"
@click="selectTheme(theme)"
>
<span class="theme-name">{{ theme.name }}</span>
<div class="color-dots">
<span
v-for="(color, idx) in theme.colors.slice(0, 6)"
:key="idx"
class="color-dot"
:style="{ backgroundColor: color }"
></span>
</div>
<button
class="edit-theme-btn"
@click.stop="openEditCustomColor(theme)"
>
<Trash2 class="h-3 w-3" />
</button>
</div>
</div>
<!-- 预设主题 -->
<div class="theme-colors">
<div
v-for="theme in themeColorPresets"
:key="theme.name"
class="theme-item"
:class="[{ active: selectedTheme === theme.name }]"
@click="selectTheme(theme)"
>
<span class="theme-name">{{ theme.name }}</span>
<div class="color-dots">
<span
v-for="(color, idx) in theme.colors.slice(0, 6)"
:key="idx"
class="color-dot"
:style="{ backgroundColor: color }"
></span>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</div>
<!-- 自定义颜色对话框 -->
<CustomColorDialog
v-model:visible="customColorDialogVisible"
:theme="editingCustomTheme"
@save="saveCustomTheme"
@delete="deleteCustomTheme"
/>
</template>
<style scoped>
.canvas-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.config-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.config-row-inline {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.config-section {
margin: 8px 0;
}
/* 按钮组 */
.button-group {
display: flex;
gap: 8px;
}
/* 适配方式按钮 */
.scale-buttons {
display: flex;
gap: 4px;
}
.scale-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
}
.scale-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.scale-btn.active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
/* 主题折叠面板 */
.theme-collapse {
margin-top: 8px;
}
.theme-collapse :deep(.el-collapse-item__header) {
background: transparent;
}
.theme-title {
display: flex;
align-items: center;
font-size: 14px;
}
.theme-colors {
display: flex;
flex-direction: column;
gap: 8px;
}
.theme-item {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
transition: all 0.2s;
}
.theme-item:hover {
background: var(--el-fill-color-light);
}
.theme-item.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.theme-name {
width: 50px;
font-size: 12px;
color: var(--el-text-color-primary);
}
.color-dots {
display: flex;
flex: 1;
gap: 4px;
}
.color-dot {
width: 16px;
height: 16px;
border-radius: 4px;
}
/* 自定义颜色 */
.custom-color-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0 8px 8px;
margin-bottom: 8px;
}
.custom-color-header span {
font-size: 13px;
color: var(--el-text-color-primary);
}
.add-custom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
}
.add-custom-btn:hover {
color: var(--el-color-primary);
background: var(--el-fill-color);
}
.custom-themes {
margin-bottom: 12px;
}
.custom-themes .theme-item {
position: relative;
}
.edit-theme-btn {
position: absolute;
top: 50%;
right: 8px;
display: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transform: translateY(-50%);
}
.custom-themes .theme-item:hover .edit-theme-btn {
display: flex;
}
.edit-theme-btn:hover {
color: var(--el-color-danger);
background: var(--el-fill-color);
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,402 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { ElButton, ElColorPicker, ElDialog, ElInput } from 'element-plus';
interface CustomTheme {
id: string;
name: string;
colors: string[];
}
const props = defineProps<{
theme?: CustomTheme | null;
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'save', theme: CustomTheme): void;
(e: 'delete', id: string): void;
}>();
// 表单数据
const formData = ref<CustomTheme>({
id: '',
name: '未命名',
colors: ['#6ae5bb', '#69e3de', '#5ac5ee', '#5ac4ee', '#4498ec', '#3c7ddf'],
});
// 默认扩展色
const defaultExtendColors = [
['#0d898a', '#2a9d8f', '#47b1a4', '#64c5b9', '#81d9ce', '#9eede3'],
['#1a535c', '#2e6b74', '#42838c', '#569ba4', '#6ab3bc', '#7ecbd4'],
];
// 透明扩展色
const transparentExtendColors = [
[
'#0d898a99',
'#2a9d8f99',
'#47b1a499',
'#64c5b999',
'#81d9ce99',
'#9eede399',
],
[
'#1a535c99',
'#2e6b7499',
'#42838c99',
'#569ba499',
'#6ab3bc99',
'#7ecbd499',
],
];
// 监听 theme 变化
watch(
() => props.theme,
(newTheme) => {
formData.value = newTheme
? { ...newTheme, colors: [...newTheme.colors] }
: {
id: `custom_${Date.now()}`,
name: '未命名',
colors: [
'#6ae5bb',
'#69e3de',
'#5ac5ee',
'#5ac4ee',
'#4498ec',
'#3c7ddf',
],
};
},
{ immediate: true },
);
// 添加颜色
const addColor = () => {
if (formData.value.colors.length < 8) {
formData.value.colors.push('#4992ff');
}
};
// 删除颜色
const removeColor = (index: number) => {
if (formData.value.colors.length > 1) {
formData.value.colors.splice(index, 1);
}
};
// 更新颜色
const updateColor = (index: number, color: null | string) => {
if (color) {
formData.value.colors[index] = color;
}
};
// 应用扩展色
const applyExtendColor = (colors: string[]) => {
formData.value.colors = [...colors];
};
// 保存
const handleSave = () => {
emit('save', { ...formData.value });
emit('update:visible', false);
};
// 删除
const handleDelete = () => {
if (props.theme?.id) {
emit('delete', props.theme.id);
emit('update:visible', false);
}
};
// 关闭
const handleClose = () => {
emit('update:visible', false);
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
title=""
width="800px"
:show-close="true"
@close="handleClose"
>
<div class="custom-color-dialog">
<div class="dialog-main">
<!-- 左侧编辑区 -->
<div class="edit-section">
<!-- 名称输入 -->
<div class="name-row">
<span class="label">名称:</span>
<ElInput
v-model="formData.name"
placeholder="请输入名称"
size="small"
class="name-input"
/>
<span class="color-count">{{ formData.colors.length }} / 8</span>
</div>
<!-- 颜色编辑 -->
<div class="colors-edit">
<div
v-for="(color, index) in formData.colors"
:key="index"
class="color-item"
>
<ElColorPicker
:model-value="color"
size="small"
@update:model-value="updateColor(index, $event)"
/>
<span class="color-value">{{ color }}</span>
<button
v-if="formData.colors.length > 1"
class="remove-btn"
@click="removeColor(index)"
>
<Trash2 class="h-3 w-3" />
</button>
</div>
<button
v-if="formData.colors.length < 8"
class="add-color-btn"
@click="addColor"
>
<Plus class="h-4 w-4" />
<span>添加</span>
</button>
</div>
<!-- 扩展色 -->
<div class="extend-colors">
<div class="extend-section">
<div class="extend-title">默认扩展色:</div>
<div class="extend-grid">
<div
v-for="(row, rowIdx) in defaultExtendColors"
:key="rowIdx"
class="extend-row"
>
<span
v-for="(c, idx) in row"
:key="idx"
class="extend-dot"
:style="{ backgroundColor: c }"
@click="applyExtendColor(row)"
></span>
</div>
</div>
</div>
<div class="extend-section">
<div class="extend-title">透明扩展色:</div>
<div class="extend-grid">
<div
v-for="(row, rowIdx) in transparentExtendColors"
:key="rowIdx"
class="extend-row"
>
<span
v-for="(c, idx) in row"
:key="idx"
class="extend-dot"
:style="{ backgroundColor: c }"
@click="applyExtendColor(row)"
></span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧操作区 -->
<div class="action-section">
<div class="action-buttons">
<ElButton type="primary" plain @click="handleSave">
<Plus class="mr-1 h-4 w-4" />
创建
</ElButton>
<ElButton
v-if="props.theme?.id"
type="danger"
plain
@click="handleDelete"
>
<Trash2 class="mr-1 h-4 w-4" />
删除
</ElButton>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="dialog-footer">
<ElButton @click="handleClose">操作完成</ElButton>
</div>
</div>
</ElDialog>
</template>
<style scoped>
.custom-color-dialog {
padding: 0 16px;
}
.dialog-main {
display: flex;
gap: 24px;
}
.edit-section {
flex: 1;
}
.name-row {
display: flex;
gap: 12px;
align-items: center;
padding: 12px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
}
.label {
font-size: 14px;
color: var(--el-text-color-primary);
}
.name-input {
flex: 1;
max-width: 200px;
}
.color-count {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.colors-edit {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
}
.color-item {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.color-value {
font-family: monospace;
font-size: 12px;
color: var(--el-text-color-primary);
}
.remove-btn {
padding: 4px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: none;
border: none;
border-radius: 4px;
}
.remove-btn:hover {
color: var(--el-color-danger);
background: var(--el-fill-color);
}
.add-color-btn {
display: flex;
gap: 4px;
align-items: center;
padding: 8px 16px;
font-size: 14px;
color: var(--el-color-primary);
cursor: pointer;
background: transparent;
border: 1px dashed var(--el-color-primary);
border-radius: 6px;
}
.add-color-btn:hover {
background: var(--el-color-primary-light-9);
}
.extend-colors {
display: flex;
gap: 24px;
}
.extend-section {
flex: 1;
}
.extend-title {
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.extend-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.extend-row {
display: flex;
gap: 4px;
}
.extend-dot {
width: 24px;
height: 24px;
cursor: pointer;
border-radius: 4px;
transition: transform 0.2s;
}
.extend-dot:hover {
transform: scale(1.1);
}
.action-section {
width: 160px;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 8px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
margin-top: 24px;
border-top: 1px solid var(--el-border-color);
}
</style>
@@ -0,0 +1,392 @@
<script setup lang="ts">
import type { WidgetStylesType } from '../../store/types';
import { computed, ref } from 'vue';
import { HelpCircle } from '@vben/icons';
import {
ElCollapse,
ElCollapseItem,
ElImage,
ElInputNumber,
ElOption,
ElSelect,
ElSlider,
ElSwitch,
ElTooltip,
} from 'element-plus';
// 预设滤镜预览图片
import presetImg from '../../image/small-icon/small-icon01.png';
import { BlendModeOptions, FilterEnum } from '../../store/types';
const props = defineProps<{
isCanvas?: boolean;
styles: WidgetStylesType;
}>();
const emit = defineEmits<{
(e: 'update:styles', key: keyof WidgetStylesType, value: any): void;
}>();
// 折叠面板激活项
const activeNames = ref(['filter']);
// 更新样式
const updateStyle = (key: keyof WidgetStylesType, value: any) => {
emit('update:styles', key, value);
};
// 滤镜开关
const filterShow = computed({
get: () => props.styles[FilterEnum.FILTERS_SHOW],
set: (val) => updateStyle(FilterEnum.FILTERS_SHOW, val),
});
// 色相
const hueRotate = computed({
get: () => props.styles[FilterEnum.HUE_ROTATE],
set: (val) => updateStyle(FilterEnum.HUE_ROTATE, val),
});
// 饱和度
const saturate = computed({
get: () => props.styles[FilterEnum.SATURATE],
set: (val) => updateStyle(FilterEnum.SATURATE, val),
});
// 对比度
const contrast = computed({
get: () => props.styles[FilterEnum.CONTRAST],
set: (val) => updateStyle(FilterEnum.CONTRAST, val),
});
// 亮度
const brightness = computed({
get: () => props.styles[FilterEnum.BRIGHTNESS],
set: (val) => updateStyle(FilterEnum.BRIGHTNESS, val),
});
// 透明度
const opacity = computed({
get: () => props.styles[FilterEnum.OPACITY],
set: (val) => updateStyle(FilterEnum.OPACITY, val),
});
// 混合模式
const blendMode = computed({
get: () => props.styles[FilterEnum.BLEND_MODE],
set: (val) => updateStyle(FilterEnum.BLEND_MODE, val),
});
// 旋转 Z
const rotateZ = computed({
get: () => props.styles[FilterEnum.ROTATE_Z],
set: (val) => updateStyle(FilterEnum.ROTATE_Z, val),
});
// 旋转 X
const rotateX = computed({
get: () => props.styles[FilterEnum.ROTATE_X],
set: (val) => updateStyle(FilterEnum.ROTATE_X, val),
});
// 旋转 Y
const rotateY = computed({
get: () => props.styles[FilterEnum.ROTATE_Y],
set: (val) => updateStyle(FilterEnum.ROTATE_Y, val),
});
// 倾斜 X
const skewX = computed({
get: () => props.styles[FilterEnum.SKEW_X],
set: (val) => updateStyle(FilterEnum.SKEW_X, val),
});
// 倾斜 Y
const skewY = computed({
get: () => props.styles[FilterEnum.SKEW_Y],
set: (val) => updateStyle(FilterEnum.SKEW_Y, val),
});
// 预设滤镜列表
interface PresetFilter {
hueRotate: number;
}
const presetFilters: PresetFilter[] = [];
for (let i = 1; i <= 12; i++) {
presetFilters.push({ hueRotate: i * 30 });
}
// 应用预设滤镜
const applyPreset = (preset: PresetFilter) => {
updateStyle(FilterEnum.HUE_ROTATE, preset.hueRotate);
};
// 格式化百分比显示
const formatPercent = (val: number) => `${(val * 100).toFixed(0)}%`;
</script>
<template>
<ElCollapse v-model="activeNames">
<ElCollapseItem name="filter">
<template #title>
<div class="legend-title">
<span>{{ isCanvas ? '滤镜' : '滤镜 / 变换' }}</span>
<ElSwitch v-model="filterShow" size="small" @click.stop />
</div>
</template>
<div class="filter-config space-y-4">
<!-- 色相 -->
<div class="config-item">
<div class="config-label">色相</div>
<div class="config-content">
<ElSlider
size="small"
v-model="hueRotate"
:min="0"
:max="360"
:step="3"
:format-tooltip="(val: number) => `${val}deg`"
/>
<div class="config-value">值:{{ hueRotate }}deg</div>
</div>
</div>
<!-- 饱和度 -->
<div class="config-item">
<div class="config-label">饱和度</div>
<div class="config-content">
<ElSlider
v-model="saturate"
:min="0"
:max="2"
:step="0.1"
:format-tooltip="formatPercent"
/>
<div class="config-value">值:{{ formatPercent(saturate) }}</div>
</div>
</div>
<!-- 对比度 -->
<div class="config-item">
<div class="config-label">对比度</div>
<div class="config-content">
<ElSlider
v-model="contrast"
:min="0"
:max="2"
:step="0.1"
:format-tooltip="formatPercent"
/>
<div class="config-value">值:{{ formatPercent(contrast) }}</div>
</div>
</div>
<!-- 亮度 -->
<div class="config-item">
<div class="config-label">亮度</div>
<div class="config-content">
<ElSlider
v-model="brightness"
:min="0"
:max="2"
:step="0.1"
:format-tooltip="formatPercent"
/>
<div class="config-value">值:{{ formatPercent(brightness) }}</div>
</div>
</div>
<!-- 透明度 -->
<div class="config-item">
<div class="config-label">透明度</div>
<div class="config-content">
<ElSlider
v-model="opacity"
:min="0"
:max="1"
:step="0.1"
:format-tooltip="formatPercent"
/>
<div class="config-value">值:{{ formatPercent(opacity) }}</div>
</div>
</div>
<!-- 预设滤镜 -->
<div class="preset-filter">
<ElImage
v-for="(preset, index) in presetFilters"
:key="index"
class="preset-img"
:class="{ 'active-preset': preset.hueRotate === hueRotate }"
:style="{ filter: `hue-rotate(${preset.hueRotate}deg)` }"
:src="presetImg"
fit="scale-down"
:preview-disabled="true"
@click="applyPreset(preset)"
/>
</div>
<!-- 混合模式 - 仅非画布时显示 -->
<div v-if="!isCanvas" class="config-item">
<div class="config-label flex items-center gap-1">
<span>混合模式</span>
<ElTooltip
content="视频组件需要底色透明一般选中滤色"
placement="top"
>
<HelpCircle class="h-4 w-4 cursor-help opacity-60" />
</ElTooltip>
</div>
<div class="config-content">
<ElSelect v-model="blendMode" size="small" class="w-full">
<ElOption
v-for="item in BlendModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</div>
</div>
<!-- 变换 - 仅非画布时显示 -->
<template v-if="!isCanvas">
<!-- 旋转 -->
<div class="config-item">
<div class="config-label">旋转°</div>
<div class="config-content space-y-2">
<div class="flex items-center gap-2">
<span class="w-24 text-xs opacity-70">Z轴(平面)</span>
<ElInputNumber
v-model="rotateZ"
:min="0"
:max="360"
size="small"
class="flex-1"
/>
</div>
<div class="flex items-center gap-2">
<span class="w-24 text-xs opacity-70">X轴</span>
<ElInputNumber
v-model="rotateX"
:min="0"
:max="360"
size="small"
class="flex-1"
/>
</div>
<div class="flex items-center gap-2">
<span class="w-24 text-xs opacity-70">Y轴</span>
<ElInputNumber
v-model="rotateY"
:min="0"
:max="360"
size="small"
class="flex-1"
/>
</div>
</div>
</div>
<!-- 倾斜 -->
<div class="config-item">
<div class="config-label">倾斜°</div>
<div class="config-content space-y-2">
<div class="flex items-center gap-2">
<span class="w-24 text-xs opacity-70">X轴</span>
<ElInputNumber
v-model="skewX"
:min="0"
:max="360"
size="small"
class="flex-1"
/>
</div>
<div class="flex items-center gap-2">
<span class="w-24 text-xs opacity-70">Y轴</span>
<ElInputNumber
v-model="skewY"
:min="0"
:max="360"
size="small"
class="flex-1"
/>
</div>
</div>
</div>
</template>
<!-- 提示 -->
<div class="text-warning rounded bg-yellow-500/10 p-2 text-xs">
若预览时大屏模糊可以尝试关闭滤镜进行修复
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px;
}
.config-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-content {
width: 100%;
}
.legend-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 12px;
}
.config-value {
margin-top: 2px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.preset-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
}
.preset-img {
width: 46px;
height: 46px;
padding: 2px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 6px;
transition: all 0.2s;
}
.preset-img:hover {
border-color: var(--el-color-primary-light-3);
}
.active-preset {
border-color: var(--el-color-primary);
}
.text-warning {
color: var(--el-color-warning);
}
</style>
@@ -0,0 +1,412 @@
<script setup lang="ts">
import type { AxisConfig } from '../../../types/chartOptionTypes';
import { computed } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
modelValue: AxisConfig;
type: 'x' | 'y';
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: AxisConfig): void;
}>();
// 轴线位置选项(X轴和Y轴不同)
const positionOptions = computed(() => {
if (props.type === 'x') {
return [
{ label: '底部', value: 'bottom' },
{ label: '顶部', value: 'top' },
];
}
return [
{ label: '左侧', value: 'left' },
{ label: '右侧', value: 'right' },
];
});
// 分割线类型选项
const lineTypeOptions = [
{ label: '实线', value: 'solid' },
{ label: '虚线', value: 'dashed' },
{ label: '点线', value: 'dotted' },
];
// 更新配置
const updateConfig = (path: string[], value: any) => {
const newConfig = JSON.parse(JSON.stringify(props.modelValue)) as Record<
string,
any
>;
let current: Record<string, any> = newConfig;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i] as string] as Record<string, any>;
}
current[path[path.length - 1] as string] = value;
emit('update:modelValue', newConfig as AxisConfig);
};
const title = computed(() => (props.type === 'x' ? 'X轴' : 'Y轴'));
</script>
<template>
<ElCollapse>
<ElCollapseItem :name="`${type}Axis`">
<template #title>
<div class="axis-title">
<span>{{ title }}</span>
<ElSwitch
:model-value="modelValue.show"
size="small"
@click.stop
@update:model-value="updateConfig(['show'], $event)"
/>
</div>
</template>
<div v-if="modelValue.show" class="config-section">
<!-- 单位配置 -->
<div class="config-group">
<div class="group-title">单位</div>
<div class="config-row">
<div class="config-item">
<ElInput
:model-value="modelValue.unit.name"
placeholder="请输入"
@update:model-value="updateConfig(['unit', 'name'], $event)"
/>
<div class="config-hint">名称</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.unit.color"
@update:model-value="
updateConfig(['unit', 'color'], $event || '#B9B8CE')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.unit.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateConfig(['unit', 'fontSize'], $event ?? 12)
"
/>
<div class="config-hint">大小</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.unit.offset"
:step="5"
controls-position="right"
@update:model-value="
updateConfig(['unit', 'offset'], $event ?? 0)
"
/>
<div class="config-hint">偏移量</div>
</div>
</div>
</div>
<!-- 标签配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">标签</span>
<ElSwitch
:model-value="modelValue.label.show"
size="small"
@update:model-value="updateConfig(['label', 'show'], $event)"
/>
</div>
<template v-if="modelValue.label.show">
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.label.color"
@update:model-value="
updateConfig(['label', 'color'], $event || '#B9B8CE')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.label.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateConfig(['label', 'fontSize'], $event ?? 12)
"
/>
<div class="config-hint">大小</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.label.offset"
:step="5"
controls-position="right"
@update:model-value="
updateConfig(['label', 'offset'], $event ?? 0)
"
/>
<div class="config-hint">偏移量</div>
</div>
</div>
</template>
</div>
<!-- 轴线配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">轴线</span>
<ElSwitch
:model-value="modelValue.axisLine.show"
size="small"
@update:model-value="updateConfig(['axisLine', 'show'], $event)"
/>
</div>
<template v-if="modelValue.axisLine.show">
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.axisLine.color"
@update:model-value="
updateConfig(['axisLine', 'color'], $event || '#B9B8CE')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.axisLine.width"
:min="1"
:max="10"
controls-position="right"
@update:model-value="
updateConfig(['axisLine', 'width'], $event ?? 1)
"
/>
<div class="config-hint">粗细</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.axisLine.position"
@update:model-value="
updateConfig(['axisLine', 'position'], $event)
"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.axisLine.alignZero"
size="small"
@update:model-value="
updateConfig(['axisLine', 'alignZero'], $event)
"
/>
<div class="config-hint">对齐零</div>
</div>
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.axisLine.inverse"
size="small"
@update:model-value="
updateConfig(['axisLine', 'inverse'], $event)
"
/>
<div class="config-hint">反向</div>
</div>
</div>
</template>
</div>
<!-- 刻度配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">刻度</span>
<ElSwitch
:model-value="modelValue.tick.show"
size="small"
@update:model-value="updateConfig(['tick', 'show'], $event)"
/>
</div>
<template v-if="modelValue.tick.show">
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.tick.length"
:min="1"
:max="20"
controls-position="right"
@update:model-value="
updateConfig(['tick', 'length'], $event ?? 5)
"
/>
<div class="config-hint">长度</div>
</div>
</div>
</template>
</div>
<!-- 分割线配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">分割线</span>
<ElSwitch
:model-value="modelValue.splitLine.show"
size="small"
@update:model-value="updateConfig(['splitLine', 'show'], $event)"
/>
</div>
<template v-if="modelValue.splitLine.show">
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.splitLine.color"
@update:model-value="
updateConfig(['splitLine', 'color'], $event || '#484753')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.splitLine.width"
:min="1"
:max="10"
controls-position="right"
@update:model-value="
updateConfig(['splitLine', 'width'], $event ?? 1)
"
/>
<div class="config-hint">粗细</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.splitLine.type"
@update:model-value="
updateConfig(['splitLine', 'type'], $event)
"
>
<ElOption
v-for="opt in lineTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">类型</div>
</div>
</div>
</template>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.axis-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 12px;
}
.config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.switch-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-color-picker) {
width: 100%;
}
:deep(.el-color-picker__trigger) {
width: 100%;
}
</style>
@@ -0,0 +1,412 @@
<script setup lang="ts">
import type { BarLineSeriesStyleConfig } from '../../../types/chartOptionTypes';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { getThemeColor as getThemeColorFn } from '../../../constants/chartTheme';
const props = defineProps<{
modelValue: BarLineSeriesStyleConfig[];
seriesNames: string[];
themeColors?: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: BarLineSeriesStyleConfig[]): void;
}>();
//
const getThemeColor = (index: number) =>
getThemeColorFn(props.themeColors, index);
//
const typeOptions = [
{ label: '折线图', value: 'line' },
{ label: '柱状图', value: 'bar' },
];
// 线
const lineTypeOptions = [
{ label: 'solid', value: 'solid' },
{ label: 'dashed', value: 'dashed' },
{ label: 'dotted', value: 'dotted' },
];
//
const positionOptions = [
{ label: '顶部', value: 'top' },
{ label: '内部', value: 'inside' },
{ label: '左侧', value: 'left' },
{ label: '右侧', value: 'right' },
];
//
const updateSeriesConfig = (index: number, path: string[], value: any) => {
const newConfigs = JSON.parse(
JSON.stringify(props.modelValue),
) as BarLineSeriesStyleConfig[];
let current: Record<string, any> = newConfigs[index] as Record<string, any>;
for (let i = 0; i < path.length - 1; i++) {
if (!current[path[i] as string]) {
current[path[i] as string] = {};
}
current = current[path[i] as string] as Record<string, any>;
}
current[path[path.length - 1] as string] = value;
emit('update:modelValue', newConfigs);
};
//
const getSeriesTitle = (index: number) => {
return props.seriesNames[index] || `系列${index + 1}`;
};
//
const getSeriesTypeLabel = (type: string) => {
return type === 'line' ? '折线图' : '柱状图';
};
</script>
<template>
<ElCollapse>
<ElCollapseItem
v-for="(config, index) in modelValue"
:key="index"
:name="`barLineSeries-${index}`"
>
<template #title>
<div class="series-title">
<span>{{ getSeriesTitle(index) }}</span>
<span class="series-type-tag">{{ getSeriesTypeLabel(config.type || 'line') }}</span>
</div>
</template>
<div class="config-section">
<!-- 类型配置 -->
<div class="config-group">
<div class="group-title">类型</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="config.type || 'line'"
@update:model-value="
updateSeriesConfig(index, ['type'], $event)
"
>
<ElOption
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">类型</div>
</div>
</div>
</div>
<!-- 折线图配置 -->
<template v-if="(config.type || 'line') === 'line'">
<div class="config-group">
<div class="group-title">线条</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.lineWidth ?? 3"
:min="1"
:max="10"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['lineWidth'], $event ?? 3)
"
/>
<div class="config-hint">宽度</div>
</div>
<div class="config-item">
<ElSelect
:model-value="config.lineType ?? 'solid'"
@update:model-value="
updateSeriesConfig(index, ['lineType'], $event)
"
>
<ElOption
v-for="opt in lineTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">类型</div>
</div>
</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="config.smooth ?? false"
size="small"
@update:model-value="
updateSeriesConfig(index, ['smooth'], $event)
"
/>
<div class="config-hint">曲线</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">实心点</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.symbolSize ?? 5"
:min="0"
:max="30"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['symbolSize'], $event ?? 5)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
</div>
</template>
<!-- 柱状图配置 -->
<template v-if="(config.type || 'line') === 'bar'">
<div class="config-group">
<div class="group-title">图形</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.barWidth ?? 15"
:min="1"
:max="100"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['barWidth'], $event ?? 15)
"
/>
<div class="config-hint">宽度</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="config.barBorderRadius ?? 0"
:min="0"
:max="50"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['barBorderRadius'], $event ?? 0)
"
/>
<div class="config-hint">圆角</div>
</div>
</div>
</div>
</template>
<!-- 颜色配置 -->
<div class="config-group">
<div class="group-title">颜色</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="config.color || getThemeColor(index)"
@update:model-value="
updateSeriesConfig(index, ['color'], $event || '')
"
/>
<button
v-if="config.color"
class="clear-btn"
title="恢复主题色"
@click="updateSeriesConfig(index, ['color'], '')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
<!-- 标签配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">标签</span>
<ElSwitch
:model-value="config.label?.show ?? true"
size="small"
@update:model-value="
updateSeriesConfig(index, ['label', 'show'], $event)
"
/>
</div>
<template v-if="config.label?.show !== false">
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.label?.fontSize ?? 12"
:min="10"
:max="30"
controls-position="right"
@update:model-value="
updateSeriesConfig(
index,
['label', 'fontSize'],
$event ?? 12,
)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="config.label?.color ?? '#fff'"
@update:model-value="
updateSeriesConfig(
index,
['label', 'color'],
$event || '#fff',
)
"
/>
<div class="config-hint">tip颜色</div>
</div>
<div class="config-item">
<ElSelect
:model-value="config.label?.position ?? 'top'"
@update:model-value="
updateSeriesConfig(index, ['label', 'position'], $event)
"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
</div>
</template>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.series-title {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
}
.series-type-tag {
margin-left: auto;
font-size: 12px;
color: var(--el-color-primary);
}
.config-section {
padding: 0 8px;
}
.config-group {
margin-bottom: 12px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.switch-item .config-hint {
margin-top: 0;
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
import type { BarSeriesStyleConfig } from '../../../types/chartOptionTypes';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { getThemeColor as getThemeColorFn } from '../../../constants/chartTheme';
const props = defineProps<{
modelValue: BarSeriesStyleConfig[];
seriesNames: string[];
themeColors?: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: BarSeriesStyleConfig[]): void;
}>();
//
const getThemeColor = (index: number) =>
getThemeColorFn(props.themeColors, index);
//
const positionOptions = [
{ label: 'top', value: 'top' },
{ label: 'inside', value: 'inside' },
{ label: 'insideTop', value: 'insideTop' },
{ label: 'insideBottom', value: 'insideBottom' },
{ label: 'left', value: 'left' },
{ label: 'right', value: 'right' },
];
//
const updateSeriesConfig = (index: number, path: string[], value: any) => {
const newConfigs = JSON.parse(
JSON.stringify(props.modelValue),
) as BarSeriesStyleConfig[];
let current: Record<string, any> = newConfigs[index] as Record<string, any>;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i] as string] as Record<string, any>;
}
current[path[path.length - 1] as string] = value;
emit('update:modelValue', newConfigs);
};
//
const getSeriesTitle = (index: number) => {
return props.seriesNames[index] || `柱状图-${index + 1}`;
};
</script>
<template>
<ElCollapse>
<ElCollapseItem
v-for="(config, index) in modelValue"
:key="index"
:name="`barSeries-${index}`"
>
<template #title>
<span>{{ getSeriesTitle(index) }}</span>
</template>
<div class="config-section">
<!-- 图形配置 -->
<div class="config-group">
<div class="group-title">图形</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.barWidth"
:min="1"
:max="100"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['barWidth'], $event ?? 15)
"
/>
<div class="config-hint">宽度</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="config.barBorderRadius"
:min="0"
:max="50"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['barBorderRadius'], $event ?? 0)
"
/>
<div class="config-hint">圆角</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="config.color || getThemeColor(index)"
@update:model-value="
updateSeriesConfig(index, ['color'], $event || '')
"
/>
<button
v-if="config.color"
class="clear-btn"
title="恢复主题色"
@click="updateSeriesConfig(index, ['color'], '')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
<!-- 标签配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">标签</span>
<ElSwitch
:model-value="config.label.show"
size="small"
@update:model-value="
updateSeriesConfig(index, ['label', 'show'], $event)
"
/>
</div>
<template v-if="config.label.show">
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.label.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateSeriesConfig(
index,
['label', 'fontSize'],
$event ?? 12,
)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="config.label.color"
@update:model-value="
updateSeriesConfig(
index,
['label', 'color'],
$event || '',
)
"
/>
<button
v-if="config.label.color && config.label.color !== '#fff'"
class="clear-btn"
title="恢复默认"
@click="
updateSeriesConfig(index, ['label', 'color'], '#fff')
"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElSelect
:model-value="config.label.position"
@update:model-value="
updateSeriesConfig(index, ['label', 'position'], $event)
"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
</div>
</template>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-color-picker) {
width: 100%;
}
:deep(.el-color-picker__trigger) {
width: 100%;
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
</style>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { ElColorPicker, ElInput, ElInputNumber, ElSwitch } from 'element-plus';
import { getThemeColor as getThemeColorFn } from '../../../constants/chartTheme';
export interface CapsuleChartConfigType {
leftMargin?: number;
rightMargin?: number;
barHeight?: number;
fontSize?: number;
unit?: string;
showValue?: boolean;
colors?: string[];
}
const props = defineProps<{
dataLength?: number;
modelValue: CapsuleChartConfigType;
themeColors?: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: CapsuleChartConfigType): void;
}>();
//
const getThemeColor = (index: number) =>
getThemeColorFn(props.themeColors, index);
//
const updateConfig = (key: keyof CapsuleChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
//
const updateColor = (index: number, color: string) => {
const colors = [...(props.modelValue.colors || [])];
colors[index] = color;
updateConfig('colors', colors);
};
//
const clearColor = (index: number) => {
const colors = [...(props.modelValue.colors || [])];
colors[index] = '';
updateConfig('colors', colors);
};
//
const colorCount = () => props.dataLength || 5;
</script>
<template>
<div class="capsule-config">
<!-- 布局配置 -->
<div class="config-group">
<div class="group-title">布局</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.leftMargin ?? 50"
:min="0"
:max="200"
controls-position="right"
@update:model-value="updateConfig('leftMargin', $event ?? 50)"
/>
<div class="config-hint">左侧边距</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.rightMargin ?? 50"
:min="0"
:max="200"
controls-position="right"
@update:model-value="updateConfig('rightMargin', $event ?? 50)"
/>
<div class="config-hint">右侧边距</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.barHeight ?? 10"
:min="5"
:max="50"
controls-position="right"
@update:model-value="updateConfig('barHeight', $event ?? 10)"
/>
<div class="config-hint">每块高度(px)</div>
</div>
</div>
</div>
<!-- 文本配置 -->
<div class="config-group">
<div class="group-title">文本</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.fontSize ?? 16"
:min="10"
:max="30"
controls-position="right"
@update:model-value="updateConfig('fontSize', $event ?? 16)"
/>
<div class="config-hint">所有文字大小</div>
</div>
<div class="config-item">
<ElInput
:model-value="modelValue.unit ?? ''"
placeholder="请输入"
@update:model-value="updateConfig('unit', $event)"
/>
<div class="config-hint">单位</div>
</div>
</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showValue ?? true"
@update:model-value="updateConfig('showValue', $event)"
/>
<span class="switch-label">显示数值</span>
</div>
</div>
</div>
<!-- 颜色配置 -->
<div class="config-group">
<div class="group-title">颜色</div>
<div class="config-row colors-row">
<div
v-for="index in colorCount()"
:key="index"
class="config-item color-item"
>
<div class="color-picker-wrap">
<ElColorPicker
:model-value="
modelValue.colors?.[index - 1] || getThemeColor(index - 1)
"
@update:model-value="updateColor(index - 1, $event || '')"
/>
<button
v-if="modelValue.colors?.[index - 1]"
class="clear-btn"
title="恢复主题色"
@click="clearColor(index - 1)"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色{{ index - 1 }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.capsule-config {
padding: 0 8px;
}
.config-group {
margin-bottom: 16px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.colors-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.config-item {
flex: 1;
min-width: 0;
}
.color-item {
flex: none;
}
.switch-item {
display: flex;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input) {
width: 100%;
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* 图表通用配置组件
* 包含容器配置X轴配置Y轴配置图例配置
* 可通过 props 控制显示哪些配置项
*/
import type { ChartCommonConfig } from '../../../types/chartOptionTypes';
import AxisConfig from './AxisConfig.vue';
import ContainerConfig from './ContainerConfig.vue';
import LegendConfig from './LegendConfig.vue';
const props = withDefaults(
defineProps<{
modelValue: ChartCommonConfig;
showContainer?: boolean;
showLegend?: boolean;
showXAxis?: boolean;
showYAxis?: boolean;
}>(),
{
showContainer: true,
showXAxis: true,
showYAxis: true,
showLegend: true,
},
);
const emit = defineEmits<{
(e: 'update:modelValue', value: ChartCommonConfig): void;
}>();
const updateContainer = (value: ChartCommonConfig['container']) => {
emit('update:modelValue', {
...props.modelValue,
container: value,
});
};
const updateXAxis = (value: NonNullable<ChartCommonConfig['xAxis']>) => {
emit('update:modelValue', {
...props.modelValue,
xAxis: value,
});
};
const updateYAxis = (value: NonNullable<ChartCommonConfig['yAxis']>) => {
emit('update:modelValue', {
...props.modelValue,
yAxis: value,
});
};
const updateLegend = (value: NonNullable<ChartCommonConfig['legend']>) => {
emit('update:modelValue', {
...props.modelValue,
legend: value,
});
};
//
const defaultLegend: NonNullable<ChartCommonConfig['legend']> = {
show: true,
position: { x: 'center', y: 'top' },
orient: 'horizontal',
icon: 'circle',
itemSize: { width: 15, height: 15 },
text: { color: '#B9B8CE', fontSize: 12 },
};
</script>
<template>
<div class="chart-common-config">
<!-- 容器配置 -->
<ContainerConfig
v-if="showContainer"
:model-value="modelValue.container"
@update:model-value="updateContainer"
/>
<!-- X轴配置 -->
<AxisConfig
v-if="showXAxis && modelValue.xAxis"
:model-value="modelValue.xAxis"
type="x"
@update:model-value="updateXAxis"
/>
<!-- Y轴配置 -->
<AxisConfig
v-if="showYAxis && modelValue.yAxis"
:model-value="modelValue.yAxis"
type="y"
@update:model-value="updateYAxis"
/>
<!-- 图例配置 -->
<LegendConfig
v-if="showLegend"
:model-value="modelValue.legend || defaultLegend"
@update:model-value="updateLegend"
/>
</div>
</template>
<style scoped>
.chart-common-config {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ContainerConfig } from '../../../types/chartOptionTypes';
import {
ElCollapse,
ElCollapseItem,
ElInput,
ElInputNumber,
} from 'element-plus';
const props = defineProps<{
modelValue: ContainerConfig;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: ContainerConfig): void;
}>();
const updateConfig = <K extends keyof ContainerConfig>(
key: K,
value: ContainerConfig[K],
) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse>
<ElCollapseItem title="容器" name="container">
<div class="config-section">
<div class="config-row">
<div class="config-item">
<!-- <div class="config-label">距离</div> -->
<ElInput
:model-value="String(modelValue.left)"
placeholder="10%"
@update:model-value="updateConfig('left', $event)"
/>
<div class="config-hint">左侧距离</div>
</div>
<div class="config-item">
<ElInput
:model-value="String(modelValue.right)"
placeholder="10%"
@update:model-value="updateConfig('right', $event)"
/>
<div class="config-hint">右侧距离</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="Number(modelValue.top)"
:min="0"
:step="10"
controls-position="right"
@update:model-value="updateConfig('top', $event ?? 0)"
/>
<div class="config-hint">上侧距离</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="Number(modelValue.bottom)"
:min="0"
:step="10"
controls-position="right"
@update:model-value="updateConfig('bottom', $event ?? 0)"
/>
<div class="config-hint">下侧距离</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.config-label {
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSlider,
ElSwitch,
} from 'element-plus';
export interface FunnelChartConfigType {
sort?: 'ascending' | 'descending';
top?: number;
borderWidth?: number;
borderColor?: string;
gap?: number;
showLabel?: boolean;
labelPosition?: 'inside' | 'left' | 'right';
labelSize?: number;
emphasisSize?: number;
}
const props = defineProps<{
modelValue: FunnelChartConfigType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: FunnelChartConfigType): void;
}>();
//
const updateConfig = (key: keyof FunnelChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
//
const sortOptions = [
{ label: '倒三角', value: 'descending' },
{ label: '正三角', value: 'ascending' },
];
//
const positionOptions = [
{ label: '内部', value: 'inside' },
{ label: '左侧', value: 'left' },
{ label: '右侧', value: 'right' },
];
</script>
<template>
<ElCollapse>
<ElCollapseItem name="funnel" title="漏斗图">
<div class="config-section">
<!-- 排序配置 -->
<div class="config-group">
<div class="group-title">排序</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.sort ?? 'descending'"
@update:model-value="updateConfig('sort', $event)"
>
<ElOption
v-for="opt in sortOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</div>
</div>
</div>
<!-- 范围配置 -->
<div class="config-group">
<div class="group-title">范围</div>
<div class="config-row slider-row">
<div class="config-item">
<ElSlider
:model-value="modelValue.top ?? 70"
:min="0"
:max="200"
:format-tooltip="(val: number) => `${val}px`"
@update:model-value="updateConfig('top', $event)"
/>
<div class="config-hint">
顶部距离: {{ modelValue.top ?? 70 }}px
</div>
</div>
</div>
</div>
<!-- 区块配置 -->
<div class="config-group">
<div class="group-title">区块</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.borderWidth ?? 0"
:min="0"
:max="10"
controls-position="right"
@update:model-value="updateConfig('borderWidth', $event ?? 0)"
/>
<div class="config-hint">边框大小</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.borderColor || '#fff'"
@update:model-value="
updateConfig('borderColor', $event || '#fff')
"
/>
<div class="config-hint">边框颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.gap ?? 5"
:min="0"
:max="30"
controls-position="right"
@update:model-value="updateConfig('gap', $event ?? 5)"
/>
<div class="config-hint">间隔</div>
</div>
</div>
</div>
<!-- 标签配置 -->
<div class="config-group">
<div class="group-title">标签</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showLabel ?? true"
size="small"
@update:model-value="updateConfig('showLabel', $event)"
/>
<span class="switch-label">标签</span>
<span class="switch-hint">是否显示</span>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.labelPosition ?? 'inside'"
@update:model-value="updateConfig('labelPosition', $event)"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.labelSize ?? 12"
:min="8"
:max="30"
controls-position="right"
@update:model-value="updateConfig('labelSize', $event ?? 12)"
/>
<div class="config-hint">大小</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.emphasisSize ?? 20"
:min="10"
:max="40"
controls-position="right"
@update:model-value="updateConfig('emphasisSize', $event ?? 20)"
/>
<div class="config-hint">悬停时大小</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
padding: 0 8px;
}
.config-group {
margin-bottom: 12px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.slider-row {
flex-direction: column;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.switch-hint {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-slider) {
padding: 0 8px;
}
</style>
@@ -0,0 +1,278 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
} from 'element-plus';
import { getThemeColor as getThemeColorFn } from '../../../constants/chartTheme';
export interface GaugeChartConfigType {
value?: number;
fontColor?: string;
fontSize?: number;
outerColor?: string;
pointerColor?: string;
pointerWidth?: number;
min?: number;
max?: number;
tickColor?: string;
}
const props = defineProps<{
modelValue: GaugeChartConfigType;
themeColors?: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: GaugeChartConfigType): void;
}>();
//
const getThemeColor = (index: number) =>
getThemeColorFn(props.themeColors, index);
//
const updateConfig = (key: keyof GaugeChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
//
const clearColor = (key: keyof GaugeChartConfigType) => {
emit('update:modelValue', {
...props.modelValue,
[key]: '',
});
};
</script>
<template>
<ElCollapse>
<ElCollapseItem name="gauge-ring" title="圆环">
<div class="config-section">
<!-- 数据配置 -->
<div class="config-group">
<div class="group-title">数据</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.value ?? 70"
:min="0"
:max="modelValue.max ?? 100"
controls-position="right"
@update:model-value="updateConfig('value', $event ?? 70)"
/>
<div class="config-hint">数值</div>
</div>
</div>
</div>
<!-- 字体配置 -->
<div class="config-group">
<div class="group-title">字体</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.fontColor || '#eee'"
@update:model-value="
updateConfig('fontColor', $event || '#eee')
"
/>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.fontSize ?? 14"
:min="10"
:max="30"
controls-position="right"
@update:model-value="updateConfig('fontSize', $event ?? 14)"
/>
<div class="config-hint">字体大小</div>
</div>
</div>
</div>
<!-- 表盘外部配置 -->
<div class="config-group">
<div class="group-title">表盘外部</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.outerColor || getThemeColor(0)"
show-alpha
@update:model-value="updateConfig('outerColor', $event || '')"
/>
<button
v-if="modelValue.outerColor"
class="clear-btn"
title="恢复主题色"
@click="clearColor('outerColor')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
<!-- 指针配置 -->
<div class="config-group">
<div class="group-title">指针</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.pointerColor || getThemeColor(0)"
@update:model-value="
updateConfig('pointerColor', $event || '')
"
/>
<button
v-if="modelValue.pointerColor"
class="clear-btn"
title="恢复主题色"
@click="clearColor('pointerColor')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.pointerWidth ?? 4"
:min="1"
:max="20"
controls-position="right"
@update:model-value="updateConfig('pointerWidth', $event ?? 4)"
/>
<div class="config-hint">宽度</div>
</div>
</div>
</div>
<!-- 刻度配置 -->
<div class="config-group">
<div class="group-title">刻度</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.min ?? 0"
:min="0"
controls-position="right"
@update:model-value="updateConfig('min', $event ?? 0)"
/>
<div class="config-hint">最小值</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.max ?? 100"
:min="1"
controls-position="right"
@update:model-value="updateConfig('max', $event ?? 100)"
/>
<div class="config-hint">最大值</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.tickColor || getThemeColor(1)"
@update:model-value="updateConfig('tickColor', $event || '')"
/>
<button
v-if="modelValue.tickColor"
class="clear-btn"
title="恢复主题色"
@click="clearColor('tickColor')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
padding: 0 8px;
}
.config-group {
margin-bottom: 12px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
export interface GraphChartConfigType {
layout?: 'circular' | 'force' | 'none';
showLabel?: boolean;
labelPosition?: 'bottom' | 'left' | 'right' | 'top';
curveness?: number;
}
const props = defineProps<{
modelValue: GraphChartConfigType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: GraphChartConfigType): void;
}>();
//
const updateConfig = (key: keyof GraphChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
//
const layoutOptions = [
{ label: '无', value: 'none' },
{ label: '力导向', value: 'force' },
{ label: '环形', value: 'circular' },
];
//
const showOptions = [
{ label: '开启', value: true },
{ label: '关闭', value: false },
];
//
const positionOptions = [
{ label: '左侧', value: 'left' },
{ label: '右侧', value: 'right' },
{ label: '顶部', value: 'top' },
{ label: '底部', value: 'bottom' },
];
</script>
<template>
<ElCollapse>
<ElCollapseItem name="graph" title="关系图">
<div class="config-section">
<!-- 样式配置 -->
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.layout ?? 'none'"
@update:model-value="updateConfig('layout', $event)"
>
<ElOption
v-for="opt in layoutOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">布局</div>
</div>
</div>
</div>
<!-- 标签配置 -->
<div class="config-group">
<div class="group-title">标签</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.showLabel ?? true"
@update:model-value="updateConfig('showLabel', $event)"
>
<ElOption
v-for="opt in showOptions"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">展示</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.labelPosition ?? 'right'"
@update:model-value="updateConfig('labelPosition', $event)"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
</div>
</div>
<!-- 线条配置 -->
<div class="config-group">
<div class="group-title">线条</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.curveness ?? 0.2"
:min="0"
:max="1"
:step="0.1"
:precision="1"
controls-position="right"
@update:model-value="updateConfig('curveness', $event ?? 0.2)"
/>
<div class="config-hint">弧线</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
padding: 0 8px;
}
.config-group {
margin-bottom: 12px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
</style>
@@ -0,0 +1,273 @@
<script setup lang="ts">
import type { LegendConfig } from '../../../types/chartOptionTypes';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
modelValue: LegendConfig;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: LegendConfig): void;
}>();
// X
const positionXOptions = [
{ label: '居左', value: 'left' },
{ label: '居中', value: 'center' },
{ label: '居右', value: 'right' },
];
// Y
const positionYOptions = [
{ label: '靠上', value: 'top' },
{ label: '居中', value: 'middle' },
{ label: '靠下', value: 'bottom' },
];
//
const orientOptions = [
{ label: '水平', value: 'horizontal' },
{ label: '垂直', value: 'vertical' },
];
//
const iconOptions = [
{ label: '圆形', value: 'circle' },
{ label: '方形', value: 'rect' },
{ label: '圆角方形', value: 'roundRect' },
{ label: '三角形', value: 'triangle' },
{ label: '菱形', value: 'diamond' },
{ label: '无', value: 'none' },
];
//
const updateConfig = (path: string[], value: any) => {
const newConfig = JSON.parse(JSON.stringify(props.modelValue)) as Record<
string,
any
>;
let current: Record<string, any> = newConfig;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i] as string] as Record<string, any>;
}
current[path[path.length - 1] as string] = value;
emit('update:modelValue', newConfig as LegendConfig);
};
</script>
<template>
<ElCollapse>
<ElCollapseItem name="legend">
<template #title>
<div class="legend-title">
<span>图例</span>
<ElSwitch
:model-value="modelValue.show"
size="small"
@click.stop
@update:model-value="updateConfig(['show'], $event)"
/>
</div>
</template>
<div v-if="modelValue.show" class="config-section">
<!-- 图例文字 -->
<div class="config-group">
<div class="group-title">图例文字</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.text.color"
@update:model-value="
updateConfig(['text', 'color'], $event || '#B9B8CE')
"
/>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.text.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateConfig(['text', 'fontSize'], $event ?? 18)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
</div>
<!-- 图例位置 -->
<div class="config-group">
<div class="group-title">图例位置</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.position.x"
@update:model-value="updateConfig(['position', 'x'], $event)"
>
<ElOption
v-for="opt in positionXOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">x轴</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.position.y"
@update:model-value="updateConfig(['position', 'y'], $event)"
>
<ElOption
v-for="opt in positionYOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">y轴</div>
</div>
</div>
</div>
<!-- 图例信息 -->
<div class="config-group">
<div class="group-title">图例信息</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.orient"
@update:model-value="updateConfig(['orient'], $event)"
>
<ElOption
v-for="opt in orientOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">方向</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.icon"
@update:model-value="updateConfig(['icon'], $event)"
>
<ElOption
v-for="opt in iconOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">形状</div>
</div>
</div>
</div>
<!-- 图例大小 -->
<div class="config-group">
<div class="group-title">图例大小</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.itemSize.width"
:min="5"
:max="50"
controls-position="right"
@update:model-value="
updateConfig(['itemSize', 'width'], $event ?? 15)
"
/>
<div class="config-hint"></div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.itemSize.height"
:min="5"
:max="50"
controls-position="right"
@update:model-value="
updateConfig(['itemSize', 'height'], $event ?? 15)
"
/>
<div class="config-hint"></div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.legend-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 12px;
}
.config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-color-picker) {
width: 100%;
}
:deep(.el-color-picker__trigger) {
width: 100%;
}
</style>
@@ -0,0 +1,343 @@
<script setup lang="ts">
import type { LineSeriesStyleConfig } from '../../../types/chartOptionTypes';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { getThemeColor as getThemeColorFn } from '../../../constants/chartTheme';
const props = defineProps<{
modelValue: LineSeriesStyleConfig[];
seriesNames: string[];
showGradient?: boolean; //
themeColors?: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: LineSeriesStyleConfig[]): void;
}>();
//
const getThemeColor = (index: number) =>
getThemeColorFn(props.themeColors, index);
// 线
const lineTypeOptions = [
{ label: 'solid', value: 'solid' },
{ label: 'dashed', value: 'dashed' },
{ label: 'dotted', value: 'dotted' },
];
//
const positionOptions = [
{ label: 'top', value: 'top' },
{ label: 'inside', value: 'inside' },
{ label: 'insideTop', value: 'insideTop' },
{ label: 'insideBottom', value: 'insideBottom' },
{ label: 'left', value: 'left' },
{ label: 'right', value: 'right' },
];
//
const updateSeriesConfig = (index: number, path: string[], value: any) => {
const newConfigs = JSON.parse(
JSON.stringify(props.modelValue),
) as LineSeriesStyleConfig[];
let current: Record<string, any> = newConfigs[index] as Record<string, any>;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i] as string] as Record<string, any>;
}
current[path[path.length - 1] as string] = value;
emit('update:modelValue', newConfigs);
};
//
const getSeriesTitle = (index: number) => {
return props.seriesNames[index] || `折线图-${index + 1}`;
};
</script>
<template>
<ElCollapse>
<ElCollapseItem
v-for="(config, index) in modelValue"
:key="index"
:name="`lineSeries-${index}`"
>
<template #title>
<span>{{ getSeriesTitle(index) }}</span>
</template>
<div class="config-section">
<!-- 线条配置 -->
<div class="config-group">
<div class="group-title">线条</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.lineWidth"
:min="1"
:max="20"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['lineWidth'], $event ?? 3)
"
/>
<div class="config-hint">宽度</div>
</div>
<div class="config-item">
<ElSelect
:model-value="config.lineType"
@update:model-value="
updateSeriesConfig(index, ['lineType'], $event)
"
>
<ElOption
v-for="opt in lineTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">类型</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="config.color || getThemeColor(index)"
@update:model-value="
updateSeriesConfig(index, ['color'], $event || '')
"
/>
<button
v-if="config.color"
class="clear-btn"
title="恢复主题色"
@click="updateSeriesConfig(index, ['color'], '')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">
{{ showGradient ? '起始颜色' : '颜色' }}
</div>
</div>
<div v-if="showGradient" class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="config.gradientColor"
@update:model-value="
updateSeriesConfig(index, ['gradientColor'], $event || '')
"
/>
<button
v-if="config.gradientColor"
class="clear-btn"
title="清除渐变色"
@click="updateSeriesConfig(index, ['gradientColor'], '')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">渐变颜色</div>
</div>
</div>
</div>
<!-- 实心点配置 -->
<div class="config-group">
<div class="group-title">实心点</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.symbolSize"
:min="0"
:max="30"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, ['symbolSize'], $event ?? 5)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
</div>
<!-- 标签配置 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">标签</span>
<ElSwitch
:model-value="config.label.show"
size="small"
@update:model-value="
updateSeriesConfig(index, ['label', 'show'], $event)
"
/>
</div>
<template v-if="config.label.show">
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.label.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateSeriesConfig(
index,
['label', 'fontSize'],
$event ?? 12,
)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="config.label.color"
@update:model-value="
updateSeriesConfig(
index,
['label', 'color'],
$event || '',
)
"
/>
<button
v-if="config.label.color && config.label.color !== '#fff'"
class="clear-btn"
title="恢复默认"
@click="
updateSeriesConfig(index, ['label', 'color'], '#fff')
"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElSelect
:model-value="config.label.position"
@update:model-value="
updateSeriesConfig(index, ['label', 'position'], $event)
"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
</div>
</template>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-color-picker) {
width: 100%;
}
:deep(.el-color-picker__trigger) {
width: 100%;
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
</style>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElForm,
ElFormItem,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import { getThemeColor } from '../../../constants/chartTheme';
export interface LiquidChartConfigType {
value: number;
shape:
| 'arrow'
| 'circle'
| 'diamond'
| 'pin'
| 'rect'
| 'roundRect'
| 'triangle';
fontSize: number;
color1: string;
color2: string;
backgroundColor: string;
}
const props = defineProps<{
modelValue: LiquidChartConfigType;
themeColors?: string[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: LiquidChartConfigType];
}>();
//
const defaultColor1 = computed(() => getThemeColor(props.themeColors, 0));
const defaultColor2 = computed(() => getThemeColor(props.themeColors, 1));
const shapeOptions = [
{ label: '圆形', value: 'circle' },
{ label: '矩形', value: 'rect' },
{ label: '圆角矩形', value: 'roundRect' },
{ label: '三角形', value: 'triangle' },
{ label: '菱形', value: 'diamond' },
{ label: '水滴', value: 'pin' },
{ label: '箭头', value: 'arrow' },
];
const updateConfig = <K extends keyof LiquidChartConfigType>(
key: K,
value: LiquidChartConfigType[K],
) => {
emit('update:modelValue', { ...props.modelValue, [key]: value });
};
</script>
<template>
<ElCollapse :model-value="['liquid']">
<ElCollapseItem title="水球" name="liquid">
<ElForm label-position="top" size="small">
<div class="grid grid-cols-2 gap-2">
<ElFormItem label="数值">
<ElInputNumber
:model-value="modelValue.value"
:min="0"
:max="1"
:step="0.01"
:precision="2"
controls-position="right"
@update:model-value="updateConfig('value', $event ?? 0.5)"
/>
</ElFormItem>
<ElFormItem label="形状">
<ElSelect
:model-value="modelValue.shape"
@update:model-value="updateConfig('shape', $event)"
>
<ElOption
v-for="opt in shapeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="文本">
<ElInputNumber
:model-value="modelValue.fontSize"
:min="12"
:max="100"
controls-position="right"
@update:model-value="updateConfig('fontSize', $event ?? 50)"
/>
</ElFormItem>
<ElFormItem label="颜色1">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.color1 || defaultColor1"
show-alpha
@update:model-value="updateConfig('color1', $event || '')"
/>
<button
v-if="modelValue.color1"
class="clear-btn"
title="恢复主题色"
@click="updateConfig('color1', '')"
>
<span class="clear-icon">×</span>
</button>
</div>
</ElFormItem>
<ElFormItem label="颜色2">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.color2 || defaultColor2"
show-alpha
@update:model-value="updateConfig('color2', $event || '')"
/>
<button
v-if="modelValue.color2"
class="clear-btn"
title="恢复主题色"
@click="updateConfig('color2', '')"
>
<span class="clear-icon">×</span>
</button>
</div>
</ElFormItem>
<ElFormItem label="背景">
<ElColorPicker
:model-value="modelValue.backgroundColor"
show-alpha
@update:model-value="
updateConfig(
'backgroundColor',
$event || 'rgba(68, 181, 226, 0.3)',
)
"
/>
</ElFormItem>
</div>
</ElForm>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
</style>
@@ -0,0 +1,907 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
export interface MapChartConfigType {
adcode?: string;
//
areaColor0?: string;
areaColor100?: string;
//
shadowColor?: string;
shadowBlur?: number;
shadowOffsetX?: number;
shadowOffsetY?: number;
//
labelShow?: boolean;
labelColor?: string;
labelFontSize?: number;
//
emphasisDisabled?: boolean;
emphasisColor?: string;
emphasisFontSize?: number;
emphasisShadowColor?: string;
emphasisBorderWidth?: number;
emphasisLabelColor?: string;
//
tooltipShow?: boolean;
tooltipFontSize?: number;
tooltipColor?: string;
tooltipBgColor?: string;
//
borderColor?: string;
borderWidth?: number;
//
markerSize?: number;
markerColor?: string;
//
markerLabelShow?: boolean;
markerLabelFontSize?: number;
markerLabelColor?: string;
//
rippleScale?: number;
rippleColor?: string;
rippleBrushType?: 'fill' | 'stroke';
// 线
flylineSpeed?: number;
flylineTrailLength?: number;
flylineSymbolSize?: number;
// 线
flylineColor?: string;
flylineWidth?: number;
//
visualMapShow?: boolean;
visualMapMin?: number;
visualMapMax?: number;
visualMapColors?: string[];
visualMapOrient?: 'horizontal' | 'vertical';
visualMapWidth?: number;
visualMapHeight?: number;
visualMapInverse?: boolean;
visualMapRealtime?: boolean;
visualMapLeft?: number;
visualMapTop?: number;
}
const props = defineProps<{
modelValue: MapChartConfigType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: MapChartConfigType): void;
}>();
//
const updateConfig = (key: keyof MapChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
//
const defaultColors = [
'#c3d7df',
'#5cb3cc',
'#8abcd1',
'#66a9c9',
'#2f90b9',
'#1781b5',
];
//
const updateVisualMapColor = (index: number, color: null | string) => {
const currentColors: string[] = [
...(props.modelValue.visualMapColors || defaultColors),
];
currentColors[index] = color || defaultColors[index] || '#c3d7df';
emit('update:modelValue', {
...props.modelValue,
visualMapColors: currentColors,
});
};
//
const mapOptions = [
{ label: '中国', value: 'china' },
{ label: '北京', value: '110000' },
{ label: '天津', value: '120000' },
{ label: '河北', value: '130000' },
{ label: '山西', value: '140000' },
{ label: '内蒙古', value: '150000' },
{ label: '辽宁', value: '210000' },
{ label: '吉林', value: '220000' },
{ label: '黑龙江', value: '230000' },
{ label: '上海', value: '310000' },
{ label: '江苏', value: '320000' },
{ label: '浙江', value: '330000' },
{ label: '安徽', value: '340000' },
{ label: '福建', value: '350000' },
{ label: '江西', value: '360000' },
{ label: '山东', value: '370000' },
{ label: '河南', value: '410000' },
{ label: '湖北', value: '420000' },
{ label: '湖南', value: '430000' },
{ label: '广东', value: '440000' },
{ label: '广西', value: '450000' },
{ label: '海南', value: '460000' },
{ label: '重庆', value: '500000' },
{ label: '四川', value: '510000' },
{ label: '贵州', value: '520000' },
{ label: '云南', value: '530000' },
{ label: '西藏', value: '540000' },
{ label: '陕西', value: '610000' },
{ label: '甘肃', value: '620000' },
{ label: '青海', value: '630000' },
{ label: '宁夏', value: '640000' },
{ label: '新疆', value: '650000' },
{ label: '台湾', value: '710000' },
{ label: '香港', value: '810000' },
{ label: '澳门', value: '820000' },
];
</script>
<template>
<ElCollapse>
<ElCollapseItem name="map" title="地图">
<div class="config-section">
<!-- 地图区域 -->
<div class="config-group">
<div class="group-title">地图区域</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.adcode ?? 'china'"
filterable
@update:model-value="updateConfig('adcode', $event)"
>
<ElOption
v-for="opt in mapOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">默认中国</div>
</div>
</div>
</div>
<!-- 区域颜色 -->
<div class="config-group">
<div class="group-title">区域颜色</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.areaColor0 || '#93ebf800'"
show-alpha
@update:model-value="
updateConfig('areaColor0', $event || '#93ebf800')
"
/>
<div class="config-hint">0%处颜色</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.areaColor100 || '#93ebf820'"
show-alpha
@update:model-value="
updateConfig('areaColor100', $event || '#93ebf820')
"
/>
<div class="config-hint">100%处颜色</div>
</div>
</div>
</div>
<!-- 阴影 -->
<div class="config-group">
<div class="group-title">阴影</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.shadowColor || '#80D9F842'"
show-alpha
@update:model-value="
updateConfig('shadowColor', $event || '#80D9F842')
"
/>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.shadowBlur ?? 10"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('shadowBlur', $event ?? 10)"
/>
<div class="config-hint">模糊程度</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.shadowOffsetX ?? -2"
:min="-50"
:max="50"
controls-position="right"
@update:model-value="
updateConfig('shadowOffsetX', $event ?? -2)
"
/>
<div class="config-hint">水平偏移</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.shadowOffsetY ?? 2"
:min="-50"
:max="50"
controls-position="right"
@update:model-value="updateConfig('shadowOffsetY', $event ?? 2)"
/>
<div class="config-hint">垂直偏移</div>
</div>
</div>
</div>
<!-- 地理信息名称 -->
<div class="config-group">
<div class="group-title">地理信息名称</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.labelShow ?? false"
size="small"
@update:model-value="updateConfig('labelShow', $event)"
/>
<span class="switch-label">显示</span>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.labelColor || '#FFFFFF'"
@update:model-value="
updateConfig('labelColor', $event || '#FFFFFF')
"
/>
<div class="config-hint">字体颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.labelFontSize ?? 12"
:min="8"
:max="36"
controls-position="right"
@update:model-value="
updateConfig('labelFontSize', $event ?? 12)
"
/>
<div class="config-hint">字体大小</div>
</div>
</div>
</div>
<!-- 鼠标悬停聚焦 -->
<div class="config-group">
<div class="group-title">鼠标悬停聚焦</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.emphasisDisabled ?? false"
size="small"
@update:model-value="updateConfig('emphasisDisabled', $event)"
/>
<span class="switch-label">禁用预览可见</span>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.emphasisColor || '#389BB7'"
@update:model-value="
updateConfig('emphasisColor', $event || '#389BB7')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.emphasisFontSize ?? 12"
:min="8"
:max="36"
controls-position="right"
@update:model-value="
updateConfig('emphasisFontSize', $event ?? 12)
"
/>
<div class="config-hint">字体大小</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.emphasisShadowColor || '#389BB7'"
@update:model-value="
updateConfig('emphasisShadowColor', $event || '#389BB7')
"
/>
<div class="config-hint">阴影</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.emphasisBorderWidth ?? 1"
:min="0"
:max="10"
controls-position="right"
@update:model-value="
updateConfig('emphasisBorderWidth', $event ?? 1)
"
/>
<div class="config-hint">边框大小</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.emphasisLabelColor || '#FFFFFF'"
@update:model-value="
updateConfig('emphasisLabelColor', $event || '#FFFFFF')
"
/>
<div class="config-hint">文字颜色</div>
</div>
</div>
</div>
<!-- 悬浮弹窗 -->
<div class="config-group">
<div class="group-title">悬浮弹窗</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.tooltipShow ?? true"
size="small"
@update:model-value="updateConfig('tooltipShow', $event)"
/>
<span class="switch-label">显示</span>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.tooltipFontSize ?? 12"
:min="8"
:max="36"
controls-position="right"
@update:model-value="
updateConfig('tooltipFontSize', $event ?? 12)
"
/>
<div class="config-hint">字体大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.tooltipColor || '#FFFFFF'"
@update:model-value="
updateConfig('tooltipColor', $event || '#FFFFFF')
"
/>
<div class="config-hint">字体颜色</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.tooltipBgColor || '#00000060'"
show-alpha
@update:model-value="
updateConfig('tooltipBgColor', $event || '#00000060')
"
/>
<div class="config-hint">背景颜色</div>
</div>
</div>
</div>
<!-- 区域边框 -->
<div class="config-group">
<div class="group-title">区域边框</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.borderColor || '#93EBF8'"
@update:model-value="
updateConfig('borderColor', $event || '#93EBF8')
"
/>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.borderWidth ?? 1"
:min="0"
:max="10"
controls-position="right"
@update:model-value="updateConfig('borderWidth', $event ?? 1)"
/>
<div class="config-hint">宽度大小</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 标记 -->
<ElCollapseItem name="marker" title="标记">
<div class="config-section">
<!-- 样式 -->
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.markerSize ?? 4"
:min="1"
:max="20"
controls-position="right"
@update:model-value="updateConfig('markerSize', $event ?? 4)"
/>
<div class="config-hint">大小</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.markerColor || '#FFFFFF'"
@update:model-value="
updateConfig('markerColor', $event || '#FFFFFF')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
<!-- 文本 -->
<div class="config-group">
<div class="group-title">文本</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.markerLabelShow ?? true"
size="small"
@update:model-value="updateConfig('markerLabelShow', $event)"
/>
<span class="switch-label">显示</span>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.markerLabelFontSize ?? 11"
:min="8"
:max="24"
controls-position="right"
@update:model-value="
updateConfig('markerLabelFontSize', $event ?? 11)
"
/>
<div class="config-hint">字体大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.markerLabelColor || '#FFFFFF'"
@update:model-value="
updateConfig('markerLabelColor', $event || '#FFFFFF')
"
/>
<div class="config-hint">字体颜色</div>
</div>
</div>
</div>
<!-- 涟漪 -->
<div class="config-group">
<div class="group-title">涟漪</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.rippleScale ?? 6"
:min="1"
:max="20"
controls-position="right"
@update:model-value="updateConfig('rippleScale', $event ?? 6)"
/>
<div class="config-hint">涟漪大小</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.rippleColor || '#FFFFFF'"
@update:model-value="
updateConfig('rippleColor', $event || '#FFFFFF')
"
/>
<div class="config-hint">涟漪颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.rippleBrushType ?? 'fill'"
@update:model-value="updateConfig('rippleBrushType', $event)"
>
<ElOption label="实心" value="fill" />
<ElOption label="空心" value="stroke" />
</ElSelect>
<div class="config-hint">涟漪的绘制方式</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 飞线 -->
<ElCollapseItem name="flyline" title="飞线">
<div class="config-section">
<!-- 箭头 -->
<div class="config-group">
<div class="group-title">箭头</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flylineSpeed ?? 4"
:min="1"
:max="20"
:step="1"
controls-position="right"
@update:model-value="updateConfig('flylineSpeed', $event ?? 4)"
/>
<div class="config-hint">速度</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flylineTrailLength ?? 0.4"
:min="0"
:max="1"
:step="0.1"
:precision="1"
controls-position="right"
@update:model-value="
updateConfig('flylineTrailLength', $event ?? 0.4)
"
/>
<div class="config-hint">尾迹</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flylineSymbolSize ?? 7"
:min="1"
:max="20"
controls-position="right"
@update:model-value="
updateConfig('flylineSymbolSize', $event ?? 7)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
</div>
<!-- 配置 -->
<div class="config-group">
<div class="group-title">配置</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.flylineColor || '#4fb6d2'"
@update:model-value="
updateConfig('flylineColor', $event || '#4fb6d2')
"
/>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flylineWidth ?? 1"
:min="1"
:max="10"
controls-position="right"
@update:model-value="updateConfig('flylineWidth', $event ?? 1)"
/>
<div class="config-hint">宽度</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 视觉映射 -->
<ElCollapseItem name="visualMap">
<template #title>
<div class="collapse-title">
<span>视觉映射</span>
<ElSwitch
:model-value="modelValue.visualMapShow ?? true"
size="small"
@click.stop
@update:model-value="updateConfig('visualMapShow', $event)"
/>
</div>
</template>
<div class="config-section">
<template v-if="modelValue.visualMapShow !== false">
<!-- 范围 -->
<div class="config-group">
<div class="group-title">范围</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.visualMapMin ?? 0"
placeholder="请输入"
controls-position="right"
@update:model-value="
updateConfig('visualMapMin', $event ?? 0)
"
/>
<div class="config-hint">最小值</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.visualMapMax ?? 100"
placeholder="请输入"
controls-position="right"
@update:model-value="
updateConfig('visualMapMax', $event ?? 100)
"
/>
<div class="config-hint">最大值</div>
</div>
</div>
</div>
<!-- 颜色 -->
<div class="config-group">
<div class="group-title">颜色</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="
(modelValue.visualMapColors || defaultColors)[0]
"
@update:model-value="updateVisualMapColor(0, $event)"
/>
<div class="config-hint">层级-1</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="
(modelValue.visualMapColors || defaultColors)[1]
"
@update:model-value="updateVisualMapColor(1, $event)"
/>
<div class="config-hint">层级-2</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="
(modelValue.visualMapColors || defaultColors)[2]
"
@update:model-value="updateVisualMapColor(2, $event)"
/>
<div class="config-hint">层级-3</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="
(modelValue.visualMapColors || defaultColors)[3]
"
@update:model-value="updateVisualMapColor(3, $event)"
/>
<div class="config-hint">层级-4</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="
(modelValue.visualMapColors || defaultColors)[4]
"
@update:model-value="updateVisualMapColor(4, $event)"
/>
<div class="config-hint">层级-5</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="
(modelValue.visualMapColors || defaultColors)[5]
"
@update:model-value="updateVisualMapColor(5, $event)"
/>
<div class="config-hint">层级-6</div>
</div>
</div>
</div>
<!-- 控制块 -->
<div class="config-group">
<div class="group-title">控制块</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.visualMapOrient ?? 'vertical'"
@update:model-value="updateConfig('visualMapOrient', $event)"
>
<ElOption label="竖直" value="vertical" />
<ElOption label="水平" value="horizontal" />
</ElSelect>
<div class="config-hint">放置方向</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.visualMapWidth"
placeholder="请输入"
controls-position="right"
@update:model-value="updateConfig('visualMapWidth', $event)"
/>
<div class="config-hint">宽度</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.visualMapHeight"
placeholder="请输入"
controls-position="right"
@update:model-value="updateConfig('visualMapHeight', $event)"
/>
<div class="config-hint">高度</div>
</div>
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.visualMapInverse ?? false"
size="small"
@update:model-value="updateConfig('visualMapInverse', $event)"
/>
<span class="switch-label">反转</span>
</div>
</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.visualMapRealtime ?? false"
size="small"
@update:model-value="
updateConfig('visualMapRealtime', $event)
"
/>
<span class="switch-label">拖拽组件实时更新</span>
</div>
</div>
</div>
<!-- 位置 -->
<div class="config-group">
<div class="group-title">位置</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.visualMapLeft ?? 0"
placeholder="请输入"
controls-position="right"
@update:model-value="
updateConfig('visualMapLeft', $event ?? 0)
"
/>
<div class="config-hint">
偏移 X: {{ modelValue.visualMapLeft ?? 0 }}px
</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.visualMapTop ?? 0"
placeholder="请输入"
controls-position="right"
@update:model-value="
updateConfig('visualMapTop', $event ?? 0)
"
/>
<div class="config-hint">
偏移 Y: {{ modelValue.visualMapTop ?? 0 }}px
</div>
</div>
</div>
</div>
</template>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
padding: 0 8px;
}
.config-group {
margin-bottom: 12px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.group-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.collapse-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 12px;
}
.sub-title {
margin: 8px 0 6px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.slider-row {
flex-direction: column;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-slider) {
padding: 0 8px;
}
</style>
@@ -0,0 +1,409 @@
<script setup lang="ts">
import type { PieStyleConfig } from '../../../types/chartOptionTypes';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
modelValue: PieStyleConfig;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: PieStyleConfig): void;
}>();
//
const typeOptions = [
{ label: '饼图', value: 'pie' },
{ label: '环形图', value: 'ring' },
{ label: '玫瑰图', value: 'rose' },
];
//
const positionOptions = [
{ label: '中心', value: 'center' },
{ label: '内部', value: 'inside' },
{ label: '外部', value: 'outside' },
];
//
const displayTypeOptions = [
{ label: '数据名', value: 'name' },
{ label: '数据值', value: 'value' },
{ label: '百分比', value: 'percent' },
{ label: '名称+值', value: 'nameValue' },
{ label: '名称+百分比', value: 'namePercent' },
];
//
const fontWeightOptions = [
{ label: '正常', value: 'normal' },
{ label: '加粗', value: 'bold' },
];
//
const updateConfig = (path: string[], value: any) => {
const newConfig = JSON.parse(JSON.stringify(props.modelValue)) as Record<
string,
any
>;
let current: Record<string, any> = newConfig;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i] as string] as Record<string, any>;
}
current[path[path.length - 1] as string] = value;
emit('update:modelValue', newConfig as PieStyleConfig);
};
</script>
<template>
<ElCollapse>
<ElCollapseItem title="饼图配置" name="pieConfig">
<div class="config-section">
<!-- 类型 -->
<div class="config-group">
<div class="group-title">类型</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.type"
@update:model-value="updateConfig(['type'], $event)"
>
<ElOption
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</div>
</div>
</div>
<!-- 动画 -->
<div class="config-group">
<div class="group-title-row">
<span class="group-title">动画</span>
<ElSwitch
:model-value="modelValue.animation"
size="small"
@update:model-value="updateConfig(['animation'], $event)"
/>
</div>
<div class="config-hint">开启将自动隐藏图例</div>
<div class="config-hint">无鼠标点击图例场景时可强行打开图例</div>
</div>
<!-- 图形 -->
<div class="config-group">
<div class="group-title">图形</div>
<div class="config-row">
<div class="config-item">
<ElInput
:model-value="modelValue.shape.innerRadius"
placeholder="40%"
@update:model-value="
updateConfig(['shape', 'innerRadius'], $event)
"
/>
<div class="config-hint">内圈范围</div>
</div>
<div class="config-item">
<ElInput
:model-value="modelValue.shape.outerRadius"
placeholder="65%"
@update:model-value="
updateConfig(['shape', 'outerRadius'], $event)
"
/>
<div class="config-hint">外圈范围</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInput
:model-value="modelValue.shape.centerX"
placeholder="50%"
@update:model-value="updateConfig(['shape', 'centerX'], $event)"
/>
<div class="config-hint">X轴中心</div>
</div>
<div class="config-item">
<ElInput
:model-value="modelValue.shape.centerY"
placeholder="60%"
@update:model-value="updateConfig(['shape', 'centerY'], $event)"
/>
<div class="config-hint">Y轴中心</div>
</div>
</div>
</div>
<!-- 标签 -->
<div class="config-group">
<div class="group-title">标签</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.label.show"
size="small"
@update:model-value="updateConfig(['label', 'show'], $event)"
/>
<div class="config-hint">展示标签</div>
</div>
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.label.showLine"
size="small"
@update:model-value="
updateConfig(['label', 'showLine'], $event)
"
/>
<div class="config-hint">引导线</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.label.position"
@update:model-value="
updateConfig(['label', 'position'], $event)
"
>
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">位置</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.label.displayType"
@update:model-value="
updateConfig(['label', 'displayType'], $event)
"
>
<ElOption
v-for="opt in displayTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">展示类型</div>
</div>
</div>
</div>
<!-- 字体 -->
<div class="config-group">
<div class="group-title">字体</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.font.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateConfig(['font', 'fontSize'], $event ?? 14)
"
/>
<div class="config-hint">大小</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.font.color"
@update:model-value="
updateConfig(['font', 'color'], $event || '#454E54')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.font.fontWeight"
@update:model-value="
updateConfig(['font', 'fontWeight'], $event)
"
>
<ElOption
v-for="opt in fontWeightOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">文字加粗</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.font.borderWidth"
:min="0"
:max="10"
controls-position="right"
@update:model-value="
updateConfig(['font', 'borderWidth'], $event ?? 1)
"
/>
<div class="config-hint">文字边框大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.font.borderColor"
@update:model-value="
updateConfig(['font', 'borderColor'], $event || '#ffffff')
"
/>
<div class="config-hint">文字边框色</div>
</div>
</div>
</div>
<!-- 分段样式 -->
<div class="config-group">
<div class="group-title">分段样式</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.segment.borderRadius"
:min="0"
:max="50"
controls-position="right"
@update:model-value="
updateConfig(['segment', 'borderRadius'], $event ?? 10)
"
/>
<div class="config-hint">圆角大小</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.segment.borderWidth"
:min="0"
:max="10"
controls-position="right"
@update:model-value="
updateConfig(['segment', 'borderWidth'], $event ?? 2)
"
/>
<div class="config-hint">线条宽度</div>
</div>
</div>
</div>
<!-- 鼠标聚焦 -->
<div class="config-group">
<div class="group-title">鼠标聚焦</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.emphasis.fontSize"
:min="8"
:max="48"
controls-position="right"
@update:model-value="
updateConfig(['emphasis', 'fontSize'], $event ?? 14)
"
/>
<div class="config-hint">文字大小</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.emphasis.fontWeight"
@update:model-value="
updateConfig(['emphasis', 'fontWeight'], $event)
"
>
<ElOption
v-for="opt in fontWeightOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">文字加粗</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.switch-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-color-picker) {
width: 100%;
}
:deep(.el-color-picker__trigger) {
width: 100%;
}
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSlider,
ElSwitch,
} from 'element-plus';
export interface RadarChartConfigType {
showBackground?: boolean;
showSplitLine?: boolean;
shape?: 'circle' | 'polygon';
showAxisLine?: boolean;
showAxisTick?: boolean;
minRadius?: number;
maxRadius?: number;
centerX?: number;
centerY?: number;
indicatorColor?: string;
indicatorSize?: number;
showLabel?: boolean;
areaOpacity?: number;
}
const props = defineProps<{
modelValue: RadarChartConfigType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: RadarChartConfigType): void;
}>();
//
const updateConfig = (key: keyof RadarChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
//
const shapeOptions = [
{ label: '多边形', value: 'polygon' },
{ label: '圆形', value: 'circle' },
];
</script>
<template>
<ElCollapse>
<ElCollapseItem name="radar" title="雷达">
<div class="config-section">
<!-- 样式配置 -->
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showBackground ?? true"
size="small"
@update:model-value="updateConfig('showBackground', $event)"
/>
<span class="switch-label">背景</span>
</div>
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showSplitLine ?? true"
size="small"
@update:model-value="updateConfig('showSplitLine', $event)"
/>
<span class="switch-label">分割线</span>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.shape ?? 'polygon'"
@update:model-value="updateConfig('shape', $event)"
>
<ElOption
v-for="opt in shapeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">雷达形状</div>
</div>
</div>
</div>
<!-- 坐标轴配置 -->
<div class="config-group">
<div class="group-title">坐标轴</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showAxisLine ?? true"
size="small"
@update:model-value="updateConfig('showAxisLine', $event)"
/>
<span class="switch-label">轴线</span>
</div>
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showAxisTick ?? true"
size="small"
@update:model-value="updateConfig('showAxisTick', $event)"
/>
<span class="switch-label">刻度</span>
</div>
</div>
</div>
<!-- 范围配置 -->
<div class="config-group">
<div class="group-title">范围</div>
<div class="config-row slider-row">
<div class="config-item">
<ElSlider
:model-value="modelValue.minRadius ?? 0"
:min="0"
:max="50"
:format-tooltip="(val: number) => `${val}%`"
@update:model-value="updateConfig('minRadius', $event)"
/>
<div class="config-hint">
最小值: {{ modelValue.minRadius ?? 0 }}%
</div>
</div>
<div class="config-item">
<ElSlider
:model-value="modelValue.maxRadius ?? 60"
:min="30"
:max="100"
:format-tooltip="(val: number) => `${val}%`"
@update:model-value="updateConfig('maxRadius', $event)"
/>
<div class="config-hint">
最大值: {{ modelValue.maxRadius ?? 60 }}%
</div>
</div>
</div>
</div>
<!-- 偏移配置 -->
<div class="config-group">
<div class="group-title">偏移</div>
<div class="config-row slider-row">
<div class="config-item">
<ElSlider
:model-value="modelValue.centerX ?? 50"
:min="0"
:max="100"
:format-tooltip="(val: number) => `${val}%`"
@update:model-value="updateConfig('centerX', $event)"
/>
<div class="config-hint">
X 轴值: {{ modelValue.centerX ?? 50 }}%
</div>
</div>
<div class="config-item">
<ElSlider
:model-value="modelValue.centerY ?? 50"
:min="0"
:max="100"
:format-tooltip="(val: number) => `${val}%`"
@update:model-value="updateConfig('centerY', $event)"
/>
<div class="config-hint">
Y 轴值: {{ modelValue.centerY ?? 50 }}%
</div>
</div>
</div>
</div>
<!-- 指示器配置 -->
<div class="config-group">
<div class="group-title">指示器</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.indicatorColor || '#eee'"
@update:model-value="
updateConfig('indicatorColor', $event || '#eee')
"
/>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.indicatorSize ?? 12"
:min="8"
:max="24"
controls-position="right"
@update:model-value="
updateConfig('indicatorSize', $event ?? 12)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showLabel ?? true"
size="small"
@update:model-value="updateConfig('showLabel', $event)"
/>
<span class="switch-label">文字标签</span>
</div>
</div>
</div>
<!-- 系列配置 -->
<div class="config-group">
<div class="group-title">系列</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.areaOpacity ?? 0.1"
:min="0"
:max="1"
:step="0.1"
:precision="1"
controls-position="right"
@update:model-value="updateConfig('areaOpacity', $event ?? 0.1)"
/>
<div class="config-hint">背景透明度</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
padding: 0 8px;
}
.config-group {
margin-bottom: 12px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.slider-row {
flex-direction: column;
gap: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-slider) {
padding: 0 8px;
}
</style>
@@ -0,0 +1,314 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
interface RingChartConfigType {
value: number;
titleColor: string;
fontSize: number;
ringColor: string;
ringShadowBlur: number;
ringShadowColor: string;
trackColor: string;
trackShadowBlur: number;
trackShadowColor: string;
ringWidth: number;
}
const props = defineProps<{
modelValue: RingChartConfigType;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: RingChartConfigType): void;
}>();
const ringWidthOptions = [
{ label: '窄', value: 8 },
{ label: '中', value: 12 },
{ label: '宽', value: 16 },
];
const updateConfig = (key: keyof RingChartConfigType, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse>
<!-- 数据 -->
<ElCollapseItem title="数据" name="data">
<div class="config-section">
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.value"
:min="0"
:max="1"
:step="0.01"
:precision="2"
controls-position="right"
@update:model-value="updateConfig('value', $event ?? 0)"
/>
<div class="config-hint">数值</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 标题 -->
<ElCollapseItem title="标题" name="title">
<div class="config-section">
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.titleColor"
@update:model-value="updateConfig('titleColor', $event || '')"
/>
<button
v-if="
modelValue.titleColor && modelValue.titleColor !== '#56B9F8'
"
class="clear-btn"
title="恢复默认"
@click="updateConfig('titleColor', '#56B9F8')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.fontSize"
:min="12"
:max="72"
controls-position="right"
@update:model-value="updateConfig('fontSize', $event ?? 30)"
/>
<div class="config-hint">字体大小</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 进度条 -->
<ElCollapseItem title="进度条" name="progress">
<div class="config-section">
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.ringColor"
@update:model-value="updateConfig('ringColor', $event || '')"
/>
<button
v-if="
modelValue.ringColor && modelValue.ringColor !== '#03a9f4'
"
class="clear-btn"
title="恢复默认"
@click="updateConfig('ringColor', '#03a9f4')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.ringShadowBlur"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('ringShadowBlur', $event ?? 10)"
/>
<div class="config-hint">阴影模糊等级</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.ringShadowColor"
@update:model-value="
updateConfig('ringShadowColor', $event || '')
"
/>
<button
v-if="
modelValue.ringShadowColor &&
modelValue.ringShadowColor !== '#97e2f5'
"
class="clear-btn"
title="恢复默认"
@click="updateConfig('ringShadowColor', '#97e2f5')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">阴影颜色</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 轨道 -->
<ElCollapseItem title="轨道" name="track">
<div class="config-section">
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.trackColor"
show-alpha
@update:model-value="updateConfig('trackColor', $event || '')"
/>
<button
v-if="
modelValue.trackColor && modelValue.trackColor !== '#00bcd44a'
"
class="clear-btn"
title="恢复默认"
@click="updateConfig('trackColor', '#00bcd44a')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">颜色</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.trackShadowBlur"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('trackShadowBlur', $event ?? 0)"
/>
<div class="config-hint">阴影模糊等级</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<div class="color-picker-wrap">
<ElColorPicker
:model-value="modelValue.trackShadowColor"
show-alpha
@update:model-value="
updateConfig('trackShadowColor', $event || '')
"
/>
<button
v-if="
modelValue.trackShadowColor &&
modelValue.trackShadowColor !== '#00bcd44a'
"
class="clear-btn"
title="恢复默认"
@click="updateConfig('trackShadowColor', '#00bcd44a')"
>
<span class="clear-icon">×</span>
</button>
</div>
<div class="config-hint">阴影颜色</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.ringWidth"
@update:model-value="updateConfig('ringWidth', $event)"
>
<ElOption
v-for="opt in ringWidthOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">轨道宽度</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-color-picker) {
width: 100%;
}
:deep(.el-color-picker__trigger) {
width: 100%;
}
.color-picker-wrap {
display: flex;
gap: 4px;
align-items: center;
}
.clear-btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.clear-btn:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-7);
}
.clear-icon {
font-size: 14px;
line-height: 1;
}
</style>
@@ -0,0 +1,270 @@
<script setup lang="ts">
import {
ElCollapse,
ElCollapseItem,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
export interface ScatterSeriesStyleConfig {
//
symbol: string;
symbolSize: number;
effectType: string;
// (markArea)
markAreaOpacity: number;
markAreaBorderWidth: number;
markAreaBorderType: string;
// (markPoint)
markPointSymbol: string;
markPointSize: number;
}
const props = defineProps<{
modelValue: ScatterSeriesStyleConfig[];
seriesNames: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: ScatterSeriesStyleConfig[]): void;
}>();
const effectTypeOptions = [
{ label: '无', value: 'none' },
{ label: '特效', value: 'ripple' },
];
const lineTypeOptions = [
{ label: '实线', value: 'solid' },
{ label: '虚线', value: 'dashed' },
{ label: '点线', value: 'dotted' },
];
const symbolOptions = [
{ label: '圆形', value: 'circle' },
{ label: '正方形', value: 'rect' },
{ label: '圆角正方形', value: 'roundRect' },
{ label: '三角形', value: 'triangle' },
{ label: '菱形', value: 'diamond' },
{ label: '水滴', value: 'pin' },
{ label: '箭头', value: 'arrow' },
{ label: '无', value: 'none' },
];
const updateSeriesConfig = (
index: number,
key: keyof ScatterSeriesStyleConfig,
value: any,
) => {
const newConfigs = JSON.parse(
JSON.stringify(props.modelValue),
) as ScatterSeriesStyleConfig[];
if (newConfigs[index]) {
(newConfigs[index] as Record<string, any>)[key] = value;
}
emit('update:modelValue', newConfigs);
};
const getSeriesTitle = (index: number) => {
return props.seriesNames[index] || `散点-${index + 1}`;
};
</script>
<template>
<ElCollapse>
<ElCollapseItem
v-for="(config, index) in modelValue"
:key="index"
:name="`scatterSeries-${index}`"
>
<template #title>
<span>{{ getSeriesTitle(index) }}</span>
</template>
<div class="config-section">
<!-- 样式 -->
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="config.symbol"
@update:model-value="
updateSeriesConfig(index, 'symbol', $event)
"
>
<ElOption
v-for="opt in symbolOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">形状</div>
</div>
<div class="config-item">
<ElSelect
:model-value="config.effectType"
@update:model-value="
updateSeriesConfig(index, 'effectType', $event)
"
>
<ElOption
v-for="opt in effectTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">特效</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.symbolSize"
:min="1"
:max="50"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, 'symbolSize', $event ?? 10)
"
/>
<div class="config-hint">大小</div>
</div>
</div>
</div>
<!-- 标域 (矩形区域) -->
<div class="config-group">
<div class="group-title">标域</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="config.markAreaOpacity"
:min="0"
:max="1"
:step="0.1"
:precision="1"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, 'markAreaOpacity', $event ?? 0)
"
/>
<div class="config-hint">透明度(0不显示)</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="config.markAreaBorderWidth"
:min="0"
:max="10"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, 'markAreaBorderWidth', $event ?? 0)
"
/>
<div class="config-hint">边框宽度</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="config.markAreaBorderType"
@update:model-value="
updateSeriesConfig(index, 'markAreaBorderType', $event)
"
>
<ElOption
v-for="opt in lineTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">边框类型</div>
</div>
</div>
</div>
<!-- 标点 (最大/最小值标注) -->
<div class="config-group">
<div class="group-title">标点</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="config.markPointSymbol"
@update:model-value="
updateSeriesConfig(index, 'markPointSymbol', $event)
"
>
<ElOption
v-for="opt in symbolOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="config-hint">形状</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="config.markPointSize"
:min="0"
:max="100"
controls-position="right"
@update:model-value="
updateSeriesConfig(index, 'markPointSize', $event ?? 0)
"
/>
<div class="config-hint">大小(0不显示)</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style scoped>
.config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.config-row {
display: flex;
gap: 12px;
}
.config-item {
flex: 1;
}
.config-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
} from 'element-plus';
const props = defineProps<{
modelValue: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void;
}>();
const activeNames = ref(['clock']);
const updateConfig = (key: string, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse v-model="activeNames">
<!-- 时钟 -->
<ElCollapseItem name="clock" title="时钟">
<div class="config-section">
<div class="config-group">
<div class="group-title">表盘</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.bgColor ?? '#84a5e9'"
show-alpha
@update:model-value="
updateConfig('bgColor', $event || '#84a5e9')
"
/>
<div class="config-hint">背景色</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.borderColor ?? '#ffffff'"
show-alpha
@update:model-value="
updateConfig('borderColor', $event || '#ffffff')
"
/>
<div class="config-hint">边框色</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.border ?? 6"
:min="0"
:max="20"
controls-position="right"
@update:model-value="updateConfig('border', $event ?? 6)"
/>
<div class="config-hint">边框大小</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">指针</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.color ?? '#ffffff'"
show-alpha
@update:model-value="updateConfig('color', $event || '#ffffff')"
/>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style lang="scss" scoped>
.config-section {
padding: 8px 0;
}
.config-group {
margin-bottom: 16px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,266 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElDatePicker,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
const props = defineProps<{
modelValue: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void;
}>();
const activeNames = ref(['countdown', 'flipper']);
const updateConfig = (key: string, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse v-model="activeNames">
<!-- 倒计时 -->
<ElCollapseItem name="countdown" title="倒计时">
<div class="config-section">
<div class="config-group">
<div class="group-title">内容</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.dataset ?? 600"
:min="0"
controls-position="right"
@update:model-value="updateConfig('dataset', $event ?? 600)"
/>
<div class="config-hint">计时()</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElDatePicker
:model-value="modelValue.endDate"
type="datetime"
placeholder="结束日期"
:disabled="!modelValue.useEndDate"
@update:model-value="
updateConfig(
'endDate',
$event ? new Date($event).getTime() : Date.now(),
)
"
/>
<div class="config-hint">结束日期</div>
</div>
</div>
<div class="config-row">
<div class="config-item switch-item">
<ElCheckbox
:model-value="modelValue.useEndDate ?? false"
@update:model-value="updateConfig('useEndDate', $event)"
/>
<span class="switch-label">使用固定结束日期</span>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.style ?? '时分秒'"
@update:model-value="updateConfig('style', $event)"
>
<ElOption label="时分秒" value="时分秒" />
<ElOption label="冒号" value="冒号" />
</ElSelect>
<div class="config-hint">风格</div>
</div>
<div class="config-item switch-item">
<ElCheckbox
:model-value="modelValue.showDay ?? false"
@update:model-value="updateConfig('showDay', $event)"
/>
<span class="switch-label">显示天</span>
</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 翻牌 -->
<ElCollapseItem name="flipper" title="翻牌">
<div class="config-section">
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperWidth ?? 30"
:min="10"
:max="200"
controls-position="right"
@update:model-value="updateConfig('flipperWidth', $event ?? 30)"
/>
<div class="config-hint">宽度</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperHeight ?? 50"
:min="10"
:max="300"
controls-position="right"
@update:model-value="
updateConfig('flipperHeight', $event ?? 50)
"
/>
<div class="config-hint">高度</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperGap ?? 10"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('flipperGap', $event ?? 10)"
/>
<div class="config-hint">间隔</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperRadius ?? 5"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('flipperRadius', $event ?? 5)"
/>
<div class="config-hint">圆角</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.flipperBgColor ?? '#16293E'"
show-alpha
@update:model-value="
updateConfig('flipperBgColor', $event || '#16293E')
"
/>
<div class="config-hint">背景色</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.flipperTextColor ?? '#4A9EF8FF'"
show-alpha
@update:model-value="
updateConfig('flipperTextColor', $event || '#4A9EF8FF')
"
/>
<div class="config-hint">字体色</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">行为</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.flipperType ?? 'down'"
@update:model-value="updateConfig('flipperType', $event)"
>
<ElOption label="下翻" value="down" />
<ElOption label="上翻" value="up" />
</ElSelect>
<div class="config-hint">动画</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperSpeed ?? 450"
:min="100"
:max="2000"
:step="50"
controls-position="right"
@update:model-value="
updateConfig('flipperSpeed', $event ?? 450)
"
/>
<div class="config-hint">翻牌速度(毫秒)</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style lang="scss" scoped>
.config-section {
padding: 8px 0;
}
.config-group {
margin-bottom: 16px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-date-editor) {
width: 100%;
}
</style>
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
const props = defineProps<{
modelValue: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void;
}>();
const activeNames = ref(['flipper']);
const updateConfig = (key: string, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse v-model="activeNames">
<!-- 翻牌 -->
<ElCollapseItem name="flipper" title="翻牌">
<div class="config-section">
<div class="config-group">
<div class="group-title">内容</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.dataset ?? 3234"
:min="0"
controls-position="right"
@update:model-value="updateConfig('dataset', $event ?? 3234)"
/>
<div class="config-hint">初始值</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperLength ?? 6"
:min="1"
:max="20"
controls-position="right"
@update:model-value="updateConfig('flipperLength', $event ?? 6)"
/>
<div class="config-hint">翻牌个数</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">样式</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperWidth ?? 30"
:min="10"
:max="200"
controls-position="right"
@update:model-value="updateConfig('flipperWidth', $event ?? 30)"
/>
<div class="config-hint">宽度</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperHeight ?? 50"
:min="10"
:max="300"
controls-position="right"
@update:model-value="
updateConfig('flipperHeight', $event ?? 50)
"
/>
<div class="config-hint">高度</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperBorderWidth ?? 0"
:min="0"
:max="20"
controls-position="right"
@update:model-value="
updateConfig('flipperBorderWidth', $event ?? 0)
"
/>
<div class="config-hint">边框</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperRadius ?? 5"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('flipperRadius', $event ?? 5)"
/>
<div class="config-hint">圆角</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperGap ?? 10"
:min="0"
:max="50"
controls-position="right"
@update:model-value="updateConfig('flipperGap', $event ?? 10)"
/>
<div class="config-hint">翻牌间隔</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.flipperBgColor ?? '#16293E'"
show-alpha
@update:model-value="
updateConfig('flipperBgColor', $event || '#16293E')
"
/>
<div class="config-hint">背景色</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.flipperTextColor ?? '#4A9EF8FF'"
show-alpha
@update:model-value="
updateConfig('flipperTextColor', $event || '#4A9EF8FF')
"
/>
<div class="config-hint">字体色</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">行为</div>
<div class="config-row">
<div class="config-item">
<ElSelect
:model-value="modelValue.flipperType ?? 'down'"
@update:model-value="updateConfig('flipperType', $event)"
>
<ElOption label="下翻" value="down" />
<ElOption label="上翻" value="up" />
</ElSelect>
<div class="config-hint">动画</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.flipperSpeed ?? 450"
:min="100"
:max="2000"
:step="50"
controls-position="right"
@update:model-value="
updateConfig('flipperSpeed', $event ?? 450)
"
/>
<div class="config-hint">翻牌速度(毫秒)</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style lang="scss" scoped>
.config-section {
padding: 8px 0;
}
.config-group {
margin-bottom: 16px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
</style>
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInput,
ElInputNumber,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
modelValue: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void;
}>();
const activeNames = ref(['content', 'animation']);
const updateConfig = (key: string, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse v-model="activeNames">
<!-- 内容 -->
<ElCollapseItem name="content" title="内容">
<div class="config-section">
<div class="config-group">
<div class="group-title">数值</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.dataset ?? 100000"
:min="0"
controls-position="right"
@update:model-value="updateConfig('dataset', $event ?? 100000)"
/>
<div class="config-hint">终点值</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.numberSize ?? 34"
:min="12"
:max="200"
controls-position="right"
@update:model-value="updateConfig('numberSize', $event ?? 34)"
/>
<div class="config-hint">大小</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.precision ?? 0"
:min="0"
:max="10"
controls-position="right"
@update:model-value="updateConfig('precision', $event ?? 0)"
/>
<div class="config-hint">精度</div>
</div>
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showSeparator ?? true"
@update:model-value="updateConfig('showSeparator', $event)"
/>
<span class="switch-label">展示分割符</span>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">数值</div>
<div class="config-row">
<div class="config-item">
<ElInput
:model-value="modelValue.prefixText ?? '¥'"
@update:model-value="updateConfig('prefixText', $event)"
/>
<div class="config-hint">前缀</div>
</div>
<div class="config-item">
<ElInput
:model-value="modelValue.suffixText ?? '元'"
@update:model-value="updateConfig('suffixText', $event)"
/>
<div class="config-hint">后缀</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">颜色</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.prefixColor ?? '#4a9ef8'"
show-alpha
@update:model-value="
updateConfig('prefixColor', $event || '#4a9ef8')
"
/>
<div class="config-hint">前缀</div>
</div>
<div class="config-item">
<ElColorPicker
:model-value="modelValue.suffixColor ?? '#4a9ef8'"
show-alpha
@update:model-value="
updateConfig('suffixColor', $event || '#4a9ef8')
"
/>
<div class="config-hint">后缀</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.numberColor ?? '#4a9ef8'"
show-alpha
@update:model-value="
updateConfig('numberColor', $event || '#4a9ef8')
"
/>
<div class="config-hint">数值</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
<!-- 动画 -->
<ElCollapseItem name="animation" title="动画">
<div class="config-section">
<div class="config-group">
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.dur ?? 3"
:min="0.1"
:max="30"
:step="0.1"
controls-position="right"
@update:model-value="updateConfig('dur', $event ?? 3)"
/>
<div class="config-hint">速度(s)</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style lang="scss" scoped>
.config-section {
padding: 8px 0;
}
.config-group {
margin-bottom: 16px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input) {
width: 100%;
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ElCollapse,
ElCollapseItem,
ElColorPicker,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
modelValue: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void;
}>();
const activeNames = ref(['content']);
const updateConfig = (key: string, value: any) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
};
</script>
<template>
<ElCollapse v-model="activeNames">
<!-- 内容 -->
<ElCollapseItem name="content" title="内容">
<div class="config-section">
<div class="config-group">
<div class="group-title">字体</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.timeSize ?? 24"
:min="12"
:max="200"
controls-position="right"
@update:model-value="updateConfig('timeSize', $event ?? 24)"
/>
<div class="config-hint">大小</div>
</div>
<div class="config-item">
<ElSelect
:model-value="modelValue.fontWeight ?? 'normal'"
@update:model-value="updateConfig('fontWeight', $event)"
>
<ElOption label="常规" value="normal" />
<ElOption label="加粗" value="bold" />
</ElSelect>
<div class="config-hint">粗细</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">间距</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.timeTextIndent ?? 2"
:min="0"
:max="50"
controls-position="right"
@update:model-value="
updateConfig('timeTextIndent', $event ?? 2)
"
/>
<div class="config-hint">字距</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.timeLineHeight ?? 50"
:min="10"
:max="200"
controls-position="right"
@update:model-value="
updateConfig('timeLineHeight', $event ?? 50)
"
/>
<div class="config-hint">行距</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">颜色</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.timeColor ?? '#E6F7FF'"
show-alpha
@update:model-value="
updateConfig('timeColor', $event || '#E6F7FF')
"
/>
<div class="config-hint">时间</div>
</div>
</div>
</div>
<div class="config-group">
<div class="group-title">阴影</div>
<div class="config-row">
<div class="config-item switch-item">
<ElSwitch
:model-value="modelValue.showShadow ?? true"
@update:model-value="updateConfig('showShadow', $event)"
/>
<span class="switch-label">展示阴影</span>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.hShadow ?? 0"
:min="-50"
:max="50"
controls-position="right"
@update:model-value="updateConfig('hShadow', $event ?? 0)"
/>
<div class="config-hint">x</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElInputNumber
:model-value="modelValue.vShadow ?? 0"
:min="-50"
:max="50"
controls-position="right"
@update:model-value="updateConfig('vShadow', $event ?? 0)"
/>
<div class="config-hint">y</div>
</div>
<div class="config-item">
<ElInputNumber
:model-value="modelValue.blurShadow ?? 8"
:min="0"
:max="100"
controls-position="right"
@update:model-value="updateConfig('blurShadow', $event ?? 8)"
/>
<div class="config-hint">模糊</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<ElColorPicker
:model-value="modelValue.colorShadow ?? '#0075ff'"
show-alpha
@update:model-value="
updateConfig('colorShadow', $event || '#0075ff')
"
/>
<div class="config-hint">颜色</div>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<style lang="scss" scoped>
.config-section {
padding: 8px 0;
}
.config-group {
margin-bottom: 16px;
}
.group-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.config-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.config-item {
flex: 1;
min-width: 0;
}
.switch-item {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
.switch-label {
font-size: 12px;
color: var(--el-text-color-regular);
}
.config-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-select) {
width: 100%;
}
</style>
File diff suppressed because it is too large Load Diff

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