mirror of
https://github.com/fofolee/uTools-quickcommand.git
synced 2025-12-18 01:44:36 +08:00
组件位置调整
This commit is contained in:
242
src/components/composer/CommandComposer.vue
Normal file
242
src/components/composer/CommandComposer.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="command-composer">
|
||||
<!-- 主体内容 -->
|
||||
<div class="composer-body row no-wrap">
|
||||
<!-- 左侧命令列表 -->
|
||||
<div class="col-3 command-section">
|
||||
<ComposerList :commands="availableCommands" @add-command="addCommand" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧命令流程 -->
|
||||
<div class="col command-section">
|
||||
<ComposerFlow
|
||||
v-model="commandFlow"
|
||||
:generate-code="generateFlowCode"
|
||||
@add-command="addCommand"
|
||||
@action="handleComposer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, provide, ref } from "vue";
|
||||
import ComposerList from "./ComposerList.vue";
|
||||
import ComposerFlow from "./ComposerFlow.vue";
|
||||
import { commandCategories } from "js/composer/composerConfig";
|
||||
import { generateCode } from "js/composer/generateCode";
|
||||
// 从commandCategories中提取所有命令
|
||||
const availableCommands = commandCategories.reduce((commands, category) => {
|
||||
return commands.concat(
|
||||
category.commands.map((cmd) => ({
|
||||
type: category.label,
|
||||
...cmd,
|
||||
}))
|
||||
);
|
||||
}, []);
|
||||
|
||||
export default defineComponent({
|
||||
name: "CommandComposer",
|
||||
components: {
|
||||
ComposerList,
|
||||
ComposerFlow,
|
||||
},
|
||||
setup() {
|
||||
const variables = ref([]);
|
||||
|
||||
const addVariable = (name, command) => {
|
||||
if (!variables.value.find((v) => v.name === name)) {
|
||||
variables.value.push({
|
||||
name,
|
||||
sourceCommand: command,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeVariable = (name) => {
|
||||
const index = variables.value.findIndex((v) => v.name === name);
|
||||
if (index !== -1) {
|
||||
variables.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
provide("composerVariables", variables);
|
||||
provide("addVariable", addVariable);
|
||||
provide("removeVariable", removeVariable);
|
||||
|
||||
return {
|
||||
variables,
|
||||
addVariable,
|
||||
removeVariable,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
commandFlow: [],
|
||||
nextId: 1,
|
||||
availableCommands,
|
||||
};
|
||||
},
|
||||
emits: ["use-composer", "update:modelValue"],
|
||||
methods: {
|
||||
addCommand(action) {
|
||||
this.commandFlow.push({
|
||||
...action,
|
||||
id: this.nextId++,
|
||||
argv: "",
|
||||
argvType: "string",
|
||||
saveOutput: false,
|
||||
outputVariable: null,
|
||||
cmd: action.value || action.cmd,
|
||||
value: action.value || action.cmd,
|
||||
});
|
||||
},
|
||||
generateFlowCode() {
|
||||
return generateCode(this.commandFlow);
|
||||
},
|
||||
handleComposer(type, flow) {
|
||||
const code = flow ? generateCode(flow) : generateCode(this.commandFlow);
|
||||
this.$emit("use-composer", { type, code });
|
||||
if (type !== "run") this.$emit("update:modelValue", false);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.command-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.body--dark .command-composer {
|
||||
background-color: #303132;
|
||||
}
|
||||
|
||||
.composer-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.command-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.body--dark .command-section {
|
||||
background: #1d1d1d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 滚动美化 */
|
||||
:deep(.q-scrollarea__thumb) {
|
||||
width: 2px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.q-scrollarea__thumb:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.command-section {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.command-section:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.body--dark .command-section:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 布局更加紧凑 */
|
||||
/* 输入框高度及字体 */
|
||||
.command-composer :deep(.q-field--filled:not(.q-textarea) .q-field__control),
|
||||
.command-composer
|
||||
:deep(.q-field--filled:not(.q-textarea) .q-field__control > *),
|
||||
.command-composer
|
||||
:deep(.q-field--filled:not(.q-field--labeled):not(.q-textarea)
|
||||
.q-field__native) {
|
||||
max-height: 36px !important;
|
||||
min-height: 36px !important;
|
||||
}
|
||||
|
||||
.command-composer :deep(.q-field--filled .q-field__control),
|
||||
.command-composer :deep(.q-field--filled .q-field__control > *),
|
||||
.command-composer :deep(.q-field--filled .q-field__native) {
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 输入框图标大小 */
|
||||
.command-composer :deep(.q-field--filled .q-field__control .q-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 输入框标签字体大小,占位时的位置 */
|
||||
.command-composer :deep(.q-field--filled .q-field__label) {
|
||||
font-size: 11px;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
/* 输入框标签悬浮的位置 */
|
||||
.command-composer :deep(.q-field--filled .q-field--float .q_field__label) {
|
||||
transform: translateY(-35%) scale(0.7);
|
||||
}
|
||||
|
||||
/* 去除filled输入框边框 */
|
||||
.command-composer :deep(.q-field--filled .q-field__control:before) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 去除filled输入框下划线 */
|
||||
.command-composer :deep(.q-field--filled .q-field__control:after) {
|
||||
height: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 输入框背景颜色及内边距 */
|
||||
.command-composer :deep(.q-field--filled .q-field__control) {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 输入框聚焦时的背景颜色 */
|
||||
.command-composer
|
||||
:deep(.q-field--filled.q-field--highlighted .q-field__control) {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的输入框背景颜色 */
|
||||
.body--dark .command-composer :deep(.q-field--filled .q-field__control) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* 暗黑模式下输入框聚焦时的背景颜色 */
|
||||
.body--dark
|
||||
.command-composer
|
||||
:deep(.q-field--filled.q-field--highlighted .q-field__control) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* checkbox大小及字体 */
|
||||
.command-composer :deep(.q-checkbox__label) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.command-composer :deep(.q-checkbox__inner) {
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
181
src/components/composer/ComposerButtons.vue
Normal file
181
src/components/composer/ComposerButtons.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="composer-buttons">
|
||||
<q-btn
|
||||
@click="$q.dark.toggle()"
|
||||
:icon="$q.dark.isActive ? 'dark_mode' : 'light_mode'"
|
||||
flat
|
||||
dense
|
||||
v-if="isDev"
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn icon="logout" dense flat v-close-popup>
|
||||
<q-tooltip>退出可视化编排</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense icon="publish" flat @click="$emit('action', 'insert')">
|
||||
<q-tooltip>插入到编辑器光标处</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense flat icon="done_all" @click="$emit('action', 'apply')">
|
||||
<q-tooltip>清空编辑器内容并插入</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="preview"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn dense flat icon="play_circle" @click="$emit('action', 'run')">
|
||||
<q-tooltip>运行</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<transition name="preview-fade">
|
||||
<div v-if="isVisible" class="preview-popup">
|
||||
<div class="preview-header">
|
||||
<q-icon name="code" size="16px" class="q-mr-xs" />
|
||||
<span>预览代码</span>
|
||||
</div>
|
||||
<pre class="preview-code"><code>{{ code }}</code></pre>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ComposerButtons",
|
||||
|
||||
props: {
|
||||
generateCode: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["action"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
isVisible: false,
|
||||
code: "",
|
||||
previewTimer: null,
|
||||
isDev: window.utools.isDev(),
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleMouseEnter() {
|
||||
this.previewTimer = setTimeout(() => {
|
||||
this.code = this.generateCode();
|
||||
this.isVisible = true;
|
||||
}, 200);
|
||||
},
|
||||
|
||||
handleMouseLeave() {
|
||||
clearTimeout(this.previewTimer);
|
||||
this.isVisible = false;
|
||||
},
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.previewTimer);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.composer-buttons {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.composer-buttons > .q-btn {
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer-buttons > .q-btn:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
color: var(--q-primary);
|
||||
}
|
||||
|
||||
.preview-popup {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 30px;
|
||||
min-width: 300px;
|
||||
max-width: 600px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
transform-origin: center right;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 10px 14px;
|
||||
background: rgba(var(--q-primary-rgb), 0.03);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--q-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
font-family: consolas, monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.preview-code::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.preview-code::-webkit-scrollbar-thumb {
|
||||
background: var(--q-primary-opacity-20);
|
||||
border-radius: 3px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.preview-code::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--q-primary-opacity-30);
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.preview-fade-enter-active,
|
||||
.preview-fade-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.preview-fade-enter-from,
|
||||
.preview-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px) scale(0.95);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.body--dark .preview-popup {
|
||||
background: #1d1d1d;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.body--dark .preview-header {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.body--dark .preview-code {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
525
src/components/composer/ComposerCard.vue
Normal file
525
src/components/composer/ComposerCard.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div
|
||||
class="composer-card q-pa-xs"
|
||||
:class="{ 'can-drop': canDrop }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<q-card class="command-item">
|
||||
<q-card-section class="q-pa-sm">
|
||||
<div class="col">
|
||||
<!-- 命令标题和描述 -->
|
||||
<div class="row items-center q-mb-sm">
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="drag-handle cursor-move q-mr-sm">
|
||||
<q-icon name="drag_indicator" size="18px" class="text-grey-6" />
|
||||
</div>
|
||||
<div class="text-subtitle2">{{ command.label }}</div>
|
||||
<q-space />
|
||||
|
||||
<!-- 输出变量设置 -->
|
||||
<div
|
||||
class="output-section row items-center no-wrap"
|
||||
v-if="command.saveOutput"
|
||||
>
|
||||
<q-input
|
||||
:model-value="command.outputVariable"
|
||||
@update:model-value="handleOutputVariableUpdate"
|
||||
dense
|
||||
outlined
|
||||
placeholder="变量名"
|
||||
class="variable-input"
|
||||
style="width: 100px"
|
||||
align="center"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
:icon="saveOutputLocal ? 'data_object' : 'output'"
|
||||
:label="saveOutputLocal ? '保存到变量' : '获取输出'"
|
||||
flat
|
||||
dense
|
||||
class="output-btn q-px-sm q-mr-sm"
|
||||
size="sm"
|
||||
@click="handleToggleOutput"
|
||||
>
|
||||
<q-tooltip>
|
||||
<div class="text-body2">
|
||||
{{
|
||||
saveOutputLocal
|
||||
? "当前命令的输出将保存到变量中"
|
||||
: "点击将此命令的输出保存为变量以供后续使用"
|
||||
}}
|
||||
</div>
|
||||
<div class="text-caption text-grey-5">
|
||||
{{
|
||||
saveOutputLocal
|
||||
? "点击取消输出到变量"
|
||||
: "保存后可在其他命令中使用此变量"
|
||||
}}
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- 运行按钮 -->
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="play_arrow"
|
||||
class="run-btn q-px-sm q-mr-sm"
|
||||
size="sm"
|
||||
@click="runCommand"
|
||||
>
|
||||
<q-tooltip>单独运行此命令并打印输出</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="close"
|
||||
@click="$emit('remove')"
|
||||
size="sm"
|
||||
class="remove-btn"
|
||||
>
|
||||
<q-tooltip>移除此命令</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- 参数输入 -->
|
||||
<div class="row items-center">
|
||||
<!-- 单独写组件的参数 -->
|
||||
<component
|
||||
:is="command.component"
|
||||
v-model="argvLocal"
|
||||
:command="command"
|
||||
class="col"
|
||||
v-if="!!command.component"
|
||||
v-bind="command.componentProps || {}"
|
||||
/>
|
||||
<!-- 通用组件参数 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(item, index) in command.config"
|
||||
:key="item.key"
|
||||
class="param-item col-12"
|
||||
:class="{ 'q-mt-sm': index > 0 }"
|
||||
>
|
||||
<VariableInput
|
||||
v-model="item.value"
|
||||
:label="item.label"
|
||||
:command="command"
|
||||
:class="[
|
||||
`col-${item.width || 12}`,
|
||||
{ 'q-mt-sm': item.width && item.width < 12 },
|
||||
]"
|
||||
v-if="item.type === 'input'"
|
||||
@update:model-value="handleArgvChange(item.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, inject } from "vue";
|
||||
import KeyEditor from "components/composer/ui/KeyEditor.vue";
|
||||
import UBrowserEditor from "components/composer/ubrowser/UBrowserEditor.vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
import AxiosConfigEditor from "components/composer/http/AxiosConfigEditor.vue";
|
||||
import SymmetricCryptoEditor from "components/composer/crypto/SymmetricCryptoEditor.vue";
|
||||
import AsymmetricCryptoEditor from "components/composer/crypto/AsymmetricCryptoEditor.vue";
|
||||
import FunctionSelector from "components/composer/ui/FunctionSelector.vue";
|
||||
import { validateVariableName } from "js/common/variableValidator";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ComposerCard",
|
||||
components: {
|
||||
KeyEditor,
|
||||
UBrowserEditor,
|
||||
VariableInput,
|
||||
AxiosConfigEditor,
|
||||
SymmetricCryptoEditor,
|
||||
AsymmetricCryptoEditor,
|
||||
FunctionSelector,
|
||||
},
|
||||
props: {
|
||||
command: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
availableOutputs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
canDrop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showKeyRecorder: false,
|
||||
};
|
||||
},
|
||||
emits: ["remove", "toggle-output", "update:argv", "update:command", "run"],
|
||||
computed: {
|
||||
saveOutputLocal: {
|
||||
get() {
|
||||
return this.command.saveOutput;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("toggle-output");
|
||||
},
|
||||
},
|
||||
argvLocal: {
|
||||
get() {
|
||||
if (this.command.hasAxiosEditor) {
|
||||
// 如果是编辑现有配置
|
||||
if (
|
||||
this.command.argv &&
|
||||
!this.command.argv.includes("axios.") &&
|
||||
!this.command.argv.includes("fetch(")
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(this.command.argv);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// 如果已经是格式化的代码,直接返回
|
||||
return this.command.argv || {};
|
||||
}
|
||||
return this.command.argv;
|
||||
},
|
||||
set(value) {
|
||||
const updatedCommand = {
|
||||
...this.command,
|
||||
argv: this.command.hasAxiosEditor
|
||||
? typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value)
|
||||
: value,
|
||||
};
|
||||
this.$emit("update:command", updatedCommand);
|
||||
},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const addVariable = inject("addVariable");
|
||||
const removeVariable = inject("removeVariable");
|
||||
const variables = inject("composerVariables", []);
|
||||
|
||||
return {
|
||||
addVariable,
|
||||
removeVariable,
|
||||
variables,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleKeyRecord(keys) {
|
||||
this.showKeyRecorder = false;
|
||||
// 从keyTap("a","control")格式中提取参数
|
||||
const matches = keys.match(/keyTap\((.*)\)/);
|
||||
if (matches && matches[1]) {
|
||||
this.$emit("update:argv", matches[1]);
|
||||
}
|
||||
},
|
||||
handleOutputVariableChange(value) {
|
||||
if (this.command.outputVariable) {
|
||||
this.removeVariable(this.command.outputVariable);
|
||||
}
|
||||
if (value) {
|
||||
this.addVariable(value, this.command);
|
||||
}
|
||||
},
|
||||
handleOutputVariableUpdate(value) {
|
||||
// 检查变量名是否合法
|
||||
const validation = validateVariableName(value);
|
||||
if (!validation.isValid) {
|
||||
quickcommand.showMessageBox(validation.error, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查变量名是否重复
|
||||
if (this.variables.some((v) => v.name === value)) {
|
||||
quickcommand.showMessageBox(`变量名 "${value}" 已经存在`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建命令的副本并更新
|
||||
const updatedCommand = {
|
||||
...this.command,
|
||||
outputVariable: value,
|
||||
};
|
||||
// 发出更新事件
|
||||
this.$emit("update:command", updatedCommand);
|
||||
// 处理变量管理
|
||||
this.handleOutputVariableChange(value);
|
||||
},
|
||||
handleToggleOutput() {
|
||||
// 创建命令的副本
|
||||
const updatedCommand = {
|
||||
...this.command,
|
||||
saveOutput: !this.command.saveOutput,
|
||||
};
|
||||
|
||||
// 如果关闭输出,清空变量名
|
||||
if (!updatedCommand.saveOutput && updatedCommand.outputVariable) {
|
||||
this.removeVariable(updatedCommand.outputVariable);
|
||||
updatedCommand.outputVariable = null;
|
||||
}
|
||||
|
||||
// 发出更新事件
|
||||
this.$emit("update:command", updatedCommand);
|
||||
},
|
||||
handleArgvChange(key, value) {
|
||||
// 收集所有参数的当前值
|
||||
const args = this.command.config.reduce((acc, item) => {
|
||||
acc[item.key] = item.key === key ? value : item.value || "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 按照配置顺序拼接参数值
|
||||
const argv = this.command.config
|
||||
.map((item) => args[item.key])
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
|
||||
this.$emit("update:argv", argv);
|
||||
},
|
||||
runCommand() {
|
||||
// 创建一个带临时变量的命令副本
|
||||
const tempCommand = {
|
||||
...this.command,
|
||||
outputVariable: this.command.outputVariable || `temp_${Date.now()}`,
|
||||
saveOutput: true,
|
||||
};
|
||||
this.$emit("run", tempCommand);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$el.classList.add("composer-card-enter-from");
|
||||
requestAnimationFrame(() => {
|
||||
this.$el.classList.remove("composer-card-enter-from");
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.composer-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: center;
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* 进入动画 */
|
||||
.composer-card-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.composer-card-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
|
||||
/* 移除动画 */
|
||||
.composer-card-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.composer-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
|
||||
/* 拖拽动画 */
|
||||
/* .composer-card:active { */
|
||||
/* transform: scale(1.02); */
|
||||
/* transition: transform 0.2s; */
|
||||
/* } */
|
||||
|
||||
.command-item {
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.command-item:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
/* transform: translateY(-1px); */
|
||||
}
|
||||
|
||||
.composer-card :deep(.q-field__label) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 可放置状态动画 */
|
||||
.can-drop {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.can-drop .command-item {
|
||||
border: 2px dashed var(--q-primary);
|
||||
background: rgba(var(--q-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.body--dark .command-item {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.body--dark .command-item:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.body--dark .can-drop {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: var(--q-primary);
|
||||
}
|
||||
|
||||
/* 添加新的样式 */
|
||||
.output-section {
|
||||
max-width: 120px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.output-section :deep(.q-field) {
|
||||
background: rgba(var(--q-primary-rgb), 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 输出按钮样式优化 */
|
||||
.output-btn {
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(var(--q-primary-rgb), 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.output-btn:hover {
|
||||
background: rgba(var(--q-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.output-btn .q-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.output-btn.q-btn--active {
|
||||
background: rgba(var(--q-primary-rgb), 0.1);
|
||||
color: var(--q-primary);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.run-btn,
|
||||
.remove-btn {
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.run-btn:hover,
|
||||
.remove-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.run-btn:hover {
|
||||
color: var(--q-positive);
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: var(--q-negative);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.body--dark .output-section :deep(.q-field) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.body--dark .output-section :deep(.q-field--focused) {
|
||||
background: #1d1d1d;
|
||||
}
|
||||
|
||||
.body--dark .output-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.body--dark .output-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 输入框内部样式美化 */
|
||||
.output-section :deep(.q-field__control) {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.output-section :deep(.q-field__marginal) {
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.output-section :deep(.q-field__native) {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
min-height: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tooltip 样式优化 */
|
||||
:deep(.q-tooltip) {
|
||||
max-width: 300px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 优化图标样式 */
|
||||
.output-section :deep(.q-icon) {
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.output-section :deep(.q-field--focused .q-icon) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 参数项样式 */
|
||||
.param-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 当参数项换行时的间距 */
|
||||
.param-item :deep(.q-field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
445
src/components/composer/ComposerFlow.vue
Normal file
445
src/components/composer/ComposerFlow.vue
Normal file
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<div class="composer-flow">
|
||||
<div class="section-header">
|
||||
<q-icon name="timeline" size="20px" class="q-mx-sm text-primary" />
|
||||
<span class="text-subtitle1">命令流程</span>
|
||||
<q-space />
|
||||
<ComposerButtons
|
||||
:generate-code="generateCode"
|
||||
@action="$emit('action', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-scroll-area class="command-scroll">
|
||||
<div
|
||||
class="command-flow-container"
|
||||
@dragover.prevent="onDragOver"
|
||||
@drop="onDrop"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
>
|
||||
<draggable
|
||||
v-model="commands"
|
||||
group="commands"
|
||||
item-key="id"
|
||||
class="flow-list"
|
||||
handle=".drag-handle"
|
||||
:animation="200"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<transition name="slide-fade" mode="out-in" appear>
|
||||
<div
|
||||
:key="element.id"
|
||||
class="flow-item"
|
||||
:class="{
|
||||
'insert-before': dragIndex === index,
|
||||
'insert-after':
|
||||
dragIndex === commands.length &&
|
||||
index === commands.length - 1,
|
||||
}"
|
||||
>
|
||||
<ComposerCard
|
||||
:command="element"
|
||||
:placeholder="getPlaceholder(element, index)"
|
||||
@remove="removeCommand(index)"
|
||||
@toggle-output="toggleSaveOutput(index)"
|
||||
@update:argv="(val) => handleArgvChange(index, val)"
|
||||
@update:command="(val) => updateCommand(index, val)"
|
||||
@run="handleRunCommand"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
</draggable>
|
||||
<div v-if="commands.length === 0" class="empty-flow">
|
||||
<div class="text-center text-grey-6">
|
||||
<q-icon name="drag_indicator" size="32px" />
|
||||
<div class="text-body2 q-mt-sm">从左侧拖拽命令到这里开始编排</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="drop-area">
|
||||
<q-icon name="add" size="32px" />
|
||||
</div>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, inject } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import ComposerCard from "./ComposerCard.vue";
|
||||
import ComposerButtons from "./ComposerButtons.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ComposerFlow",
|
||||
components: {
|
||||
draggable,
|
||||
ComposerCard,
|
||||
ComposerButtons,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
generateCode: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "add-command", "action"],
|
||||
computed: {
|
||||
commands: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const removeVariable = inject("removeVariable");
|
||||
|
||||
return {
|
||||
removeVariable,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dragIndex: -1,
|
||||
isDragging: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onDragStart() {
|
||||
this.isDragging = true;
|
||||
},
|
||||
|
||||
onDragEnd() {
|
||||
this.isDragging = false;
|
||||
this.dragIndex = -1;
|
||||
},
|
||||
|
||||
onDragOver(event) {
|
||||
if (!this.isDragging) {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const items = this.$el.querySelectorAll(".flow-item");
|
||||
const mouseY = event.clientY;
|
||||
|
||||
// 找到最近的插入位置
|
||||
let closestIndex = -1;
|
||||
let minDistance = Infinity;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const itemCenter = itemRect.top + itemRect.height / 2;
|
||||
const distance = Math.abs(mouseY - itemCenter);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
// 如果鼠标在最后一个元素下方,则设置为末尾
|
||||
const lastItem = items[items.length - 1];
|
||||
if (lastItem && mouseY > lastItem.getBoundingClientRect().bottom) {
|
||||
closestIndex = this.commands.length;
|
||||
}
|
||||
|
||||
this.dragIndex = closestIndex;
|
||||
}
|
||||
},
|
||||
|
||||
onDragLeave() {
|
||||
if (!this.isDragging) {
|
||||
this.dragIndex = -1;
|
||||
}
|
||||
},
|
||||
|
||||
onDrop(event) {
|
||||
try {
|
||||
// 尝试获取拖拽数据
|
||||
const actionData = event.dataTransfer.getData("action");
|
||||
|
||||
// 如果没有action数据,说明是内部排序,直接返回
|
||||
if (!actionData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析外部拖入的新命令数据
|
||||
const parsedAction = JSON.parse(actionData);
|
||||
|
||||
const newCommand = {
|
||||
...parsedAction,
|
||||
id: Date.now(),
|
||||
argv: "",
|
||||
saveOutput: false,
|
||||
useOutput: null,
|
||||
outputVariable: null,
|
||||
cmd: parsedAction.value || parsedAction.cmd,
|
||||
value: parsedAction.value || parsedAction.cmd,
|
||||
};
|
||||
|
||||
const newCommands = [...this.commands];
|
||||
if (this.dragIndex >= 0) {
|
||||
newCommands.splice(this.dragIndex, 0, newCommand);
|
||||
} else {
|
||||
newCommands.push(newCommand);
|
||||
}
|
||||
|
||||
this.$emit("update:modelValue", newCommands);
|
||||
this.dragIndex = -1;
|
||||
|
||||
document.querySelectorAll(".dragging").forEach((el) => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
} catch (error) {
|
||||
// 忽略内部拖动排序的错误
|
||||
console.debug("Internal drag & drop reorder", error);
|
||||
}
|
||||
},
|
||||
removeCommand(index) {
|
||||
const command = this.commands[index];
|
||||
// 如果命令有输出变量,需要先清理
|
||||
if (command.outputVariable) {
|
||||
this.removeVariable(command.outputVariable);
|
||||
}
|
||||
const newCommands = [...this.commands];
|
||||
newCommands.splice(index, 1);
|
||||
this.$emit("update:modelValue", newCommands);
|
||||
},
|
||||
getPlaceholder(element, index) {
|
||||
return element.desc;
|
||||
},
|
||||
toggleSaveOutput(index) {
|
||||
const newCommands = [...this.commands];
|
||||
newCommands[index].saveOutput = !newCommands[index].saveOutput;
|
||||
if (!newCommands[index].saveOutput) {
|
||||
newCommands.forEach((cmd, i) => {
|
||||
if (i > index && cmd.useOutput === index) {
|
||||
cmd.useOutput = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
this.$emit("update:modelValue", newCommands);
|
||||
},
|
||||
handleArgvChange(index, value) {
|
||||
const newCommands = [...this.commands];
|
||||
newCommands[index] = {
|
||||
...newCommands[index],
|
||||
argv: value,
|
||||
};
|
||||
this.$emit("update:modelValue", newCommands);
|
||||
},
|
||||
updateCommand(index, updatedCommand) {
|
||||
const newCommands = [...this.commands];
|
||||
newCommands[index] = {
|
||||
...newCommands[index],
|
||||
...updatedCommand,
|
||||
};
|
||||
this.$emit("update:modelValue", newCommands);
|
||||
},
|
||||
handleRunCommand(command) {
|
||||
// 创建一个临时的命令流程
|
||||
const tempFlow = [
|
||||
command,
|
||||
{
|
||||
value: "console.log",
|
||||
argv: command.outputVariable,
|
||||
},
|
||||
];
|
||||
// 触发运行事件
|
||||
this.$emit("action", "run", tempFlow);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.composer-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.command-flow-container {
|
||||
padding: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.body--dark .command-flow-container {
|
||||
background-color: rgba(32, 32, 32, 0.8);
|
||||
}
|
||||
|
||||
/* .flow-list {
|
||||
min-height: 50px;
|
||||
} */
|
||||
|
||||
.drop-area {
|
||||
flex: 1;
|
||||
min-height: 50px;
|
||||
border-radius: 8px;
|
||||
margin: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9994;
|
||||
border: 2px dashed #9994;
|
||||
}
|
||||
|
||||
.body--dark .drop-area {
|
||||
color: #6664;
|
||||
border: 2px dashed #6664;
|
||||
}
|
||||
|
||||
.empty-flow {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed #e0e0e0;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
transition: all 0.3s ease;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.body--dark .empty-flow {
|
||||
border: 2px dashed #676666;
|
||||
}
|
||||
|
||||
.empty-flow:hover {
|
||||
border-color: #bdbdbd;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.body--dark .empty-flow:hover {
|
||||
border-color: #676666;
|
||||
background-color: #303132;
|
||||
}
|
||||
|
||||
/* 滑动淡出动画 */
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
/* 拖拽指示器基础样式 */
|
||||
.flow-item::before,
|
||||
.flow-item::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(0, 0, 0, 0.08) 10%,
|
||||
rgba(0, 0, 0, 0.15) 50%,
|
||||
rgba(0, 0, 0, 0.08) 90%,
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transform: scaleX(0.95) translateY(0);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
filter: blur(0.2px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.flow-item::before {
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.flow-item::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
/* 激活状态 - 插入到元素之前 */
|
||||
.flow-item.insert-before::before {
|
||||
opacity: 1;
|
||||
transform: scaleX(1) translateY(0);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.03), 0 0 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 激活状态 - 插入到最后 */
|
||||
.flow-item.insert-after::after {
|
||||
opacity: 1;
|
||||
transform: scaleX(1) translateY(0);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.03), 0 0 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 拖拽时的卡片效果 */
|
||||
.flow-item {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-item.insert-before {
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
.flow-item.insert-after {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* 拖拽时相邻元素的间距调整 */
|
||||
.flow-item.insert-before + .flow-item {
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.body--dark .flow-item::before,
|
||||
.body--dark .flow-item::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.08) 10%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.08) 90%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.03),
|
||||
0 0 4px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.body--dark .section-header {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
280
src/components/composer/ComposerList.vue
Normal file
280
src/components/composer/ComposerList.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="composer-list">
|
||||
<div class="section-header">
|
||||
<q-input
|
||||
v-model="searchQuery"
|
||||
dense
|
||||
borderless
|
||||
placeholder="搜索命令..."
|
||||
class="search-input"
|
||||
ref="searchInput"
|
||||
autofocus
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" size="sm" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-scroll-area class="command-scroll">
|
||||
<div>
|
||||
<q-list separator class="rounded-borders">
|
||||
<template
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.label"
|
||||
>
|
||||
<q-expansion-item
|
||||
:label="category.label"
|
||||
:icon="category.icon"
|
||||
:model-value="isExpanded(category)"
|
||||
@update:model-value="updateExpanded(category, $event)"
|
||||
dense-toggle
|
||||
class="category-item"
|
||||
header-class="category-header"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<div class="row items-center">
|
||||
<q-icon
|
||||
:name="category.icon"
|
||||
color="primary"
|
||||
size="sm"
|
||||
class="q-mr-sm"
|
||||
/>
|
||||
<span class="text-weight-medium">{{ category.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<q-item
|
||||
v-for="command in getCategoryCommands(category)"
|
||||
:key="command.value"
|
||||
class="command-item q-py-xs"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, command)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
class="text-weight-medium"
|
||||
v-html="highlightText(command.label)"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section side style="padding-left: 8px">
|
||||
<q-icon name="drag_indicator" color="grey-6" size="16px" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { commandCategories } from "js/composer/composerConfig";
|
||||
import pinyinMatch from "pinyin-match";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ComposerList",
|
||||
props: {
|
||||
commands: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
commandCategories,
|
||||
searchQuery: "",
|
||||
expandedCategories: new Set(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredCategories() {
|
||||
if (!this.searchQuery) return this.commandCategories;
|
||||
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
const filtered = this.commandCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
commands: this.commands
|
||||
.filter(
|
||||
(cmd) =>
|
||||
(cmd.label && pinyinMatch.match(cmd.label, query)) ||
|
||||
(cmd.value && pinyinMatch.match(cmd.value, query))
|
||||
)
|
||||
.filter((cmd) => cmd.type === category.label),
|
||||
}))
|
||||
.filter((category) => category.commands.length > 0);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
filtered.forEach((category) => {
|
||||
this.expandedCategories.add(category.label);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getCategoryCommands(category) {
|
||||
return this.commands.filter((cmd) =>
|
||||
category.commands.some(
|
||||
(catCmd) => catCmd.value === cmd.value || catCmd.value === cmd.cmd
|
||||
)
|
||||
);
|
||||
},
|
||||
onDragStart(event, command) {
|
||||
event.dataTransfer.setData(
|
||||
"action",
|
||||
JSON.stringify({
|
||||
...command,
|
||||
value: command.value,
|
||||
})
|
||||
);
|
||||
event.target.classList.add("dragging");
|
||||
event.target.addEventListener(
|
||||
"dragend",
|
||||
() => {
|
||||
event.target.classList.remove("dragging");
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
},
|
||||
highlightText(text) {
|
||||
if (!this.searchQuery) return text;
|
||||
|
||||
const matches = pinyinMatch.match(text, this.searchQuery);
|
||||
if (!matches) return text;
|
||||
|
||||
const [start, end] = matches;
|
||||
return (
|
||||
text.slice(0, start) +
|
||||
`<span class="highlight">${text.slice(start, end + 1)}</span>` +
|
||||
text.slice(end + 1)
|
||||
);
|
||||
},
|
||||
isExpanded(category) {
|
||||
return (
|
||||
category.defaultOpened || this.expandedCategories.has(category.label)
|
||||
);
|
||||
},
|
||||
updateExpanded(category, value) {
|
||||
if (value) {
|
||||
this.expandedCategories.add(category.label);
|
||||
} else {
|
||||
this.expandedCategories.delete(category.label);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.searchInput.focus();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.composer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.command-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input :deep(.q-field__control) {
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.search-input :deep(.q-field__native) {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-input :deep(.q-field__marginal) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.composer-list :deep(.q-expansion-item) {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.body--dark .composer-list {
|
||||
background-color: rgba(32, 32, 32, 0.8);
|
||||
}
|
||||
|
||||
.category-item {
|
||||
margin: 4px 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.q-item.category-header) {
|
||||
min-height: 40px;
|
||||
margin: 0 8px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.command-item.q-item-type {
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
margin: 4px 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
cursor: grab;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.command-item.q-item-type:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.command-item.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.body--dark .command-item.q-item-type {
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.body--dark .command-item.q-item-type:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.body--dark .section-header {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.command-item :deep(.highlight) {
|
||||
color: var(--q-primary);
|
||||
font-weight: bold;
|
||||
padding: 0 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
398
src/components/composer/crypto/AsymmetricCryptoEditor.vue
Normal file
398
src/components/composer/crypto/AsymmetricCryptoEditor.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="asymmetric-crypto-editor">
|
||||
<!-- 加密/解密切换 -->
|
||||
<q-btn-toggle
|
||||
v-model="operation"
|
||||
:options="[
|
||||
{ label: '加密', value: 'encrypt' },
|
||||
{ label: '解密', value: 'decrypt' },
|
||||
]"
|
||||
spread
|
||||
dense
|
||||
no-caps
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
/>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<div class="row">
|
||||
<VariableInput
|
||||
v-model="text"
|
||||
:label="operation === 'encrypt' ? '要加密的文本' : '要解密的文本'"
|
||||
:command="{
|
||||
icon:
|
||||
operation === 'encrypt' ? 'enhanced_encryption' : 'no_encryption',
|
||||
}"
|
||||
class="col-12"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 算法选择 -->
|
||||
<q-select
|
||||
v-model="algorithm"
|
||||
:options="algorithms"
|
||||
label="加密算法"
|
||||
dense
|
||||
filled
|
||||
class="col-grow"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<!-- RSA填充选择 -->
|
||||
<q-select
|
||||
v-if="algorithm === 'RSA'"
|
||||
v-model="padding"
|
||||
:options="paddings"
|
||||
label="填充方式"
|
||||
dense
|
||||
filled
|
||||
class="col-grow"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<!-- SM2密文格式选择 -->
|
||||
<q-select
|
||||
v-if="algorithm === 'SM2'"
|
||||
v-model="cipherMode"
|
||||
:options="[
|
||||
{ label: 'C1C3C2', value: 1 },
|
||||
{ label: 'C1C2C3', value: 0 },
|
||||
]"
|
||||
label="密文格式"
|
||||
dense
|
||||
filled
|
||||
class="col-grow"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<!-- 格式选择 -->
|
||||
<q-select
|
||||
v-model="format"
|
||||
:options="operation === 'encrypt' ? outputFormats : inputFormats"
|
||||
:label="operation === 'encrypt' ? '输出格式' : '输入格式'"
|
||||
dense
|
||||
filled
|
||||
class="col-grow"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 密钥输入区域 -->
|
||||
<div class="col-6 key-input">
|
||||
<div class="key-wrapper">
|
||||
<q-input
|
||||
v-model="publicKey"
|
||||
type="textarea"
|
||||
filled
|
||||
autogrow
|
||||
label="公钥"
|
||||
class="key-textarea"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
<q-btn-dropdown
|
||||
flat
|
||||
dense
|
||||
:label="publicKeyCodec"
|
||||
class="codec-dropdown"
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="codec in keyCodecs"
|
||||
:key="codec.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="publicKeyCodec = codec.value"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ codec.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 key-input">
|
||||
<div class="key-wrapper">
|
||||
<q-input
|
||||
v-model="privateKey"
|
||||
type="textarea"
|
||||
filled
|
||||
autogrow
|
||||
label="私钥"
|
||||
class="key-textarea"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
<q-btn-dropdown
|
||||
flat
|
||||
dense
|
||||
:label="privateKeyCodec"
|
||||
class="codec-dropdown"
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="codec in keyCodecs"
|
||||
:key="codec.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="privateKeyCodec = codec.value"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ codec.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
import { formatJsonVariables } from "js/composer/formatString";
|
||||
|
||||
export default defineComponent({
|
||||
name: "AsymmetricCryptoEditor",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
operation: "encrypt",
|
||||
text: "",
|
||||
algorithm: "RSA",
|
||||
padding: "RSAES-PKCS1-V1_5",
|
||||
cipherMode: 1,
|
||||
publicKey: "",
|
||||
privateKey: "",
|
||||
publicKeyCodec: "Pem",
|
||||
privateKeyCodec: "Pem",
|
||||
format: "Base64",
|
||||
algorithms: [
|
||||
{ label: "RSA", value: "RSA" },
|
||||
{ label: "SM2", value: "SM2" },
|
||||
],
|
||||
paddings: [
|
||||
{ label: "PKCS#1 v1.5", value: "RSAES-PKCS1-V1_5" },
|
||||
{ label: "OAEP", value: "RSA-OAEP" },
|
||||
{ label: "OAEP/SHA-256", value: "RSA-OAEP-256" },
|
||||
],
|
||||
keyCodecs: [
|
||||
{ label: "PEM", value: "Pem" },
|
||||
{ label: "Base64", value: "Base64" },
|
||||
{ label: "Hex", value: "Hex" },
|
||||
],
|
||||
outputFormats: [
|
||||
{ label: "Base64", value: "Base64" },
|
||||
{ label: "Hex", value: "Hex" },
|
||||
],
|
||||
inputFormats: [
|
||||
{ label: "Base64", value: "Base64" },
|
||||
{ label: "Hex", value: "Hex" },
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateConfig() {
|
||||
const code = `quickcomposer.textProcessing.asymmetricCrypto(${formatJsonVariables(
|
||||
{
|
||||
text: this.text,
|
||||
algorithm: this.algorithm,
|
||||
operation: this.operation,
|
||||
format: this.format,
|
||||
publicKey: {
|
||||
key: this.publicKey,
|
||||
codec: this.publicKeyCodec,
|
||||
},
|
||||
privateKey: {
|
||||
key: this.privateKey,
|
||||
codec: this.privateKeyCodec,
|
||||
},
|
||||
padding: this.algorithm === "RSA" ? this.padding : undefined,
|
||||
cipherMode: this.algorithm === "SM2" ? this.cipherMode : undefined,
|
||||
},
|
||||
["text"]
|
||||
)})`;
|
||||
|
||||
this.$emit("update:model-value", code);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
operation() {
|
||||
this.format = "Base64";
|
||||
this.updateConfig();
|
||||
},
|
||||
algorithm() {
|
||||
if (this.algorithm === "RSA") {
|
||||
this.padding = "PKCS#1";
|
||||
} else {
|
||||
this.cipherMode = 1;
|
||||
}
|
||||
this.updateConfig();
|
||||
},
|
||||
// 监听所有可能改变的值
|
||||
text() {
|
||||
this.updateConfig();
|
||||
},
|
||||
padding() {
|
||||
this.updateConfig();
|
||||
},
|
||||
cipherMode() {
|
||||
this.updateConfig();
|
||||
},
|
||||
publicKey() {
|
||||
this.updateConfig();
|
||||
},
|
||||
privateKey() {
|
||||
this.updateConfig();
|
||||
},
|
||||
publicKeyCodec() {
|
||||
this.updateConfig();
|
||||
},
|
||||
privateKeyCodec() {
|
||||
this.updateConfig();
|
||||
},
|
||||
format() {
|
||||
this.updateConfig();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asymmetric-crypto-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.col-grow {
|
||||
flex: 1 1 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 密钥输入区域样式 */
|
||||
.key-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: calc(50% - 8px);
|
||||
max-width: calc(50% - 8px);
|
||||
}
|
||||
|
||||
.key-wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.key-textarea {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 确保输入框占满容器 */
|
||||
.key-textarea :deep(.q-field) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 确保文本区域占满输入框 */
|
||||
.key-textarea :deep(.q-field__native) {
|
||||
min-height: 120px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* 编码选择下拉按钮样式 */
|
||||
.codec-dropdown {
|
||||
min-width: 45px;
|
||||
max-width: 45px;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
height: 20px;
|
||||
line-height: 16px;
|
||||
border-radius: 4px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* 下拉菜单项样式 */
|
||||
.codec-dropdown :deep(.q-btn-dropdown__arrow) {
|
||||
font-size: 10px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.codec-dropdown :deep(.q-list) {
|
||||
min-width: 60px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.codec-dropdown :deep(.q-item) {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.codec-dropdown :deep(.q-item__label) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.body--dark .codec-dropdown {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.body--dark .codec-dropdown :deep(.q-list) {
|
||||
background: #1d1d1d;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 确保选择器行在空间不够时换行美观 */
|
||||
@media (max-width: 600px) {
|
||||
.col-grow {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.key-input {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.q-btn-toggle {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.body--dark .q-btn-toggle {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* 确保下拉按钮内容垂直居中 */
|
||||
.codec-dropdown :deep(.q-btn__content) {
|
||||
min-height: unset;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 调整下拉按钮的内容间距 */
|
||||
.codec-dropdown :deep(.q-btn__wrapper) {
|
||||
padding: 0 4px;
|
||||
min-height: unset;
|
||||
}
|
||||
</style>
|
||||
432
src/components/composer/crypto/SymmetricCryptoEditor.vue
Normal file
432
src/components/composer/crypto/SymmetricCryptoEditor.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<div class="symmetric-crypto-editor q-gutter-y-sm">
|
||||
<!-- 加密/解密切换 -->
|
||||
<q-btn-toggle
|
||||
v-model="operation"
|
||||
:options="[
|
||||
{ label: '加密', value: 'encrypt' },
|
||||
{ label: '解密', value: 'decrypt' },
|
||||
]"
|
||||
spread
|
||||
dense
|
||||
no-caps
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
/>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<div class="row">
|
||||
<VariableInput
|
||||
v-model="text"
|
||||
:label="operation === 'encrypt' ? '要加密的文本' : '要解密的文本'"
|
||||
:command="{
|
||||
icon:
|
||||
operation === 'encrypt' ? 'enhanced_encryption' : 'no_encryption',
|
||||
}"
|
||||
class="col-8"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
<q-select
|
||||
v-model="format"
|
||||
:options="operation === 'encrypt' ? outputFormats : inputFormats"
|
||||
:label="operation === 'encrypt' ? '输出格式' : '输入格式'"
|
||||
dense
|
||||
filled
|
||||
class="col-4"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 算法选择 -->
|
||||
<q-select
|
||||
v-model="algorithm"
|
||||
:options="algorithms"
|
||||
label="加密算法"
|
||||
dense
|
||||
filled
|
||||
class="col-select"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<!-- AES密钥长度选择 -->
|
||||
<q-select
|
||||
v-if="showKeyLength"
|
||||
v-model="keyLength"
|
||||
:options="[
|
||||
{ label: '128位', value: 128 },
|
||||
{ label: '192位', value: 192 },
|
||||
{ label: '256位', value: 256 },
|
||||
]"
|
||||
label="密钥长度"
|
||||
dense
|
||||
filled
|
||||
class="col-select"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<!-- 模式选择 -->
|
||||
<q-select
|
||||
v-model="mode"
|
||||
:options="modes"
|
||||
label="加密模式"
|
||||
dense
|
||||
filled
|
||||
class="col-select"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<!-- Padding选择 -->
|
||||
<q-select
|
||||
v-model="padding"
|
||||
:options="paddings"
|
||||
label="填充方式"
|
||||
dense
|
||||
filled
|
||||
class="col-select"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 密钥输入区域 -->
|
||||
<div class="col-grow key-input">
|
||||
<div class="key-wrapper">
|
||||
<q-input
|
||||
v-model="key"
|
||||
filled
|
||||
label="密钥"
|
||||
class="key-input"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
<q-btn-dropdown flat dense :label="keyCodec" class="codec-dropdown">
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="codec in keyCodecs"
|
||||
:key="codec.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="keyCodec = codec.value"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ codec.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<!-- IV输入区域 -->
|
||||
<div v-if="showIV" class="col-grow key-input">
|
||||
<div class="key-wrapper">
|
||||
<q-input
|
||||
v-model="iv"
|
||||
filled
|
||||
label="IV"
|
||||
class="key-input"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
<q-btn-dropdown flat dense :label="ivCodec" class="codec-dropdown">
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="codec in keyCodecs"
|
||||
:key="codec.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="ivCodec = codec.value"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ codec.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
import { formatJsonVariables } from "js/composer/formatString";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SymmetricCryptoEditor",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Object],
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
operation: "encrypt",
|
||||
text: "",
|
||||
algorithm: "AES",
|
||||
keyLength: 128,
|
||||
mode: "CBC",
|
||||
padding: "Pkcs7",
|
||||
key: "",
|
||||
keyCodec: "Utf8",
|
||||
iv: "",
|
||||
ivCodec: "Utf8",
|
||||
format: "Base64",
|
||||
keyCodecs: [
|
||||
{ label: "UTF-8", value: "Utf8" },
|
||||
{ label: "Base64", value: "Base64" },
|
||||
{ label: "Hex", value: "Hex" },
|
||||
],
|
||||
algorithms: [
|
||||
{ label: "AES", value: "AES" },
|
||||
{ label: "SM4", value: "SM4" },
|
||||
],
|
||||
outputFormats: [
|
||||
{ label: "Base64", value: "Base64" },
|
||||
{ label: "Hex", value: "Hex" },
|
||||
],
|
||||
inputFormats: [
|
||||
{ label: "Base64", value: "Base64" },
|
||||
{ label: "Hex", value: "Hex" },
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modes() {
|
||||
// SM4 只支持 ECB/CBC
|
||||
if (this.algorithm === "SM4") {
|
||||
return [
|
||||
{ label: "ECB", value: "ECB" },
|
||||
{ label: "CBC", value: "CBC" },
|
||||
];
|
||||
}
|
||||
// AES/DES/3DES 支持更多模式
|
||||
return [
|
||||
{ label: "ECB", value: "ECB" },
|
||||
{ label: "CBC", value: "CBC" },
|
||||
{ label: "CFB", value: "CFB" },
|
||||
{ label: "OFB", value: "OFB" },
|
||||
{ label: "CTR", value: "CTR" },
|
||||
{ label: "GCM", value: "GCM" },
|
||||
];
|
||||
},
|
||||
paddings() {
|
||||
// SM4 支持的填充方式
|
||||
if (this.algorithm === "SM4") {
|
||||
return [
|
||||
{ label: "PKCS#7", value: "pkcs#7" },
|
||||
{ label: "None", value: "none" },
|
||||
];
|
||||
}
|
||||
// AES/DES/3DES 支持的填充方式
|
||||
return [
|
||||
{ label: "PKCS7", value: "Pkcs7" },
|
||||
{ label: "Zero Padding", value: "ZeroPadding" },
|
||||
{ label: "No Padding", value: "NoPadding" },
|
||||
{ label: "ISO-10126", value: "Iso10126" },
|
||||
{ label: "ANSI X.923", value: "AnsiX923" },
|
||||
{ label: "ISO-97971", value: "Iso97971" },
|
||||
];
|
||||
},
|
||||
showIV() {
|
||||
return this.mode !== "ECB";
|
||||
},
|
||||
showKeyLength() {
|
||||
return this.algorithm === "AES";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateConfig() {
|
||||
const code = `quickcomposer.textProcessing.symmetricCrypto(${formatJsonVariables(
|
||||
{
|
||||
text: this.text,
|
||||
algorithm: this.algorithm,
|
||||
mode: this.mode,
|
||||
padding: this.padding,
|
||||
key: {
|
||||
value: this.key,
|
||||
codec: this.keyCodec,
|
||||
},
|
||||
keyLength: this.keyLength,
|
||||
operation: this.operation,
|
||||
format: this.format,
|
||||
iv:
|
||||
this.mode !== "ECB"
|
||||
? {
|
||||
value: this.iv,
|
||||
codec: this.ivCodec,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
["text"]
|
||||
)})`;
|
||||
|
||||
this.$emit("update:model-value", code);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
operation() {
|
||||
// 切换操作时重置格式为默认值
|
||||
this.format = "Base64";
|
||||
this.updateConfig();
|
||||
},
|
||||
text() {
|
||||
this.updateConfig();
|
||||
},
|
||||
algorithm() {
|
||||
// 切换算法时重置模式和填充
|
||||
if (this.algorithm === "SM4") {
|
||||
this.mode = "ECB";
|
||||
this.padding = "pkcs#7";
|
||||
} else {
|
||||
this.mode = "CBC";
|
||||
this.padding = "Pkcs7";
|
||||
}
|
||||
this.updateConfig();
|
||||
},
|
||||
mode() {
|
||||
this.updateConfig();
|
||||
},
|
||||
padding() {
|
||||
this.updateConfig();
|
||||
},
|
||||
format() {
|
||||
this.updateConfig();
|
||||
},
|
||||
keyLength() {
|
||||
this.updateConfig();
|
||||
},
|
||||
key() {
|
||||
this.updateConfig();
|
||||
},
|
||||
iv() {
|
||||
this.updateConfig();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crypto-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.col-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.col-grow {
|
||||
flex: 1 1 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 确保第一行的输入框和格式选择器的比例固定 */
|
||||
.row:first-of-type .col-8 {
|
||||
flex: 4;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.row:first-of-type .col-4 {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 确保选择器行在空间不够时换行美观 */
|
||||
@media (max-width: 600px) {
|
||||
.col-select {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.q-btn-toggle {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.body--dark .q-btn-toggle {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.key-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.key-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 编码选择下拉按钮样式 */
|
||||
.codec-dropdown {
|
||||
min-width: 45px;
|
||||
max-width: 45px;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
height: 20px;
|
||||
line-height: 16px;
|
||||
border-radius: 4px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* 下拉菜单项样式 */
|
||||
.codec-dropdown :deep(.q-btn-dropdown__arrow) {
|
||||
font-size: 10px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.codec-dropdown :deep(.q-list) {
|
||||
min-width: 60px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.codec-dropdown :deep(.q-item) {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.codec-dropdown :deep(.q-item__label) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.body--dark .codec-dropdown {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.body--dark .codec-dropdown :deep(.q-list) {
|
||||
background: #1d1d1d;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 确保下拉按钮内容垂直居中 */
|
||||
.codec-dropdown :deep(.q-btn__content) {
|
||||
min-height: unset;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 调整下拉按钮的内容间距 */
|
||||
.codec-dropdown :deep(.q-btn__wrapper) {
|
||||
padding: 0 4px;
|
||||
min-height: unset;
|
||||
}
|
||||
</style>
|
||||
373
src/components/composer/http/AxiosConfigEditor.vue
Normal file
373
src/components/composer/http/AxiosConfigEditor.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<!-- 基础配置 -->
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-3">
|
||||
<q-select
|
||||
v-model="localConfig.method"
|
||||
:options="[
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
'PATCH',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
]"
|
||||
label="请求方法"
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
@update:model-value="updateConfig"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="send" />
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
v-model="localConfig.url"
|
||||
label="请求地址"
|
||||
:command="{ icon: 'link' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 响应设置 -->
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-3">
|
||||
<q-select
|
||||
v-model="localConfig.responseType"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
:options="['json', 'text', 'blob', 'arraybuffer']"
|
||||
label="响应类型"
|
||||
@update:model-value="updateConfig"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="data_object" />
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
v-model="localConfig.maxRedirects"
|
||||
label="最大重定向次数"
|
||||
:command="{ icon: 'repeat', inputType: 'number' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
v-model="localConfig.timeout"
|
||||
label="超时时间(ms)"
|
||||
:command="{ icon: 'timer', inputType: 'number' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Headers -->
|
||||
<!-- Content-Type -->
|
||||
<q-select
|
||||
v-model="localConfig.headers['Content-Type']"
|
||||
label="Content-Type"
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
:options="contentTypes"
|
||||
class="col-12"
|
||||
@update:model-value="updateConfig"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="data_object" />
|
||||
</template>
|
||||
</q-select>
|
||||
<!-- User-Agent -->
|
||||
<div class="col-12 row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
v-model="localConfig.headers['User-Agent']"
|
||||
label="User Agent"
|
||||
:command="{ icon: 'devices' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto flex items-center">
|
||||
<q-btn-dropdown flat dense dropdown-icon="menu">
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="ua in userAgentOptions"
|
||||
:key="ua.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="setUserAgent(ua.value)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ ua.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Headers -->
|
||||
<div class="col-12" style="margin-top: -8px">
|
||||
<BorderLabel label="Headers">
|
||||
<DictEditor
|
||||
v-model="otherHeaders"
|
||||
:options="{
|
||||
items: commonHeaderOptions,
|
||||
}"
|
||||
@update:model-value="updateHeaders"
|
||||
/>
|
||||
</BorderLabel>
|
||||
</div>
|
||||
|
||||
<!-- 请求体 -->
|
||||
<div v-if="hasRequestData" class="col-12" style="margin-top: -8px">
|
||||
<BorderLabel label="请求体">
|
||||
<DictEditor
|
||||
v-model="localConfig.data"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</BorderLabel>
|
||||
</div>
|
||||
|
||||
<!-- 请求参数 -->
|
||||
<div class="col-12" style="margin-top: -8px">
|
||||
<BorderLabel label="URL参数">
|
||||
<DictEditor
|
||||
v-model="localConfig.params"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</BorderLabel>
|
||||
</div>
|
||||
|
||||
<!-- 认证信息 -->
|
||||
<div class="col-12" style="margin-top: -8px">
|
||||
<BorderLabel label="HTTP认证">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<VariableInput
|
||||
v-model="localConfig.auth.username"
|
||||
label="用户名"
|
||||
:command="{ icon: 'person' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<VariableInput
|
||||
v-model="localConfig.auth.password"
|
||||
label="密码"
|
||||
:command="{ icon: 'password' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BorderLabel>
|
||||
</div>
|
||||
|
||||
<!-- 代理设置 -->
|
||||
<div class="col-12" style="margin-top: -8px">
|
||||
<BorderLabel label="代理设置">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfig.proxy.host"
|
||||
label="主机"
|
||||
:command="{ icon: 'dns' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfig.proxy.port"
|
||||
label="端口"
|
||||
:command="{ icon: 'router', inputType: 'number' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfig.proxy.auth.username"
|
||||
label="用户名"
|
||||
:command="{ icon: 'person' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfig.proxy.auth.password"
|
||||
label="密码"
|
||||
:command="{ icon: 'password' }"
|
||||
@update:model-value="updateConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BorderLabel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
import DictEditor from "components/composer/ui/DictEditor.vue";
|
||||
import { formatJsonVariables } from "js/composer/formatString";
|
||||
import { userAgent, commonHeaders, contentTypes } from "js/options/httpHeaders";
|
||||
import BorderLabel from "components/composer/ui/BorderLabel.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "AxiosConfigEditor",
|
||||
components: {
|
||||
VariableInput,
|
||||
DictEditor,
|
||||
BorderLabel,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [Object, String],
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
let initialConfig = {};
|
||||
if (typeof this.modelValue === "string") {
|
||||
try {
|
||||
const match = this.modelValue.match(
|
||||
/axios\.\w+\([^{]*({\s*[^]*})\s*\)/
|
||||
);
|
||||
if (match) {
|
||||
initialConfig = JSON.parse(match[1]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse config from code string");
|
||||
}
|
||||
} else {
|
||||
initialConfig = this.modelValue;
|
||||
}
|
||||
|
||||
return {
|
||||
localConfig: {
|
||||
url: "",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
params: {},
|
||||
data: {},
|
||||
timeout: 0,
|
||||
maxRedirects: 5,
|
||||
responseType: "json",
|
||||
auth: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
proxy: {
|
||||
host: "",
|
||||
port: "",
|
||||
auth: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
},
|
||||
...initialConfig,
|
||||
},
|
||||
userAgentOptions: userAgent,
|
||||
contentTypes,
|
||||
commonHeaderOptions: commonHeaders
|
||||
.filter((h) => !["User-Agent", "Content-Type"].includes(h.value))
|
||||
.map((h) => h.value),
|
||||
otherHeaders: {},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 将headers对象转换为数组形式
|
||||
this.headers = Object.entries(this.localConfig.headers || {}).map(
|
||||
([name, value]) => ({ name, value })
|
||||
);
|
||||
},
|
||||
computed: {
|
||||
hasRequestData() {
|
||||
return ["PUT", "POST", "PATCH"].includes(this.localConfig.method);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateConfig() {
|
||||
// 生成代码
|
||||
const { method = "GET", url, data, ...restConfig } = this.localConfig;
|
||||
if (!url) return;
|
||||
|
||||
// 这两个字段非VariableInput获取,不进行处理
|
||||
const excludeFields = ["headers.Content-Type", "responseType"];
|
||||
const configStr = Object.keys(restConfig).length
|
||||
? `, ${formatJsonVariables(restConfig, null, excludeFields)}`
|
||||
: "";
|
||||
|
||||
const code = `axios.${method.toLowerCase()}(${url}${
|
||||
this.hasRequestData ? `, ${formatJsonVariables(data)}` : ""
|
||||
}${configStr})?.data`;
|
||||
|
||||
this.$emit("update:modelValue", code);
|
||||
},
|
||||
updateHeaders(headers) {
|
||||
// 保留 Content-Type 和 User-Agent
|
||||
const { "Content-Type": contentType, "User-Agent": userAgent } =
|
||||
this.localConfig.headers;
|
||||
// 重置 headers,只保留特殊字段
|
||||
this.localConfig.headers = {
|
||||
"Content-Type": contentType,
|
||||
...(userAgent ? { "User-Agent": userAgent } : {}),
|
||||
...headers,
|
||||
};
|
||||
this.updateConfig();
|
||||
},
|
||||
setUserAgent(value) {
|
||||
this.localConfig.headers["User-Agent"] = value;
|
||||
this.updateConfig();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
deep: true,
|
||||
handler(newValue) {
|
||||
if (typeof newValue === "string") {
|
||||
// 如果是字符串,明是编辑现有的配置
|
||||
try {
|
||||
const config = JSON.parse(newValue);
|
||||
this.localConfig = {
|
||||
...this.localConfig,
|
||||
...config,
|
||||
};
|
||||
this.headers = Object.entries(config.headers || {}).map(
|
||||
([name, value]) => ({ name, value })
|
||||
);
|
||||
} catch (e) {
|
||||
// 如果解析失败,保持当前状态
|
||||
}
|
||||
} else {
|
||||
this.localConfig = {
|
||||
...this.localConfig,
|
||||
...newValue,
|
||||
};
|
||||
this.headers = Object.entries(this.localConfig.headers || {}).map(
|
||||
([name, value]) => ({ name, value })
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
129
src/components/composer/ubrowser/UBrowserBasic.vue
Normal file
129
src/components/composer/ubrowser/UBrowserBasic.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<!-- 基础配置 -->
|
||||
<div class="col-12">
|
||||
<VariableInput
|
||||
v-model="localConfigs.goto.url"
|
||||
label="网址"
|
||||
:command="{ icon: 'link' }"
|
||||
@update:model-value="updateConfigs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Headers配置 -->
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12">
|
||||
<VariableInput
|
||||
v-model="localConfigs.goto.headers.Referer"
|
||||
label="Referer"
|
||||
:command="{ icon: 'link' }"
|
||||
@update:model-value="updateConfigs"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
v-model="localConfigs.goto.headers.userAgent"
|
||||
label="User-Agent"
|
||||
:command="{ icon: 'devices' }"
|
||||
@update:model-value="updateConfigs"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-select
|
||||
v-model="selectedUA"
|
||||
:options="userAgentOptions"
|
||||
label="常用 UA"
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
options-dense
|
||||
style="min-width: 150px"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="list" />
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 超时配置 -->
|
||||
<div class="col-12">
|
||||
<VariableInput
|
||||
v-model="localConfigs.goto.timeout"
|
||||
:command="{ icon: 'timer', inputType: 'number' }"
|
||||
label="超时时间(ms)"
|
||||
@update:model-value="updateConfigs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { userAgent } from "js/options/httpHeaders";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserBasic",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
configs: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedUA: null,
|
||||
localConfigs: {
|
||||
useragent: {
|
||||
preset: null,
|
||||
value: "",
|
||||
},
|
||||
goto: {
|
||||
url: "",
|
||||
headers: {
|
||||
Referer: "",
|
||||
userAgent: "",
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
},
|
||||
userAgentOptions: userAgent,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 初始化本地配置
|
||||
this.localConfigs = window.lodashM.cloneDeep(this.configs);
|
||||
},
|
||||
methods: {
|
||||
updateConfigs() {
|
||||
this.$emit("update:configs", window.lodashM.cloneDeep(this.localConfigs));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
configs: {
|
||||
deep: true,
|
||||
handler(newConfigs) {
|
||||
this.localConfigs = window.lodashM.cloneDeep(newConfigs);
|
||||
},
|
||||
},
|
||||
selectedUA(value) {
|
||||
if (value) {
|
||||
this.localConfigs.goto.headers.userAgent = value;
|
||||
this.updateConfigs();
|
||||
this.selectedUA = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
143
src/components/composer/ubrowser/UBrowserEditor.vue
Normal file
143
src/components/composer/ubrowser/UBrowserEditor.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="ubrowser-editor">
|
||||
<q-stepper
|
||||
v-model="step"
|
||||
vertical
|
||||
color="primary"
|
||||
header-nav
|
||||
animated
|
||||
alternative-labels
|
||||
flat
|
||||
class="ubrowser-stepper"
|
||||
>
|
||||
<!-- 基础参数步骤 -->
|
||||
<q-step :name="1" title="基础参数" icon="settings" :done="step > 1">
|
||||
<UBrowserBasic :configs="configs" @update:configs="updateConfigs" />
|
||||
</q-step>
|
||||
|
||||
<!-- 浏览器操作步骤 -->
|
||||
<q-step :name="2" title="浏览器操作" icon="touch_app" :done="step > 2">
|
||||
<UBrowserOperations
|
||||
:configs="configs"
|
||||
@update:configs="updateConfigs"
|
||||
v-model:selected-actions="selectedActions"
|
||||
@remove-action="removeAction"
|
||||
/>
|
||||
</q-step>
|
||||
|
||||
<!-- 运行参数步骤 -->
|
||||
<q-step
|
||||
:name="3"
|
||||
title="运行参数"
|
||||
icon="settings_applications"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<UBrowserRun :configs="configs" @update:configs="updateConfigs" />
|
||||
</q-step>
|
||||
</q-stepper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ubrowser-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ubrowser-stepper {
|
||||
box-shadow: none;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.body--dark .ubrowser-stepper {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ubrowser-stepper :deep(.q-stepper__header) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ubrowser-stepper :deep(.q-stepper__step-inner) {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import UBrowserBasic from "./UBrowserBasic.vue";
|
||||
import UBrowserOperations from "./UBrowserOperations.vue";
|
||||
import UBrowserRun from "./UBrowserRun.vue";
|
||||
import { defaultUBrowserConfigs } from "js/composer/ubrowserConfig";
|
||||
import { generateUBrowserCode } from "js/composer/generateUBrowserCode";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserEditor",
|
||||
components: {
|
||||
UBrowserBasic,
|
||||
UBrowserOperations,
|
||||
UBrowserRun,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
step: 1,
|
||||
selectedActions: [],
|
||||
configs: window.lodashM.cloneDeep(defaultUBrowserConfigs),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateConfigs(newConfigs) {
|
||||
this.configs = newConfigs;
|
||||
},
|
||||
removeAction(action) {
|
||||
const newActions = this.selectedActions.filter((a) => a.id !== action.id);
|
||||
this.selectedActions = newActions;
|
||||
const newConfigs = { ...this.configs };
|
||||
delete newConfigs[action.value];
|
||||
this.configs = newConfigs;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
configs: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$emit(
|
||||
"update:modelValue",
|
||||
generateUBrowserCode(this.configs, this.selectedActions)
|
||||
);
|
||||
},
|
||||
},
|
||||
selectedActions: {
|
||||
handler() {
|
||||
this.$emit(
|
||||
"update:modelValue",
|
||||
generateUBrowserCode(this.configs, this.selectedActions)
|
||||
);
|
||||
},
|
||||
},
|
||||
step: {
|
||||
handler() {
|
||||
this.$emit(
|
||||
"update:modelValue",
|
||||
generateUBrowserCode(this.configs, this.selectedActions)
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ubrowser-editor :deep(.q-stepper) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ubrowser-editor :deep(.q-stepper__tab) {
|
||||
padding: 5px 25px;
|
||||
}
|
||||
</style>
|
||||
282
src/components/composer/ubrowser/UBrowserOperations.vue
Normal file
282
src/components/composer/ubrowser/UBrowserOperations.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12">
|
||||
<!-- 操作选择网格 -->
|
||||
<div class="row q-col-gutter-xs">
|
||||
<div
|
||||
v-for="action in ubrowserOperationConfigs"
|
||||
:key="action.value"
|
||||
class="col-2"
|
||||
>
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="action-card cursor-pointer"
|
||||
:class="{
|
||||
'action-selected': selectedActions.some(
|
||||
(a) => a.value === action.value
|
||||
),
|
||||
}"
|
||||
@click="toggleAction(action)"
|
||||
>
|
||||
<div class="q-pa-xs text-caption text-wrap text-center">
|
||||
{{ action.label }}
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选操作列表 -->
|
||||
<q-list separator class="operation-list q-mt-md">
|
||||
<div
|
||||
v-for="(action, index) in selectedActions"
|
||||
:key="action.id"
|
||||
class="operation-item"
|
||||
>
|
||||
<div class="row items-center justify-between">
|
||||
<q-chip
|
||||
square
|
||||
removable
|
||||
@remove="$emit('remove-action', action)"
|
||||
class="text-caption q-mx-none q-mb-sm"
|
||||
>
|
||||
<q-avatar color="primary">
|
||||
<q-icon
|
||||
color="white"
|
||||
:name="getActionProps(action, 'icon') || 'touch_app'"
|
||||
size="14px"
|
||||
/>
|
||||
</q-avatar>
|
||||
<div class="q-mx-sm">{{ action.label }}</div>
|
||||
</q-chip>
|
||||
<div class="row items-start q-gutter-xs">
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
color="primary"
|
||||
icon="north"
|
||||
v-show="index !== 0"
|
||||
@click="moveAction(index, -1)"
|
||||
size="xs"
|
||||
class="q-mb-xs move-btn"
|
||||
/>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
color="primary"
|
||||
icon="south"
|
||||
v-show="index !== selectedActions.length - 1"
|
||||
@click="moveAction(index, 1)"
|
||||
size="xs"
|
||||
class="move-btn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getActionProps(action, 'config')">
|
||||
<UBrowserOperation
|
||||
:configs="configs"
|
||||
:action="action.value"
|
||||
:fields="getActionProps(action, 'config')"
|
||||
@update:configs="$emit('update:configs', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { ubrowserOperationConfigs } from "js/composer/composerConfig";
|
||||
import UBrowserOperation from "./operations/UBrowserOperation.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserOperations",
|
||||
components: {
|
||||
UBrowserOperation,
|
||||
},
|
||||
props: {
|
||||
configs: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
selectedActions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ubrowserOperationConfigs: ubrowserOperationConfigs,
|
||||
};
|
||||
},
|
||||
emits: ["remove-action", "update:selectedActions", "update:configs"],
|
||||
methods: {
|
||||
moveAction(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex >= 0 && newIndex < this.selectedActions.length) {
|
||||
const actions = [...this.selectedActions];
|
||||
const temp = actions[index];
|
||||
actions[index] = actions[newIndex];
|
||||
actions[newIndex] = temp;
|
||||
this.$emit("update:selectedActions", actions);
|
||||
}
|
||||
},
|
||||
toggleAction(action) {
|
||||
const index = this.selectedActions.findIndex(
|
||||
(a) => a.value === action.value
|
||||
);
|
||||
if (index === -1) {
|
||||
// 添加操作
|
||||
this.$emit("update:selectedActions", [
|
||||
...this.selectedActions,
|
||||
{
|
||||
...action,
|
||||
id: Date.now(),
|
||||
argv: "",
|
||||
saveOutput: false,
|
||||
useOutput: null,
|
||||
cmd: action.value || action.cmd,
|
||||
value: action.value || action.cmd,
|
||||
},
|
||||
]);
|
||||
|
||||
// 初始化配置对象
|
||||
const { config } = action;
|
||||
if (config) {
|
||||
const newConfigs = { ...this.configs };
|
||||
if (!newConfigs[action.value]) {
|
||||
newConfigs[action.value] = {};
|
||||
}
|
||||
// 设置默认值
|
||||
config.forEach((field) => {
|
||||
if (field.defaultValue !== undefined) {
|
||||
newConfigs[action.value][field.key] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
this.$emit("update:configs", newConfigs);
|
||||
}
|
||||
} else {
|
||||
// 移除操作
|
||||
const newActions = [...this.selectedActions];
|
||||
newActions.splice(index, 1);
|
||||
this.$emit("update:selectedActions", newActions);
|
||||
}
|
||||
},
|
||||
getActionProps(action, key) {
|
||||
return this.ubrowserOperationConfigs.find(
|
||||
(a) => a.value === action.value
|
||||
)[key];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operation-list {
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.operation-list :deep(.q-field) div,
|
||||
.operation-list :deep(div.q-checkbox__label) {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.operation-item {
|
||||
transition: all 0.3s;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
/*
|
||||
.operation-item:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.body--dark .operation-item:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
} */
|
||||
|
||||
.move-btn {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.operation-item:hover .move-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.operation-item:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.text-subtitle2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.q-item-section {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.operation-item:hover .q-item-section {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
/* min-height: 42px; */
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
background: var(--q-primary-opacity-5);
|
||||
}
|
||||
|
||||
.action-selected {
|
||||
border-color: var(--q-primary);
|
||||
background: var(--q-primary-opacity-10);
|
||||
}
|
||||
|
||||
.body--dark .action-selected {
|
||||
background: var(--q-primary-opacity-40);
|
||||
}
|
||||
|
||||
.body--dark .action-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.body--dark .action-card:hover {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: var(--q-primary-opacity-20);
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.q-card__section {
|
||||
padding: 4px !important;
|
||||
}
|
||||
|
||||
.row.q-col-gutter-xs {
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.row.q-col-gutter-xs > * {
|
||||
padding: 2px;
|
||||
}
|
||||
</style>
|
||||
202
src/components/composer/ubrowser/UBrowserRun.vue
Normal file
202
src/components/composer/ubrowser/UBrowserRun.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<!-- 窗口显示控制 -->
|
||||
<div class="col-12">
|
||||
<div class="row items-center q-gutter-x-md">
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.show"
|
||||
label="显示窗口"
|
||||
@update:model-value="updateConfig('show', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.center"
|
||||
label="居中显示"
|
||||
@update:model-value="updateConfig('center', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.alwaysOnTop"
|
||||
label="总在最前"
|
||||
@update:model-value="updateConfig('alwaysOnTop', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.fullscreen"
|
||||
label="全屏显示"
|
||||
@update:model-value="updateConfig('fullscreen', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.fullscreenable"
|
||||
label="允许全屏"
|
||||
@update:model-value="updateConfig('fullscreenable', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 窗口尺寸和位置 -->
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.width"
|
||||
label="窗口宽度"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('width', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.height"
|
||||
label="窗口高度"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('height', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.x"
|
||||
label="X坐标"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('x', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.y"
|
||||
label="Y坐标"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('y', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最大最小尺寸 -->
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.minWidth"
|
||||
label="最小宽度"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('minWidth', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.minHeight"
|
||||
label="最小高度"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('minHeight', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.maxWidth"
|
||||
label="最大宽度"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('maxWidth', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<VariableInput
|
||||
v-model="localConfigs.run.maxHeight"
|
||||
label="最大高度"
|
||||
:command="{ inputType: 'number' }"
|
||||
@update:model-value="updateConfig('maxHeight', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 窗口行为控制 -->
|
||||
<div class="col-12">
|
||||
<div class="row items-center q-gutter-x-md">
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.resizable"
|
||||
label="可调整大小"
|
||||
@update:model-value="updateConfig('resizable', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.movable"
|
||||
label="可移动"
|
||||
@update:model-value="updateConfig('movable', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.minimizable"
|
||||
label="可最小化"
|
||||
@update:model-value="updateConfig('minimizable', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.maximizable"
|
||||
label="可最大化"
|
||||
@update:model-value="updateConfig('maximizable', $event)"
|
||||
/>
|
||||
<q-checkbox
|
||||
:model-value="localConfigs.run.enableLargerThanScreen"
|
||||
label="允许超出屏幕"
|
||||
@update:model-value="updateConfig('enableLargerThanScreen', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 透明度控制 -->
|
||||
<div class="col-12">
|
||||
<div class="row items-center" style="height: 36px">
|
||||
<div class="q-mr-md" style="font-size: 12px">透明度</div>
|
||||
<q-slider
|
||||
class="col"
|
||||
v-model="localConfigs.run.opacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
label
|
||||
color="primary"
|
||||
switch-label-side
|
||||
dense
|
||||
@update:model-value="updateConfig('opacity', $event)"
|
||||
>
|
||||
<template v-slot:thumb-label>
|
||||
{{ localConfigs.run.opacity.toFixed(1) }}
|
||||
</template>
|
||||
</q-slider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserRun",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
configs: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:configs"],
|
||||
data() {
|
||||
return {
|
||||
localConfigs: window.lodashM.cloneDeep(this.configs),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
updateConfig(key, value) {
|
||||
this.localConfigs.run[key] = value;
|
||||
this.$emit("update:configs", window.lodashM.cloneDeep(this.localConfigs));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
configs: {
|
||||
deep: true,
|
||||
handler(newConfigs) {
|
||||
this.localConfigs = window.lodashM.cloneDeep(newConfigs);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="row items-center no-wrap">
|
||||
<q-badge class="q-pa-xs">{{ label }}</q-badge>
|
||||
<q-btn-toggle
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
dense
|
||||
flat
|
||||
no-caps
|
||||
spread
|
||||
class="button-group"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserButtonToggle",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean],
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-group {
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.button-group :deep(.q-btn) {
|
||||
min-height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="row items-center no-wrap">
|
||||
<q-badge class="q-pa-xs">{{ label }}</q-badge>
|
||||
<q-btn-toggle
|
||||
:model-value="modelValue ? 'true' : 'false'"
|
||||
:options="[
|
||||
{ label: '是', value: 'true' },
|
||||
{ label: '否', value: 'false' },
|
||||
]"
|
||||
dense
|
||||
flat
|
||||
no-caps
|
||||
spread
|
||||
class="button-group"
|
||||
@update:model-value="$emit('update:modelValue', $event === 'true')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserCheckbox",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="row items-center">
|
||||
<q-option-group
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
type="checkbox"
|
||||
inline
|
||||
dense
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserCheckboxGroup",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div
|
||||
v-for="(cookie, index) in modelValue || [{}]"
|
||||
:key="index"
|
||||
class="col-12"
|
||||
>
|
||||
<div class="row items-center q-gutter-x-sm">
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
:model-value="cookie.name"
|
||||
label="名称"
|
||||
:command="{ icon: 'label' }"
|
||||
@update:model-value="
|
||||
(value) => handleUpdate(index, 'name', value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
:model-value="cookie.value"
|
||||
label="值"
|
||||
:command="{ icon: 'edit' }"
|
||||
@update:model-value="
|
||||
(value) => handleUpdate(index, 'value', value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="negative"
|
||||
icon="remove"
|
||||
@click="removeCookie(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="添加Cookie"
|
||||
@click="addCookie"
|
||||
class="q-mt-xs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserCookieList",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [{ name: "", value: "" }],
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
methods: {
|
||||
addCookie() {
|
||||
const newValue = [...this.modelValue, { name: "", value: "" }];
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
removeCookie(index) {
|
||||
const newValue = [...this.modelValue];
|
||||
newValue.splice(index, 1);
|
||||
if (newValue.length === 0) {
|
||||
newValue.push({ name: "", value: "" });
|
||||
}
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
handleUpdate(index, field, value) {
|
||||
const newValue = [...this.modelValue];
|
||||
newValue[index] = { ...newValue[index], [field]: value };
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
:command="{ icon: icon }"
|
||||
:model-value="modelValue"
|
||||
:label="label"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-select
|
||||
v-model="selectedDevice"
|
||||
:options="deviceOptions"
|
||||
label="常用设备"
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
options-dense
|
||||
style="min-width: 150px"
|
||||
@update:model-value="handleDeviceSelect"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="list" />
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { deviceName } from "js/options/httpHeaders";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserDeviceName",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
selectedDevice: null,
|
||||
deviceOptions: deviceName,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleDeviceSelect(value) {
|
||||
if (value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
this.selectedDevice = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<VariableInput
|
||||
v-model.number="size.width"
|
||||
label="宽度"
|
||||
:command="{ icon: 'width', inputType: 'number' }"
|
||||
@update:model-value="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<VariableInput
|
||||
v-model.number="size.height"
|
||||
label="高度"
|
||||
:command="{ icon: 'height', inputType: 'number' }"
|
||||
@update:model-value="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserDeviceSize",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({ width: 0, height: 0 }),
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
size: {
|
||||
width: this.modelValue.width,
|
||||
height: this.modelValue.height,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleUpdate() {
|
||||
this.$emit("update:modelValue", { ...this.size });
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
deep: true,
|
||||
handler(newValue) {
|
||||
this.size = { ...newValue };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div
|
||||
v-for="(file, index) in modelValue || []"
|
||||
:key="index"
|
||||
class="col-12"
|
||||
>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
:model-value="modelValue[index]"
|
||||
label="文件路径"
|
||||
:command="{ icon: 'folder' }"
|
||||
@update:model-value="(value) => handleUpdate(index, value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="negative"
|
||||
icon="remove"
|
||||
@click="removeFile(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="添加文件"
|
||||
@click="addFile"
|
||||
class="q-mt-xs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserFileList",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
methods: {
|
||||
addFile() {
|
||||
const newValue = [...(this.modelValue || []), ""];
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
removeFile(index) {
|
||||
const newValue = [...this.modelValue];
|
||||
newValue.splice(index, 1);
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
handleUpdate(index, value) {
|
||||
const newValue = [...this.modelValue];
|
||||
newValue[index] = value;
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm ubrowser-function-input">
|
||||
<div class="col-12">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-3">
|
||||
<q-select
|
||||
v-model="localParams"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
dense
|
||||
borderless
|
||||
hide-dropdown-icon
|
||||
options-dense
|
||||
input-debounce="0"
|
||||
new-value-mode="add-unique"
|
||||
label="参数"
|
||||
@update:model-value="updateParams"
|
||||
@input-value="handleInput"
|
||||
@blur="handleBlur"
|
||||
ref="paramSelect"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="text-primary func-symbol">(</div>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<div class="text-primary func-symbol">)</div>
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<q-input
|
||||
v-model="localFunction"
|
||||
:label="label"
|
||||
type="textarea"
|
||||
dense
|
||||
borderless
|
||||
style="font-family: monospace, monoca, consola"
|
||||
autogrow
|
||||
@update:model-value="updateFunction"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="text-primary func-symbol">=> {</div>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<div class="text-primary func-symbol">}</div>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="localParams.length">
|
||||
<div v-for="param in localParams" :key="param" class="col-12">
|
||||
<div class="row q-col-gutter-sm items-center">
|
||||
<div class="col-3">
|
||||
<q-chip
|
||||
dense
|
||||
color="primary"
|
||||
text-color="white"
|
||||
removable
|
||||
@remove="removeParam(param)"
|
||||
>
|
||||
{{ param }}
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<q-input
|
||||
v-model="paramValues[param]"
|
||||
:label="`传递给参数 ${param} 的值`"
|
||||
dense
|
||||
filled
|
||||
@update:model-value="updateParamValue(param, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserFunctionInput",
|
||||
props: {
|
||||
function: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
args: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "函数内容",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "code",
|
||||
},
|
||||
},
|
||||
emits: ["update:function", "update:args"],
|
||||
data() {
|
||||
return {
|
||||
localFunction: "",
|
||||
localParams: [],
|
||||
paramValues: {},
|
||||
newParamName: "",
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 初始化本地数据
|
||||
this.localFunction = this.function;
|
||||
this.localParams = this.args?.map((arg) => arg.name) || [];
|
||||
this.paramValues = Object.fromEntries(
|
||||
this.args?.map((arg) => [arg.name, arg.value]) || []
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
updateFunction(value) {
|
||||
this.localFunction = value;
|
||||
this.emitUpdate();
|
||||
},
|
||||
updateParams(value) {
|
||||
this.localParams = value;
|
||||
this.emitUpdate();
|
||||
},
|
||||
removeParam(param) {
|
||||
const index = this.localParams.indexOf(param);
|
||||
if (index > -1) {
|
||||
this.localParams.splice(index, 1);
|
||||
delete this.paramValues[param];
|
||||
this.emitUpdate();
|
||||
}
|
||||
},
|
||||
updateParamValue(param, value) {
|
||||
this.paramValues[param] = value;
|
||||
this.emitUpdate();
|
||||
},
|
||||
emitUpdate() {
|
||||
this.$emit("update:function", this.localFunction);
|
||||
this.$emit(
|
||||
"update:args",
|
||||
this.localParams.map((name) => ({
|
||||
name,
|
||||
value: this.paramValues[name] || "",
|
||||
}))
|
||||
);
|
||||
},
|
||||
handleInput(val) {
|
||||
if (!val) return;
|
||||
this.newParamName = val;
|
||||
|
||||
if (val.includes(",") || val.includes(" ")) {
|
||||
const params = val
|
||||
.split(/[,\s]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p);
|
||||
params.forEach((param) => {
|
||||
if (param && !this.localParams.includes(param)) {
|
||||
this.localParams = [...this.localParams, param];
|
||||
this.paramValues[param] = "";
|
||||
}
|
||||
});
|
||||
this.newParamName = "";
|
||||
this.emitUpdate();
|
||||
this.$refs.paramSelect.updateInputValue("");
|
||||
}
|
||||
},
|
||||
handleBlur() {
|
||||
if (this.newParamName && !this.localParams.includes(this.newParamName)) {
|
||||
this.localParams = [...this.localParams, this.newParamName];
|
||||
this.paramValues[this.newParamName] = "";
|
||||
this.newParamName = "";
|
||||
this.emitUpdate();
|
||||
this.$refs.paramSelect.updateInputValue("");
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
function: {
|
||||
handler(newValue) {
|
||||
this.localFunction = newValue;
|
||||
},
|
||||
},
|
||||
args: {
|
||||
deep: true,
|
||||
handler(newValue) {
|
||||
this.localParams = newValue?.map((arg) => arg.name) || [];
|
||||
this.paramValues = Object.fromEntries(
|
||||
newValue?.map((arg) => [arg.name, arg.value]) || []
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ubrowser-function-input :deep(.q-field__control) .text-primary.func-symbol {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.ubrowser-function-input :deep(.q-select__input) {
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
.ubrowser-function-input :deep(.q-select .q-field__native) {
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
.ubrowser-function-input :deep(.q-select .q-field__native > div) {
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
.ubrowser-function-input :deep(.q-select .q-chip) {
|
||||
flex: 0 0 auto !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
.ubrowser-function-input :deep(.q-select__input::-webkit-scrollbar),
|
||||
.ubrowser-function-input :deep(.q-select .q-field__native::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-caption q-mb-sm">{{ label }}</div>
|
||||
<div
|
||||
v-for="(param, index) in modelValue || []"
|
||||
:key="index"
|
||||
class="row q-col-gutter-sm q-mb-sm"
|
||||
>
|
||||
<div class="col-5">
|
||||
<VariableInput
|
||||
:model-value="param.name"
|
||||
label="参数名"
|
||||
:command="{ icon: 'label' }"
|
||||
@update:model-value="(value) => handleUpdate(index, 'name', value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<VariableInput
|
||||
:model-value="param.value"
|
||||
label="传递给参数的值"
|
||||
:command="{ icon: 'edit' }"
|
||||
@update:model-value="(value) => handleUpdate(index, 'value', value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="negative"
|
||||
icon="remove"
|
||||
@click="removeParam(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="添加参数"
|
||||
@click="addParam"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserNamedParamList",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [{ name: "", value: "" }],
|
||||
},
|
||||
label: String,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
methods: {
|
||||
addParam() {
|
||||
const newValue = [...(this.modelValue || []), { name: "", value: "" }];
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
removeParam(index) {
|
||||
const newValue = [...this.modelValue];
|
||||
newValue.splice(index, 1);
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
handleUpdate(index, field, value) {
|
||||
const newValue = [...this.modelValue];
|
||||
newValue[index] = { ...newValue[index], [field]: value };
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm items-center">
|
||||
<template v-for="field in fields" :key="field.key">
|
||||
<div
|
||||
v-if="!field.showWhen || fieldValue[field.showWhen] === field.showValue"
|
||||
:class="['col', field.width ? `col-${field.width}` : 'col-12']"
|
||||
>
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="field.type === 'checkbox-group'">
|
||||
<UBrowserCheckboxGroup
|
||||
v-model="fieldValue[field.key]"
|
||||
:options="field.options"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单个复选框 -->
|
||||
<template v-else-if="field.type === 'checkbox'">
|
||||
<UBrowserCheckbox
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 基本输入类型的处理 -->
|
||||
<template v-if="field.type === 'input'">
|
||||
<!-- 设备名称特殊处理 -->
|
||||
<template v-if="field.key === 'deviceName'">
|
||||
<UBrowserDeviceName
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
:icon="field.icon"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
<!-- 普通输入框 -->
|
||||
<template v-else>
|
||||
<VariableInput
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
:command="{
|
||||
icon: field.icon,
|
||||
inputType: field.inputType,
|
||||
}"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 文本区域 -->
|
||||
<template v-else-if="field.type === 'textarea'">
|
||||
<UBrowserTextarea
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
:icon="field.icon"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 选择框 -->
|
||||
<template v-else-if="field.type === 'select'">
|
||||
<UBrowserSelect
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
:icon="field.icon"
|
||||
:options="field.options"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Cookie列表 -->
|
||||
<template v-else-if="field.type === 'cookie-list'">
|
||||
<UBrowserCookieList
|
||||
v-model="fieldValue[field.key]"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 命名参数列表 -->
|
||||
<template v-else-if="field.type === 'named-param-list'">
|
||||
<UBrowserNamedParamList
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<template v-else-if="field.type === 'file-list'">
|
||||
<UBrowserFileList
|
||||
v-model="fieldValue[field.key]"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 按钮组 -->
|
||||
<template v-else-if="field.type === 'button-toggle'">
|
||||
<UBrowserButtonToggle
|
||||
v-model="fieldValue[field.key]"
|
||||
:label="field.label"
|
||||
:options="field.options"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 设备尺寸 -->
|
||||
<template v-else-if="field.type === 'device-size'">
|
||||
<UBrowserDeviceSize
|
||||
v-model="fieldValue.size"
|
||||
@update:model-value="updateValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 带参数的函数输入 -->
|
||||
<template v-else-if="field.type === 'function-with-params'">
|
||||
<UBrowserFunctionInput
|
||||
v-model:function="fieldValue.function"
|
||||
v-model:args="fieldValue.args"
|
||||
:label="field.label"
|
||||
:icon="field.icon"
|
||||
@update:function="(value) => updateValue('function', value)"
|
||||
@update:args="(value) => updateValue('args', value)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import { get, set } from "lodash";
|
||||
import UBrowserFunctionInput from "./UBrowserFunctionInput.vue";
|
||||
import UBrowserCheckbox from "./UBrowserCheckbox.vue";
|
||||
import UBrowserFileList from "./UBrowserFileList.vue";
|
||||
import UBrowserCookieList from "./UBrowserCookieList.vue";
|
||||
import UBrowserButtonToggle from "./UBrowserButtonToggle.vue";
|
||||
import UBrowserDeviceSize from "./UBrowserDeviceSize.vue";
|
||||
import UBrowserNamedParamList from "./UBrowserNamedParamList.vue";
|
||||
import UBrowserSelect from "./UBrowserSelect.vue";
|
||||
import UBrowserDeviceName from "./UBrowserDeviceName.vue";
|
||||
import UBrowserTextarea from "./UBrowserTextarea.vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
import UBrowserCheckboxGroup from "./UBrowserCheckboxGroup.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserOperation",
|
||||
components: {
|
||||
UBrowserFunctionInput,
|
||||
UBrowserCheckbox,
|
||||
UBrowserFileList,
|
||||
UBrowserCookieList,
|
||||
UBrowserButtonToggle,
|
||||
UBrowserDeviceSize,
|
||||
UBrowserNamedParamList,
|
||||
UBrowserSelect,
|
||||
UBrowserDeviceName,
|
||||
UBrowserTextarea,
|
||||
VariableInput,
|
||||
UBrowserCheckboxGroup,
|
||||
},
|
||||
props: {
|
||||
configs: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:configs"],
|
||||
data() {
|
||||
return {
|
||||
fieldValue: {},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 初始化字段值,确保有默认值
|
||||
this.fields.forEach((field) => {
|
||||
const value = get(this.configs[this.action], field.key);
|
||||
// 根据字段类型设置适当的默认值
|
||||
let defaultValue;
|
||||
if (field.type === "checkbox-group") {
|
||||
defaultValue = field.defaultValue || [];
|
||||
} else if (field.type === "checkbox") {
|
||||
defaultValue = field.defaultValue || false;
|
||||
} else if (field.type === "function-with-params") {
|
||||
// 为function-with-params类型设置特殊的默认值结构
|
||||
this.fieldValue.function = value?.function || "";
|
||||
this.fieldValue.args = value?.args || [];
|
||||
return; // 跳过后续的赋值
|
||||
} else {
|
||||
defaultValue = field.defaultValue;
|
||||
}
|
||||
this.fieldValue[field.key] = value !== undefined ? value : defaultValue;
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
updateValue(key, value) {
|
||||
// 更新本地值
|
||||
this.fieldValue[key] = value;
|
||||
|
||||
// 创建新的配置对
|
||||
const newConfigs = { ...this.configs };
|
||||
if (!newConfigs[this.action]) {
|
||||
newConfigs[this.action] = {};
|
||||
}
|
||||
|
||||
// 使用 lodash 的 set 来处理嵌套路径
|
||||
set(newConfigs[this.action], key, value);
|
||||
|
||||
// 发出更新事件
|
||||
this.$emit("update:configs", newConfigs);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 监听配置变化
|
||||
configs: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.fields.forEach((field) => {
|
||||
const value = get(this.configs[this.action], field.key);
|
||||
if (field.type === "function-with-params") {
|
||||
// 为function-with-params类型设置特殊的更新逻辑
|
||||
this.fieldValue.function =
|
||||
value?.function || this.fieldValue.function || "";
|
||||
this.fieldValue.args = value?.args || this.fieldValue.args || [];
|
||||
return;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
this.fieldValue[field.key] = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<q-select
|
||||
:model-value="modelValue"
|
||||
:label="label"
|
||||
:options="options"
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon :name="icon" />
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserSelect",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<q-input
|
||||
:model-value="modelValue"
|
||||
:label="label"
|
||||
type="textarea"
|
||||
dense
|
||||
filled
|
||||
autogrow
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon :name="icon" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UBrowserTextarea",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
});
|
||||
</script>
|
||||
60
src/components/composer/ui/BorderLabel.vue
Normal file
60
src/components/composer/ui/BorderLabel.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="border-label" :data-label="label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "BorderLabel",
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.border-label {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.border-label::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
left: 16px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 0 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.border-label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.body--dark .border-label {
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.body--dark .border-label::before {
|
||||
background: #303133;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
261
src/components/composer/ui/DictEditor.vue
Normal file
261
src/components/composer/ui/DictEditor.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="dict-editor">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="row q-col-gutter-sm items-center"
|
||||
>
|
||||
<div class="col-4">
|
||||
<q-select
|
||||
v-if="options?.items"
|
||||
:model-value="item.key"
|
||||
:options="options.items"
|
||||
label="名称"
|
||||
dense
|
||||
filled
|
||||
use-input
|
||||
input-debounce="0"
|
||||
:hide-selected="!!inputValue"
|
||||
@filter="filterFn"
|
||||
@update:model-value="(val) => handleSelect(val, index)"
|
||||
@input-value="(val) => handleInput(val, index)"
|
||||
@blur="handleBlur"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="code" />
|
||||
</template>
|
||||
</q-select>
|
||||
<q-input
|
||||
v-else
|
||||
:model-value="item.key"
|
||||
label="名称"
|
||||
dense
|
||||
filled
|
||||
@update:model-value="(val) => updateItemKey(val, index)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="code" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
:model-value="item.value"
|
||||
:label="item.key || '值'"
|
||||
:command="{ icon: 'code' }"
|
||||
@update:model-value="(val) => updateItemValue(val, index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-container">
|
||||
<template v-if="items.length === 1">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
icon="add"
|
||||
class="center-btn"
|
||||
@click="addItem"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="index === items.length - 1">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
icon="remove"
|
||||
class="top-btn"
|
||||
@click="removeItem(index)"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
icon="add"
|
||||
class="bottom-btn"
|
||||
@click="addItem"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
icon="remove"
|
||||
class="center-btn"
|
||||
@click="removeItem(index)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "DictEditor",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
const modelEntries = Object.entries(this.modelValue || {});
|
||||
return {
|
||||
localItems: modelEntries.length
|
||||
? modelEntries.map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === "string" ? value : JSON.stringify(value),
|
||||
}))
|
||||
: [{ key: "", value: "" }],
|
||||
filterOptions: this.options?.items || [],
|
||||
inputValue: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
items: {
|
||||
get() {
|
||||
return this.localItems;
|
||||
},
|
||||
set(newItems) {
|
||||
this.localItems = newItems;
|
||||
const dict = {};
|
||||
newItems.forEach((item) => {
|
||||
if (item.key && item.value) {
|
||||
dict[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
this.$emit("update:modelValue", dict);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addItem() {
|
||||
this.items = [...this.items, { key: "", value: "" }];
|
||||
},
|
||||
removeItem(index) {
|
||||
const newItems = [...this.items];
|
||||
newItems.splice(index, 1);
|
||||
if (newItems.length === 0) {
|
||||
newItems.push({ key: "", value: "" });
|
||||
}
|
||||
this.items = newItems;
|
||||
const dict = {};
|
||||
newItems.forEach((item) => {
|
||||
if (item.key && item.value) {
|
||||
dict[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
this.$emit("update:modelValue", dict);
|
||||
},
|
||||
updateItemKey(val, index) {
|
||||
const newItems = [...this.items];
|
||||
newItems[index].key = val;
|
||||
this.items = newItems;
|
||||
},
|
||||
updateItemValue(val, index) {
|
||||
const newItems = [...this.items];
|
||||
newItems[index].value = val;
|
||||
this.items = newItems;
|
||||
},
|
||||
handleInput(val, index) {
|
||||
this.inputValue = val;
|
||||
if (val && !this.filterOptions.includes(val)) {
|
||||
const newItems = [...this.items];
|
||||
newItems[index].key = val;
|
||||
this.items = newItems;
|
||||
}
|
||||
},
|
||||
handleSelect(val, index) {
|
||||
this.inputValue = "";
|
||||
const newItems = [...this.items];
|
||||
newItems[index].key = val;
|
||||
this.items = newItems;
|
||||
},
|
||||
handleBlur() {
|
||||
this.inputValue = "";
|
||||
},
|
||||
filterFn(val, update) {
|
||||
if (!this.options?.items) return;
|
||||
|
||||
update(() => {
|
||||
if (val === "") {
|
||||
this.filterOptions = this.options.items;
|
||||
} else {
|
||||
const needle = val.toLowerCase();
|
||||
this.filterOptions = this.options.items.filter(
|
||||
(v) => v.toLowerCase().indexOf(needle) > -1
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-editor {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 防止输入框换行 */
|
||||
:deep(.q-field__native) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-container .q-btn {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-container .center-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-container .top-btn {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.btn-container .bottom-btn {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.q-btn .q-icon) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.q-btn.q-btn--dense) {
|
||||
padding: 0;
|
||||
min-height: 16px;
|
||||
}
|
||||
</style>
|
||||
101
src/components/composer/ui/FunctionSelector.vue
Normal file
101
src/components/composer/ui/FunctionSelector.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<VariableInput
|
||||
:model-value="inputValue"
|
||||
:label="inputLabel"
|
||||
:command="command"
|
||||
@update:model-value="handleInputChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-select
|
||||
v-model="selectedFunction"
|
||||
:options="options"
|
||||
:label="selectLabel"
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
@update:model-value="handleFunctionChange"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon :name="command.icon || 'functions'" />
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
import VariableInput from "components/composer/ui/VariableInput.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "FunctionSelector",
|
||||
components: {
|
||||
VariableInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
command: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
inputLabel: {
|
||||
type: String,
|
||||
default: "输入值",
|
||||
},
|
||||
selectLabel: {
|
||||
type: String,
|
||||
default: "选择函数",
|
||||
},
|
||||
},
|
||||
emits: ["update:model-value"],
|
||||
data() {
|
||||
return {
|
||||
selectedFunction: this.options[0]?.value || "",
|
||||
inputValue: "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (!val) {
|
||||
this.selectedFunction = this.options[0]?.value || "";
|
||||
this.inputValue = "";
|
||||
return;
|
||||
}
|
||||
// 从代码字符串解析出函数名和参数
|
||||
const match = val.match(/(.+?)\((.*)\)/);
|
||||
if (match) {
|
||||
this.selectedFunction = match[1];
|
||||
this.inputValue = match[2];
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
generateCode() {
|
||||
if (!this.selectedFunction || !this.inputValue) return "";
|
||||
return `${this.selectedFunction}(${this.inputValue})`;
|
||||
},
|
||||
handleInputChange(value) {
|
||||
this.inputValue = value;
|
||||
this.$emit("update:model-value", this.generateCode());
|
||||
},
|
||||
handleFunctionChange(value) {
|
||||
this.selectedFunction = value;
|
||||
this.$emit("update:model-value", this.generateCode());
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
356
src/components/composer/ui/KeyEditor.vue
Normal file
356
src/components/composer/ui/KeyEditor.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="key-editor">
|
||||
<div class="row items-center q-gutter-x-sm full-width">
|
||||
<!-- 按键选择/输入区域 -->
|
||||
<q-select
|
||||
v-model="mainKey"
|
||||
:options="commonKeys"
|
||||
dense
|
||||
filled
|
||||
use-input
|
||||
hide-dropdown-icon
|
||||
new-value-mode="add-unique"
|
||||
input-debounce="0"
|
||||
emit-value
|
||||
map-options
|
||||
options-dense
|
||||
behavior="menu"
|
||||
class="col q-px-sm"
|
||||
placeholder="选择或输入按键"
|
||||
@filter="filterFn"
|
||||
@update:model-value="handleKeyInput"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<!-- 修饰键 -->
|
||||
<div class="row items-center q-gutter-x-xs no-wrap">
|
||||
<q-chip
|
||||
v-for="(active, key) in modifiers"
|
||||
:key="key"
|
||||
:color="active ? 'primary' : 'grey-4'"
|
||||
:text-color="active ? 'white' : 'grey-7'"
|
||||
dense
|
||||
clickable
|
||||
class="modifier-chip"
|
||||
@click="toggleModifier(key)"
|
||||
>
|
||||
{{ modifierLabels[key] }}
|
||||
</q-chip>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 添加自定义选中值显示 -->
|
||||
<template v-slot:selected>
|
||||
<q-badge
|
||||
v-if="mainKey"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
class="main-key"
|
||||
>
|
||||
{{ mainKeyDisplay }}
|
||||
</q-badge>
|
||||
</template>
|
||||
</q-select>
|
||||
<!-- 录制按钮 -->
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
:icon="isRecording ? 'fiber_manual_record' : 'radio_button_unchecked'"
|
||||
:color="isRecording ? 'negative' : 'primary'"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<q-tooltip>{{ isRecording ? "停止录制" : "开始录制" }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
// 检测操作系统
|
||||
const isMac = window.utools.isMacOs();
|
||||
|
||||
export default defineComponent({
|
||||
name: "KeyEditor",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isRecording: false,
|
||||
showKeySelect: false,
|
||||
mainKey: "",
|
||||
modifiers: {
|
||||
control: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
command: false,
|
||||
},
|
||||
modifierLabels: isMac
|
||||
? {
|
||||
control: "⌃",
|
||||
alt: "⌥",
|
||||
shift: "⇧",
|
||||
command: "⌘",
|
||||
}
|
||||
: {
|
||||
control: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Shift",
|
||||
command: "Win",
|
||||
},
|
||||
commonKeys: [
|
||||
{ label: "Enter ↵", value: "enter" },
|
||||
{ label: "Tab ⇥", value: "tab" },
|
||||
{ label: "Space", value: "space" },
|
||||
{ label: "Backspace ⌫", value: "backspace" },
|
||||
{ label: "Delete ⌦", value: "delete" },
|
||||
{ label: "Escape ⎋", value: "escape" },
|
||||
{ label: "↑", value: "up" },
|
||||
{ label: "↓", value: "down" },
|
||||
{ label: "←", value: "left" },
|
||||
{ label: "→", value: "right" },
|
||||
{ label: "Home", value: "home" },
|
||||
{ label: "End", value: "end" },
|
||||
{ label: "Page Up", value: "pageup" },
|
||||
{ label: "Page Down", value: "pagedown" },
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
mainKeyDisplay() {
|
||||
if (!this.mainKey) return "";
|
||||
// 特殊按键映射表
|
||||
const specialKeyMap = {
|
||||
enter: "↵",
|
||||
tab: "⇥",
|
||||
space: "␣",
|
||||
backspace: "⌫",
|
||||
delete: "⌦",
|
||||
escape: "⎋",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
};
|
||||
return (
|
||||
specialKeyMap[this.mainKey] ||
|
||||
(this.mainKey.length === 1
|
||||
? this.mainKey.toUpperCase()
|
||||
: this.mainKey.charAt(0).toUpperCase() + this.mainKey.slice(1))
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (val) {
|
||||
this.parseKeyString(val);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleModifier(key) {
|
||||
this.modifiers[key] = !this.modifiers[key];
|
||||
this.updateValue();
|
||||
},
|
||||
toggleRecording() {
|
||||
if (!this.isRecording) {
|
||||
this.startRecording();
|
||||
} else {
|
||||
this.stopRecording();
|
||||
}
|
||||
},
|
||||
startRecording() {
|
||||
this.isRecording = true;
|
||||
let lastKeyTime = 0;
|
||||
let lastKey = null;
|
||||
|
||||
this.recordEvent = (event) => {
|
||||
event.preventDefault();
|
||||
const currentTime = Date.now();
|
||||
|
||||
// 重置所有修饰键状态
|
||||
Object.keys(this.modifiers).forEach((key) => {
|
||||
this.modifiers[key] = false;
|
||||
});
|
||||
|
||||
// 根据操作系统设置修饰键
|
||||
if (isMac) {
|
||||
if (event.metaKey) this.modifiers.command = true;
|
||||
if (event.ctrlKey) this.modifiers.control = true;
|
||||
} else {
|
||||
if (event.ctrlKey) this.modifiers.control = true;
|
||||
if (event.metaKey || event.winKey) this.modifiers.command = true;
|
||||
}
|
||||
if (event.altKey) this.modifiers.alt = true;
|
||||
if (event.shiftKey) this.modifiers.shift = true;
|
||||
|
||||
// 设置主按键
|
||||
let key = null;
|
||||
|
||||
// 处理字母键
|
||||
if (event.code.startsWith("Key")) {
|
||||
key = event.code.slice(-1).toLowerCase();
|
||||
}
|
||||
// 处理数字键
|
||||
else if (event.code.startsWith("Digit")) {
|
||||
key = event.code.slice(-1);
|
||||
}
|
||||
// 处理功能键
|
||||
else if (event.code.startsWith("F") && !isNaN(event.code.slice(1))) {
|
||||
key = event.code.toLowerCase();
|
||||
}
|
||||
// 处理其他特殊键
|
||||
else {
|
||||
const keyMap = {
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
Enter: "enter",
|
||||
Space: "space",
|
||||
Escape: "escape",
|
||||
Delete: "delete",
|
||||
Backspace: "backspace",
|
||||
Tab: "tab",
|
||||
Home: "home",
|
||||
End: "end",
|
||||
PageUp: "pageup",
|
||||
PageDown: "pagedown",
|
||||
Control: "control",
|
||||
Alt: "alt",
|
||||
Shift: "shift",
|
||||
Meta: "command",
|
||||
};
|
||||
key = keyMap[event.code] || event.key.toLowerCase();
|
||||
}
|
||||
|
||||
// 处理双击修饰键
|
||||
if (["control", "alt", "shift", "command"].includes(key)) {
|
||||
if (key === lastKey && currentTime - lastKeyTime < 500) {
|
||||
this.mainKey = key;
|
||||
this.modifiers[key] = false; // 清除修饰键状态
|
||||
this.stopRecording();
|
||||
this.updateValue();
|
||||
return;
|
||||
}
|
||||
lastKey = key;
|
||||
lastKeyTime = currentTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理空格键和其他按键
|
||||
if (
|
||||
key === "space" ||
|
||||
!["meta", "control", "shift", "alt", "command"].includes(key)
|
||||
) {
|
||||
this.mainKey = key;
|
||||
this.stopRecording();
|
||||
this.updateValue();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", this.recordEvent);
|
||||
},
|
||||
stopRecording() {
|
||||
this.isRecording = false;
|
||||
document.removeEventListener("keydown", this.recordEvent);
|
||||
},
|
||||
updateValue() {
|
||||
if (!this.mainKey) return;
|
||||
const activeModifiers = Object.entries(this.modifiers)
|
||||
.filter(([_, active]) => active)
|
||||
.map(([key]) => key)
|
||||
// 在非 Mac 系统上,将 command 换为 meta
|
||||
.map((key) => (!isMac && key === "command" ? "meta" : key));
|
||||
|
||||
const args = [this.mainKey, ...activeModifiers];
|
||||
// 为每个参数添加引号
|
||||
this.$emit("update:modelValue", `keyTap("${args.join('","')}")`);
|
||||
},
|
||||
parseKeyString(val) {
|
||||
try {
|
||||
// 移除 keyTap 和引号
|
||||
const cleanVal = val.replace(/^keyTap\("/, "").replace(/"\)$/, "");
|
||||
// 分割并移除每个参数的引号
|
||||
const args = cleanVal
|
||||
.split('","')
|
||||
.map((arg) => arg.replace(/^"|"$/g, ""));
|
||||
if (args.length > 0) {
|
||||
this.mainKey = args[0];
|
||||
Object.keys(this.modifiers).forEach((key) => {
|
||||
// 在非 Mac 系统上,将 meta 转换为 command
|
||||
const modKey = !isMac && args.includes("meta") ? "command" : key;
|
||||
this.modifiers[key] = args.includes(modKey);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse key string:", e);
|
||||
}
|
||||
},
|
||||
filterFn(val, update, abort) {
|
||||
// 如果是直接输入(长度为1),则中止过滤
|
||||
if (val.length === 1) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则只在输入内容匹配预设选项时显示下拉列表
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
const matchedOptions = this.commonKeys.filter(
|
||||
(key) =>
|
||||
key.value === needle || key.label.toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
},
|
||||
handleKeyInput(val) {
|
||||
if (val === null) {
|
||||
this.mainKey = "";
|
||||
} else if (typeof val === "string") {
|
||||
// 检查是否是预设选项
|
||||
const matchedOption = this.commonKeys.find(
|
||||
(key) => key.value === val.toLowerCase()
|
||||
);
|
||||
if (matchedOption) {
|
||||
this.mainKey = matchedOption.value;
|
||||
} else {
|
||||
this.mainKey = val.charAt(0).toLowerCase();
|
||||
}
|
||||
}
|
||||
this.updateValue();
|
||||
},
|
||||
handleInput(val) {
|
||||
// 直接输入时,取第一个字符并更新值
|
||||
if (val) {
|
||||
this.mainKey = val.charAt(0).toLowerCase();
|
||||
this.updateValue();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.key-editor {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.modifier-chip {
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.main-key {
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
372
src/components/composer/ui/VariableInput.vue
Normal file
372
src/components/composer/ui/VariableInput.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<q-input
|
||||
v-if="!isNumber"
|
||||
v-model="localValue"
|
||||
dense
|
||||
filled
|
||||
:label="label"
|
||||
class="variable-input"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
v-if="hasSelectedVariable"
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="close"
|
||||
size="sm"
|
||||
class="clear-btn q-mr-xs"
|
||||
@click="clearVariable"
|
||||
>
|
||||
<q-tooltip>清除选中的变量</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
:icon="isString ? 'format_quote' : 'data_object'"
|
||||
size="sm"
|
||||
class="string-toggle"
|
||||
@click="toggleStringType"
|
||||
v-if="!hasSelectedVariable"
|
||||
>
|
||||
<q-tooltip>{{
|
||||
isString
|
||||
? "当前类型是:字符串,点击切换"
|
||||
: "当前类型是:变量、数字、表达式等,点击切换"
|
||||
}}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn-dropdown
|
||||
flat
|
||||
dense
|
||||
:class="{
|
||||
'text-primary': hasSelectedVariable,
|
||||
'text-grey-6': !hasSelectedVariable,
|
||||
}"
|
||||
class="variable-dropdown"
|
||||
size="sm"
|
||||
v-if="variables.length"
|
||||
>
|
||||
<q-list class="variable-list">
|
||||
<q-item-label header class="text-subtitle2">
|
||||
<q-icon name="functions" size="16px" class="q-mr-sm" />
|
||||
选择变量
|
||||
</q-item-label>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<template v-if="variables.length">
|
||||
<q-item
|
||||
v-for="variable in variables"
|
||||
:key="variable.name"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="insertVariable(variable)"
|
||||
class="variable-item"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label class="variable-name">
|
||||
{{ variable.name }}
|
||||
</q-item-label>
|
||||
<q-item-label caption class="variable-source">
|
||||
来自: {{ variable.sourceCommand.label }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</template>
|
||||
<template v-slot:prepend>
|
||||
<q-icon :name="command.icon || 'code'" />
|
||||
</template>
|
||||
</q-input>
|
||||
<!-- 强制为数字类型时,不支持切换类型 -->
|
||||
<q-input
|
||||
v-else
|
||||
type="number"
|
||||
v-model.number="localValue"
|
||||
dense
|
||||
filled
|
||||
:label="label"
|
||||
class="number-input"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon v-if="command.icon" :name="command.icon" />
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<!-- <q-icon name="pin" size="xs" /> -->
|
||||
<div class="column items-center number-controls">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="keyboard_arrow_up"
|
||||
size="xs"
|
||||
class="number-btn"
|
||||
@click="updateNumber(100)"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="keyboard_arrow_down"
|
||||
size="xs"
|
||||
class="number-btn"
|
||||
@click="updateNumber(-100)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, inject } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "VariableInput",
|
||||
|
||||
props: {
|
||||
modelValue: [String, Number],
|
||||
label: String,
|
||||
command: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["update:modelValue"],
|
||||
|
||||
setup() {
|
||||
const variables = inject("composerVariables", []);
|
||||
return { variables };
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedVariable: null,
|
||||
// 根据输入类型初始化字符串状态
|
||||
isString: this.command.inputType !== "number",
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
localValue: {
|
||||
get() {
|
||||
// 数字类型直接返回原值
|
||||
if (this.isNumber) return this.modelValue;
|
||||
// 非数字类型时,根据isString状态决定是否显示引号
|
||||
const val = this.modelValue || "";
|
||||
return this.isString ? val.replace(/^"|"$/g, "") : val;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", this.formatValue(value));
|
||||
},
|
||||
},
|
||||
|
||||
// 判断是否有选中的变量,用于控制UI显示和行为
|
||||
hasSelectedVariable() {
|
||||
return this.selectedVariable !== null;
|
||||
},
|
||||
|
||||
isNumber() {
|
||||
return this.command.inputType === "number";
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 格式化值,处理引号的添加和移除
|
||||
formatValue(value) {
|
||||
// 空值、变量模式或数字类型时不处理
|
||||
if (!value || this.hasSelectedVariable || this.isNumber) return value;
|
||||
// 根据isString状态添加或移除引号
|
||||
return this.isString
|
||||
? `"${value.replace(/^"|"$/g, "")}"`
|
||||
: value.replace(/^"|"$/g, "");
|
||||
},
|
||||
|
||||
// 切换字符串/非字符串模式
|
||||
toggleStringType() {
|
||||
if (!this.hasSelectedVariable) {
|
||||
this.isString = !this.isString;
|
||||
// 有值时需要重新格式化
|
||||
if (this.modelValue) {
|
||||
this.$emit("update:modelValue", this.formatValue(this.modelValue));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 外部调用的方法,用于设置字符串状态
|
||||
setIsString(value) {
|
||||
if (!this.hasSelectedVariable && this.isString !== value) {
|
||||
this.isString = value;
|
||||
// 有值时需要重新格式化
|
||||
if (this.modelValue) {
|
||||
this.$emit("update:modelValue", this.formatValue(this.modelValue));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 插入变量时的处理
|
||||
insertVariable(variable) {
|
||||
this.selectedVariable = variable;
|
||||
this.isString = false; // 变量模式下不需要字符串处理
|
||||
this.$emit("update:modelValue", variable.name);
|
||||
},
|
||||
|
||||
// 清除变量时的处理
|
||||
clearVariable() {
|
||||
this.selectedVariable = null;
|
||||
this.isString = true; // 恢复到字符串模式
|
||||
this.$emit("update:modelValue", "");
|
||||
},
|
||||
|
||||
// 数字类型特有的增减处理
|
||||
updateNumber(delta) {
|
||||
const current = Number(this.localValue) || 0;
|
||||
this.$emit("update:modelValue", current + delta);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// 解决通过外部传入值时,无法触发字符串处理的问题
|
||||
modelValue: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
// 只在有值且非变量模式且非数字类型时处理
|
||||
if (newVal && !this.hasSelectedVariable && !this.isNumber) {
|
||||
const formattedValue = this.formatValue(newVal);
|
||||
// 只在值真正需要更新时才发更新
|
||||
if (formattedValue !== newVal) {
|
||||
this.$emit("update:modelValue", formattedValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.variable-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.variable-input :deep(.q-field__control) {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* 字符串切换按钮样式 */
|
||||
.string-toggle {
|
||||
min-width: 24px;
|
||||
padding: 4px;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.string-toggle:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 变量下拉框样式 */
|
||||
.variable-dropdown {
|
||||
min-width: 32px;
|
||||
padding: 4px;
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.variable-dropdown:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 变量列表样式 */
|
||||
.variable-list {
|
||||
min-width: 200px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.variable-item {
|
||||
border-radius: 4px;
|
||||
margin: 2px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.variable-item:hover {
|
||||
background: var(--q-primary-opacity-10);
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.variable-source {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.body--dark .variable-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 清空按钮样式 */
|
||||
.clear-btn {
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
color: var(--q-negative);
|
||||
}
|
||||
|
||||
/* 数字输入框样式 */
|
||||
.number-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 隐藏默认的数字输入框箭头 - Chrome, Safari, Edge, Opera */
|
||||
.number-input :deep(input[type="number"]::-webkit-outer-spin-button),
|
||||
.number-input :deep(input[type="number"]::-webkit-inner-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.number-input :deep(.q-field__control) {
|
||||
padding-left: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.number-controls {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
width: 32px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.number-btn {
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-height: 16px;
|
||||
height: 16px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.number-btn :deep(.q-icon) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.number-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--q-primary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user