feat(all): 迁移扩展相关功能,对应的面板迁移
This commit is contained in:
parent
d7c0dba569
commit
2605656dc4
@ -539,6 +539,154 @@ export default {
|
||||
htmlPanel: {
|
||||
Content: "内容"
|
||||
},
|
||||
Heatmap: "热力图",
|
||||
Path: "路径",
|
||||
"UI panel": "UI面板",
|
||||
"Water pool": "水池",
|
||||
heatmap: {
|
||||
"Heatmap Texture": "热力图纹理",
|
||||
Blur: "模糊",
|
||||
Gradient: "色阶",
|
||||
Add: "添加",
|
||||
Remove: "移除",
|
||||
Data: "数据",
|
||||
Points: "点",
|
||||
"Controls the opacity of the darkest and brightest parts of a thermal texture": "控制热力纹理最暗处和最亮处的不透明度",
|
||||
"Height Scale": "高度缩放",
|
||||
"Heatmap Points": "热力图点",
|
||||
Error: "错误",
|
||||
"Points must be an array.": "点数据必须是数组。",
|
||||
"Point[{index}] must be an object.": "点[{index}]必须是对象。",
|
||||
"Point[{index}].x must be a number.": "点[{index}].x必须是数字。",
|
||||
"Point[{index}].y must be a number.": "点[{index}].y必须是数字。",
|
||||
"Point[{index}].value must be a number.": "点[{index}].value必须是数字。",
|
||||
"Point[{index}].radius must be a number.": "点[{index}].radius必须是数字。",
|
||||
},
|
||||
path: {
|
||||
"Path Params": "路径参数",
|
||||
"Ribbon Params": "平面路径",
|
||||
"Tube Params": "管状路径",
|
||||
"Flow Params": "流动效果",
|
||||
Type: "类型",
|
||||
Ribbon: "平面",
|
||||
Tube: "管状",
|
||||
"Corner Radius": "拐角半径",
|
||||
"Corner Split": "拐角分段",
|
||||
Progress: "进度",
|
||||
Arrow: "箭头",
|
||||
Side: "绘制侧",
|
||||
Both: "双侧",
|
||||
Left: "左侧",
|
||||
Right: "右侧",
|
||||
"Start Rad": "起始弧度",
|
||||
Flow: "流动",
|
||||
"Flow Speed": "流动速度",
|
||||
"Flow Direction": "流动方向",
|
||||
},
|
||||
uipanel: {
|
||||
Node: "节点",
|
||||
"Select a node to edit": "选择节点以编辑",
|
||||
Type: "类型",
|
||||
Text: "文本",
|
||||
Color: "颜色",
|
||||
Padding: "内边距",
|
||||
Margin: "外边距",
|
||||
Offset: "偏移",
|
||||
"Font opacity": "字体透明度",
|
||||
"Font supersampling": "字体超采样",
|
||||
"Letter spacing": "字间距",
|
||||
"Line height": "行高",
|
||||
"White space": "空白处理",
|
||||
"Break on": "换行字符",
|
||||
"Background color": "背景色",
|
||||
"Background opacity": "背景透明度",
|
||||
"Background image URL": "背景图链接",
|
||||
"Select local image": "选择本地图片",
|
||||
"Image size must be <= {size}MB": "图片大小不能超过{size}MB",
|
||||
"Background size": "背景图大小",
|
||||
Cover: "覆盖",
|
||||
Contain: "包含",
|
||||
Stretch: "拉伸",
|
||||
"Border radius": "圆角",
|
||||
"Border width": "边框宽度",
|
||||
"Border color": "边框颜色",
|
||||
"Hidden overflow": "隐藏溢出",
|
||||
"Flex direction": "布局方向",
|
||||
Row: "横向",
|
||||
"Row reverse": "横向反转",
|
||||
Column: "纵向",
|
||||
"Column reverse": "纵向反转",
|
||||
"Justify content": "主轴对齐",
|
||||
"Align items": "交叉轴对齐",
|
||||
States: "状态样式",
|
||||
Hover: "悬停",
|
||||
Active: "点击",
|
||||
"Hover background": "悬停背景色",
|
||||
"Hover opacity": "悬停透明度",
|
||||
"Active background": "点击背景色",
|
||||
"Active opacity": "点击透明度",
|
||||
"Hover color": "悬停颜色",
|
||||
"Active color": "点击颜色",
|
||||
Start: "开始",
|
||||
Center: "居中",
|
||||
End: "结束",
|
||||
"Space between": "两端对齐",
|
||||
"Space around": "环绕",
|
||||
"Space evenly": "均分",
|
||||
"Font size": "字号",
|
||||
"Text align": "文字对齐",
|
||||
Left: "左对齐",
|
||||
Right: "右对齐",
|
||||
"Add Block": "添加容器",
|
||||
"Add Text": "添加文本",
|
||||
},
|
||||
waterPool: {
|
||||
Basic: "基础",
|
||||
Type: "水池类型",
|
||||
Cylinder: "圆柱",
|
||||
Square: "方形",
|
||||
WallMode: "墙体模式",
|
||||
WallNone: "无墙体",
|
||||
Wall: "墙体",
|
||||
Volume: "体积水",
|
||||
WallOpacity: "墙体透明度",
|
||||
ScreenRefraction: "屏幕空间折射",
|
||||
Size: "尺寸",
|
||||
Diameter: "直径",
|
||||
Width: "宽度",
|
||||
Depth: "深度",
|
||||
Height: "高度",
|
||||
Light: "光照",
|
||||
Direction: "方向",
|
||||
Color: "颜色",
|
||||
VolumeColor: "体积水颜色",
|
||||
SurfaceColor: "水面颜色",
|
||||
Disturbance: "水波",
|
||||
Enabled: "启用",
|
||||
Mode: "模式",
|
||||
ModeDrag: "拖动",
|
||||
ModeUniform: "均匀",
|
||||
DropsPerStep: "每步水滴数",
|
||||
RadiusMin: "半径最小",
|
||||
RadiusMax: "半径最大",
|
||||
StrengthMin: "强度最小",
|
||||
StrengthMax: "强度最大",
|
||||
TravelRadius: "活动半径",
|
||||
DriftSpeed: "漂移速度",
|
||||
Jitter: "抖动强度",
|
||||
Spread: "扩散范围",
|
||||
Refraction: "折射与波纹",
|
||||
SurfaceTransmittance: "透视强度",
|
||||
NormalStrength: "法线强度",
|
||||
RefractionStrength: "折射强度",
|
||||
Texture: "贴图",
|
||||
WallTexture: "墙体纹理",
|
||||
Quality: "模拟精度",
|
||||
SimulationSize: "模拟尺寸",
|
||||
CausticsSize: "焦散尺寸",
|
||||
WaterSegments: "水面分段",
|
||||
WallSegments: "墙体分段",
|
||||
},
|
||||
"3D Tiles": "3D Tiles",
|
||||
tiles: {
|
||||
"Color mode": "颜色模式",
|
||||
|
||||
@ -18,7 +18,10 @@ import {
|
||||
ImageReference,
|
||||
LocationHeart,
|
||||
LocationCompany,
|
||||
ChoroplethMap
|
||||
ChoroplethMap,
|
||||
DirectionLoopLeft,
|
||||
Gui,
|
||||
ChartWaterfall
|
||||
} from "@vicons/carbon";
|
||||
|
||||
import { t } from "@/language";
|
||||
@ -38,6 +41,10 @@ import SidebarParticle from "./sidebar/SidebarParticle.vue";
|
||||
import SidebarBillboard from "./sidebar/SidebarBillboard.vue";
|
||||
import SidebarHtmlPanel from "./sidebar/SidebarHtmlPanel.vue";
|
||||
import Sidebar3DTiles from "./sidebar/Sidebar3DTiles.vue";
|
||||
import SidebarHeatmap from "./sidebar/SidebarHeatmap.vue";
|
||||
import SidebarPath from "./sidebar/SidebarPath.vue";
|
||||
import SidebarUIPanel from "./sidebar/SidebarUIPanel.vue";
|
||||
import SidebarWaterPool from "./sidebar/SidebarWaterPool.vue";
|
||||
|
||||
const tabsInstRef = ref<TabsInst | null>(null);
|
||||
const tabs = ref<Array<any>>([]);
|
||||
@ -96,6 +103,30 @@ function setTabs(object){
|
||||
|
||||
current.value = '3DTiles';
|
||||
break;
|
||||
case "Heatmap":
|
||||
object3DTabs.push({ name: 'heatmap', icon: { text: 'Heatmap',color:"#A9575F", component: markRaw(HeatMap) }, component: markRaw(SidebarHeatmap) })
|
||||
|
||||
current.value = 'heatmap';
|
||||
break;
|
||||
case "Path":
|
||||
object3DTabs.push({ name: 'path', icon: { text: 'Path',color:"#A9575F", component: markRaw(DirectionLoopLeft) }, component: markRaw(SidebarPath) })
|
||||
|
||||
current.value = 'path';
|
||||
break;
|
||||
case "WaterPool":
|
||||
object3DTabs.push({ name: 'waterPool', icon: { text: 'Water pool',color:"#4aa3b5", component: markRaw(ChartWaterfall) }, component: markRaw(SidebarWaterPool) })
|
||||
|
||||
current.value = 'waterPool';
|
||||
break;
|
||||
case "UIPanel":
|
||||
case "UIPanelBlock":
|
||||
case "UIPanelText":
|
||||
case "UIPanelInline":
|
||||
case "UIPanelInlineBlock":
|
||||
object3DTabs.push({ name: 'uipanel', icon: { text: 'UI panel',color:"#A9575F", component: markRaw(Gui) }, component: markRaw(SidebarUIPanel) })
|
||||
|
||||
current.value = 'uipanel';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,6 +161,30 @@ function setTabs(object){
|
||||
current.value = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
if (current.value === 'heatmap') {
|
||||
if (!(object && object.type === 'Heatmap')){
|
||||
current.value = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
if (current.value === 'path') {
|
||||
if (!(object && object.type === 'Path')){
|
||||
current.value = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
if (current.value === 'waterPool') {
|
||||
if (!(object && object.type === 'WaterPool')){
|
||||
current.value = 'object';
|
||||
}
|
||||
}
|
||||
|
||||
if (current.value === 'uipanel') {
|
||||
if (!(object && ["UIPanel", "UIPanelBlock", "UIPanelText", "UIPanelInline", "UIPanelInlineBlock"].includes(object.type))){
|
||||
current.value = 'object';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(()=>{
|
||||
|
||||
@ -0,0 +1,534 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { CaretForwardOutline } from "@vicons/ionicons5";
|
||||
import { t } from "@/language";
|
||||
import { App, Hooks, Utils, getDefaultHeatmapOptions } from "@astral3d/engine";
|
||||
import CodeEditor from "@/components/code/CodeEditor.vue";
|
||||
import EsTip from "@/components/es/EsTip.vue";
|
||||
|
||||
type GradientStep = {
|
||||
id: string;
|
||||
step: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const defaultOptions = getDefaultHeatmapOptions();
|
||||
const heatmapData = reactive(getDefaultHeatmapOptions() as any);
|
||||
const opacityRange = ref<[number, number]>([heatmapData.heatmap.minOpacity, heatmapData.heatmap.maxOpacity]);
|
||||
const isHeightMode = computed(() => heatmapData.mode === "height");
|
||||
const pointsPreview = computed(() => JSON.stringify(heatmapData.data?.points || [], null, 2));
|
||||
const pointsEditorShow = ref(false);
|
||||
const pointsEditorRef = ref();
|
||||
const pointsSource = ref("[]");
|
||||
const pointsErrors = ref<string[]>([]);
|
||||
const gradientSteps = ref<GradientStep[]>([]);
|
||||
|
||||
function getHeatmapObject() {
|
||||
const object = App.selected;
|
||||
if (!object) return null;
|
||||
if (object.type !== "Heatmap" || !object.options) return null;
|
||||
return object as any;
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const object = getHeatmapObject();
|
||||
if (!object) return;
|
||||
|
||||
Utils.deepAssign(heatmapData, object.options);
|
||||
if (object.options.heatmap?.gradient) {
|
||||
heatmapData.heatmap.gradient = { ...object.options.heatmap.gradient };
|
||||
}
|
||||
opacityRange.value = [heatmapData.heatmap.minOpacity, heatmapData.heatmap.maxOpacity];
|
||||
|
||||
ensureDataShape();
|
||||
syncGradientSteps();
|
||||
}
|
||||
|
||||
function ensureDataShape() {
|
||||
if (!heatmapData.data) {
|
||||
heatmapData.data = JSON.parse(JSON.stringify(defaultOptions.data));
|
||||
}
|
||||
if (!Array.isArray(heatmapData.data.points)) {
|
||||
heatmapData.data.points = [];
|
||||
}
|
||||
if (!heatmapData.height) {
|
||||
heatmapData.height = JSON.parse(JSON.stringify(defaultOptions.height));
|
||||
}
|
||||
if (!heatmapData.heatmap) {
|
||||
heatmapData.heatmap = JSON.parse(JSON.stringify(defaultOptions.heatmap));
|
||||
}
|
||||
if (!heatmapData.heatmap.gradient || typeof heatmapData.heatmap.gradient !== "object" || Array.isArray(heatmapData.heatmap.gradient)) {
|
||||
heatmapData.heatmap.gradient = JSON.parse(JSON.stringify(defaultOptions.heatmap.gradient));
|
||||
}
|
||||
}
|
||||
|
||||
function clampStep(value: number) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function normalizeGradientSteps(gradient: Record<string, string>) {
|
||||
const steps: GradientStep[] = [];
|
||||
let index = 0;
|
||||
|
||||
Object.entries(gradient).forEach(([step, color]) => {
|
||||
const numericStep = Number(step);
|
||||
if (!Number.isFinite(numericStep)) return;
|
||||
steps.push({
|
||||
id: `step-${index}-${numericStep}`,
|
||||
step: clampStep(numericStep),
|
||||
color: typeof color === "string" && color.trim() !== "" ? color : "#ffffff",
|
||||
});
|
||||
index += 1;
|
||||
});
|
||||
|
||||
if (steps.length === 0) {
|
||||
steps.push({ id: `step-${Date.now()}`, step: 0, color: "#ffffff" });
|
||||
}
|
||||
|
||||
steps.sort((a, b) => a.step - b.step);
|
||||
return steps;
|
||||
}
|
||||
|
||||
function syncGradientSteps() {
|
||||
ensureDataShape();
|
||||
gradientSteps.value = normalizeGradientSteps(heatmapData.heatmap.gradient || {});
|
||||
}
|
||||
|
||||
function buildGradientFromSteps() {
|
||||
const gradient: Record<string, string> = {};
|
||||
const sortedSteps = [...gradientSteps.value].sort((a, b) => a.step - b.step);
|
||||
|
||||
sortedSteps.forEach(step => {
|
||||
const clamped = clampStep(Number(step.step));
|
||||
gradient[clamped.toFixed(2)] = step.color || "#ffffff";
|
||||
});
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
function updateGradient() {
|
||||
ensureDataShape();
|
||||
heatmapData.heatmap.gradient = buildGradientFromSteps();
|
||||
update("heatmap");
|
||||
}
|
||||
|
||||
function getNextGradientStep() {
|
||||
const steps = gradientSteps.value
|
||||
.map(step => step.step)
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (steps.length === 0) return 0;
|
||||
for (let i = 0; i < steps.length - 1; i += 1) {
|
||||
const gap = steps[i + 1] - steps[i];
|
||||
if (gap > 0.1) {
|
||||
return Number(((steps[i] + steps[i + 1]) / 2).toFixed(2));
|
||||
}
|
||||
}
|
||||
const last = steps[steps.length - 1];
|
||||
if (last < 1) {
|
||||
return Number(Math.min(1, last + 0.1).toFixed(2));
|
||||
}
|
||||
const first = steps[0];
|
||||
return Number(Math.max(0, first - 0.1).toFixed(2));
|
||||
}
|
||||
|
||||
function addGradientStep() {
|
||||
const step = getNextGradientStep();
|
||||
const fallbackColor = gradientSteps.value.length ? gradientSteps.value[gradientSteps.value.length - 1].color : "#ffffff";
|
||||
gradientSteps.value.push({ id: `step-${Date.now()}-${Math.random().toString(16).slice(2)}`, step, color: fallbackColor });
|
||||
updateGradient();
|
||||
}
|
||||
|
||||
function removeGradientStep(index: number) {
|
||||
if (gradientSteps.value.length <= 1) return;
|
||||
gradientSteps.value.splice(index, 1);
|
||||
updateGradient();
|
||||
}
|
||||
|
||||
function updateGradientStep(index: number) {
|
||||
const item = gradientSteps.value[index];
|
||||
if (!item) return;
|
||||
const step = clampStep(Number(item.step ?? 0));
|
||||
item.step = step;
|
||||
updateGradient();
|
||||
}
|
||||
|
||||
function updateGradientColor(index: number, color: string | null) {
|
||||
const item = gradientSteps.value[index];
|
||||
if (!item) return;
|
||||
item.color = color || "#ffffff";
|
||||
updateGradient();
|
||||
}
|
||||
|
||||
function requestRender(object) {
|
||||
Hooks.useDispatchSignal("objectChanged", object);
|
||||
}
|
||||
|
||||
function update(key: string) {
|
||||
const object = getHeatmapObject();
|
||||
if (!object) return;
|
||||
|
||||
const call = {
|
||||
mode: () => {
|
||||
object.setMode(heatmapData.mode);
|
||||
},
|
||||
size: () => {
|
||||
object.setSize({ width: heatmapData.size.width, height: heatmapData.size.height });
|
||||
},
|
||||
heatmap: () => {
|
||||
heatmapData.heatmap.minOpacity = opacityRange.value[0];
|
||||
heatmapData.heatmap.maxOpacity = opacityRange.value[1];
|
||||
|
||||
object.updateHeatmapConfig(heatmapData.heatmap);
|
||||
},
|
||||
height: () => {
|
||||
object.options.height = {
|
||||
scale: heatmapData.height.scale,
|
||||
segments: {
|
||||
width: heatmapData.height.segments.width,
|
||||
height: heatmapData.height.segments.height,
|
||||
},
|
||||
};
|
||||
object.setSize({ width: heatmapData.size.width, height: heatmapData.size.height });
|
||||
},
|
||||
};
|
||||
|
||||
call[key]?.();
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function updateData() {
|
||||
const object = getHeatmapObject();
|
||||
if (!object) return;
|
||||
|
||||
ensureDataShape();
|
||||
object.setData({
|
||||
max: heatmapData.data.max,
|
||||
min: heatmapData.data.min,
|
||||
points: heatmapData.data.points || [],
|
||||
});
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function openPointsEditor() {
|
||||
pointsEditorShow.value = true;
|
||||
}
|
||||
|
||||
function validatePoints(value: unknown) {
|
||||
const errors: string[] = [];
|
||||
const points: Array<{ x: number; y: number; value: number; radius?: number }> = [];
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return { errors: [t("layout.sider.heatmap.Points must be an array.")], points };
|
||||
}
|
||||
|
||||
value.forEach((item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
errors.push(t("layout.sider.heatmap.Point[{index}] must be an object.", { index }));
|
||||
return;
|
||||
}
|
||||
|
||||
const point = item as Record<string, unknown>;
|
||||
const x = Number(point.x);
|
||||
const y = Number(point.y);
|
||||
const v = Number(point.value);
|
||||
const radius = point.radius === undefined ? undefined : Number(point.radius);
|
||||
|
||||
if (!Number.isFinite(x)) errors.push(t("layout.sider.heatmap.Point[{index}].x must be a number.", { index }));
|
||||
if (!Number.isFinite(y)) errors.push(t("layout.sider.heatmap.Point[{index}].y must be a number.", { index }));
|
||||
if (!Number.isFinite(v)) errors.push(t("layout.sider.heatmap.Point[{index}].value must be a number.", { index }));
|
||||
if (radius !== undefined && !Number.isFinite(radius)) {
|
||||
errors.push(t("layout.sider.heatmap.Point[{index}].radius must be a number.", { index }));
|
||||
}
|
||||
|
||||
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(v)) {
|
||||
const normalized: { x: number; y: number; value: number; radius?: number } = { x, y, value: v };
|
||||
if (radius !== undefined && Number.isFinite(radius)) {
|
||||
normalized.radius = radius;
|
||||
}
|
||||
points.push(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
return { errors, points };
|
||||
}
|
||||
|
||||
function submitPoints(e: Event) {
|
||||
e.stopPropagation();
|
||||
|
||||
pointsErrors.value = pointsEditorRef.value?.getErrors?.() || [];
|
||||
if (pointsErrors.value.length > 0) return;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(pointsSource.value);
|
||||
} catch (error) {
|
||||
pointsErrors.value = [String((error as Error).message || error)];
|
||||
return;
|
||||
}
|
||||
|
||||
const { errors, points } = validatePoints(parsed);
|
||||
if (errors.length > 0) {
|
||||
pointsErrors.value = errors;
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDataShape();
|
||||
heatmapData.data.points = points;
|
||||
updateData();
|
||||
pointsEditorShow.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => pointsEditorShow.value,
|
||||
show => {
|
||||
if (!show) return;
|
||||
ensureDataShape();
|
||||
pointsSource.value = JSON.stringify(heatmapData.data.points || [], null, 2);
|
||||
pointsErrors.value = [];
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
Hooks.useAddSignal("objectSelected", updateUI);
|
||||
updateUI();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
Hooks.useRemoveSignal("objectSelected", updateUI);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-collapse display-directive="show" :default-expanded-names="['basic', 'heatmap', 'data', 'height']">
|
||||
<template #arrow>
|
||||
<n-icon>
|
||||
<CaretForwardOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<!-- 基础 -->
|
||||
<n-collapse-item :title="t('extra.resource.expansion.Heat map')" name="basic">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.particle.Type") }}</span>
|
||||
<n-select
|
||||
v-model:value="heatmapData.mode"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: t('extra.resource.expansion.Flat heatmap'), value: 'flat' },
|
||||
{ label: t('extra.resource.expansion.Elevation heatmap'), value: 'height' },
|
||||
]"
|
||||
@update:value="update('mode')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("other.Width") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.size.width"
|
||||
size="small"
|
||||
:min="0.1"
|
||||
:max="Infinity"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="update('size')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("other.Height") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.size.height"
|
||||
size="small"
|
||||
:min="0.1"
|
||||
:max="Infinity"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="update('size')"
|
||||
/>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<!-- 高程 -->
|
||||
<n-collapse-item v-if="isHeightMode" :title="t('extra.resource.expansion.Elevation heatmap')" name="height">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.heatmap.Height Scale") }}</span>
|
||||
<EsInputNumber v-model:value="heatmapData.height.scale" size="small" :min="0" :max="100" :decimal="2" :show-button="false" @change="update('height')" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Width segments") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.height.segments.width"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="512"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
@change="update('height')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Height segments") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.height.segments.height"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="512"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
@change="update('height')"
|
||||
/>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<!-- 热力图纹理参数 -->
|
||||
<n-collapse-item :title="t('layout.sider.heatmap.Heatmap Texture')" name="heatmap">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.postProcessing.Radius") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.heatmap.radius"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="Infinity"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
@change="update('heatmap')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.heatmap.Blur") }}</span>
|
||||
<n-slider v-model:value="heatmapData.heatmap.blur" :min="0" :max="1" :step="0.01" @update:value="update('heatmap')" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<n-text>
|
||||
<EsTip class="!justify-start" content="Alpha">
|
||||
{{ t("layout.sider.heatmap.Controls the opacity of the darkest and brightest parts of a thermal texture") }}
|
||||
</EsTip>
|
||||
</n-text>
|
||||
<n-slider v-model:value="opacityRange" range :min="0" :max="1" :step="0.01" @update:value="update('heatmap')" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.heatmap.Gradient") }}</span>
|
||||
<div class="heatmap-gradient-actions">
|
||||
<n-button size="tiny" tertiary @click="addGradientStep">{{ t("layout.sider.heatmap.Add") }}</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="heatmap-gradient-item" v-for="(step, index) in gradientSteps" :key="step.id">
|
||||
<EsInputNumber
|
||||
v-model:value="step.step"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
class="heatmap-gradient-step"
|
||||
@change="updateGradientStep(index)"
|
||||
/>
|
||||
<n-color-picker
|
||||
v-model:value="step.color"
|
||||
:show-alpha="false"
|
||||
:modes="['hex']"
|
||||
size="small"
|
||||
class="heatmap-gradient-color"
|
||||
:render-label="() => ''"
|
||||
@update:value="updateGradientColor(index, $event)"
|
||||
/>
|
||||
<n-button size="tiny" quaternary :disabled="gradientSteps.length <= 1" @click="removeGradientStep(index)">
|
||||
{{ t("layout.sider.heatmap.Remove") }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<!-- 数据 -->
|
||||
<n-collapse-item :title="t('layout.sider.heatmap.Data')" name="data">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("other.Maximum") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.data.max"
|
||||
size="small"
|
||||
:min="-Infinity"
|
||||
:max="Infinity"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="updateData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("other.Minimum") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="heatmapData.data.min"
|
||||
size="small"
|
||||
:min="-Infinity"
|
||||
:max="Infinity"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="updateData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<n-input class="!w-full" type="textarea" :value="pointsPreview" :rows="6" readonly round @click.stop="openPointsEditor" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<n-modal
|
||||
:show="pointsEditorShow"
|
||||
@update:show="show => (pointsEditorShow = show)"
|
||||
class="!w-60vw"
|
||||
preset="dialog"
|
||||
:title="t('layout.sider.heatmap.Heatmap Points')"
|
||||
:showIcon="false"
|
||||
>
|
||||
<CodeEditor ref="pointsEditorRef" v-model:source="pointsSource" mode="json" class="!h-400px" />
|
||||
|
||||
<n-alert
|
||||
:title="t('layout.sider.heatmap.Error')"
|
||||
type="error"
|
||||
v-if="pointsErrors.length"
|
||||
closable
|
||||
@close="pointsErrors = []"
|
||||
class="absolute bottom-0 w-full z-9999"
|
||||
>
|
||||
<n-text depth="1" v-for="(error, index) in pointsErrors" :key="index" class="block">{{ error }}</n-text>
|
||||
</n-alert>
|
||||
<div class="float-right mt-10px">
|
||||
<n-button size="small" @click="pointsEditorShow = false">{{ t("other.Cancel") }}</n-button>
|
||||
<n-button class="ml-5px" type="primary" size="small" @click="submitPoints">{{ t("other.Ok") }}</n-button>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.heatmap-gradient-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.heatmap-gradient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem 0.5rem 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.heatmap-gradient-step {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.heatmap-gradient-color {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.n-input__textarea) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
223
packages/editor/src/views/editor/layouts/sidebar/SidebarPath.vue
Normal file
223
packages/editor/src/views/editor/layouts/sidebar/SidebarPath.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive } from "vue";
|
||||
import { CaretForwardOutline } from "@vicons/ionicons5";
|
||||
import { t } from "@/language";
|
||||
import { App, Hooks, Utils, getDefaultPathOptions } from "@astral3d/engine";
|
||||
import EsInputNumber from "@/components/es/EsInputNumber.vue";
|
||||
|
||||
const defaultOptions = getDefaultPathOptions();
|
||||
const pathData = reactive(getDefaultPathOptions() as any);
|
||||
const isTubeMode = computed(() => pathData.mode === "tube");
|
||||
|
||||
function getPathObject() {
|
||||
const object = App.selected;
|
||||
if (!object) return null;
|
||||
if (object.type !== "Path") return null;
|
||||
return object as any;
|
||||
}
|
||||
|
||||
function ensureDataShape() {
|
||||
if (!pathData.path) {
|
||||
pathData.path = JSON.parse(JSON.stringify(defaultOptions.path));
|
||||
}
|
||||
if (!pathData.tube) {
|
||||
pathData.tube = JSON.parse(JSON.stringify(defaultOptions.tube));
|
||||
}
|
||||
if (!pathData.flow) {
|
||||
pathData.flow = JSON.parse(JSON.stringify(defaultOptions.flow));
|
||||
}
|
||||
if (!Array.isArray(pathData.flow.direction)) {
|
||||
pathData.flow.direction = defaultOptions.flow.direction.slice();
|
||||
}
|
||||
if (typeof pathData.cornerRadius !== "number") {
|
||||
pathData.cornerRadius = defaultOptions.cornerRadius;
|
||||
}
|
||||
if (typeof pathData.cornerSplit !== "number") {
|
||||
pathData.cornerSplit = defaultOptions.cornerSplit;
|
||||
}
|
||||
if (typeof pathData.closed !== "boolean") {
|
||||
pathData.closed = defaultOptions.closed;
|
||||
}
|
||||
if (!pathData.mode) {
|
||||
pathData.mode = defaultOptions.mode;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const object = getPathObject();
|
||||
if (!object) return;
|
||||
Utils.deepAssign(pathData, object.options);
|
||||
ensureDataShape();
|
||||
}
|
||||
|
||||
function updateOptions() {
|
||||
const object = getPathObject();
|
||||
if (!object) return;
|
||||
|
||||
object.updateOptions({
|
||||
mode: pathData.mode,
|
||||
closed: pathData.closed,
|
||||
cornerRadius: pathData.cornerRadius,
|
||||
cornerSplit: pathData.cornerSplit,
|
||||
path: { ...pathData.path },
|
||||
tube: { ...pathData.tube },
|
||||
flow: {
|
||||
...pathData.flow,
|
||||
direction: Array.isArray(pathData.flow.direction) ? pathData.flow.direction.slice() : defaultOptions.flow.direction.slice(),
|
||||
},
|
||||
});
|
||||
|
||||
Hooks.useDispatchSignal("objectChanged", object);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Hooks.useAddSignal("objectSelected", updateUI);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Hooks.useRemoveSignal("objectSelected", updateUI);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-collapse display-directive="show" :default-expanded-names="['base', 'shape', 'flow']">
|
||||
<template #arrow>
|
||||
<n-icon>
|
||||
<CaretForwardOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.path.Path Params')" name="base">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Type") }}</span>
|
||||
<n-select
|
||||
v-model:value="pathData.mode"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: t('layout.sider.path.Ribbon'), value: 'path' },
|
||||
{ label: t('layout.sider.path.Tube'), value: 'tube' },
|
||||
]"
|
||||
@update:value="updateOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Corner Radius") }}</span>
|
||||
<EsInputNumber v-model:value="pathData.cornerRadius" size="small" :min="0" :max="100" :decimal="2" :show-button="false" @change="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Corner Split") }}</span>
|
||||
<EsInputNumber v-model:value="pathData.cornerSplit" size="small" :min="0" :max="50" :decimal="0" :show-button="false" @change="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Closed") }}</span>
|
||||
<n-checkbox size="small" v-model:checked="pathData.closed" @update:checked="updateOptions" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item v-if="!isTubeMode" :title="t('layout.sider.path.Ribbon Params')" name="shape">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("other.Width") }}</span>
|
||||
<EsInputNumber v-model:value="pathData.path.width" size="small" :min="0.01" :max="100" :decimal="2" :show-button="false" @change="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Progress") }}</span>
|
||||
<n-slider v-model:value="pathData.path.progress" :min="0" :max="1" :step="0.01" @update:value="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Arrow") }}</span>
|
||||
<n-switch size="small" v-model:value="pathData.path.arrow" @update:value="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Side") }}</span>
|
||||
<n-select
|
||||
v-model:value="pathData.path.side"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: t('layout.sider.path.Both'), value: 'both' },
|
||||
{ label: t('layout.sider.path.Left'), value: 'left' },
|
||||
{ label: t('layout.sider.path.Right'), value: 'right' },
|
||||
]"
|
||||
@update:value="updateOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item v-else :title="t('layout.sider.path.Tube Params')" name="shape">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Radius") }}</span>
|
||||
<EsInputNumber v-model:value="pathData.tube.radius" size="small" :min="0.01" :max="100" :decimal="2" :show-button="false" @change="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene['Radial segments']") }}</span>
|
||||
<EsInputNumber v-model:value="pathData.tube.radialSegments" size="small" :min="2" :max="64" :decimal="0" :show-button="false" @change="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Progress") }}</span>
|
||||
<n-slider v-model:value="pathData.tube.progress" :min="0" :max="1" :step="0.01" @update:value="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Start Rad") }}</span>
|
||||
<EsInputNumber v-model:value="pathData.tube.startRad" size="small" :min="-6.28" :max="6.28" :decimal="2" :show-button="false" @change="updateOptions" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.path.Flow Params')" name="flow">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Flow") }}</span>
|
||||
<n-switch size="small" v-model:value="pathData.flow.enabled" @update:value="updateOptions" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Flow Speed") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="pathData.flow.speed"
|
||||
size="small"
|
||||
:min="-10"
|
||||
:max="10"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
:disabled="!pathData.flow.enabled"
|
||||
@change="updateOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.path.Flow Direction") }}</span>
|
||||
<div class="flex">
|
||||
<EsInputNumber
|
||||
v-model:value="pathData.flow.direction[0]"
|
||||
size="small"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
:disabled="!pathData.flow.enabled"
|
||||
@change="updateOptions"
|
||||
/>
|
||||
<EsInputNumber
|
||||
v-model:value="pathData.flow.direction[1]"
|
||||
size="small"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
:disabled="!pathData.flow.enabled"
|
||||
@change="updateOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { CaretForwardOutline } from "@vicons/ionicons5";
|
||||
import { t } from "@/language";
|
||||
import { App, Hooks, UIPanelBlock, UIPanelText, Utils } from "@astral3d/engine";
|
||||
import SidebarUIPanelBlock from "./uipanel/Sidebar.UIPanel.Block.vue";
|
||||
import SidebarUIPanelText from "./uipanel/Sidebar.UIPanel.Text.vue";
|
||||
|
||||
type UIPanelFormType = "Block" | "Text";
|
||||
type UIPanelStateMap = Record<string, Record<string, any>>;
|
||||
|
||||
const UIPANEL_TYPES = new Set(["UIPanel", "UIPanelBlock", "UIPanelText", "UIPanelInline", "UIPanelInlineBlock"]);
|
||||
// 只存储是否有选中对象的状态,3D 对象完全脱离 Vue 响应式系统
|
||||
const hasSelection = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
type: "Block" as UIPanelFormType,
|
||||
props: {} as Record<string, any>,
|
||||
states: {} as UIPanelStateMap,
|
||||
});
|
||||
|
||||
function getUIPanelContext() {
|
||||
const object = App.selected as any;
|
||||
if (!object || !UIPANEL_TYPES.has(object.type)) return null;
|
||||
const panel = Utils.findUIPanelRoot(object);
|
||||
if (!panel) return null;
|
||||
return { panel, object };
|
||||
}
|
||||
|
||||
function normalizeFormData() {
|
||||
if (!formData.props || typeof formData.props !== "object") formData.props = {};
|
||||
if (!formData.states || typeof formData.states !== "object") formData.states = {};
|
||||
|
||||
if (formData.type === "Block") {
|
||||
if (!Number.isFinite(formData.props.width)) formData.props.width = 1;
|
||||
if (!Number.isFinite(formData.props.height)) formData.props.height = 0.4;
|
||||
if (!Number.isFinite(formData.props.padding)) formData.props.padding = 0.04;
|
||||
if (!Number.isFinite(formData.props.margin)) formData.props.margin = 0;
|
||||
if (!Number.isFinite(formData.props.offset)) formData.props.offset = 0.005;
|
||||
if (!Number.isFinite(formData.props.backgroundOpacity)) formData.props.backgroundOpacity = 0.8;
|
||||
if (!Number.isFinite(formData.props.borderRadius)) formData.props.borderRadius = 0.04;
|
||||
if (!Number.isFinite(formData.props.borderWidth)) formData.props.borderWidth = 0;
|
||||
if (!formData.props.backgroundColor) formData.props.backgroundColor = "#2a2a2a";
|
||||
if (!formData.props.borderColor) formData.props.borderColor = "#ffffff";
|
||||
if (!formData.props.flexDirection) formData.props.flexDirection = "column";
|
||||
if (!formData.props.justifyContent) formData.props.justifyContent = "center";
|
||||
if (!formData.props.alignItems) formData.props.alignItems = "center";
|
||||
if (!formData.props.backgroundSize) formData.props.backgroundSize = "cover";
|
||||
if (!formData.props.overflow) formData.props.overflow = "visible";
|
||||
if (!formData.props.bestFit) formData.props.bestFit = "none";
|
||||
} else {
|
||||
if (!formData.props.textContent) formData.props.textContent = "";
|
||||
// Text 类型的 width/height 使用 "auto" 表示自动尺寸,
|
||||
// 若 props 中未定义或为 "auto",则保持 "auto";仅当为有限数值时显示具体值
|
||||
if (formData.props.width !== "auto" && !Number.isFinite(formData.props.width)) formData.props.width = "auto";
|
||||
if (formData.props.height !== "auto" && !Number.isFinite(formData.props.height)) formData.props.height = "auto";
|
||||
if (!Number.isFinite(formData.props.fontSize)) formData.props.fontSize = 0.05;
|
||||
if (!Number.isFinite(formData.props.fontOpacity)) formData.props.fontOpacity = 1;
|
||||
if (!Number.isFinite(formData.props.letterSpacing)) formData.props.letterSpacing = 0;
|
||||
if (!Number.isFinite(formData.props.lineHeight)) formData.props.lineHeight = 1.2;
|
||||
if (!Number.isFinite(formData.props.padding)) formData.props.padding = 0;
|
||||
if (!Number.isFinite(formData.props.margin)) formData.props.margin = 0;
|
||||
if (!Number.isFinite(formData.props.offset)) formData.props.offset = 0.005;
|
||||
if (!formData.props.color) formData.props.color = "#ffffff";
|
||||
if (!formData.props.textAlign) formData.props.textAlign = "center";
|
||||
if (!formData.props.fontKerning) formData.props.fontKerning = "normal";
|
||||
if (!formData.props.whiteSpace) formData.props.whiteSpace = "pre-line";
|
||||
if (!formData.props.fontSmooth) formData.props.fontSmooth = "antialiased";
|
||||
if (!formData.props.breakOn) formData.props.breakOn = "- ,.:?!\n";
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const context = getUIPanelContext();
|
||||
if (!context) {
|
||||
hasSelection.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hasSelection.value = true;
|
||||
|
||||
const options = context.object.options || {};
|
||||
formData.type = context.object.type === "UIPanelText" || context.object.type === "UIPanelInline" ? "Text" : "Block";
|
||||
formData.props = JSON.parse(JSON.stringify(options.props || {}));
|
||||
formData.states = JSON.parse(JSON.stringify(options.states || {}));
|
||||
normalizeFormData();
|
||||
}
|
||||
|
||||
const canAddNodes = computed(() => {
|
||||
if (!hasSelection.value) return false;
|
||||
const object = App.selected as any;
|
||||
return object && (object.type === "UIPanel" || object.type === "UIPanelBlock");
|
||||
});
|
||||
|
||||
function getAddParent() {
|
||||
const object = App.selected as any;
|
||||
if (object && (object.type === "UIPanel" || object.type === "UIPanelBlock")) {
|
||||
return object;
|
||||
}
|
||||
return Utils.findUIPanelRoot(object);
|
||||
}
|
||||
|
||||
function createBlock() {
|
||||
return new UIPanelBlock({
|
||||
name: "Block",
|
||||
props: {
|
||||
width: 1,
|
||||
height: 0.4,
|
||||
padding: 0.04,
|
||||
backgroundColor: "#2a2a2a",
|
||||
backgroundOpacity: 0.8,
|
||||
borderRadius: 0.04,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createText() {
|
||||
return new UIPanelText({
|
||||
name: "Text",
|
||||
props: {
|
||||
textContent: "",
|
||||
fontSize: 0.05,
|
||||
color: "#ffffff",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function addNode(type: "Block" | "Text") {
|
||||
const parent = getAddParent();
|
||||
if (!parent) return;
|
||||
const node = type === "Block" ? createBlock() : createText();
|
||||
parent.add?.(node);
|
||||
Hooks.useDispatchSignal("objectChanged", parent);
|
||||
App.select(node);
|
||||
}
|
||||
|
||||
function updateProps(patch: Record<string, any>) {
|
||||
const object = App.selected as any;
|
||||
if (!object) return;
|
||||
object.setProps?.(patch);
|
||||
Hooks.useDispatchSignal("objectChanged", object);
|
||||
}
|
||||
|
||||
function updateStates() {
|
||||
const object = App.selected as any;
|
||||
if (!object) return;
|
||||
object.setStates?.(formData.states);
|
||||
Hooks.useDispatchSignal("objectChanged", object);
|
||||
}
|
||||
|
||||
function isStateEnabled(key: string) {
|
||||
return Boolean(formData.states && formData.states[key]);
|
||||
}
|
||||
|
||||
function toggleState(key: string, enabled: boolean) {
|
||||
if (enabled) {
|
||||
if (!formData.states) formData.states = {};
|
||||
if (!formData.states[key]) {
|
||||
// 首次启用状态时,根据类型设置不同的默认值
|
||||
if (formData.type === "Block") {
|
||||
// Block: 使用当前背景色和透明度 1
|
||||
formData.states[key] = {
|
||||
backgroundColor: formData.props.backgroundColor || "#2a2a2a",
|
||||
backgroundOpacity: 1,
|
||||
};
|
||||
} else {
|
||||
// Text: 使用当前文字颜色
|
||||
formData.states[key] = {
|
||||
color: formData.props.color || "#ffffff",
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (formData.states) {
|
||||
delete formData.states[key];
|
||||
if (Object.keys(formData.states).length === 0) {
|
||||
formData.states = {};
|
||||
}
|
||||
}
|
||||
updateStates();
|
||||
}
|
||||
|
||||
function updateStateProps(key: string, patch: Record<string, any>) {
|
||||
if (!formData.states) formData.states = {};
|
||||
if (!formData.states[key]) formData.states[key] = {};
|
||||
formData.states[key] = { ...formData.states[key], ...patch };
|
||||
updateStates();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Hooks.useAddSignal("objectSelected", updateUI);
|
||||
Hooks.useAddSignal("objectChanged", updateUI);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Hooks.useRemoveSignal("objectSelected", updateUI);
|
||||
Hooks.useRemoveSignal("objectChanged", updateUI);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="uipanel-actions">
|
||||
<n-space size="small" align="center" justify="center">
|
||||
<n-button v-if="canAddNodes" @click="addNode('Block')">{{ t("layout.sider.uipanel['Add Block']") }}</n-button>
|
||||
<n-button v-if="canAddNodes" @click="addNode('Text')">{{ t("layout.sider.uipanel['Add Text']") }}</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<n-collapse display-directive="show" :default-expanded-names="['node']">
|
||||
<template #arrow>
|
||||
<n-icon>
|
||||
<CaretForwardOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.uipanel.Node')" name="node">
|
||||
<div v-if="!hasSelection" class="text-12px opacity-70">
|
||||
{{ t("layout.sider.uipanel['Select a node to edit']") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Type") }}</span>
|
||||
<div class="text-center">{{ formData.type }}</div>
|
||||
</div>
|
||||
|
||||
<SidebarUIPanelBlock
|
||||
v-if="formData.type === 'Block'"
|
||||
:form-data="formData"
|
||||
:is-state-enabled="isStateEnabled"
|
||||
:toggle-state="toggleState"
|
||||
:update-props="updateProps"
|
||||
:update-state-props="updateStateProps"
|
||||
/>
|
||||
|
||||
<SidebarUIPanelText
|
||||
v-else
|
||||
:form-data="formData"
|
||||
:is-state-enabled="isStateEnabled"
|
||||
:toggle-state="toggleState"
|
||||
:update-props="updateProps"
|
||||
:update-state-props="updateStateProps"
|
||||
/>
|
||||
</template>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.uipanel-actions {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,687 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRaw } from "vue";
|
||||
import { CaretForwardOutline } from "@vicons/ionicons5";
|
||||
import { Color, CubeTexture, Texture } from "three";
|
||||
import { App, Hooks, Water, WaterPool } from "@astral3d/engine";
|
||||
import EsInputNumber from "@/components/es/EsInputNumber.vue";
|
||||
import EsTexture from "@/components/es/EsTexture.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const poolData = reactive({
|
||||
type: "cylinder",
|
||||
light: { x: 0.7, y: 1, z: -0.3 },
|
||||
wallMode: "volume",
|
||||
wallOpacity: 0.3,
|
||||
disturbanceEnabled: true,
|
||||
disturbanceMode: "drag",
|
||||
disturbanceDropsPerStep: 1,
|
||||
disturbanceRadiusMin: 0.01,
|
||||
disturbanceRadiusMax: 0.05,
|
||||
disturbanceStrengthMin: 0.005,
|
||||
disturbanceStrengthMax: 0.02,
|
||||
disturbanceTravelRadius: 0.85,
|
||||
disturbanceDriftSpeed: 0.015,
|
||||
disturbanceJitter: 0.01,
|
||||
disturbanceSpread: 0.08,
|
||||
useSceneRefraction: true,
|
||||
surfaceTransmittance: 0.65,
|
||||
normalStrength: 0.6,
|
||||
refractionStrength: 0.035,
|
||||
simulationSize: 256,
|
||||
causticsSize: 1024,
|
||||
waterSegments: 200,
|
||||
wallSegments: 64,
|
||||
diameter: 2,
|
||||
width: 2,
|
||||
depth: 2,
|
||||
height: 2,
|
||||
});
|
||||
|
||||
const tilesTexture = ref<Color | Texture | CubeTexture | null>(null);
|
||||
const volumeColor = ref("#2c5965");
|
||||
const surfaceColor = ref("#4a8aa0");
|
||||
const rebuilding = ref(false);
|
||||
let qualityTimer: number | null = null;
|
||||
|
||||
const isCylinder = computed(() => poolData.type === "cylinder");
|
||||
|
||||
function getWaterPoolObject() {
|
||||
const object = App.selected;
|
||||
if (!object) return null;
|
||||
if (object.type !== "WaterPool") return null;
|
||||
return object as any;
|
||||
}
|
||||
|
||||
function syncColors(object) {
|
||||
const volume = object.volumeColor instanceof Color ? object.volumeColor : new Color(object.volumeColor ?? 0x2c5965);
|
||||
const surface = object.surfaceColor instanceof Color ? object.surfaceColor : new Color(object.surfaceColor ?? volume.getHex());
|
||||
volumeColor.value = `#${volume.getHexString()}`;
|
||||
surfaceColor.value = `#${surface.getHexString()}`;
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
poolData.type = object.poolType === "cylinder" ? "cylinder" : "square";
|
||||
poolData.light.x = object.light?.[0] ?? 0.7;
|
||||
poolData.light.y = object.light?.[1] ?? 1;
|
||||
poolData.light.z = object.light?.[2] ?? -0.3;
|
||||
poolData.wallMode = object.wallMode ?? (object.hasWall ? "wall" : "none");
|
||||
poolData.wallOpacity = Number.isFinite(object.wallOpacity) ? object.wallOpacity : 1;
|
||||
const disturbance = object._disturbance || null;
|
||||
if (disturbance) {
|
||||
poolData.disturbanceEnabled = true;
|
||||
poolData.disturbanceMode = disturbance.mode === "uniform" ? "uniform" : "drag";
|
||||
poolData.disturbanceDropsPerStep = disturbance.dropsPerStep ?? 1;
|
||||
poolData.disturbanceRadiusMin = disturbance.radiusMin ?? 0.01;
|
||||
poolData.disturbanceRadiusMax = disturbance.radiusMax ?? 0.05;
|
||||
poolData.disturbanceStrengthMin = disturbance.strengthMin ?? 0.005;
|
||||
poolData.disturbanceStrengthMax = disturbance.strengthMax ?? 0.02;
|
||||
poolData.disturbanceTravelRadius = disturbance.travelRadius ?? 0.85;
|
||||
poolData.disturbanceDriftSpeed = disturbance.driftSpeed ?? 0.015;
|
||||
poolData.disturbanceJitter = disturbance.jitter ?? 0.01;
|
||||
poolData.disturbanceSpread = disturbance.spread ?? 0.08;
|
||||
} else {
|
||||
poolData.disturbanceEnabled = true;
|
||||
poolData.disturbanceMode = "drag";
|
||||
poolData.disturbanceDropsPerStep = 1;
|
||||
poolData.disturbanceRadiusMin = 0.01;
|
||||
poolData.disturbanceRadiusMax = 0.05;
|
||||
poolData.disturbanceStrengthMin = 0.005;
|
||||
poolData.disturbanceStrengthMax = 0.02;
|
||||
poolData.disturbanceTravelRadius = 0.85;
|
||||
poolData.disturbanceDriftSpeed = 0.015;
|
||||
poolData.disturbanceJitter = 0.01;
|
||||
poolData.disturbanceSpread = 0.08;
|
||||
}
|
||||
poolData.useSceneRefraction = !!object.useSceneRefraction;
|
||||
poolData.surfaceTransmittance = Number.isFinite(object.surfaceTransmittance) ? object.surfaceTransmittance : 0.65;
|
||||
poolData.normalStrength = Number.isFinite(object.normalStrength) ? object.normalStrength : 0.6;
|
||||
poolData.refractionStrength = Number.isFinite(object.refractionStrength) ? object.refractionStrength : 0.035;
|
||||
poolData.simulationSize = object.simulationSize ?? 256;
|
||||
poolData.causticsSize = object.causticsSize ?? 1024;
|
||||
poolData.waterSegments = object.waterSegments ?? 200;
|
||||
poolData.wallSegments = object.wallSegments ?? 64;
|
||||
poolData.height = Number.isFinite(object.height) ? object.height : (object._poolHeight ?? 2);
|
||||
|
||||
if (poolData.type === "cylinder") {
|
||||
poolData.diameter = Number.isFinite(object.diameter) ? object.diameter : object._radius ? object._radius * 2 : 2;
|
||||
} else {
|
||||
poolData.width = Number.isFinite(object.width) ? object.width : 2;
|
||||
poolData.depth = Number.isFinite(object.depth) ? object.depth : 2;
|
||||
}
|
||||
|
||||
tilesTexture.value = object.tiles || null;
|
||||
syncColors(object);
|
||||
applyDisturbance();
|
||||
}
|
||||
|
||||
function requestRender(object) {
|
||||
Hooks.useDispatchSignal("objectChanged", object);
|
||||
}
|
||||
|
||||
function applyLight() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
const light = [poolData.light.x, poolData.light.y, poolData.light.z];
|
||||
object.light = light;
|
||||
|
||||
const materials = typeof object._getWaterMaterials === "function" ? object._getWaterMaterials() : [];
|
||||
materials.forEach(material => {
|
||||
if (material?.uniforms?.light) material.uniforms.light.value = light;
|
||||
});
|
||||
|
||||
const wallMaterial = object.wall?._material;
|
||||
if (wallMaterial?.uniforms?.light) wallMaterial.uniforms.light.value = light;
|
||||
|
||||
const causticsMaterial = typeof object._getCausticsMaterial === "function" ? object._getCausticsMaterial() : null;
|
||||
if (causticsMaterial?.uniforms?.light) causticsMaterial.uniforms.light.value = light;
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function applySize() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
if (poolData.type === "cylinder") {
|
||||
object.setSize({ diameter: poolData.diameter, height: poolData.height });
|
||||
} else {
|
||||
object.setSize({ width: poolData.width, depth: poolData.depth, height: poolData.height });
|
||||
}
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function applyTiles() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
if (poolData.wallMode !== "wall") return;
|
||||
|
||||
const texture = (toRaw(tilesTexture.value) as any) || null;
|
||||
object.tiles = texture;
|
||||
|
||||
const materials = typeof object._getWaterMaterials === "function" ? object._getWaterMaterials() : [];
|
||||
materials.forEach(material => {
|
||||
if (material?.uniforms?.tiles) material.uniforms.tiles.value = texture;
|
||||
if (material?.uniforms?.useTiles) material.uniforms.useTiles.value = texture ? 1 : 0;
|
||||
if (material?.uniforms?.tilesColor) material.uniforms.tilesColor.value = object.volumeColor;
|
||||
});
|
||||
|
||||
const wallMaterial = object.wall?._material;
|
||||
if (wallMaterial?.uniforms?.tiles) wallMaterial.uniforms.tiles.value = texture;
|
||||
if (wallMaterial?.uniforms?.useTiles) wallMaterial.uniforms.useTiles.value = texture ? 1 : 0;
|
||||
if (wallMaterial?.uniforms?.tilesColor) wallMaterial.uniforms.tilesColor.value = object.volumeColor;
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function applyColors() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
object.volumeColor = new Color(volumeColor.value || "#2c5965");
|
||||
object.surfaceColor = new Color(surfaceColor.value || volumeColor.value || "#2c5965");
|
||||
|
||||
if (typeof object._applyUniforms === "function") {
|
||||
object._applyUniforms();
|
||||
}
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function applyRefraction() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
object.useSceneRefraction = poolData.useSceneRefraction ? 1 : 0;
|
||||
object.surfaceTransmittance = poolData.surfaceTransmittance;
|
||||
object.normalStrength = poolData.normalStrength;
|
||||
object.refractionStrength = poolData.refractionStrength;
|
||||
|
||||
if (typeof object._applyUniforms === "function") {
|
||||
object._applyUniforms();
|
||||
}
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function applyDisturbance() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
if (!poolData.disturbanceEnabled) {
|
||||
object.stopDisturbance?.();
|
||||
requestRender(object);
|
||||
return;
|
||||
}
|
||||
|
||||
object.startDisturbance?.({
|
||||
mode: poolData.disturbanceMode === "uniform" ? "uniform" : "drag",
|
||||
dropsPerStep: Math.max(1, Math.round(poolData.disturbanceDropsPerStep)),
|
||||
radiusMin: Math.max(0, poolData.disturbanceRadiusMin),
|
||||
radiusMax: Math.max(poolData.disturbanceRadiusMin, poolData.disturbanceRadiusMax),
|
||||
strengthMin: poolData.disturbanceStrengthMin,
|
||||
strengthMax: poolData.disturbanceStrengthMax,
|
||||
travelRadius: Math.max(0, poolData.disturbanceTravelRadius),
|
||||
driftSpeed: Math.max(0, poolData.disturbanceDriftSpeed),
|
||||
jitter: Math.max(0, poolData.disturbanceJitter),
|
||||
spread: Math.max(0, poolData.disturbanceSpread),
|
||||
});
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function applyWallOpacity() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object) return;
|
||||
|
||||
object.wallOpacity = poolData.wallOpacity;
|
||||
if (typeof object._applyUniforms === "function") {
|
||||
object._applyUniforms();
|
||||
}
|
||||
|
||||
const wallMaterial = object.wall?._material;
|
||||
if (wallMaterial) {
|
||||
const transparent = object.wallOpacity < 1;
|
||||
wallMaterial.transparent = transparent;
|
||||
wallMaterial.depthWrite = !transparent;
|
||||
wallMaterial.needsUpdate = true;
|
||||
}
|
||||
|
||||
requestRender(object);
|
||||
}
|
||||
|
||||
function buildOptionsFromUI(object) {
|
||||
const options: any = {
|
||||
name: object?.name || "WaterPool",
|
||||
type: poolData.type,
|
||||
light: [poolData.light.x, poolData.light.y, poolData.light.z],
|
||||
tiles: poolData.wallMode === "wall" ? (toRaw(tilesTexture.value) as any) || null : object?.tiles || null,
|
||||
sky: object?.sky || null,
|
||||
wallMode: poolData.wallMode,
|
||||
wall: poolData.wallMode !== "none",
|
||||
wallOpacity: poolData.wallOpacity,
|
||||
useSceneRefraction: poolData.useSceneRefraction ? 1 : 0,
|
||||
surfaceTransmittance: poolData.surfaceTransmittance,
|
||||
normalStrength: poolData.normalStrength,
|
||||
refractionStrength: poolData.refractionStrength,
|
||||
volumeColor: new Color(volumeColor.value || "#2c5965"),
|
||||
surfaceColor: new Color(surfaceColor.value || volumeColor.value || "#2c5965"),
|
||||
simulationSize: Math.max(16, Math.round(poolData.simulationSize)),
|
||||
causticsSize: Math.max(64, Math.round(poolData.causticsSize)),
|
||||
waterSegments: Math.max(1, Math.round(poolData.waterSegments)),
|
||||
wallSegments: Math.max(3, Math.round(poolData.wallSegments)),
|
||||
};
|
||||
|
||||
if (poolData.type === "cylinder") {
|
||||
options.diameter = poolData.diameter;
|
||||
} else {
|
||||
options.width = poolData.width;
|
||||
options.depth = poolData.depth;
|
||||
}
|
||||
options.height = poolData.height;
|
||||
|
||||
if (object && Number.isFinite(object._renderOrderBase)) {
|
||||
options.renderOrder = object._renderOrderBase;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function rebuildPool() {
|
||||
const object = getWaterPoolObject();
|
||||
if (!object || rebuilding.value) return;
|
||||
|
||||
rebuilding.value = true;
|
||||
try {
|
||||
const options = buildOptionsFromUI(object);
|
||||
|
||||
const newPool = new WaterPool(options);
|
||||
await newPool.loaded;
|
||||
|
||||
newPool.name = object.name;
|
||||
newPool.position.copy(object.position);
|
||||
newPool.quaternion.copy(object.quaternion);
|
||||
newPool.scale.copy(object.scale);
|
||||
newPool.visible = object.visible;
|
||||
newPool.layers.mask = object.layers.mask;
|
||||
newPool.userData = { ...object.userData };
|
||||
|
||||
const parent = object.parent ?? App.scene;
|
||||
if (parent) {
|
||||
const oldIndex = parent.children.indexOf(object);
|
||||
parent.add(newPool);
|
||||
const newIndex = parent.children.indexOf(newPool);
|
||||
if (oldIndex >= 0 && newIndex >= 0 && newIndex !== oldIndex) {
|
||||
parent.children.splice(newIndex, 1);
|
||||
parent.children.splice(oldIndex, 0, newPool);
|
||||
}
|
||||
}
|
||||
|
||||
Water.registerRendering();
|
||||
|
||||
object.parent?.remove(object);
|
||||
|
||||
App.select(newPool);
|
||||
requestRender(newPool);
|
||||
} catch (error) {
|
||||
// 避免阻断侧边栏操作
|
||||
console.warn("WaterPool 重建失败:", error);
|
||||
} finally {
|
||||
rebuilding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyWallMode() {
|
||||
rebuildPool();
|
||||
}
|
||||
|
||||
function applyType() {
|
||||
rebuildPool();
|
||||
}
|
||||
|
||||
function applyQuality() {
|
||||
rebuildPool();
|
||||
}
|
||||
|
||||
function scheduleQualityRebuild() {
|
||||
if (qualityTimer !== null) {
|
||||
clearTimeout(qualityTimer);
|
||||
}
|
||||
qualityTimer = window.setTimeout(() => {
|
||||
qualityTimer = null;
|
||||
applyQuality();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Hooks.useAddSignal("objectSelected", updateUI);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (qualityTimer !== null) {
|
||||
clearTimeout(qualityTimer);
|
||||
qualityTimer = null;
|
||||
}
|
||||
Hooks.useRemoveSignal("objectSelected", updateUI);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-collapse display-directive="show" :default-expanded-names="['basic', 'size', 'light', 'color', 'disturbance', 'refraction', 'texture', 'quality']">
|
||||
<template #arrow>
|
||||
<n-icon>
|
||||
<CaretForwardOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Basic')" name="basic">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Type") }}</span>
|
||||
<n-select
|
||||
v-model:value="poolData.type"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: t('layout.sider.waterPool.Cylinder'), value: 'cylinder' },
|
||||
{ label: t('layout.sider.waterPool.Square'), value: 'square' },
|
||||
]"
|
||||
:disabled="rebuilding"
|
||||
@update:value="applyType"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.WallMode") }}</span>
|
||||
<n-select
|
||||
v-model:value="poolData.wallMode"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: t('layout.sider.waterPool.WallNone'), value: 'none' },
|
||||
{ label: t('layout.sider.waterPool.Wall'), value: 'wall' },
|
||||
{ label: t('layout.sider.waterPool.Volume'), value: 'volume' },
|
||||
]"
|
||||
:disabled="rebuilding"
|
||||
@update:value="applyWallMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.WallOpacity") }}</span>
|
||||
<n-slider
|
||||
v-model:value="poolData.wallOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:disabled="poolData.wallMode === 'none'"
|
||||
@update:value="applyWallOpacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.ScreenRefraction") }}</span>
|
||||
<n-switch size="small" v-model:value="poolData.useSceneRefraction" @update:value="applyRefraction" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Size')" name="size">
|
||||
<div class="sidebar-config-item" v-if="isCylinder">
|
||||
<span>{{ t("layout.sider.waterPool.Diameter") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.diameter"
|
||||
size="small"
|
||||
:min="0.1"
|
||||
:max="Infinity"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="applySize"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item" v-else>
|
||||
<span>{{ t("layout.sider.waterPool.Width") }}</span>
|
||||
<EsInputNumber v-model:value="poolData.width" size="small" :min="0.1" :max="Infinity" :decimal="2" :show-button="false" @change="applySize" />
|
||||
</div>
|
||||
<div class="sidebar-config-item" v-if="!isCylinder">
|
||||
<span>{{ t("layout.sider.waterPool.Depth") }}</span>
|
||||
<EsInputNumber v-model:value="poolData.depth" size="small" :min="0.1" :max="Infinity" :decimal="2" :show-button="false" @change="applySize" />
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Height") }}</span>
|
||||
<EsInputNumber v-model:value="poolData.height" size="small" :min="0.1" :max="Infinity" :decimal="2" :show-button="false" @change="applySize" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Light')" name="light">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Direction") }}</span>
|
||||
<div class="flex gap-6px">
|
||||
<EsInputNumber v-model:value="poolData.light.x" size="small" :min="-1" :max="1" :decimal="2" :show-button="false" @change="applyLight" />
|
||||
<EsInputNumber v-model:value="poolData.light.y" size="small" :min="-1" :max="1" :decimal="2" :show-button="false" @change="applyLight" />
|
||||
<EsInputNumber v-model:value="poolData.light.z" size="small" :min="-1" :max="1" :decimal="2" :show-button="false" @change="applyLight" />
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Color')" name="color">
|
||||
<div class="sidebar-config-item" v-if="poolData.wallMode !== 'none'">
|
||||
<span>{{ t("layout.sider.waterPool.VolumeColor") }}</span>
|
||||
<n-color-picker v-model:value="volumeColor" :show-alpha="false" :modes="['hex']" size="small" @update:value="applyColors" />
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.SurfaceColor") }}</span>
|
||||
<n-color-picker v-model:value="surfaceColor" :show-alpha="false" :modes="['hex']" size="small" @update:value="applyColors" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Disturbance')" name="disturbance">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Enabled") }}</span>
|
||||
<n-switch size="small" v-model:value="poolData.disturbanceEnabled" @update:value="applyDisturbance" />
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Mode") }}</span>
|
||||
<n-select
|
||||
v-model:value="poolData.disturbanceMode"
|
||||
size="small"
|
||||
:options="[
|
||||
{ label: t('layout.sider.waterPool.ModeDrag'), value: 'drag' },
|
||||
{ label: t('layout.sider.waterPool.ModeUniform'), value: 'uniform' },
|
||||
]"
|
||||
@update:value="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.DropsPerStep") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceDropsPerStep"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.RadiusMin") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceRadiusMin"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.RadiusMax") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceRadiusMax"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.StrengthMin") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceStrengthMin"
|
||||
size="small"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.StrengthMax") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceStrengthMax"
|
||||
size="small"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.TravelRadius") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceTravelRadius"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.DriftSpeed") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceDriftSpeed"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Jitter") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceJitter"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.Spread") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.disturbanceSpread"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="applyDisturbance"
|
||||
/>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Refraction')" name="refraction">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.SurfaceTransmittance") }}</span>
|
||||
<n-slider v-model:value="poolData.surfaceTransmittance" :min="0" :max="1" :step="0.01" @update:value="applyRefraction" />
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.NormalStrength") }}</span>
|
||||
<n-slider v-model:value="poolData.normalStrength" :min="0" :max="1" :step="0.01" @update:value="applyRefraction" />
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.RefractionStrength") }}</span>
|
||||
<n-slider v-model:value="poolData.refractionStrength" :min="0" :max="1" :step="0.001" @update:value="applyRefraction" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item v-if="poolData.wallMode === 'wall'" :title="t('layout.sider.waterPool.Texture')" name="texture">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.WallTexture") }}</span>
|
||||
<EsTexture v-model:texture="tilesTexture" width="26px" height="26px" class="ml-5px" @change="applyTiles" />
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item :title="t('layout.sider.waterPool.Quality')" name="quality">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.SimulationSize") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.simulationSize"
|
||||
size="small"
|
||||
:min="16"
|
||||
:max="2048"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
:disabled="rebuilding"
|
||||
@update:value="scheduleQualityRebuild"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.CausticsSize") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.causticsSize"
|
||||
size="small"
|
||||
:min="64"
|
||||
:max="4096"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
:disabled="rebuilding"
|
||||
@update:value="scheduleQualityRebuild"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.WaterSegments") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.waterSegments"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="1024"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
:disabled="rebuilding"
|
||||
@update:value="scheduleQualityRebuild"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.waterPool.WallSegments") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="poolData.wallSegments"
|
||||
size="small"
|
||||
:min="3"
|
||||
:max="512"
|
||||
:decimal="0"
|
||||
:show-button="false"
|
||||
:disabled="rebuilding"
|
||||
@update:value="scheduleQualityRebuild"
|
||||
/>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from "vue";
|
||||
import { t } from "@/language";
|
||||
import EsInputNumber from "@/components/es/EsInputNumber.vue";
|
||||
|
||||
type UIPanelStateMap = Record<string, Record<string, any>>;
|
||||
|
||||
const props = defineProps<{
|
||||
formData: { props: Record<string, any>; states: UIPanelStateMap };
|
||||
updateProps: (patch: Record<string, any>) => void;
|
||||
updateStateProps: (key: string, patch: Record<string, any>) => void;
|
||||
isStateEnabled: (key: string) => boolean;
|
||||
toggleState: (key: string, enabled: boolean) => void;
|
||||
}>();
|
||||
|
||||
const { formData } = toRefs(props);
|
||||
const backgroundImageInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const overflowHidden = computed({
|
||||
get: () => formData.value.props.overflow === "hidden",
|
||||
set: (value: boolean) => {
|
||||
formData.value.props.overflow = value ? "hidden" : "visible";
|
||||
props.updateProps({ overflow: formData.value.props.overflow });
|
||||
},
|
||||
});
|
||||
|
||||
const flexOptions = computed(() => [
|
||||
{ label: t("layout.sider.uipanel.Row"), value: "row" },
|
||||
{ label: t("layout.sider.uipanel.Row reverse"), value: "row-reverse" },
|
||||
{ label: t("layout.sider.uipanel.Column"), value: "column" },
|
||||
{ label: t("layout.sider.uipanel.Column reverse"), value: "column-reverse" },
|
||||
]);
|
||||
|
||||
const justifyOptions = computed(() => [
|
||||
{ label: t("layout.sider.uipanel.Start"), value: "start" },
|
||||
{ label: t("layout.sider.uipanel.Center"), value: "center" },
|
||||
{ label: t("layout.sider.uipanel.End"), value: "end" },
|
||||
{ label: t("layout.sider.uipanel['Space between']"), value: "space-between" },
|
||||
{ label: t("layout.sider.uipanel['Space around']"), value: "space-around" },
|
||||
{ label: t("layout.sider.uipanel['Space evenly']"), value: "space-evenly" },
|
||||
]);
|
||||
|
||||
const alignOptions = computed(() => [
|
||||
{ label: t("layout.sider.uipanel.Start"), value: "start" },
|
||||
{ label: t("layout.sider.uipanel.Center"), value: "center" },
|
||||
{ label: t("layout.sider.uipanel.End"), value: "end" },
|
||||
{ label: t("layout.sider.uipanel.Stretch"), value: "stretch" },
|
||||
]);
|
||||
|
||||
const backgroundSizeOptions = computed(() => [
|
||||
{ label: t("layout.sider.uipanel.Cover"), value: "cover" },
|
||||
{ label: t("layout.sider.uipanel.Contain"), value: "contain" },
|
||||
{ label: t("layout.sider.uipanel.Stretch"), value: "stretch" },
|
||||
]);
|
||||
|
||||
function handleBackgroundImageUrl(value: string) {
|
||||
formData.value.props.backgroundImage = value;
|
||||
props.updateProps({ backgroundImage: value });
|
||||
}
|
||||
|
||||
function openBackgroundImagePicker() {
|
||||
backgroundImageInput.value?.click();
|
||||
}
|
||||
|
||||
function handleBackgroundImageFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const maxSizeMb = 3;
|
||||
if (file.size / (1024 * 1024) > maxSizeMb) {
|
||||
console.log(file.size / (1024 * 1024));
|
||||
const message = t("layout.sider.uipanel['Image size must be <= {size}MB']").replace("{size}", String(maxSizeMb));
|
||||
window.$message?.warning(message);
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== "string") return;
|
||||
formData.value.props.backgroundImage = reader.result;
|
||||
props.updateProps({ backgroundImage: reader.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
input.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Width") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.width"
|
||||
size="small"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ width: formData.props.width })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Height") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.height"
|
||||
size="small"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ height: formData.props.height })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Padding") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.padding"
|
||||
size="small"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ padding: formData.props.padding })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Margin") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.margin"
|
||||
size="small"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ margin: formData.props.margin })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Offset") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.offset"
|
||||
size="small"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ offset: formData.props.offset })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-divider class="!my-2" />
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Background color']") }}</span>
|
||||
<n-color-picker
|
||||
v-model:value="formData.props.backgroundColor"
|
||||
:show-alpha="false"
|
||||
size="small"
|
||||
@update:value="props.updateProps({ backgroundColor: formData.props.backgroundColor })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Background opacity']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.backgroundOpacity"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ backgroundOpacity: formData.props.backgroundOpacity })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Background image URL']") }}</span>
|
||||
<n-input v-model:value="formData.props.backgroundImage" size="small" @update:value="handleBackgroundImageUrl" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Select local image']") }}</span>
|
||||
<div class="uipanel-bg-image">
|
||||
<n-button size="small" @click="openBackgroundImagePicker">
|
||||
{{ t("layout.sider.uipanel['Select local image']") }}
|
||||
</n-button>
|
||||
<input ref="backgroundImageInput" class="uipanel-bg-image-input" type="file" accept="image/*" @change="handleBackgroundImageFile" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Background size']") }}</span>
|
||||
<n-select
|
||||
v-model:value="formData.props.backgroundSize"
|
||||
size="small"
|
||||
:options="backgroundSizeOptions"
|
||||
@update:value="props.updateProps({ backgroundSize: formData.props.backgroundSize })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Border radius']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.borderRadius"
|
||||
size="small"
|
||||
:min="0"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ borderRadius: formData.props.borderRadius })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Border width']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.borderWidth"
|
||||
size="small"
|
||||
:min="0"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ borderWidth: formData.props.borderWidth })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Border color']") }}</span>
|
||||
<n-color-picker
|
||||
v-model:value="formData.props.borderColor"
|
||||
:show-alpha="false"
|
||||
size="small"
|
||||
@update:value="props.updateProps({ borderColor: formData.props.borderColor })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Hidden overflow']") }}</span>
|
||||
<n-switch v-model:value="overflowHidden" size="small" />
|
||||
</div>
|
||||
|
||||
<n-divider class="!my-2" />
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Flex direction']") }}</span>
|
||||
<n-select
|
||||
v-model:value="formData.props.flexDirection"
|
||||
size="small"
|
||||
:options="flexOptions"
|
||||
@update:value="props.updateProps({ flexDirection: formData.props.flexDirection })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Justify content']") }}</span>
|
||||
<n-select
|
||||
v-model:value="formData.props.justifyContent"
|
||||
size="small"
|
||||
:options="justifyOptions"
|
||||
@update:value="props.updateProps({ justifyContent: formData.props.justifyContent })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Align items']") }}</span>
|
||||
<n-select
|
||||
v-model:value="formData.props.alignItems"
|
||||
size="small"
|
||||
:options="alignOptions"
|
||||
@update:value="props.updateProps({ alignItems: formData.props.alignItems })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-divider class="!my-2" />
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.States") }}</span>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Hover") }}</span>
|
||||
<n-switch size="small" :value="props.isStateEnabled('hover')" @update:value="(value: boolean) => props.toggleState('hover', value)" />
|
||||
</div>
|
||||
|
||||
<template v-if="props.isStateEnabled('hover')">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Hover background']") }}</span>
|
||||
<n-color-picker
|
||||
v-model:value="formData.states.hover.backgroundColor"
|
||||
:show-alpha="false"
|
||||
size="small"
|
||||
@update:value="props.updateStateProps('hover', { backgroundColor: formData.states.hover.backgroundColor })"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Hover opacity']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.states.hover.backgroundOpacity"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateStateProps('hover', { backgroundOpacity: formData.states.hover.backgroundOpacity })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Active") }}</span>
|
||||
<n-switch size="small" :value="props.isStateEnabled('active')" @update:value="(value: boolean) => props.toggleState('active', value)" />
|
||||
</div>
|
||||
|
||||
<template v-if="props.isStateEnabled('active')">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Active background']") }}</span>
|
||||
<n-color-picker
|
||||
v-model:value="formData.states.active.backgroundColor"
|
||||
:show-alpha="false"
|
||||
size="small"
|
||||
@update:value="props.updateStateProps('active', { backgroundColor: formData.states.active.backgroundColor })"
|
||||
/>
|
||||
</div>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Active opacity']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.states.active.backgroundOpacity"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateStateProps('active', { backgroundOpacity: formData.states.active.backgroundOpacity })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.uipanel-bg-image {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.uipanel-bg-image-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRefs } from "vue";
|
||||
import { t } from "@/language";
|
||||
import EsInputNumber from "@/components/es/EsInputNumber.vue";
|
||||
|
||||
type UIPanelStateMap = Record<string, Record<string, any>>;
|
||||
|
||||
const props = defineProps<{
|
||||
formData: { props: Record<string, any>; states: UIPanelStateMap };
|
||||
updateProps: (patch: Record<string, any>) => void;
|
||||
updateStateProps: (key: string, patch: Record<string, any>) => void;
|
||||
isStateEnabled: (key: string) => boolean;
|
||||
toggleState: (key: string, enabled: boolean) => void;
|
||||
}>();
|
||||
|
||||
const { formData } = toRefs(props);
|
||||
|
||||
const fontSupersampling = computed({
|
||||
get: () => formData.value.props.fontSmooth !== "none",
|
||||
set: (value: boolean) => {
|
||||
formData.value.props.fontSmooth = value ? "antialiased" : "none";
|
||||
props.updateProps({ fontSmooth: formData.value.props.fontSmooth });
|
||||
},
|
||||
});
|
||||
|
||||
// 宽度是否使用 auto
|
||||
const widthIsAuto = computed({
|
||||
get: () => formData.value.props.width === "auto",
|
||||
set: (value: boolean) => {
|
||||
formData.value.props.width = value ? "auto" : 0.5;
|
||||
props.updateProps({ width: formData.value.props.width });
|
||||
},
|
||||
});
|
||||
|
||||
// 高度是否使用 auto
|
||||
const heightIsAuto = computed({
|
||||
get: () => formData.value.props.height === "auto",
|
||||
set: (value: boolean) => {
|
||||
formData.value.props.height = value ? "auto" : 0.1;
|
||||
props.updateProps({ height: formData.value.props.height });
|
||||
},
|
||||
});
|
||||
|
||||
// 数值化的宽度(用于绑定数字输入)
|
||||
const numericWidth = computed({
|
||||
get: () => (typeof formData.value.props.width === "number" ? formData.value.props.width : 0),
|
||||
set: (value: number) => {
|
||||
formData.value.props.width = value;
|
||||
props.updateProps({ width: value });
|
||||
},
|
||||
});
|
||||
|
||||
// 数值化的高度(用于绑定数字输入)
|
||||
const numericHeight = computed({
|
||||
get: () => (typeof formData.value.props.height === "number" ? formData.value.props.height : 0),
|
||||
set: (value: number) => {
|
||||
formData.value.props.height = value;
|
||||
props.updateProps({ height: value });
|
||||
},
|
||||
});
|
||||
|
||||
const textAlignOptions = computed(() => [
|
||||
{ label: t("layout.sider.uipanel.Left"), value: "left" },
|
||||
{ label: t("layout.sider.uipanel.Center"), value: "center" },
|
||||
{ label: t("layout.sider.uipanel.Right"), value: "right" }
|
||||
]);
|
||||
|
||||
const whiteSpaceOptions = [
|
||||
{ label: "normal", value: "normal" },
|
||||
{ label: "pre-line", value: "pre-line" },
|
||||
{ label: "pre-wrap", value: "pre-wrap" },
|
||||
{ label: "pre", value: "pre" },
|
||||
{ label: "nowrap", value: "nowrap" },
|
||||
];
|
||||
|
||||
function handleTextContent(value: string) {
|
||||
formData.value.props.textContent = value;
|
||||
props.updateProps({ textContent: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Text") }}</span>
|
||||
<n-input v-model:value="formData.props.textContent" type="textarea" size="small" :autosize="{ minRows: 1, maxRows: 3 }" @update:value="handleTextContent" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Width") }}</span>
|
||||
<div class="uipanel-auto-size">
|
||||
<n-switch v-model:value="widthIsAuto" size="small" />
|
||||
<span class="uipanel-auto-label">auto</span>
|
||||
<EsInputNumber v-if="!widthIsAuto" v-model:value="numericWidth" size="small" :min="0" :decimal="3" :show-button="false" @change="numericWidth = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.scene.Height") }}</span>
|
||||
<div class="uipanel-auto-size">
|
||||
<n-switch v-model:value="heightIsAuto" size="small" />
|
||||
<span class="uipanel-auto-label">auto</span>
|
||||
<EsInputNumber
|
||||
v-if="!heightIsAuto"
|
||||
v-model:value="numericHeight"
|
||||
size="small"
|
||||
:min="0"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="numericHeight = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Font size']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.fontSize"
|
||||
size="small"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ fontSize: formData.props.fontSize })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Color") }}</span>
|
||||
<n-color-picker v-model:value="formData.props.color" :show-alpha="false" size="small" @update:value="props.updateProps({ color: formData.props.color })" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Font opacity']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.fontOpacity"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ fontOpacity: formData.props.fontOpacity })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Font supersampling']") }}</span>
|
||||
<n-switch v-model:value="fontSupersampling" size="small" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Letter spacing']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.letterSpacing"
|
||||
size="small"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ letterSpacing: formData.props.letterSpacing })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Line height']") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.lineHeight"
|
||||
size="small"
|
||||
:decimal="2"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ lineHeight: formData.props.lineHeight })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Padding") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.padding"
|
||||
size="small"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ padding: formData.props.padding })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Margin") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.margin"
|
||||
size="small"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ margin: formData.props.margin })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Offset") }}</span>
|
||||
<EsInputNumber
|
||||
v-model:value="formData.props.offset"
|
||||
size="small"
|
||||
:decimal="3"
|
||||
:show-button="false"
|
||||
@change="props.updateProps({ offset: formData.props.offset })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-divider class="!my-2" />
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Text align']") }}</span>
|
||||
<n-select
|
||||
v-model:value="formData.props.textAlign"
|
||||
size="small"
|
||||
:options="textAlignOptions"
|
||||
@update:value="props.updateProps({ textAlign: formData.props.textAlign })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['White space']") }}</span>
|
||||
<n-select
|
||||
v-model:value="formData.props.whiteSpace"
|
||||
size="small"
|
||||
:options="whiteSpaceOptions"
|
||||
@update:value="props.updateProps({ whiteSpace: formData.props.whiteSpace })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Break on']") }}</span>
|
||||
<n-input v-model:value="formData.props.breakOn" size="small" @update:value="(value: string) => props.updateProps({ breakOn: value })" />
|
||||
</div>
|
||||
|
||||
<n-divider class="!my-2" />
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.States") }}</span>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Hover") }}</span>
|
||||
<n-switch size="small" :value="props.isStateEnabled('hover')" @update:value="(value: boolean) => props.toggleState('hover', value)" />
|
||||
</div>
|
||||
|
||||
<template v-if="props.isStateEnabled('hover')">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Hover color']") }}</span>
|
||||
<n-color-picker
|
||||
v-model:value="formData.states.hover.color"
|
||||
:show-alpha="false"
|
||||
size="small"
|
||||
@update:value="props.updateStateProps('hover', { color: formData.states.hover.color })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel.Active") }}</span>
|
||||
<n-switch size="small" :value="props.isStateEnabled('active')" @update:value="(value: boolean) => props.toggleState('active', value)" />
|
||||
</div>
|
||||
|
||||
<template v-if="props.isStateEnabled('active')">
|
||||
<div class="sidebar-config-item">
|
||||
<span>{{ t("layout.sider.uipanel['Active color']") }}</span>
|
||||
<n-color-picker
|
||||
v-model:value="formData.states.active.color"
|
||||
:show-alpha="false"
|
||||
size="small"
|
||||
@update:value="props.updateStateProps('active', { color: formData.states.active.color })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.uipanel-auto-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.uipanel-auto-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
min-width: 30px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user