Appearance
ts
const api = $inject.api;
const isOverseas = api.formData()?.isOverseas
console.log("isOverseas",isOverseas)
api.setValue({
regions:undefined
})
if(isOverseas=='1'){
console.log("111")
api.mergeRules({
customerLicense:{
"$required":undefined
},
regions:{
props:{
regionMode: 'foreign'
}
}
})
}else if(isOverseas=='0'){
console.log("222")
api.mergeRules({
customerLicense:{
"$required":"纳税识别号/企业标识ID不能为空"
},
regions:{
props:{
regionMode: 'zh'
}
}
})
}ts
const api = $inject.api;
// 获取顶级表单的API对象
const topFormApi = api.top;
console.log(api,'api')
const childApi = api.children;
// 重新加载顶级表单的规则
if (topFormApi) {
//跟进记录动态表单
const tableInstance = topFormApi.el('ref_Fzfymkgjlu1zhhc');
const tableApi = tableInstance.fapi;
console.log("tableInstance",tableInstance)
if(tableInstance){
const idddd = tableApi.formData() // 拿不到formData????
console.log(idddd,'kkkk',tableApi,tableApi.getValue('productIds'))
const followAddItemForms = topFormApi.formData()?.followAddItemForms ?? []
console.log("followAddItemForms",followAddItemForms)
const allIds = []
if(Array.isArray(followAddItemForms)){
for(let i = 0;i<followAddItemForms.length;i++){
const productIds = followAddItemForms[i]?.productIds ?? []
allIds.push(...productIds)
}
}
console.log("allIds",allIds)
//拿到具体的跟进产品的api
console.log("tableApi",tableApi)
tableApi.mergeRules({
productIds:{
props:{
filterIds: allIds
}
}
})
tableApi.setValue({
productIds:undefined
})
}
}ts
const api = $inject.api;
// 获取顶级表单的API对象
const topFormApi = api.top;
console.log(api,'api')
const childApi = api.children;
// 重新加载顶级表单的规则
if (topFormApi) {
//跟进记录动态表单
const tableInstance = topFormApi.el('ref_Fzfymkgjlu1zhhc');
const tableApi = tableInstance.fapi;
console.log("tableInstance",tableInstance)
if(tableInstance){
nextTick(() => {
const idddd = tableApi.formData() // 拿不到formData????
console.log(idddd,'kkkk',tableApi,tableApi.getValue('productIds'))
const followAddItemForms = topFormApi.formData()?.followAddItemForms ?? []
console.log("followAddItemForms",followAddItemForms)
const allIds = []
if(Array.isArray(followAddItemForms)){
for(let i = 0;i<followAddItemForms.length;i++){
const productIds = followAddItemForms[i]?.productIds ?? []
allIds.push(...productIds)
}
}
console.log("allIds",allIds)
//拿到具体的跟进产品的api
console.log("tableApi",tableApi)
tableApi.mergeRules({
productIds:{
props:{
filterIds: allIds
}
}
})
tableApi.setValue({
productIds:undefined
})
})
}
}
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import VueDraggableResizable from '/@/components/VueDraggable/vue-draggable-resizable.vue'
import { buildUUID } from '/@/utils/uuid'
// ========================
// 网格常量
// ========================
const COLS = 16 // 总列数
const ROW_H = 90 // 每行高度 px
const GAP = 10 // 间距 px
// ========================
// 组件数据结构
// ========================
interface WidgetItem {
id: string
title: string
col: number // 起始列 (0-based)
row: number // 起始行 (0-based)
colSpan: number // 占几列
rowSpan: number // 占几行
}
// ========================
// 容器尺寸
// ========================
const containerRef = ref<HTMLElement | null>(null)
const containerWidth = ref(0)
/** 每列像素宽度(不含间距) */
const cellW = computed(() =>
containerWidth.value > 0
? (containerWidth.value - GAP * (COLS + 1)) / COLS
: 0
)
// 像素 ↔ 网格互转
const colToX = (c: number) => GAP + c * (cellW.value + GAP)
const rowToY = (r: number) => GAP + r * (ROW_H + GAP)
const xToCol = (x: number) => Math.max(0, Math.min(COLS - 1, Math.round((x - GAP) / (cellW.value + GAP))))
const yToRow = (y: number) => Math.max(0, Math.round((y - GAP) / (ROW_H + GAP)))
const wToSpan = (w: number) => Math.max(1, Math.round((w + GAP) / (cellW.value + GAP)))
const hToSpan = (h: number) => Math.max(1, Math.round((h + GAP) / (ROW_H + GAP)))
const itemPxW = (item: WidgetItem) => item.colSpan * (cellW.value + GAP) - GAP
const itemPxH = (item: WidgetItem) => item.rowSpan * (ROW_H + GAP) - GAP
const itemX = (item: WidgetItem) => colToX(item.col)
const itemY = (item: WidgetItem) => rowToY(item.row)
// ========================
// 布局数据
// ========================
const widgets = ref<WidgetItem[]>([
{ id: buildUUID(), title: '销售概览', col: 0, row: 0, colSpan: 8, rowSpan: 1 },
{ id: buildUUID(), title: '订单统计', col: 8, row: 0, colSpan: 8, rowSpan: 1 },
{ id: buildUUID(), title: '用户活跃趋势', col: 0, row: 1, colSpan: 10, rowSpan: 2 },
{ id: buildUUID(), title: '待办事项', col: 10, row: 1, colSpan: 6, rowSpan: 2 },
])
// 用于强制 VDR 重建,解决 moveHorizontally snapToGrid 绝对坐标漂移问题
const layoutKey = ref(0)
const bumpKey = () => { layoutKey.value++ }
// ========================
// 布局算法
// ========================
/** 两组件是否在网格上重叠 */
function isOverlap(a: WidgetItem, b: WidgetItem): boolean {
return (
a.col < b.col + b.colSpan &&
a.col + a.colSpan > b.col &&
a.row < b.row + b.rowSpan &&
a.row + a.rowSpan > b.row
)
}
/**
* 布局计算:
* 1. 把 anchor(拖动项)锁在目标位置
* 2. 把与 anchor 冲突的项向下推
* 3. 重复碰撞检测直到稳定
* 4. 对所有项做向上 compact
*/
function resolveLayout(list: WidgetItem[], anchorId?: string): WidgetItem[] {
// 深拷贝,避免直接修改 reactive 对象
let items: WidgetItem[] = list.map(i => ({ ...i }))
// -- 步骤 1-3:先推走与 anchor 冲突的项(重复至稳定)--
if (anchorId) {
const MAX_ITER = 30
let changed = true
let iter = 0
while (changed && iter < MAX_ITER) {
changed = false
iter++
const anchor = items.find(i => i.id === anchorId)!
for (const item of items) {
if (item.id === anchorId) continue
if (isOverlap(anchor, item)) {
// 把被压项推到 anchor 下方
item.row = anchor.row + anchor.rowSpan
changed = true
}
}
// 推走后还可能互相冲突,再排查一遍
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
if (items[i].id === anchorId || items[j].id === anchorId) continue
if (isOverlap(items[i], items[j])) {
// 让行号大的那个再往下挪
const lower = items[i].row >= items[j].row ? items[i] : items[j]
const upper = lower === items[i] ? items[j] : items[i]
lower.row = upper.row + upper.rowSpan
changed = true
}
}
}
}
}
// -- 步骤 4:按行列排序后向上 compact --
items.sort((a, b) => a.row !== b.row ? a.row - b.row : a.col - b.col)
// 占格矩阵
const occupied = new Map<string, string>()
const mark = (item: WidgetItem) => {
for (let r = item.row; r < item.row + item.rowSpan; r++) {
for (let c = item.col; c < item.col + item.colSpan; c++) {
occupied.set(`${r}_${c}`, item.id)
}
}
}
const conflict = (item: WidgetItem, row: number): boolean => {
for (let r = row; r < row + item.rowSpan; r++) {
for (let c = item.col; c < item.col + item.colSpan; c++) {
if (c >= COLS) return true
const k = occupied.get(`${r}_${c}`)
if (k && k !== item.id) return true
}
}
return false
}
const compacted: WidgetItem[] = []
for (const item of items) {
let row = 0
while (conflict(item, row)) row++
const final = { ...item, row }
mark(final)
compacted.push(final)
}
return compacted
}
function applyLayout(anchorId?: string) {
widgets.value = resolveLayout(widgets.value, anchorId)
nextTick(bumpKey)
}
// ========================
// 容器总高度
// ========================
const containerHeight = computed(() => {
if (!widgets.value.length) return 300
const maxRow = Math.max(...widgets.value.map(w => w.row + w.rowSpan))
return maxRow * (ROW_H + GAP) + GAP
})
// ========================
// 拖拽 / Resize 事件
// ========================
// dragstop(left, top, customId)
function handleDragStop(left: number, top: number, id: string) {
const item = widgets.value.find(w => w.id === id)
if (!item) return
item.col = Math.max(0, Math.min(COLS - item.colSpan, xToCol(left)))
item.row = Math.max(0, yToRow(top))
applyLayout(id)
}
// resizestop(customId, left, top, width, height)
function handleResizeStop(id: string, _l: number, _t: number, width: number, height: number) {
const item = widgets.value.find(w => w.id === id)
if (!item) return
const newColSpan = Math.max(1, Math.min(COLS - item.col, wToSpan(width)))
const newRowSpan = Math.max(1, hToSpan(height))
item.colSpan = newColSpan
item.rowSpan = newRowSpan
applyLayout(id)
}
// ========================
// 激活状态
// ========================
const activeId = ref<string | undefined>()
function handleActivated(id: string) { activeId.value = id }
// ========================
// 添加 / 删除组件
// ========================
const widgetTemplates = [
{ title: '快捷入口', colSpan: 4, rowSpan: 1 },
{ title: '数据看板', colSpan: 8, rowSpan: 2 },
{ title: '消息通知', colSpan: 4, rowSpan: 1 },
{ title: '图表分析', colSpan: 12, rowSpan: 2 },
]
function addWidget(tpl: typeof widgetTemplates[0]) {
const maxRow = widgets.value.length
? Math.max(...widgets.value.map(w => w.row + w.rowSpan))
: 0
widgets.value.push({
id: buildUUID(),
title: tpl.title,
col: 0,
row: maxRow,
colSpan: tpl.colSpan,
rowSpan: tpl.rowSpan,
})
applyLayout()
}
function removeWidget(id: string) {
widgets.value = widgets.value.filter(w => w.id !== id)
applyLayout()
}
// ========================
// 响应式宽度监听
// ========================
let ro: ResizeObserver | null = null
onMounted(() => {
nextTick(() => {
if (containerRef.value) {
containerWidth.value = containerRef.value.clientWidth
ro = new ResizeObserver(entries => {
containerWidth.value = entries[0].contentRect.width
bumpKey()
})
ro.observe(containerRef.value)
}
})
})
onUnmounted(() => ro?.disconnect())
</script>
<template>
<div class="workspace-page">
<!-- 顶部工具栏 -->
<div class="toolbar">
<span class="toolbar-title">工作台设计</span>
<div class="toolbar-actions">
<span class="add-label">添加组件:</span>
<a-button
v-for="tpl in widgetTemplates"
:key="tpl.title"
size="small"
class="add-btn"
@click="addWidget(tpl)"
>
+ {{ tpl.title }}
</a-button>
</div>
</div>
<!-- 网格工作区 -->
<div class="workspace-scroll">
<div
ref="containerRef"
class="workspace-container"
:style="{ height: containerHeight + 'px' }"
>
<!-- 列参考线 -->
<template v-if="cellW > 0">
<div
v-for="c in COLS"
:key="'col-' + c"
class="grid-col-guide"
:style="{
left: colToX(c - 1) + 'px',
width: cellW + 'px',
top: GAP + 'px',
bottom: GAP + 'px',
}"
/>
</template>
<!-- 组件列表:key 包含 layoutKey,触发 VDR 重建以精确定位 -->
<template v-if="cellW > 0">
<VueDraggableResizable
v-for="item in widgets"
:key="item.id + '_' + layoutKey"
:customId="item.id"
:w="itemPxW(item)"
:h="itemPxH(item)"
:x="itemX(item)"
:y="itemY(item)"
:min-width="cellW"
:min-height="ROW_H"
:parent="true"
:handles="['mr', 'ml', 'br', 'bm', 'bl']"
class-name-active="widget-active"
class-name="widget-vdr"
@activated="handleActivated(item.id)"
@dragstop="handleDragStop"
@resizestop="handleResizeStop"
>
<div
class="widget-card"
:class="{ 'is-active': activeId === item.id }"
>
<div class="widget-header">
<span class="widget-title">{{ item.title }}</span>
<span class="widget-meta">{{ item.colSpan }}列 × {{ item.rowSpan }}行</span>
<a-button
size="small"
danger
class="widget-del"
@click.stop="removeWidget(item.id)"
>删除</a-button>
</div>
<div class="widget-body">
<div class="widget-placeholder">
<div class="placeholder-icon">📊</div>
<div>{{ item.title }}</div>
</div>
</div>
</div>
</VueDraggableResizable>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.workspace-page {
display: flex;
flex-direction: column;
height: 100%;
background: #f0f2f5;
}
/* 工具栏 */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
flex-wrap: wrap;
}
.toolbar-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.add-label { font-size: 13px; color: #666; }
.add-btn { border-radius: 6px; }
/* 工作区 */
.workspace-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
}
.workspace-container {
position: relative;
width: 100%;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
/* 列参考线 */
.grid-col-guide {
position: absolute;
background: rgba(24, 144, 255, 0.04);
border: 1px dashed rgba(24, 144, 255, 0.15);
border-radius: 4px;
pointer-events: none;
z-index: 0;
}
/* VDR 覆写 */
:deep(.widget-vdr) {
position: absolute !important;
border: none !important;
background: transparent !important;
z-index: 1;
}
:deep(.widget-active) {
z-index: 10 !important;
}
:deep(.handle) {
width: 8px;
height: 8px;
background: #1890ff;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #1890ff;
}
/* 组件卡片 */
.widget-card {
width: 100%;
height: 100%;
background: #fff;
border-radius: 8px;
border: 2px solid transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
cursor: grab;
}
.widget-card:active { cursor: grabbing; }
.widget-card.is-active {
border-color: #1890ff;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15), 0 4px 16px rgba(0, 0, 0, 0.12);
}
.widget-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-bottom: 1px solid #eef0f8;
gap: 8px;
flex-shrink: 0;
}
.widget-title { font-size: 13px; font-weight: 600; color: #1a1a2e; flex: 1; }
.widget-meta { font-size: 11px; color: #aaa; }
.widget-del { flex-shrink: 0; }
.widget-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
overflow: hidden;
}
.widget-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: #bbb;
font-size: 13px;
}
.placeholder-icon { font-size: 28px; line-height: 1; }
</style>