支持 meilisearch 管理
This commit is contained in:
@@ -9,6 +9,7 @@ import shareCodeLogsRouter from './shareCodeLogs.js';
|
||||
import usersRouter from './users.js';
|
||||
import dashboardRouter from './dashboard.js';
|
||||
import siteRouter from './site.js';
|
||||
import meilisearchRouter from './meilisearch.js';
|
||||
import {
|
||||
wwwGlobalRouter,
|
||||
wwwNavRouter,
|
||||
@@ -36,6 +37,7 @@ const routes: Router[] = [
|
||||
shareCodeLogsRouter,
|
||||
usersRouter,
|
||||
dashboardRouter,
|
||||
meilisearchRouter,
|
||||
// 官网 CMS
|
||||
wwwGlobalRouter,
|
||||
wwwNavRouter,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Router, type Request, type Response, type Router as RouterType } from 'express';
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import logger from '../config/logger.js';
|
||||
import { ApiResponse } from '../utils/response.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
/**
|
||||
* Meilisearch 管理接口
|
||||
*
|
||||
* 连接信息全部来自环境变量(MEILISEARCH_URL / MEILISEARCH_API_KEY / MEILISEARCH_INDEX),
|
||||
* 对该默认索引提供简单的文档增删改查。
|
||||
*/
|
||||
const router: RouterType = Router();
|
||||
|
||||
const MEILISEARCH_URL = (process.env.MEILISEARCH_URL || 'http://localhost:7700').replace(/\/+$/, '');
|
||||
const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || '';
|
||||
const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models';
|
||||
|
||||
// 所有接口需登录
|
||||
router.use('/api/meilisearch', authMiddleware);
|
||||
|
||||
function msHeaders() {
|
||||
return { Authorization: `Bearer ${MEILISEARCH_API_KEY}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** 统一请求 Meilisearch REST API,失败时抛出带详情的 Error */
|
||||
async function msRequest(method: AxiosRequestConfig['method'], path: string, data?: unknown) {
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url: `${MEILISEARCH_URL}${path}`,
|
||||
headers: msHeaders(),
|
||||
data,
|
||||
timeout: 30000,
|
||||
validateStatus: status => status >= 200 && status < 300,
|
||||
});
|
||||
return response.data;
|
||||
} catch (e: any) {
|
||||
const detail = e.response?.data?.message || e.response?.data ? JSON.stringify(e.response?.data) : e.message;
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMsError(res: Response, e: unknown, action: string) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`Meilisearch ${action} failed: ${msg}`);
|
||||
// 连接失败/上游错误统一返回 502 语义,便于前端区分后端自身错误
|
||||
res.status(502).json(ApiResponse.error(`Meilisearch ${action} 失败:${msg}`));
|
||||
}
|
||||
|
||||
/** 查询索引元信息(主键),索引不存在返回 null */
|
||||
async function getIndexInfo() {
|
||||
try {
|
||||
return await msRequest('GET', `/indexes/${MEILISEARCH_INDEX}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询索引统计(文档数等,v1.x 的 GET /indexes 不返回 numberOfDocuments) */
|
||||
async function getIndexStats() {
|
||||
try {
|
||||
return await msRequest('GET', `/indexes/${MEILISEARCH_INDEX}/stats`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询索引的可搜索字段(settings.searchableAttributes,"*" 表示全部字段) */
|
||||
async function getSearchableAttributes(): Promise<string[] | null> {
|
||||
try {
|
||||
const settings = await msRequest('GET', `/indexes/${MEILISEARCH_INDEX}/settings`);
|
||||
return Array.isArray(settings?.searchableAttributes) ? settings.searchableAttributes : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/meilisearch/info —— 连接信息与索引概况
|
||||
router.get('/api/meilisearch/info', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const version = await msRequest('GET', '/version').catch(() => null);
|
||||
const index = await getIndexInfo();
|
||||
const stats = await getIndexStats();
|
||||
// 实例级统计(GET /stats 返回全部索引的磁盘占用,单位字节;
|
||||
// 新版字段为 databaseSize,旧版(如 v1.52)为 database_size,两者兼容)
|
||||
const globalStats = await msRequest('GET', '/stats').catch(() => null);
|
||||
res.json(
|
||||
ApiResponse.success({
|
||||
url: MEILISEARCH_URL,
|
||||
index: MEILISEARCH_INDEX,
|
||||
primary_key: index?.primaryKey ?? null,
|
||||
number_of_documents: stats?.numberOfDocuments ?? 0,
|
||||
index_exists: !!index,
|
||||
version: version?.['pkgVersion'] ?? null,
|
||||
database_size: globalStats?.databaseSize ?? globalStats?.database_size ?? null,
|
||||
index_database_size: stats?.databaseSize ?? stats?.database_size ?? null,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '获取连接信息');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/meilisearch/documents —— 文档列表(分页;q 非空时按 brand_name/name 关键词筛选)
|
||||
router.get('/api/meilisearch/documents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const skip = Math.max(parseInt((req.query.skip as string) || '0', 10) || 0, 0);
|
||||
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '20', 10) || 20, 1), 1000);
|
||||
const q = String(req.query.q || '').trim();
|
||||
const index = await getIndexInfo();
|
||||
|
||||
// 带关键词:走 search 端点,仅在 brand_name/name 两个字段上检索(id 不参与筛选),
|
||||
// 但 attributesToSearchOn 必须是索引 searchableAttributes 的子集,
|
||||
// 否则 Meilisearch 报 invalid_search_attributes_to_search_on,
|
||||
// 故先取交集;交集为空时省略该参数(退化为在全部可搜索字段上检索)
|
||||
if (q) {
|
||||
const wanted = ['brand_name', 'name'];
|
||||
const searchable = await getSearchableAttributes();
|
||||
const attributesToSearchOn =
|
||||
searchable === null || searchable.includes('*') ? wanted : wanted.filter(a => searchable.includes(a));
|
||||
|
||||
const search = await msRequest('POST', `/indexes/${MEILISEARCH_INDEX}/search`, {
|
||||
q,
|
||||
offset: skip,
|
||||
limit,
|
||||
...(attributesToSearchOn.length > 0 ? { attributesToSearchOn } : {}),
|
||||
});
|
||||
res.json(
|
||||
ApiResponse.success({
|
||||
items: search.hits ?? [],
|
||||
total: search.estimatedTotalHits ?? 0,
|
||||
skip,
|
||||
limit,
|
||||
primary_key: index?.primaryKey ?? null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await msRequest(
|
||||
'GET',
|
||||
`/indexes/${MEILISEARCH_INDEX}/documents?offset=${skip}&limit=${limit}`
|
||||
);
|
||||
res.json(
|
||||
ApiResponse.success({
|
||||
items: data.results ?? [],
|
||||
total: data.total ?? 0,
|
||||
skip,
|
||||
limit,
|
||||
primary_key: index?.primaryKey ?? null,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '获取文档列表');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/meilisearch/documents —— 新增文档
|
||||
router.post('/api/meilisearch/documents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const doc = req.body;
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
||||
res.status(400).json(ApiResponse.error('文档必须是 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
const index = await getIndexInfo();
|
||||
if (index?.primaryKey && doc[index.primaryKey] === undefined) {
|
||||
res.status(400).json(ApiResponse.error(`文档缺少主键字段「${index.primaryKey}」`));
|
||||
return;
|
||||
}
|
||||
const task = await msRequest('POST', `/indexes/${MEILISEARCH_INDEX}/documents`, [doc]);
|
||||
logger.info(`Meilisearch document added, taskUid=${task?.taskUid}`);
|
||||
res.json(ApiResponse.success(task, '已提交新增任务'));
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '新增文档');
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/meilisearch/documents/:id —— 更新文档(按主键覆盖)
|
||||
router.put('/api/meilisearch/documents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const doc = req.body;
|
||||
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
||||
res.status(400).json(ApiResponse.error('文档必须是 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
const index = await getIndexInfo();
|
||||
const pk = index?.primaryKey ?? 'id';
|
||||
if (doc[pk] === undefined) doc[pk] = req.params.id;
|
||||
if (String(doc[pk]) !== String(req.params.id)) {
|
||||
res.status(400).json(ApiResponse.error(`文档主键「${pk}」与 URL 中的 id 不一致`));
|
||||
return;
|
||||
}
|
||||
const task = await msRequest('PUT', `/indexes/${MEILISEARCH_INDEX}/documents`, [doc]);
|
||||
logger.info(`Meilisearch document updated: id=${req.params.id}, taskUid=${task?.taskUid}`);
|
||||
res.json(ApiResponse.success(task, '已提交更新任务'));
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '更新文档');
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/meilisearch/documents/:id —— 删除文档
|
||||
router.delete('/api/meilisearch/documents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const task = await msRequest('DELETE', `/indexes/${MEILISEARCH_INDEX}/documents/${req.params.id}`);
|
||||
logger.info(`Meilisearch document deleted: id=${req.params.id}, taskUid=${task?.taskUid}`);
|
||||
res.json(ApiResponse.success(task, '已提交删除任务'));
|
||||
} catch (e) {
|
||||
handleMsError(res, e, '删除文档');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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>
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 统一构建脚本:在对应项目目录执行构建命令
|
||||
# backend -> dashboard/backend pnpm build(tsc)
|
||||
# frontend -> dashboard/frontend pnpm build(vite build --mode prod)
|
||||
# www -> www pnpm generate(nuxt 静态生成)
|
||||
#
|
||||
# 不带参数时默认构建全部三个项目(顺序:backend -> frontend -> www)
|
||||
#
|
||||
# 用法:./deploy/build.sh [项目...]
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
# 仓库根目录 = 本脚本所在目录的上一级
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
用法: $(basename "$0") [项目...]
|
||||
|
||||
可构建项目:
|
||||
backend, b dashboard/backend pnpm build(tsc)
|
||||
frontend, f dashboard/frontend pnpm build(vite build --mode prod)
|
||||
www, w www pnpm generate(nuxt 静态生成)
|
||||
|
||||
不带参数时默认构建全部三个项目(顺序:backend -> frontend -> www)。
|
||||
|
||||
示例:
|
||||
$(basename "$0") backend 仅构建后端
|
||||
$(basename "$0") frontend www 构建前端与 www
|
||||
$(basename "$0") 构建全部
|
||||
EOF
|
||||
}
|
||||
|
||||
declare -a TARGETS=()
|
||||
|
||||
add_target() {
|
||||
local t="$1"
|
||||
# 去重
|
||||
for existing in "${TARGETS[@]:-}"; do
|
||||
[[ "$existing" == "$t" ]] && return
|
||||
done
|
||||
TARGETS+=("$t")
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
backend|b) add_target backend ;;
|
||||
frontend|f) add_target frontend ;;
|
||||
www|w) add_target www ;;
|
||||
*) echo "错误: 未知项目「${arg}」" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 默认构建全部
|
||||
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
||||
TARGETS=(backend frontend www)
|
||||
fi
|
||||
|
||||
run_build() {
|
||||
local name="$1" dir="$2" cmd="$3"
|
||||
echo ""
|
||||
echo "==> [$name] 在 $dir 执行: $cmd"
|
||||
(cd "$ROOT/$dir" && $cmd)
|
||||
echo "==> [$name] 构建完成"
|
||||
}
|
||||
|
||||
for t in "${TARGETS[@]}"; do
|
||||
case "$t" in
|
||||
backend) run_build backend dashboard/backend "pnpm build" ;;
|
||||
frontend) run_build frontend dashboard/frontend "pnpm build" ;;
|
||||
www) run_build www www "pnpm generate" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "全部构建完成: ${TARGETS[*]}"
|
||||
Reference in New Issue
Block a user