feat: API接口编辑弹窗改为分步表单并接入Markdown编辑器
构建并部署 / 检测变更 (push) Successful in 6s
构建并部署 / 打包部署 starapi-gateway (push) Skipped
构建并部署 / 打包部署 starapi-admin (push) Successful in 3m43s
构建并部署 / 打包部署 portal-ui (push) Successful in 1m20s
构建并部署 / 打包部署 admin-ui (push) Successful in 1m24s

This commit is contained in:
x6g2
2026-11-10 00:43:20 +08:00
parent bb52aa4537
commit efb9684454
2 changed files with 890 additions and 58 deletions
@@ -0,0 +1,757 @@
<template>
<div class="md-editor" :class="{ 'is-disabled': disabled, 'is-preview': mode === 'preview' }">
<div class="md-toolbar">
<template v-for="item in toolbarItems" :key="item.key">
<el-tooltip :content="item.title" placement="top" :show-after="200">
<el-button
link
class="md-toolbar-btn"
:class="item.className"
:disabled="disabled || mode === 'preview'"
@click="item.action"
>
<el-icon v-if="item.icon" class="md-toolbar-icon">
<component :is="item.icon" />
</el-icon>
<span v-else class="md-toolbar-text">{{ item.text }}</span>
</el-button>
</el-tooltip>
<span v-if="item.groupEnd" class="md-toolbar-divider"></span>
</template>
<span class="md-toolbar-spacer"></span>
<el-tooltip :content="mode === 'edit' ? '预览效果' : '返回编辑'" placement="top" :show-after="200">
<el-button link class="md-toolbar-btn md-toolbar-switch" :disabled="disabled" @click="toggleMode">
<el-icon class="md-toolbar-icon">
<View v-if="mode === 'edit'" />
<EditPen v-else />
</el-icon>
<span class="md-toolbar-text">{{ mode === 'edit' ? '预览' : '编辑' }}</span>
</el-button>
</el-tooltip>
</div>
<div ref="wrapRef" class="md-body" :style="{ height: `${height}px` }">
<div v-show="mode === 'edit'" class="md-edit-pane">
<el-input
ref="inputRef"
v-model="content"
type="textarea"
class="md-textarea"
resize="none"
:placeholder="placeholder"
:disabled="disabled"
@keydown="handleKeydown"
/>
</div>
<div v-show="mode === 'preview'" class="md-preview">
<div v-if="mode === 'preview' && content" class="md-preview-content" v-html="previewHtml"></div>
<div v-else-if="mode === 'preview'" class="md-preview-empty">{{ placeholder || '暂无预览内容' }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
import { sanitizeHtml } from '@/utils/sanitize';
defineOptions({ name: 'MarkdownEditor' });
const props = defineProps({
/* Markdown 文本内容 */
modelValue: propTypes.string.def(''),
/* 编辑/预览区域高度(px) */
height: propTypes.number.def(360),
/* 占位提示 */
placeholder: propTypes.string.def('请输入内容,支持 Markdown 语法'),
/* 只读 */
disabled: propTypes.bool.def(false)
});
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>();
const mode = ref<'edit' | 'preview'>('edit');
const content = ref(props.modelValue ?? '');
const inputRef = ref<ElInputInstance>();
const wrapRef = ref<HTMLElement>();
interface ToolbarItem {
key: string;
title: string;
text?: string;
icon?: string;
className?: string;
groupEnd?: boolean;
action: () => void;
}
/* ==================== 编辑器取值与光标 ==================== */
const getTextarea = (): HTMLTextAreaElement | null => {
const exposed = (inputRef.value as unknown as { textarea?: HTMLTextAreaElement })?.textarea;
if (exposed instanceof HTMLTextAreaElement) {
return exposed;
}
return wrapRef.value?.querySelector('textarea') ?? null;
};
const getSelection = (): { start: number; end: number } => {
const value = content.value || '';
const textarea = getTextarea();
if (!textarea || mode.value !== 'edit') {
return { start: value.length, end: value.length };
}
return {
start: textarea.selectionStart ?? value.length,
end: textarea.selectionEnd ?? value.length
};
};
/** 写入新内容并在 DOM 更新后恢复光标位置 */
const applyEdit = (nextValue: string, selectionStart: number, selectionEnd = selectionStart) => {
content.value = nextValue;
nextTick(() => {
const textarea = getTextarea();
if (!textarea) return;
textarea.focus();
const max = nextValue.length;
textarea.setSelectionRange(Math.min(selectionStart, max), Math.min(selectionEnd, max));
});
};
/** 用前后缀包裹当前选区;无选区时插入占位文本并选中 */
const wrapSelection = (prefix: string, suffix: string, placeholder: string) => {
if (props.disabled || mode.value !== 'edit') return;
const value = content.value || '';
const { start, end } = getSelection();
const selected = value.slice(start, end) || placeholder;
const next = value.slice(0, start) + prefix + selected + suffix + value.slice(end);
applyEdit(next, start + prefix.length, start + prefix.length + selected.length);
};
/** 取选区覆盖的完整行范围 */
const getLineRange = () => {
const value = content.value || '';
const { start, end } = getSelection();
const lineStart = value.lastIndexOf('\n', Math.max(start - 1, 0)) + 1;
const nextBreak = value.indexOf('\n', end);
const lineEnd = nextBreak === -1 ? value.length : nextBreak;
return { value, start, end, lineStart, lineEnd, block: value.slice(lineStart, lineEnd) };
};
/**
* 按行加前缀(列表、引用)
* @param prefixFor 每行前缀生成函数
* @param existingRe 已存在该前缀的匹配规则,命中时不再重复添加
* @param toggle 命中 existingRe 时是否移除前缀(引用使用)
*/
const applyLinePrefix = (prefixFor: (lineIndex: number) => string, existingRe: RegExp, toggle = false) => {
if (props.disabled || mode.value !== 'edit') return;
const { value, start, end, lineStart, lineEnd, block } = getLineRange();
const rendered = block.split('\n').map((line, index) => {
if (existingRe.test(line)) {
return toggle ? line.replace(existingRe, '') : line;
}
return prefixFor(index) + line;
});
const nextBlock = rendered.join('\n');
const next = value.slice(0, lineStart) + nextBlock + value.slice(lineEnd);
if (start === end) {
applyEdit(next, start + (nextBlock.length - block.length));
} else {
applyEdit(next, lineStart, lineStart + nextBlock.length);
}
};
/** 在当前行设置标题级别,已存在标题时替换 */
const setHeading = (level: number) => {
if (props.disabled || mode.value !== 'edit') return;
const { value, start, end, lineStart, lineEnd, block } = getLineRange();
const marker = `${'#'.repeat(level)} `;
const nextBlock = block
.split('\n')
.map(line => marker + line.replace(/^#{1,6}\s+/, ''))
.join('\n');
const next = value.slice(0, lineStart) + nextBlock + value.slice(lineEnd);
if (start === end) {
applyEdit(next, start + (nextBlock.length - block.length));
} else {
applyEdit(next, lineStart, lineStart + nextBlock.length);
}
};
/** 插入块级内容,自动补齐前后换行;可选中块内指定文本 */
const insertBlock = (block: string, selectText?: string) => {
if (props.disabled || mode.value !== 'edit') return;
const value = content.value || '';
const { start, end } = getSelection();
const before = value.slice(0, start);
const after = value.slice(end);
const leading = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
const trailing = after.length > 0 && !after.startsWith('\n') ? '\n' : '';
const inserted = leading + block + trailing;
const next = before + inserted + after;
if (selectText) {
const offset = inserted.indexOf(selectText);
if (offset >= 0) {
applyEdit(next, start + offset, start + offset + selectText.length);
return;
}
}
applyEdit(next, start + inserted.length);
};
const insertCodeBlock = () => {
if (props.disabled || mode.value !== 'edit') return;
const value = content.value || '';
const { start, end } = getSelection();
const selected = value.slice(start, end) || '代码';
const before = value.slice(0, start);
const after = value.slice(end);
const leading = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
const trailing = after.length > 0 && !after.startsWith('\n') ? '\n' : '';
const block = `${leading}\`\`\`\n${selected}\n\`\`\`${trailing}`;
applyEdit(before + block + after, start + leading.length + 4, start + leading.length + 4 + selected.length);
};
const insertLink = () => {
const value = content.value || '';
const { start, end } = getSelection();
const selected = value.slice(start, end) || '链接文字';
const next = `${value.slice(0, start)}[${selected}](https://example.com)${value.slice(end)}`;
applyEdit(next, start + selected.length + 3, start + selected.length + 3 + 'https://example.com'.length);
};
const insertImage = () => {
const value = content.value || '';
const { start, end } = getSelection();
const selected = value.slice(start, end) || '图片描述';
const next = `${value.slice(0, start)}![${selected}](https://example.com/image.png)${value.slice(end)}`;
applyEdit(next, start + selected.length + 4, start + selected.length + 4 + 'https://example.com/image.png'.length);
};
const insertTable = () => {
insertBlock('| 列1 | 列2 |\n| --- | --- |\n| 内容1 | 内容2 |', '列1');
};
const insertDivider = () => {
insertBlock('---');
};
const toggleMode = () => {
mode.value = mode.value === 'edit' ? 'preview' : 'edit';
if (mode.value === 'edit') {
nextTick(() => {
getTextarea()?.focus();
});
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (props.disabled || mode.value !== 'edit') return;
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return;
const key = event.key.toLowerCase();
if (key === 'b') {
event.preventDefault();
wrapSelection('**', '**', '粗体');
} else if (key === 'i') {
event.preventDefault();
wrapSelection('*', '*', '斜体');
}
};
const toolbarItems: ToolbarItem[] = [
{ key: 'h1', title: '一级标题', text: 'H1', action: () => setHeading(1) },
{ key: 'h2', title: '二级标题', text: 'H2', action: () => setHeading(2) },
{ key: 'h3', title: '三级标题', text: 'H3', groupEnd: true, action: () => setHeading(3) },
{
key: 'bold',
title: '粗体 (Ctrl+B)',
text: 'B',
className: 'is-bold',
action: () => wrapSelection('**', '**', '粗体')
},
{
key: 'italic',
title: '斜体 (Ctrl+I)',
text: 'I',
className: 'is-italic',
action: () => wrapSelection('*', '*', '斜体')
},
{
key: 'strike',
title: '删除线',
text: 'S',
className: 'is-strike',
groupEnd: true,
action: () => wrapSelection('~~', '~~', '删除线')
},
{ key: 'quote', title: '引用', text: '引用', action: () => applyLinePrefix(() => '> ', /^\s*>\s?/, true) },
{ key: 'list', title: '无序列表', icon: 'List', action: () => applyLinePrefix(() => '- ', /^\s*[-*+]\s+/) },
{
key: 'ordered',
title: '有序列表',
text: '1.',
groupEnd: true,
action: () => applyLinePrefix(index => `${index + 1}. `, /^\s*\d+[.)]\s+/)
},
{ key: 'inline-code', title: '行内代码', text: '`c`', action: () => wrapSelection('`', '`', '代码') },
{ key: 'code-block', title: '代码块', text: '{ }', groupEnd: true, action: insertCodeBlock },
{ key: 'link', title: '链接', icon: 'Link', action: insertLink },
{ key: 'image', title: '图片', icon: 'Picture', action: insertImage },
{ key: 'table', title: '表格', icon: 'Grid', action: insertTable },
{ key: 'divider', title: '分割线', icon: 'Minus', action: insertDivider }
];
/* ==================== 轻量 Markdown 渲染 ==================== */
const escapeHtml = (text: string) =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const TOKEN_PREFIX = '\u0001';
/** 行内语法:行内代码 → 图片 → 链接 → 粗体 → 删除线 → 斜体 */
const renderInline = (escaped: string): string => {
const codes: string[] = [];
let output = escaped.replace(/`([^`\n]+)`/g, (_match, code: string) => {
codes.push(`<code>${code}</code>`);
return `${TOKEN_PREFIX}C${codes.length - 1}${TOKEN_PREFIX}`;
});
output = output.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_match, alt: string, url: string) => {
return `<img src="${url}" alt="${alt}" />`;
});
output = output.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, url: string) => {
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
});
output = output.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
output = output.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
output = output.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
output = output.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
output = output.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
output = output.replace(new RegExp(`${TOKEN_PREFIX}C(\\d+)${TOKEN_PREFIX}`, 'g'), (_match, index: string) => {
return codes[Number(index)] ?? '';
});
return output;
};
const renderInlineText = (raw: string): string => renderInline(escapeHtml(raw));
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
const FENCE_RE = /^\s*```(\S*)\s*$/;
const FENCE_END_RE = /^\s*```\s*$/;
const QUOTE_RE = /^\s*>\s?/;
const UL_RE = /^\s*[-*+]\s+/;
const OL_RE = /^\s*\d+[.)]\s+/;
const HR_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$/;
const isBlockStart = (line: string) => {
if (!line.trim()) return true;
return (
FENCE_RE.test(line) ||
HEADING_RE.test(line) ||
QUOTE_RE.test(line) ||
UL_RE.test(line) ||
OL_RE.test(line) ||
HR_RE.test(line)
);
};
const splitTableRow = (row: string) =>
row
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(cell => cell.trim());
const alignStyle = (separator: string | undefined) => {
if (!separator) return '';
const starts = separator.startsWith(':');
const ends = separator.endsWith(':');
if (starts && ends) return ' style="text-align: center"';
if (ends) return ' style="text-align: right"';
if (starts) return ' style="text-align: left"';
return '';
};
const renderBlocks = (source: string, depth = 0): string => {
const lines = source.replace(/\r\n?/g, '\n').split('\n');
const html: string[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index++;
continue;
}
/* 代码块 */
const fence = line.match(FENCE_RE);
if (fence) {
const language = fence[1].replace(/[^\w-]/g, '');
const codeLines: string[] = [];
index++;
while (index < lines.length && !FENCE_END_RE.test(lines[index])) {
codeLines.push(lines[index]);
index++;
}
if (index < lines.length) index++;
const className = language ? ` class="language-${language}"` : '';
html.push(`<pre><code${className}>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
continue;
}
/* 标题 */
const heading = line.match(HEADING_RE);
if (heading) {
const level = heading[1].length;
html.push(`<h${level}>${renderInlineText(heading[2].trim())}</h${level}>`);
index++;
continue;
}
/* 分割线 */
if (HR_RE.test(line)) {
html.push('<hr />');
index++;
continue;
}
/* 表格 */
if (line.includes('|') && index + 1 < lines.length && TABLE_SEPARATOR_RE.test(lines[index + 1])) {
const header = splitTableRow(line);
const alignments = splitTableRow(lines[index + 1]);
index += 2;
const rows: string[][] = [];
while (index < lines.length && lines[index].trim() && lines[index].includes('|')) {
rows.push(splitTableRow(lines[index]));
index++;
}
const thead = header
.map((cell, cellIndex) => `<th${alignStyle(alignments[cellIndex])}>${renderInlineText(cell)}</th>`)
.join('');
const tbody = rows
.map(row => {
const cells = header
.map(
(_cell, cellIndex) =>
`<td${alignStyle(alignments[cellIndex])}>${renderInlineText(row[cellIndex] ?? '')}</td>`
)
.join('');
return `<tr>${cells}</tr>`;
})
.join('');
html.push(
`<div class="md-table-wrap"><table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></div>`
);
continue;
}
/* 引用(支持嵌套一层) */
if (QUOTE_RE.test(line)) {
const quoteLines: string[] = [];
while (index < lines.length && QUOTE_RE.test(lines[index])) {
quoteLines.push(lines[index].replace(QUOTE_RE, ''));
index++;
}
const inner =
depth < 2 ? renderBlocks(quoteLines.join('\n'), depth + 1) : quoteLines.map(renderInlineText).join('<br />');
html.push(`<blockquote>${inner}</blockquote>`);
continue;
}
/* 无序列表 */
if (UL_RE.test(line)) {
const items: string[] = [];
while (index < lines.length && UL_RE.test(lines[index])) {
items.push(lines[index].replace(UL_RE, ''));
index++;
}
html.push(`<ul>${items.map(item => `<li>${renderInlineText(item)}</li>`).join('')}</ul>`);
continue;
}
/* 有序列表 */
if (OL_RE.test(line)) {
const items: string[] = [];
while (index < lines.length && OL_RE.test(lines[index])) {
items.push(lines[index].replace(OL_RE, ''));
index++;
}
html.push(`<ol>${items.map(item => `<li>${renderInlineText(item)}</li>`).join('')}</ol>`);
continue;
}
/* 段落 */
const paragraph: string[] = [];
while (index < lines.length && !isBlockStart(lines[index])) {
paragraph.push(lines[index].trim());
index++;
}
if (paragraph.length === 0) {
paragraph.push(line.trim());
index++;
}
html.push(`<p>${paragraph.map(renderInlineText).join('<br />')}</p>`);
}
return html.join('');
};
const previewHtml = computed(() => sanitizeHtml(renderBlocks(content.value || '')));
/* ==================== 双向绑定 ==================== */
watch(
() => props.modelValue,
value => {
const next = value ?? '';
if (next !== content.value) {
content.value = next;
}
}
);
watch(content, value => {
if (value !== (props.modelValue ?? '')) {
emit('update:modelValue', value);
}
});
</script>
<style scoped>
.md-editor {
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-bg-color);
overflow: hidden;
}
.md-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 4px 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
}
.md-toolbar-btn {
min-width: 26px;
height: 26px;
padding: 0 6px;
color: var(--el-text-color-regular);
}
.md-toolbar-btn:not(.is-disabled):hover {
color: var(--el-color-primary);
}
.md-toolbar-icon {
font-size: 15px;
}
.md-toolbar-text {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.md-toolbar-btn.is-bold .md-toolbar-text {
font-weight: 700;
}
.md-toolbar-btn.is-italic .md-toolbar-text {
font-style: italic;
}
.md-toolbar-btn.is-strike .md-toolbar-text {
text-decoration: line-through;
}
.md-toolbar-divider {
width: 1px;
height: 16px;
margin: 0 4px;
background: var(--el-border-color);
}
.md-toolbar-spacer {
flex: 1;
}
.md-toolbar-switch .md-toolbar-text {
margin-left: 4px;
}
.md-body {
position: relative;
display: flex;
flex-direction: column;
}
.md-edit-pane {
flex: 1;
min-height: 0;
}
.md-textarea {
height: 100%;
}
.md-textarea :deep(.el-textarea__inner) {
height: 100%;
padding: 8px 12px;
border: 0;
border-radius: 0;
box-shadow: none;
font-family: var(--el-font-family-mono, 'JetBrains Mono', Consolas, Menlo, monospace);
font-size: 13px;
line-height: 1.7;
background: transparent;
}
.md-preview {
flex: 1;
min-height: 0;
height: 100%;
overflow: auto;
padding: 12px 16px;
box-sizing: border-box;
color: var(--el-text-color-primary);
font-size: 14px;
line-height: 1.8;
word-break: break-word;
}
.md-preview-empty {
color: var(--el-text-color-placeholder);
}
.md-preview-content :deep(h1),
.md-preview-content :deep(h2),
.md-preview-content :deep(h3),
.md-preview-content :deep(h4),
.md-preview-content :deep(h5),
.md-preview-content :deep(h6) {
margin: 16px 0 8px;
font-weight: 600;
line-height: 1.4;
color: var(--app-text-title, var(--el-text-color-primary));
}
.md-preview-content :deep(h1) {
font-size: 22px;
}
.md-preview-content :deep(h2) {
font-size: 19px;
}
.md-preview-content :deep(h3) {
font-size: 16px;
}
.md-preview-content :deep(p) {
margin: 8px 0;
}
.md-preview-content :deep(a) {
color: var(--el-color-primary);
text-decoration: none;
}
.md-preview-content :deep(a:hover) {
text-decoration: underline;
}
.md-preview-content :deep(code) {
padding: 1px 5px;
border-radius: var(--app-radius-sm, 4px);
background: var(--el-fill-color);
font-family: var(--el-font-family-mono, 'JetBrains Mono', Consolas, Menlo, monospace);
font-size: 12.5px;
}
.md-preview-content :deep(pre) {
margin: 10px 0;
padding: 10px 12px;
overflow: auto;
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-dark, var(--el-fill-color));
color: var(--el-text-color-primary);
}
.md-preview-content :deep(pre code) {
padding: 0;
background: transparent;
font-size: 12.5px;
line-height: 1.6;
}
.md-preview-content :deep(blockquote) {
margin: 10px 0;
padding: 6px 12px;
border-left: 3px solid var(--el-color-primary);
background: var(--app-elevated-soft-bg, var(--el-fill-color-lighter));
color: var(--app-text-muted, var(--el-text-color-secondary));
}
.md-preview-content :deep(blockquote p) {
margin: 4px 0;
}
.md-preview-content :deep(ul),
.md-preview-content :deep(ol) {
margin: 8px 0;
padding-left: 24px;
}
.md-preview-content :deep(li) {
margin: 4px 0;
}
.md-preview-content :deep(hr) {
margin: 16px 0;
border: 0;
border-top: 1px solid var(--el-border-color);
}
.md-preview-content :deep(img) {
max-width: 100%;
border-radius: var(--app-radius-sm, 4px);
}
.md-table-wrap {
margin: 10px 0;
overflow-x: auto;
}
.md-preview-content :deep(table) {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.md-preview-content :deep(th),
.md-preview-content :deep(td) {
padding: 6px 10px;
border: 1px solid var(--el-border-color);
text-align: left;
}
.md-preview-content :deep(th) {
background: var(--el-fill-color-lighter);
font-weight: 600;
}
</style>
+133 -58
View File
@@ -24,7 +24,12 @@
</el-form-item>
<el-form-item label="计费模式" prop="chargeType">
<el-select v-model="queryParams.chargeType" placeholder="计费模式" clearable>
<el-option v-for="dict in openapi_charge_type" :key="dict.value" :label="dict.label" :value="dict.value" />
<el-option
v-for="dict in openapi_charge_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="接口状态" prop="status">
@@ -173,66 +178,87 @@
/>
</el-card>
<!-- 添加或修改API接口对话框 -->
<el-dialog v-model="dialog.visible" :title="dialog.title" width="760px" append-to-body>
<el-form ref="apiInfoFormRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="分类" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="请选择分类" clearable>
<el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="接口名称" prop="name">
<el-input v-model="form.name" placeholder="请输入接口名称" />
</el-form-item>
<el-form-item label="接口编码" prop="apiCode">
<el-input v-model="form.apiCode" :disabled="form.id != null" placeholder="weather_query" />
</el-form-item>
<el-form-item label="网关路由路径" prop="path">
<el-input v-model="form.path" placeholder="/api/v1/weather" />
</el-form-item>
<el-form-item label="请求方法" prop="method">
<el-select v-model="form.method" placeholder="请选择请求方法">
<el-option v-for="dict in openapi_api_method" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="后端代理地址" prop="upstreamUrl">
<el-input v-model="form.upstreamUrl" placeholder="http://127.0.0.1:8081/internal/xxx" />
</el-form-item>
<el-form-item label="超时时间(毫秒)" prop="timeoutMs">
<el-input-number v-model="form.timeoutMs" :min="1" :step="500" controls-position="right" />
</el-form-item>
<el-form-item label="计费模式" prop="chargeType">
<el-radio-group v-model="form.chargeType">
<el-radio v-for="dict in openapi_charge_type" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调用单价(元)" prop="pricePerCall">
<el-input-number
v-model="form.pricePerCall"
:min="0"
:precision="4"
:step="0.0001"
controls-position="right"
/>
</el-form-item>
<el-form-item label="免费赠送次数" prop="freeCalls">
<el-input-number v-model="form.freeCalls" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="QPS上限" prop="qpsLimit">
<el-input-number v-model="form.qpsLimit" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="接口状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio v-for="dict in openapi_api_status" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="接口说明" prop="description">
<el-input v-model="form.description" type="textarea" :rows="5" placeholder="支持 Markdown" />
</el-form-item>
<el-dialog v-model="dialog.visible" :title="dialog.title" width="820px" append-to-body>
<el-steps :active="dialogStep" simple class="api-dialog-steps">
<el-step title="基本信息" />
<el-step title="接口说明" />
</el-steps>
<el-form ref="apiInfoFormRef" :model="form" :rules="rules" label-width="120px" class="api-dialog-form">
<div v-show="dialogStep === 0">
<el-form-item label="分类" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="请选择分类" clearable>
<el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="接口名称" prop="name">
<el-input v-model="form.name" placeholder="请输入接口名称" />
</el-form-item>
<el-form-item label="接口编码" prop="apiCode">
<el-input v-model="form.apiCode" :disabled="form.id != null" placeholder="weather_query" />
</el-form-item>
<el-form-item label="网关路由路径" prop="path">
<el-input v-model="form.path" placeholder="/api/v1/weather" />
</el-form-item>
<el-form-item label="请求方法" prop="method">
<el-select v-model="form.method" placeholder="请选择请求方法">
<el-option v-for="dict in openapi_api_method" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="后端代理地址" prop="upstreamUrl">
<el-input v-model="form.upstreamUrl" placeholder="http://127.0.0.1:8081/internal/xxx" />
</el-form-item>
<el-form-item label="超时时间(毫秒)" prop="timeoutMs">
<el-input-number v-model="form.timeoutMs" :min="1" :step="500" controls-position="right" />
</el-form-item>
<el-form-item label="计费模式" prop="chargeType">
<el-radio-group v-model="form.chargeType">
<el-radio v-for="dict in openapi_charge_type" :key="dict.value" :value="dict.value">
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调用单价(元)" prop="pricePerCall">
<el-input-number
v-model="form.pricePerCall"
:min="0"
:precision="4"
:step="0.0001"
controls-position="right"
/>
</el-form-item>
<el-form-item label="免费赠送次数" prop="freeCalls">
<el-input-number v-model="form.freeCalls" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="QPS上限" prop="qpsLimit">
<el-input-number v-model="form.qpsLimit" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="接口状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio v-for="dict in openapi_api_status" :key="dict.value" :value="dict.value">
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</div>
<div v-show="dialogStep === 1" class="api-dialog-description">
<el-form-item label="接口说明" prop="description">
<markdown-editor
v-model="form.description"
:height="360"
placeholder="请输入接口说明,支持 Markdown 语法"
/>
</el-form-item>
<div class="api-dialog-tip">支持 Markdown,可使用上方工具栏插入语法</div>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
<el-button v-show="dialogStep === 0" @click="handleNextStep">下一步</el-button>
<el-button v-show="dialogStep === 1" @click="handlePrevStep">上一步</el-button>
<el-button v-show="dialogStep === 1" :loading="buttonLoading" type="primary" @click="submitForm">
确 定
</el-button>
</div>
</template>
</el-dialog>
@@ -275,6 +301,11 @@ const {
const queryFormRef = ref<ElFormInstance>();
const apiInfoFormRef = ref<ElFormInstance>();
/** 弹窗当前步骤:0-基本信息,1-接口说明 */
const dialogStep = ref(0);
/** 第一步需要校验的字段(与 rules 中 required 字段保持一致) */
const STEP_ONE_FIELDS = ['name', 'apiCode', 'path', 'method', 'upstreamUrl'];
const initFormData: ApiInfoForm = {
id: undefined,
categoryId: undefined,
@@ -350,6 +381,26 @@ const getList = async () => {
const cancel = () => {
closeDialog();
resetForm();
dialogStep.value = 0;
};
/** 上一步:回到基本信息 */
const handlePrevStep = () => {
dialogStep.value = 0;
};
/** 下一步:先校验基本信息必填项,通过后进入接口说明 */
const handleNextStep = () => {
const formInstance = apiInfoFormRef.value;
if (!formInstance) {
dialogStep.value = 1;
return;
}
formInstance.validateField(STEP_ONE_FIELDS, (valid: boolean) => {
if (valid) {
dialogStep.value = 1;
}
});
};
/** 搜索按钮操作 */
@@ -360,12 +411,14 @@ const handleQuery = () => {
/** 新增按钮操作 */
const handleAdd = () => {
dialogStep.value = 0;
openDialog('添加API接口');
};
/** 修改按钮操作 */
const handleUpdate = async (row?: Partial<ApiInfoVO>) => {
resetForm();
dialogStep.value = 0;
const apiInfoId = row?.id || ids.value[0];
const res = await getApiInfo(apiInfoId);
Object.assign(form.value, res.data);
@@ -374,7 +427,7 @@ const handleUpdate = async (row?: Partial<ApiInfoVO>) => {
/** 提交按钮 */
const submitForm = () => {
apiInfoFormRef.value?.validate(async (valid: boolean) => {
apiInfoFormRef.value?.validate(async (valid: boolean, invalidFields?: Record<string, unknown>) => {
if (valid) {
await withButtonLoading(async () => {
if (form.value.id) {
@@ -386,6 +439,11 @@ const submitForm = () => {
modal.msgSuccess('操作成功');
closeDialog();
await getList();
return;
}
// 校验失败且错误属于基本信息字段时,回到第一步展示错误
if (invalidFields && STEP_ONE_FIELDS.some(field => field in invalidFields)) {
dialogStep.value = 0;
}
});
};
@@ -423,3 +481,20 @@ onMounted(async () => {
categoryOptions.value = res.data?.rows;
});
</script>
<style scoped>
.api-dialog-steps {
margin: -8px 0 20px;
}
.api-dialog-description {
min-height: 412px;
}
.api-dialog-tip {
margin: -8px 0 0 120px;
color: var(--app-text-muted, var(--el-text-color-secondary));
font-size: 12px;
line-height: 1.6;
}
</style>