支持 meilisearch 管理
This commit is contained in:
@@ -243,6 +243,7 @@ const local: App.I18n.Schema = {
|
||||
system_users: 'Users',
|
||||
toolbox: 'Toolbox',
|
||||
'toolbox_luxsin-controller': 'Luxsin Controller',
|
||||
meilisearch: 'meilisearch',
|
||||
www: 'Website',
|
||||
www_global: 'Global Config',
|
||||
www_global_basic: 'Site Info',
|
||||
|
||||
@@ -239,6 +239,7 @@ const local: App.I18n.Schema = {
|
||||
system_users: '账号管理',
|
||||
toolbox: '工具箱',
|
||||
'toolbox_luxsin-controller': 'luxsin 控制器',
|
||||
meilisearch: 'meilisearch',
|
||||
// 官网管理
|
||||
www: '官网管理',
|
||||
www_global: '全局配置',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
headphone_brand: () => import("@/views/headphone/brand/index.vue"),
|
||||
headphone_model: () => import("@/views/headphone/model/index.vue"),
|
||||
home: () => import("@/views/home/index.vue"),
|
||||
meilisearch: () => import("@/views/meilisearch/index.vue"),
|
||||
"share-code_log": () => import("@/views/share-code/log/index.vue"),
|
||||
system_users: () => import("@/views/system/users/index.vue"),
|
||||
"toolbox_luxsin-controller": () => import("@/views/toolbox/luxsin-controller/index.vue"),
|
||||
|
||||
@@ -113,6 +113,18 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'meilisearch',
|
||||
path: '/meilisearch',
|
||||
component: 'layout.base$view.meilisearch',
|
||||
meta: {
|
||||
title: 'meilisearch',
|
||||
i18nKey: 'route.meilisearch',
|
||||
icon: 'simple-icons:meilisearch',
|
||||
order: 8,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'share-code',
|
||||
path: '/share-code',
|
||||
|
||||
@@ -172,6 +172,7 @@ const routeMap: RouteMap = {
|
||||
"home": "/home",
|
||||
"iframe-page": "/iframe-page/:url",
|
||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
|
||||
"meilisearch": "/meilisearch",
|
||||
"share-code": "/share-code",
|
||||
"share-code_log": "/share-code/log",
|
||||
"system": "/system",
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './blacklist';
|
||||
export * from './ota-target-device';
|
||||
export * from './share-code-log';
|
||||
export * from './model';
|
||||
export * from './meilisearch';
|
||||
|
||||
// 官网 CMS
|
||||
export * from './www-global';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { request } from '../request';
|
||||
|
||||
/** 获取 Meilisearch 连接信息与索引概况 */
|
||||
export function fetchGetMeilisearchInfo() {
|
||||
return request<Api.Meilisearch.Info>({
|
||||
url: '/meilisearch/info',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** 分页获取文档列表(q 非空时按 brand_name/name 筛选) */
|
||||
export function fetchGetMeilisearchDocuments(params: { skip?: number; limit?: number; q?: string }) {
|
||||
return request<Api.Meilisearch.DocList>({
|
||||
url: '/meilisearch/documents',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增文档 */
|
||||
export function fetchCreateMeilisearchDocument(data: Api.Meilisearch.Document) {
|
||||
return request<Api.Meilisearch.TaskResult>({
|
||||
url: '/meilisearch/documents',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新文档(按主键覆盖) */
|
||||
export function fetchUpdateMeilisearchDocument(id: string | number, data: Api.Meilisearch.Document) {
|
||||
return request<Api.Meilisearch.TaskResult>({
|
||||
url: `/meilisearch/documents/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除文档 */
|
||||
export function fetchDeleteMeilisearchDocument(id: string | number) {
|
||||
return request<Api.Meilisearch.TaskResult>({
|
||||
url: `/meilisearch/documents/${id}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
declare namespace Api {
|
||||
/**
|
||||
* namespace Meilisearch
|
||||
*
|
||||
* backend api module: "meilisearch"
|
||||
*/
|
||||
namespace Meilisearch {
|
||||
/** 连接信息(来自后端环境变量) */
|
||||
interface Info {
|
||||
url: string;
|
||||
index: string;
|
||||
primary_key: string | null;
|
||||
number_of_documents: number;
|
||||
index_exists: boolean;
|
||||
version: string | null;
|
||||
/** 实例级磁盘占用(字节,GET /stats 的 database_size) */
|
||||
database_size: number | null;
|
||||
/** 当前索引磁盘占用(字节) */
|
||||
index_database_size: number | null;
|
||||
}
|
||||
|
||||
/** 单条文档(任意 JSON 对象) */
|
||||
type Document = Record<string, any>;
|
||||
|
||||
/** 文档分页列表 */
|
||||
interface DocList {
|
||||
items: Document[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
primary_key: string | null;
|
||||
}
|
||||
|
||||
/** Meilisearch 异步任务回执 */
|
||||
interface TaskResult {
|
||||
taskUid: number;
|
||||
indexUid?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ declare module 'vue' {
|
||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAutoComplete: typeof import('naive-ui')['NAutoComplete']
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
|
||||
@@ -123,6 +124,7 @@ declare global {
|
||||
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
const MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||
const NAlert: typeof import('naive-ui')['NAlert']
|
||||
const NAutoComplete: typeof import('naive-ui')['NAutoComplete']
|
||||
const NBadge: typeof import('naive-ui')['NBadge']
|
||||
const NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
|
||||
|
||||
@@ -26,6 +26,7 @@ declare module "@elegant-router/types" {
|
||||
"home": "/home";
|
||||
"iframe-page": "/iframe-page/:url";
|
||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
||||
"meilisearch": "/meilisearch";
|
||||
"share-code": "/share-code";
|
||||
"share-code_log": "/share-code/log";
|
||||
"system": "/system";
|
||||
@@ -101,6 +102,7 @@ declare module "@elegant-router/types" {
|
||||
| "home"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "meilisearch"
|
||||
| "share-code"
|
||||
| "system"
|
||||
| "toolbox"
|
||||
@@ -130,6 +132,7 @@ declare module "@elegant-router/types" {
|
||||
| "headphone_brand"
|
||||
| "headphone_model"
|
||||
| "home"
|
||||
| "meilisearch"
|
||||
| "share-code_log"
|
||||
| "system_users"
|
||||
| "toolbox_luxsin-controller"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { NButton, NPopconfirm, NSpace, NTag, type DataTableColumns } from 'naive-ui';
|
||||
import {
|
||||
fetchCreateMeilisearchDocument,
|
||||
fetchDeleteMeilisearchDocument,
|
||||
fetchGetMeilisearchDocuments,
|
||||
fetchGetMeilisearchInfo,
|
||||
fetchUpdateMeilisearchDocument
|
||||
} from '@/service/api';
|
||||
|
||||
/**
|
||||
* Meilisearch 管理
|
||||
*
|
||||
* 连接信息来自后端环境变量(MEILISEARCH_URL / MEILISEARCH_API_KEY / MEILISEARCH_INDEX),
|
||||
* 对默认索引的文档提供简单增删改查。
|
||||
*/
|
||||
defineOptions({ name: 'Meilisearch' });
|
||||
|
||||
type DocRow = Api.Meilisearch.Document;
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref<DocRow[]>([]);
|
||||
const primaryKey = ref<string>('id');
|
||||
|
||||
const info = ref<Api.Meilisearch.Info | null>(null);
|
||||
const infoLoading = ref(false);
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
});
|
||||
|
||||
// ===== 关键词筛选(仅检索 brand_name / name 字段) =====
|
||||
const keyword = ref('');
|
||||
/** 已生效的筛选关键词(点击搜索/回车后才参与请求) */
|
||||
const appliedKeyword = ref('');
|
||||
|
||||
function handleSearch() {
|
||||
appliedKeyword.value = keyword.value.trim();
|
||||
pagination.page = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
keyword.value = '';
|
||||
appliedKeyword.value = '';
|
||||
pagination.page = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
/** 字节数转人类可读大小 */
|
||||
function formatBytes(bytes: number | null) {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return '-';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// ===== 新增/编辑弹窗(JSON 编辑) =====
|
||||
const modalVisible = ref(false);
|
||||
const modalMode = ref<'create' | 'edit'>('create');
|
||||
const modalSubmitting = ref(false);
|
||||
const modalJson = ref('');
|
||||
/** 编辑时记录的原主键值(更新接口按该 id 提交) */
|
||||
const editingId = ref<string>('');
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 列随数据动态生成:当前页所有文档的字段并集,主键列固定第一列 */
|
||||
const columns = computed<DataTableColumns<DocRow>>(() => {
|
||||
const keys: string[] = [];
|
||||
for (const row of tableData.value) {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (!keys.includes(key)) keys.push(key);
|
||||
}
|
||||
}
|
||||
// 主键列置顶
|
||||
const pk = primaryKey.value;
|
||||
const sorted = [pk, ...keys.filter(k => k !== pk)].filter(k => keys.includes(k) || k === pk);
|
||||
|
||||
const dataColumns: DataTableColumns<DocRow> = sorted.map(key => ({
|
||||
title: key === pk ? `${key}(主键)` : key,
|
||||
key,
|
||||
minWidth: key === pk ? 100 : 140,
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
return formatCellValue(row[key]);
|
||||
}
|
||||
}));
|
||||
|
||||
dataColumns.push({
|
||||
title: '操作',
|
||||
key: '__actions',
|
||||
width: 140,
|
||||
render(row) {
|
||||
return h(NSpace, { size: 8 }, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', tertiary: true, type: 'primary', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row),
|
||||
positiveText: '删除',
|
||||
negativeText: '取消'
|
||||
},
|
||||
{
|
||||
default: () => `确认删除文档「${formatCellValue(row[pk])}」?`,
|
||||
trigger: () => h(NButton, { size: 'small', tertiary: true, type: 'error' }, { default: () => '删除' })
|
||||
}
|
||||
)
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return dataColumns;
|
||||
});
|
||||
|
||||
function rowKey(row: DocRow) {
|
||||
return formatCellValue(row[primaryKey.value]);
|
||||
}
|
||||
|
||||
async function loadInfo() {
|
||||
infoLoading.value = true;
|
||||
const { data, error } = await fetchGetMeilisearchInfo();
|
||||
if (!error && data) {
|
||||
info.value = data;
|
||||
if (data.primary_key) primaryKey.value = data.primary_key;
|
||||
}
|
||||
infoLoading.value = false;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
const { data, error } = await fetchGetMeilisearchDocuments({
|
||||
skip: (pagination.page - 1) * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
q: appliedKeyword.value || undefined
|
||||
});
|
||||
if (!error && data) {
|
||||
tableData.value = data.items || [];
|
||||
pagination.itemCount = data.total || 0;
|
||||
if (data.primary_key) primaryKey.value = data.primary_key;
|
||||
} else if (!error) {
|
||||
tableData.value = [];
|
||||
pagination.itemCount = 0;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.page = page;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadInfo();
|
||||
loadData();
|
||||
}
|
||||
|
||||
// ===== 新增 / 编辑 =====
|
||||
function openCreate() {
|
||||
modalMode.value = 'create';
|
||||
editingId.value = '';
|
||||
modalJson.value = JSON.stringify({ [primaryKey.value]: '' }, null, 2);
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: DocRow) {
|
||||
modalMode.value = 'edit';
|
||||
editingId.value = formatCellValue(row[primaryKey.value]);
|
||||
modalJson.value = JSON.stringify(row, null, 2);
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function parseModalJson(): DocRow | null {
|
||||
try {
|
||||
const parsed = JSON.parse(modalJson.value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
window.$message?.error('文档必须是 JSON 对象');
|
||||
return null;
|
||||
}
|
||||
return parsed as DocRow;
|
||||
} catch {
|
||||
window.$message?.error('JSON 格式错误,请检查后重试');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const doc = parseModalJson();
|
||||
if (!doc) return;
|
||||
|
||||
modalSubmitting.value = true;
|
||||
const isCreate = modalMode.value === 'create';
|
||||
const { error } = isCreate
|
||||
? await fetchCreateMeilisearchDocument(doc)
|
||||
: await fetchUpdateMeilisearchDocument(editingId.value, doc);
|
||||
modalSubmitting.value = false;
|
||||
|
||||
if (error) return;
|
||||
window.$message?.success(isCreate ? '已提交新增任务' : '已提交更新任务');
|
||||
modalVisible.value = false;
|
||||
// Meilisearch 写入为异步任务,稍等片刻再刷新列表
|
||||
setTimeout(handleRefresh, 600);
|
||||
}
|
||||
|
||||
async function handleDelete(row: DocRow) {
|
||||
const id = formatCellValue(row[primaryKey.value]);
|
||||
const { error } = await fetchDeleteMeilisearchDocument(id);
|
||||
if (error) return;
|
||||
window.$message?.success('已提交删除任务');
|
||||
setTimeout(handleRefresh, 600);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<NCard :bordered="false" class="card-wrapper" size="small" title="连接信息">
|
||||
<template #header-extra>
|
||||
<NButton size="small" :loading="infoLoading" @click="handleRefresh">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:refresh" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
</template>
|
||||
<NDescriptions v-if="info" :column="4" label-placement="left" size="small">
|
||||
<NDescriptionsItem label="服务地址">{{ info.url }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="索引">{{ info.index }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="主键">{{ info.primary_key || '-' }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="文档数">{{ info.number_of_documents }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="磁盘占用">
|
||||
{{ formatBytes(info.database_size ?? info.index_database_size) }}
|
||||
<span
|
||||
v-if="info.index_database_size !== null && info.database_size !== info.index_database_size"
|
||||
class="text-12px op-60"
|
||||
>
|
||||
(当前索引 {{ formatBytes(info.index_database_size) }})
|
||||
</span>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="版本">{{ info.version || '-' }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="索引状态">
|
||||
<NTag :type="info.index_exists ? 'success' : 'error'" size="small">
|
||||
{{ info.index_exists ? '存在' : '不存在' }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
<NAlert v-else type="warning" title="无法连接 Meilisearch">
|
||||
请检查后端环境变量 MEILISEARCH_URL / MEILISEARCH_API_KEY / MEILISEARCH_INDEX 配置,或确认服务已启动。
|
||||
</NAlert>
|
||||
</NCard>
|
||||
|
||||
<NCard :bordered="false" class="card-wrapper" size="small" title="文档列表">
|
||||
|
||||
<div class="mb-12px flex items-center gap-8px">
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
clearable
|
||||
placeholder="按 brand_name / name 筛选"
|
||||
class="w-320px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleClearSearch"
|
||||
/>
|
||||
<NButton type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:magnify" />
|
||||
</template>
|
||||
搜索
|
||||
</NButton>
|
||||
<NButton type="primary" @click="openCreate">
|
||||
<template #icon>
|
||||
<SvgIcon icon="mdi:plus" />
|
||||
</template>
|
||||
新增文档
|
||||
</NButton>
|
||||
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:row-key="rowKey"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
:scroll-x="800"
|
||||
/>
|
||||
|
||||
<div class="mt-16px flex justify-end">
|
||||
<NPagination
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:item-count="pagination.itemCount"
|
||||
:page-sizes="pagination.pageSizes"
|
||||
show-size-picker
|
||||
:prefix="({ itemCount }) => `共 ${itemCount ?? 0} 条`"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NModal
|
||||
v-model:show="modalVisible"
|
||||
preset="card"
|
||||
:title="modalMode === 'create' ? '新增文档' : `编辑文档 ${editingId}`"
|
||||
class="w-680px"
|
||||
>
|
||||
<NAlert type="info" class="mb-12px" :show-icon="true">
|
||||
请输入完整 JSON 对象,必须包含主键字段「{{ primaryKey }}」;编辑提交后按主键整体覆盖原文档。
|
||||
</NAlert>
|
||||
<NInput
|
||||
v-model:value="modalJson"
|
||||
type="textarea"
|
||||
placeholder='{ "id": 1, "name": "..." }'
|
||||
:autosize="{ minRows: 12, maxRows: 24 }"
|
||||
class="json-textarea"
|
||||
/>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="modalVisible = false">取消</NButton>
|
||||
<NButton type="primary" :loading="modalSubmitting" @click="handleSubmit">
|
||||
{{ modalMode === 'create' ? '新增' : '保存' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-textarea :deep(textarea) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user