feat: API接口编辑弹窗改为分步表单并接入Markdown编辑器
This commit is contained in:
@@ -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)}${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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user