支持 meilisearch 管理

This commit is contained in:
eafonyang
2026-08-14 11:26:20 +08:00
parent f99f622977
commit 160307087d
14 changed files with 759 additions and 0 deletions
@@ -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'
});
}
+42
View File
@@ -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;
}
}
}
+2
View File
@@ -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']
+3
View File
@@ -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>