mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-01 16:28:46 +00:00
add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息 (#122)
* add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息 * add: 修复联表情况下获取列信息的问题 * fix: 修复联表场景下的SQL语句提示 * remove: 移除不需要的调试打印 * add: 补充'列详情'多语言字段 * remove: 移除不必要的函数 * remove: 移除不必要的导入 * add: 目前会分析SQL语句内引用的表,并且只加载对应的表内的列信息,不再对全表做列信息拉取
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { X, Copy } from "lucide-vue-next";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
table: string;
|
||||
dataType?: string;
|
||||
isNullable?: boolean;
|
||||
columnDefault?: string | null;
|
||||
isPrimaryKey?: boolean;
|
||||
comment?: string | null;
|
||||
extra?: string | null;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
columns: ColumnInfo[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute top-0 right-0 bottom-0 z-10 w-72 bg-card border-l border-border shadow-lg flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span class="text-sm font-medium">{{ t("grid.columnDetails") }}</span>
|
||||
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="emit('close')">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<template v-if="loading">
|
||||
<div class="text-xs text-muted-foreground animate-pulse">{{ t("common.loading") }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="error">
|
||||
<div class="text-xs text-destructive">{{ error }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Column detail sections -->
|
||||
<div v-for="(col, idx) in columns" :key="idx" class="space-y-2">
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-sm text-foreground">{{ col.name }}</span>
|
||||
<Badge
|
||||
v-if="col.isPrimaryKey"
|
||||
variant="outline"
|
||||
class="h-4 px-1 text-[10px] border-primary/40 text-primary"
|
||||
>
|
||||
PK
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-5 w-5 p-0" @click="copyText(col.name)">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Table origin -->
|
||||
<div class="text-xs text-muted-foreground">{{ t("tabs.tooltipTable") }} {{ col.table }}</div>
|
||||
|
||||
<!-- Detail rows -->
|
||||
<div class="rounded-md border border-border divide-y divide-border text-xs">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
|
||||
<span class="font-medium max-w-[180px] truncate" :title="col.dataType">
|
||||
{{ col.dataType || "—" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-muted-foreground">{{ t("structureEditor.nullable") }}</span>
|
||||
<span class="font-medium">{{ col.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</div>
|
||||
<div v-if="col.columnDefault" class="flex items-start justify-between px-2 py-1 gap-2">
|
||||
<span class="text-muted-foreground shrink-0">{{ t("structureEditor.defaultValue") }}</span>
|
||||
<span class="font-medium text-right max-w-[180px] truncate" :title="col.columnDefault">
|
||||
{{ col.columnDefault }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="col.comment" class="flex items-start justify-between px-2 py-1 gap-2">
|
||||
<span class="text-muted-foreground shrink-0">{{ t("structureEditor.comment") }}</span>
|
||||
<span class="font-medium text-right max-w-[180px] truncate" :title="col.comment">
|
||||
{{ col.comment }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="col.extra" class="flex items-start justify-between px-2 py-1 gap-2">
|
||||
<span class="text-muted-foreground shrink-0">Extra</span>
|
||||
<span class="font-medium text-right max-w-[180px] truncate" :title="col.extra">
|
||||
{{ col.extra }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,7 +7,9 @@ import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { buildSqlCompletionItemsFromContext, getSqlCompletionContext } from "@/lib/sqlCompletion";
|
||||
import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
|
||||
import type { SqlCompletionColumn } from "@/lib/sqlCompletion";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
@@ -24,6 +26,9 @@ const emit = defineEmits<{
|
||||
cursorChange: [pos: number];
|
||||
formatError: [message: string];
|
||||
execute: [sql: string];
|
||||
clickTable: [tableName: string];
|
||||
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
|
||||
closeColumnPanel: [];
|
||||
}>();
|
||||
|
||||
const editorRef = ref<HTMLDivElement>();
|
||||
@@ -36,6 +41,11 @@ let editorViewModule: typeof import("@codemirror/view") | null = null;
|
||||
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
|
||||
|
||||
// Completion cache
|
||||
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
|
||||
// Persistent column cache keyed by "schema.table" or "table"
|
||||
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
|
||||
function setFontSize(size: number) {
|
||||
const next = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size));
|
||||
const ss = settingsStore.editorSettings;
|
||||
@@ -104,30 +114,57 @@ async function formatCurrentSql() {
|
||||
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number) {
|
||||
if (!props.connectionId || !props.database) return null;
|
||||
|
||||
const completionContext = getSqlCompletionContext(currentState.doc.toString(), position);
|
||||
const fullDoc = currentState.doc.toString();
|
||||
const completionContext = getSqlCompletionContext(fullDoc, position);
|
||||
const tables = await connectionStore.listCompletionTables(props.connectionId, props.database);
|
||||
const columnsByTable = new Map<string, Awaited<ReturnType<typeof connectionStore.listCompletionColumns>>>();
|
||||
|
||||
if (completionContext.suggestColumns) {
|
||||
const relatedTables = completionContext.qualifier
|
||||
? completionContext.referencedTables.filter(
|
||||
(table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier,
|
||||
)
|
||||
: completionContext.referencedTables;
|
||||
// Collect referenced tables — enrich with schema from cached tables list
|
||||
let refs = completionContext.referencedTables.map((rt) => {
|
||||
// If no schema, look it up in the cached tables
|
||||
if (!rt.schema) {
|
||||
const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase());
|
||||
if (cached && cached.schema) {
|
||||
return { ...rt, schema: cached.schema };
|
||||
}
|
||||
}
|
||||
return rt;
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
relatedTables.map(async (table) => {
|
||||
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
|
||||
if (columnsByTable.has(cacheKey)) return;
|
||||
// If no referenced tables but qualifier exists, infer table from tables list
|
||||
if (refs.length === 0 && completionContext.qualifier) {
|
||||
const q = completionContext.qualifier.toLowerCase();
|
||||
const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q));
|
||||
refs = matched.map((t) => ({ name: t.name, schema: t.schema }));
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
refs.map(async (refTable) => {
|
||||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||
if (cachedColumnsByTable.has(cacheKey)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const columns = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
table.name,
|
||||
table.schema,
|
||||
refTable.name,
|
||||
refTable.schema,
|
||||
);
|
||||
columnsByTable.set(cacheKey, columns);
|
||||
}),
|
||||
);
|
||||
cachedColumnsByTable.set(cacheKey, columns);
|
||||
} catch (e) {
|
||||
console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Build columnsByTable from persistent cache — only include columns for referenced tables
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||
for (const refTable of refs) {
|
||||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||
const cached = cachedColumnsByTable.get(cacheKey);
|
||||
if (cached) {
|
||||
columnsByTable.set(cacheKey, cached);
|
||||
}
|
||||
}
|
||||
|
||||
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
||||
@@ -149,6 +186,17 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshCompletionCache() {
|
||||
if (!props.connectionId || !props.database) return;
|
||||
try {
|
||||
cachedTables = await connectionStore.listCompletionTables(props.connectionId, props.database);
|
||||
} catch {
|
||||
cachedTables = [];
|
||||
}
|
||||
// Clear column cache when connection/database changes
|
||||
cachedColumnsByTable.clear();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorRef.value) return;
|
||||
|
||||
@@ -260,11 +308,139 @@ onMounted(async () => {
|
||||
else if (event.deltaY > 0) zoomOut();
|
||||
return true;
|
||||
},
|
||||
mousedown: (event: MouseEvent) => {
|
||||
// Click without modifier -> close column panel
|
||||
if (!event.metaKey && !event.ctrlKey) {
|
||||
if (event.button === 0) {
|
||||
emit("closeColumnPanel");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Only handle Ctrl/Cmd + left click
|
||||
if (event.button !== 0) return false;
|
||||
|
||||
const currentView = view.value;
|
||||
if (!currentView || !props.connectionId || !props.database) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use posAtCoords for accurate click position
|
||||
const coords = { x: event.clientX, y: event.clientY };
|
||||
const pos = currentView.posAtCoords(coords);
|
||||
if (pos == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doc = currentView.state.doc.toString();
|
||||
const identifier = extractIdentifierAt(doc, pos);
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
if (isSqlKeyword(identifier)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent default, resolve async
|
||||
event.preventDefault();
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Ensure table cache is populated
|
||||
if (cachedTables.length === 0) {
|
||||
cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!);
|
||||
}
|
||||
|
||||
// 1. Check if it's a table name
|
||||
const matchedTable = matchTable(identifier, cachedTables);
|
||||
if (matchedTable) {
|
||||
emit(
|
||||
"clickTable",
|
||||
matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Parse SQL at click position to get referenced tables
|
||||
const context = getSqlCompletionContext(doc, pos);
|
||||
let referencedTables = context.referencedTables;
|
||||
// Enrich referenced tables with schema from cachedTables
|
||||
referencedTables = referencedTables.map((rt) => {
|
||||
const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase());
|
||||
if (cached && cached.schema && !rt.schema) {
|
||||
return { ...rt, schema: cached.schema };
|
||||
}
|
||||
return rt;
|
||||
});
|
||||
|
||||
// Check if identifier has a qualifier (e.g., c.card_name)
|
||||
const qualifierMatch = /^(.+)\.(.+)$/.exec(identifier);
|
||||
const qualifier = qualifierMatch ? qualifierMatch[1] : null;
|
||||
const colName = qualifierMatch ? qualifierMatch[2] : identifier;
|
||||
const colLower = colName.toLowerCase();
|
||||
|
||||
if (referencedTables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
|
||||
const tablesToCheck = qualifier
|
||||
? referencedTables.filter(
|
||||
(rt) =>
|
||||
rt.alias?.toLowerCase() === qualifier.toLowerCase() ||
|
||||
rt.name.toLowerCase() === qualifier.toLowerCase(),
|
||||
)
|
||||
: referencedTables;
|
||||
|
||||
if (tablesToCheck.length === 0 && qualifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedCols: Array<{ name: string; table: string; schema?: string }> = [];
|
||||
|
||||
for (const refTable of tablesToCheck) {
|
||||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||
|
||||
// Use persistent column cache; fetch only if missing
|
||||
let cols = cachedColumnsByTable.get(cacheKey);
|
||||
if (!cols) {
|
||||
try {
|
||||
cols = await connectionStore.listCompletionColumns(
|
||||
props.connectionId!,
|
||||
props.database!,
|
||||
refTable.name,
|
||||
refTable.schema,
|
||||
);
|
||||
cachedColumnsByTable.set(cacheKey, cols);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const col of cols) {
|
||||
if (col.name.toLowerCase() === colLower) {
|
||||
matchedCols.push({
|
||||
name: col.name,
|
||||
table: refTable.name,
|
||||
schema: col.schema || refTable.schema,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedCols.length > 0) {
|
||||
emit("clickColumn", matchedCols);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DBX] Ctrl+click error:", e);
|
||||
}
|
||||
}, 0);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view.value = new EditorView({ state, parent: editorRef.value });
|
||||
|
||||
// Fetch completion data
|
||||
refreshCompletionCache();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -285,7 +461,21 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
// Reactively apply editor settings changes (theme, font family, font size)
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
refreshCompletionCache();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.database,
|
||||
() => {
|
||||
refreshCompletionCache();
|
||||
},
|
||||
);
|
||||
|
||||
// Reactively apply editor settings changes
|
||||
watch(
|
||||
() => settingsStore.editorSettings,
|
||||
async (ss) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import { computed, ref, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Loader2, Square, Bot, Table2, GitBranch, BarChart3 } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
@@ -9,6 +9,8 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
|
||||
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
|
||||
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
|
||||
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -41,11 +43,18 @@ const emit = defineEmits<{
|
||||
paginate: [offset: number, limit: number, whereInput?: string];
|
||||
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
|
||||
executeSql: [sql: string];
|
||||
clickTable: [tableName: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
// Column info panel state
|
||||
const showColumnInfo = ref(false);
|
||||
const columnInfoColumns = ref<ColumnInfo[]>([]);
|
||||
const columnInfoLoading = ref(false);
|
||||
const columnInfoError = ref<string | undefined>(undefined);
|
||||
|
||||
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
||||
switch (props.activeConnection?.db_type) {
|
||||
case "mysql":
|
||||
@@ -70,6 +79,79 @@ const hasNumericData = computed(() => {
|
||||
if (!r || r.rows.length === 0) return false;
|
||||
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
|
||||
});
|
||||
|
||||
// Column info panel handlers
|
||||
async function onHandleClickColumn(
|
||||
matchedCols: Array<{ name: string; table: string; schema?: string }>,
|
||||
errorMsg?: string,
|
||||
) {
|
||||
if (!props.activeTab.connectionId || !props.activeTab.database) return;
|
||||
|
||||
// If error or no columns, silently ignore — don't show the panel
|
||||
if (errorMsg || matchedCols.length === 0) return;
|
||||
|
||||
columnInfoLoading.value = true;
|
||||
columnInfoError.value = undefined;
|
||||
|
||||
try {
|
||||
// Fetch full column details from API
|
||||
const apiModule = await import("@/lib/api");
|
||||
const results: ColumnInfo[] = [];
|
||||
|
||||
for (const matchedCol of matchedCols) {
|
||||
const querySchema = matchedCol.schema || props.activeTab.database || "";
|
||||
try {
|
||||
const fullColumns = await apiModule.getColumns(
|
||||
props.activeTab.connectionId,
|
||||
props.activeTab.database,
|
||||
querySchema,
|
||||
matchedCol.table,
|
||||
);
|
||||
for (const col of fullColumns) {
|
||||
if (col.name === matchedCol.name) {
|
||||
results.push({
|
||||
name: col.name,
|
||||
table: matchedCol.table,
|
||||
dataType: col.data_type,
|
||||
isNullable: col.is_nullable,
|
||||
columnDefault: col.column_default,
|
||||
isPrimaryKey: col.is_primary_key,
|
||||
comment: col.comment,
|
||||
extra: col.extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip tables that fail
|
||||
}
|
||||
}
|
||||
|
||||
columnInfoColumns.value = results;
|
||||
} catch (e: any) {
|
||||
// Silently ignore errors
|
||||
console.error("[DBX] Failed to fetch column info:", e);
|
||||
return;
|
||||
} finally {
|
||||
columnInfoLoading.value = false;
|
||||
showColumnInfo.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeColumnInfo() {
|
||||
showColumnInfo.value = false;
|
||||
columnInfoColumns.value = [];
|
||||
columnInfoError.value = undefined;
|
||||
}
|
||||
|
||||
function onHandleClickTable(tableName: string) {
|
||||
emit("clickTable", tableName);
|
||||
}
|
||||
|
||||
function onHandleCloseColumnPanel() {
|
||||
showColumnInfo.value = false;
|
||||
columnInfoColumns.value = [];
|
||||
columnInfoError.value = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,7 +160,7 @@ const hasNumericData = computed(() => {
|
||||
<template v-if="activeTab.mode === 'query'">
|
||||
<Splitpanes horizontal class="flex-1">
|
||||
<Pane :size="40" :min-size="15">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="h-full flex flex-col relative">
|
||||
<QueryEditor
|
||||
class="flex-1"
|
||||
:model-value="activeTab.sql"
|
||||
@@ -92,6 +174,16 @@ const hasNumericData = computed(() => {
|
||||
@cursor-change="emit('editorCursorChange', $event)"
|
||||
@format-error="emit('formatError')"
|
||||
@execute="emit('execute')"
|
||||
@click-table="onHandleClickTable"
|
||||
@click-column="onHandleClickColumn"
|
||||
@close-column-panel="onHandleCloseColumnPanel"
|
||||
/>
|
||||
<ColumnInfoPanel
|
||||
v-if="showColumnInfo"
|
||||
:columns="columnInfoColumns"
|
||||
:loading="columnInfoLoading"
|
||||
:error="columnInfoError"
|
||||
@close="closeColumnInfo"
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
Reference in New Issue
Block a user