diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/Index.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/Index.vue index 778d2143f6..44e823babb 100644 --- a/dbm-ui/frontend/src/views/db-manage/common/webconsole/Index.vue +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/Index.vue @@ -11,8 +11,7 @@ @remove-tab="handleClickClearScreen" /> <RawSwitcher v-if="dbType === DBTypes.REDIS" - v-model="isRaw" - :db-type="dbType" /> + v-model="isRaw" /> <ClearScreen @change="handleClickClearScreen" /> <ExportData @export="handleClickExport" /> <UsageHelp @@ -30,12 +29,12 @@ </div> <div class="content-main"> <KeepAlive> - <ConsolePanel + <Component + :is="consolePanelMap[dbType]" v-if="clusterInfo" :key="clusterInfo.id" ref="consolePanelRef" - v-model="clusterInfo" - :db-type="dbType" + :cluster="clusterInfo" :raw="isRaw" :style="currentFontConfig" /> </KeepAlive> @@ -59,13 +58,12 @@ import screenfull from 'screenfull'; import { useI18n } from 'vue-i18n'; - import { queryAllTypeCluster } from '@services/source/dbbase'; - import { DBTypes } from '@common/const'; import ClearScreen from './components/ClearScreen.vue'; - import ClusterTabs from './components/ClusterTabs.vue'; - import ConsolePanel from './components/console-panel/Index.vue'; + import ClusterTabs, { type ClusterItem } from './components/ClusterTabs.vue'; + import MysqlConsolePanel from './components/console-panel/mysql/Index.vue'; + import RedisConsolePanel from './components/console-panel/redis/Index.vue'; import ExportData from './components/ExportData.vue'; import FontSetting from './components/FontSetting.vue'; import FullScreen from './components/FullScreen.vue'; @@ -76,17 +74,21 @@ dbType?: DBTypes; } - type ClusterItem = ServiceReturnType<typeof queryAllTypeCluster>[number]; - const props = withDefaults(defineProps<Props>(), { dbType: DBTypes.MYSQL, }); const { t } = useI18n(); + const consolePanelMap = { + [DBTypes.MYSQL]: MysqlConsolePanel, + [DBTypes.TENDBCLUSTER]: MysqlConsolePanel, + [DBTypes.REDIS]: RedisConsolePanel, + } as Record<DBTypes, any>; + const rootRef = ref(); const clusterTabsRef = ref(); - const consolePanelRef = ref<InstanceType<typeof ConsolePanel>>(); + const consolePanelRef = ref<InstanceType<typeof MysqlConsolePanel>>(); const clusterInfo = ref<ClusterItem>(); const currentFontConfig = ref({ fontSize: '12px', diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/ClusterTabs.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/ClusterTabs.vue index cf5a9dbfbb..1dca2bce9f 100644 --- a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/ClusterTabs.vue +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/ClusterTabs.vue @@ -74,7 +74,7 @@ import { messageWarn } from '@utils'; - type ClusterItem = ServiceReturnType<typeof queryAllTypeCluster>[number]; + export type ClusterItem = ServiceReturnType<typeof queryAllTypeCluster>[number]; interface Props { dbType: DBTypes; diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/RawSwitcher.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/RawSwitcher.vue index c169ef845a..4251283cdd 100644 --- a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/RawSwitcher.vue +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/RawSwitcher.vue @@ -17,14 +17,6 @@ <script lang="ts" setup> import { useI18n } from 'vue-i18n'; - import { DBTypes } from '@common/const'; - - interface Props { - dbType: DBTypes; - } - - const props = defineProps<Props>(); - const { t } = useI18n(); const modelValue = defineModel<boolean | undefined>({ @@ -32,7 +24,7 @@ }); const handleRawSwitch = (value: boolean) => { - modelValue.value = props.dbType === DBTypes.REDIS ? value : undefined; + modelValue.value = value; }; </script> diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/Index.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/ConsoleInput.vue similarity index 56% rename from dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/Index.vue rename to dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/ConsoleInput.vue index 96c9c59a79..a3c07a9cf0 100644 --- a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/Index.vue +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/ConsoleInput.vue @@ -13,9 +13,7 @@ <span :class="{ 'error-text': item.type === 'error' }">{{ item.message }}</span> </div> <template v-else> - <Component - :is="consoleConfig.renderMessage" - :data="item.message" /> + <slot :message="item.message" /> </template> </template> </div> @@ -36,36 +34,54 @@ </div> </div> </template> +<script lang="ts"> + // 未执行的命令 + const noExecuteCommand: Record<number, string> = {}; + // 已执行过的命令 + const executedCommands: Record<number, string[]> = {}; + + const panelInputMap = reactive< + Record< + number, + Array<{ + message: string | Record<string, string>[]; + type: 'success' | 'error' | 'normal' | 'command' | string; + }> + > + >({}); +</script> <script setup lang="ts"> import _ from 'lodash'; import { queryAllTypeCluster, queryWebconsole } from '@services/source/dbbase'; - import { DBTypes } from '@common/const'; - import { downloadText } from '@utils'; - import RenderMysqlMessage, { validate } from './components/RenderMysqlMessage.vue'; - import RenderRedisMessage, { - getInputPlaceholder as getRedisPlaceholder, - switchDbIndex, - } from './components/RenderRedisMessage.vue'; - - type ClusterItem = ServiceReturnType<typeof queryAllTypeCluster>[number]; + export interface Props { + cluster: ServiceReturnType<typeof queryAllTypeCluster>[number]; + placeholder?: string; + extParams?: Record<string, unknown>; + preCheck?: (value: string) => string; + } - interface Props { - modelValue: ClusterItem; - dbType: DBTypes; - raw?: boolean; + interface Emits { + (e: 'success', cmd: string, message: ServiceReturnType<typeof queryWebconsole>['query'], ...args: unknown[]): void; } interface Expose { - clearCurrentScreen: (id?: number) => void; + updateCommand: () => void; export: () => void; isInputed: (id?: number) => boolean; + clearCurrentScreen: (id?: number) => void; } - const props = defineProps<Props>(); + const props = withDefaults(defineProps<Props>(), { + placeholder: '', + extParams: () => ({}), + preCheck: () => '', + }); + + const emits = defineEmits<Emits>(); const command = ref(''); const consolePanelRef = ref(); @@ -73,74 +89,24 @@ const isFrozenTextarea = ref(false); const inputRef = ref(); const realHeight = ref('52px'); - const panelInputMap = reactive< - Record< - number, - Array<{ - message: string | Record<string, string>[]; - type: 'success' | 'error' | 'normal' | 'command' | string; - }> - > - >({}); - const commandInputMap: Record<number, string[]> = {}; - const noExecuteCommand: Record<number, string> = {}; - let currentCommandIndex = 0; - let inputPlaceholder = ''; - let baseParams: { - cluster_id: number; - cmd?: string; - [key: string]: unknown; - }; - const configMap: Record< - string, - { - renderMessage: any; - validate?: (cmd: string) => string; - getInputPlaceholder?: (clusterId: number, domain: string) => string; - switchDbIndex?: (params: { clusterId: number; cmd: string; queryResult: string; commandInputs: string[] }) => { - dbIndex: number; - commandInputs: string[]; - }; - } - > = { - [DBTypes.MYSQL]: { - renderMessage: RenderMysqlMessage, - validate, - }, - [DBTypes.TENDBCLUSTER]: { - renderMessage: RenderMysqlMessage, - validate, - }, - [DBTypes.REDIS]: { - renderMessage: RenderRedisMessage, - getInputPlaceholder: getRedisPlaceholder, - switchDbIndex, - }, - }; + // 用于查找命令的索引 + let commandIndex = 0; - const clusterId = computed(() => props.modelValue.id); - const consoleConfig = computed(() => configMap[props.dbType as keyof typeof configMap]); + const clusterId = computed(() => props.cluster.id); + const localPlaceholder = computed(() => props.placeholder || `${props.cluster.immute_domain} > `); watch( clusterId, () => { if (clusterId.value) { - const domain = props.modelValue.immute_domain; - inputPlaceholder = consoleConfig.value.getInputPlaceholder - ? consoleConfig.value.getInputPlaceholder(clusterId.value, domain) - : `${domain} > `; - baseParams = { - ...baseParams, - cluster_id: clusterId.value, - }; - command.value = noExecuteCommand[clusterId.value] ?? inputPlaceholder; + command.value = localPlaceholder.value + (noExecuteCommand[clusterId.value] ?? ''); - if (!commandInputMap[clusterId.value]) { - commandInputMap[clusterId.value] = []; - currentCommandIndex = 0; + if (!executedCommands[clusterId.value]) { + executedCommands[clusterId.value] = []; + commandIndex = 0; } else { - currentCommandIndex = commandInputMap[clusterId.value].length; + commandIndex = executedCommands[clusterId.value].length; } if (!panelInputMap[clusterId.value]) { @@ -172,45 +138,43 @@ // 回车输入指令 const handleClickSendCommand = async (e: any) => { // 输入预处理 - let cmd = e.target.value.trim() as string; - const isInputed = cmd.length > inputPlaceholder.length; + const inputValue = e.target.value.trim() as string; + const isInputed = inputValue.length > localPlaceholder.value.length; + + // 截取输入的命令 + const cmd = inputValue.substring(localPlaceholder.value.length); + executedCommands[clusterId.value].push(cmd); + commandIndex = executedCommands[clusterId.value].length; + command.value = localPlaceholder.value; + + // 命令行渲染 const commandLine = { - message: isInputed ? cmd : inputPlaceholder, + message: isInputed ? inputValue : localPlaceholder.value, type: 'command', }; - commandInputMap[clusterId.value].push(cmd); - currentCommandIndex = commandInputMap[clusterId.value].length; panelInputMap[clusterId.value].push(commandLine); - command.value = inputPlaceholder; + if (!isInputed) { return; } - // 校验语句 - cmd = cmd.substring(inputPlaceholder.length); - if (consoleConfig.value.validate) { - const validateResult = consoleConfig.value.validate(cmd); - if (validateResult) { - const errorLine = { - message: validateResult, - type: 'error', - }; - panelInputMap[clusterId.value].push(errorLine); - return; - } + // 语句预检 + const preCheckResult = props.preCheck(cmd); + if (preCheckResult) { + const errorLine = { + message: preCheckResult, + type: 'error', + }; + panelInputMap[clusterId.value].push(errorLine); + return; } // 开始请求 try { loading.value = true; - if (typeof props.raw === 'boolean') { - baseParams = { - ...baseParams, - raw: props.raw, - }; - } const executeResult = await queryWebconsole({ - ...baseParams, + ...props.extParams, + cluster_id: clusterId.value, cmd, }); @@ -230,24 +194,7 @@ }; panelInputMap[clusterId.value].push(normalLine); - // 切换数据库、修改行前缀 - const config = consoleConfig.value; - if (config.switchDbIndex) { - const { dbIndex, commandInputs } = config.switchDbIndex({ - clusterId: clusterId.value, - cmd, - queryResult: executeResult.query as string, - commandInputs: commandInputMap[clusterId.value], - }); - baseParams = { - ...baseParams, - db_num: dbIndex, - }; - commandInputMap[clusterId.value] = commandInputs; - if (config.getInputPlaceholder) { - command.value = config.getInputPlaceholder(clusterId.value, props.modelValue.immute_domain); - } - } + emits('success', cmd, executeResult.query); } } finally { loading.value = false; @@ -263,21 +210,21 @@ const recentOnceInput = command.value; command.value = ''; nextTick(() => { - command.value = isRestore ? recentOnceInput : inputPlaceholder; + command.value = isRestore ? recentOnceInput : localPlaceholder.value; }); setTimeout(() => { - const cursorIndex = inputPlaceholder.length; + const cursorIndex = localPlaceholder.value.length; inputRef.value.setSelectionRange(cursorIndex, cursorIndex); }); }; // 输入 const handleInputChange = (e: any) => { - if (inputRef.value.selectionStart === inputPlaceholder.length - 1) { + if (inputRef.value.selectionStart === localPlaceholder.value.length - 1) { restoreInput(); return; } - if (inputRef.value.selectionStart < inputPlaceholder.length) { + if (inputRef.value.selectionStart < localPlaceholder.value.length) { restoreInput(false); return; } @@ -290,35 +237,32 @@ // 当前tab有未执行的command暂存,切换回来回显 const handleInputBlur = () => { - if (command.value.length > inputPlaceholder.length) { - noExecuteCommand[clusterId.value] = command.value; + if (command.value.length > localPlaceholder.value.length) { + noExecuteCommand[clusterId.value] = command.value.substring(localPlaceholder.value.length); } }; // 键盘 ↑ 键 const handleClickUpBtn = () => { - if (commandInputMap[clusterId.value].length === 0 || currentCommandIndex === 0) { + if (executedCommands[clusterId.value].length === 0 || commandIndex === 0) { checkCursorPosition(true); return; } - currentCommandIndex = currentCommandIndex - 1; - command.value = commandInputMap[clusterId.value][currentCommandIndex]; + commandIndex = commandIndex - 1; + command.value = localPlaceholder.value + executedCommands[clusterId.value][commandIndex]; const cursorIndex = command.value.length; inputRef.value.setSelectionRange(cursorIndex, cursorIndex); }; // 键盘 ↓ 键 const handleClickDownBtn = () => { - if ( - commandInputMap[clusterId.value].length === 0 || - currentCommandIndex === commandInputMap[clusterId.value].length - ) { + if (executedCommands[clusterId.value].length === 0 || commandIndex === executedCommands[clusterId.value].length) { return; } - currentCommandIndex = currentCommandIndex + 1; - command.value = commandInputMap[clusterId.value][currentCommandIndex] ?? inputPlaceholder; + commandIndex = commandIndex + 1; + command.value = localPlaceholder.value + (executedCommands[clusterId.value][commandIndex] ?? ''); }; // 键盘 ← 键 @@ -328,19 +272,17 @@ // 校正光标位置 const checkCursorPosition = (isStartToTextEnd = false) => { - if (inputRef.value.selectionStart <= inputPlaceholder.length) { - const cursorIndex = isStartToTextEnd ? command.value.length : inputPlaceholder.length; + if (inputRef.value.selectionStart <= localPlaceholder.value.length) { + const cursorIndex = isStartToTextEnd ? command.value.length : localPlaceholder.value.length; inputRef.value.setSelectionRange(cursorIndex, cursorIndex); } }; defineExpose<Expose>({ - clearCurrentScreen(id?: number) { - const currentClusterId = id ?? clusterId.value; - panelInputMap[currentClusterId] = []; - commandInputMap[currentClusterId] = []; - noExecuteCommand[currentClusterId] = ''; - command.value = inputPlaceholder; + updateCommand() { + nextTick(() => { + command.value = localPlaceholder.value; + }); }, export() { const lines = panelInputMap[clusterId.value].map((item) => item.message); @@ -366,15 +308,19 @@ } }); - const fileName = `${props.modelValue.immute_domain}.txt`; + const fileName = `${props.cluster.immute_domain}.txt`; downloadText(fileName, exportTxt); }, isInputed(id?: number) { const currentClusterId = id ?? clusterId.value; - return ( - commandInputMap[currentClusterId]?.some((cmd) => cmd.length > inputPlaceholder.length) || - noExecuteCommand[currentClusterId]?.substring(inputPlaceholder.length).length > 0 - ); + return executedCommands[currentClusterId]?.some(Boolean) || noExecuteCommand[currentClusterId]?.length > 0; + }, + clearCurrentScreen(id?: number) { + const currentClusterId = id ?? clusterId.value; + panelInputMap[currentClusterId] = []; + executedCommands[currentClusterId] = []; + noExecuteCommand[currentClusterId] = ''; + command.value = localPlaceholder.value; }, }); </script> diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/RenderRedisMessage.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/RenderRedisMessage.vue deleted file mode 100644 index 625451b6eb..0000000000 --- a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/RenderRedisMessage.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> - <p - v-for="(row, index) in rows" - :key="index" - class="preserve-whitespace"> - {{ row }} - </p> -</template> -<script lang="ts"> - interface Props { - data: string; - } - - // 集群所选的数据库索引 - const clusterSelectedDbIndex: Record<number, number> = {}; - - // 设置当前集群id所选数据库索引 - const setDbIndexByClusterId = (clusterId: number, value = 0) => { - clusterSelectedDbIndex[clusterId] = value; - }; - - // 命令行前缀 - export const getInputPlaceholder = (clusterId: number, domain: string) => { - clusterSelectedDbIndex[clusterId] ??= 0; - return `${domain}[${clusterSelectedDbIndex[clusterId]}] > `; - }; - - // 切换数据库索引 - export const switchDbIndex = ({ - clusterId, - cmd, - queryResult, - commandInputs, - }: { - clusterId: number; - cmd: string; - queryResult: string; - commandInputs: string[]; - }) => { - if (/^\s*select\s+.*$/.test(cmd) && /^OK/.test(queryResult)) { - const newDbIndex = Number(cmd.substring('select '.length)); - setDbIndexByClusterId(clusterId, newDbIndex); - const newCommandInputs = commandInputs.map((item) => item.replace(/\[(\d+)\]/, `[${newDbIndex}]`)); - return { - dbIndex: newDbIndex, - commandInputs: newCommandInputs, - }; - } - return { - dbIndex: clusterSelectedDbIndex[clusterId], - commandInputs, - }; - }; -</script> -<script setup lang="ts"> - const props = defineProps<Props>(); - - const rows = computed(() => props.data.split('\n') || []); -</script> - -<style lang="less" scoped> - .preserve-whitespace { - font-family: 'Courier New', Courier, monospace; /* 等宽字体 */ - white-space: pre; - } -</style> diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/mysql/Index.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/mysql/Index.vue new file mode 100644 index 0000000000..302aa44b1b --- /dev/null +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/mysql/Index.vue @@ -0,0 +1,43 @@ +<template> + <ConsoleInput + ref="consoleInputRef" + :cluster="cluster" + :pre-check="preCheck"> + <template #default="{ message }"> + <RenderMessage :data="message" /> + </template> + </ConsoleInput> +</template> + +<script setup lang="ts"> + import { useI18n } from 'vue-i18n'; + + import type { queryAllTypeCluster } from '@services/source/dbbase'; + + import ConsoleInput from '../components/ConsoleInput.vue'; + + import RenderMessage from './components/RenderMessage.vue'; + + interface Props { + cluster: ServiceReturnType<typeof queryAllTypeCluster>[number]; + } + + defineProps<Props>(); + + const { t } = useI18n(); + + const consoleInputRef = ref<typeof ConsoleInput>(); + + const preCheck = (cmd: string) => { + if (/^\s*use\s+.*$/.test(cmd)) { + return t('暂不支持 use 语句,请使用 db.table 指定 database'); + } + return ''; + }; + + defineExpose({ + isInputed: (clusterId: number) => consoleInputRef.value!.isInputed(clusterId), + clearCurrentScreen: (clusterId: number) => consoleInputRef.value!.clearCurrentScreen(clusterId), + export: () => consoleInputRef.value!.export(), + }); +</script> diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/RenderMysqlMessage.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/mysql/components/RenderMessage.vue similarity index 86% rename from dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/RenderMysqlMessage.vue rename to dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/mysql/components/RenderMessage.vue index 054cbf1128..b639eb2f71 100644 --- a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/components/RenderMysqlMessage.vue +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/mysql/components/RenderMessage.vue @@ -24,21 +24,11 @@ </table> </div> </template> -<script lang="ts"> - import { t } from '@locales/index'; - +<script setup lang="ts"> interface Props { data: Record<string, string>[]; } - export const validate = (cmd: string) => { - if (/^\s*use\s+.*$/.test(cmd)) { - return t('暂不支持 use 语句,请使用 db.table 指定 database'); - } - return ''; - }; -</script> -<script setup lang="ts"> const props = defineProps<Props>(); const headColumnList = computed(() => (props.data ? Object.keys(props.data[0]) : [])); diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/redis/Index.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/redis/Index.vue new file mode 100644 index 0000000000..562ec35670 --- /dev/null +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/redis/Index.vue @@ -0,0 +1,49 @@ +<template> + <ConsoleInput + ref="consoleInputRef" + :cluster="cluster" + :ext-params="{ + dbNum, + raw, + }" + :placeholder="placeholder" + @success="handleSuccess"> + <template #default="{ message }"> + <RenderMessage :data="message" /> + </template> + </ConsoleInput> +</template> + +<script setup lang="ts"> + import type { queryAllTypeCluster, queryWebconsole } from '@services/source/dbbase'; + + import ConsoleInput from '../components/ConsoleInput.vue'; + + import RenderMessage from './components/RenderMessage.vue'; + + interface Props { + cluster: ServiceReturnType<typeof queryAllTypeCluster>[number]; + raw: boolean; + } + + const props = defineProps<Props>(); + + const consoleInputRef = ref<typeof ConsoleInput>(); + const dbNum = ref(0); + + const placeholder = computed(() => `${props.cluster.immute_domain}[${dbNum.value}] > `); + + const handleSuccess = (cmd: string, message: ServiceReturnType<typeof queryWebconsole>['query']) => { + // 切换数据库索引 + if (/^\s*select\s+.*$/.test(cmd) && /^OK/.test(message as string)) { + dbNum.value = Number(cmd.substring('select '.length)); + consoleInputRef.value!.updateCommand(); + } + }; + + defineExpose({ + isInputed: (clusterId: number) => consoleInputRef.value!.isInputed(clusterId), + clearCurrentScreen: (clusterId: number) => consoleInputRef.value!.clearCurrentScreen(clusterId), + export: () => consoleInputRef.value!.export(), + }); +</script> diff --git a/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/redis/components/RenderMessage.vue b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/redis/components/RenderMessage.vue new file mode 100644 index 0000000000..10b03e8497 --- /dev/null +++ b/dbm-ui/frontend/src/views/db-manage/common/webconsole/components/console-panel/redis/components/RenderMessage.vue @@ -0,0 +1,25 @@ +<template> + <p + v-for="(row, index) in rows" + :key="index" + class="preserve-whitespace"> + {{ row }} + </p> +</template> + +<script setup lang="ts"> + interface Props { + data: string; + } + + const props = defineProps<Props>(); + + const rows = computed(() => props.data.split('\n') || []); +</script> + +<style lang="less" scoped> + .preserve-whitespace { + font-family: 'Courier New', Courier, monospace; /* 等宽字体 */ + white-space: pre; + } +</style>