diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts index b096749..56988f2 100644 --- a/dashboard/backend/src/routes/index.ts +++ b/dashboard/backend/src/routes/index.ts @@ -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, diff --git a/dashboard/backend/src/routes/meilisearch.ts b/dashboard/backend/src/routes/meilisearch.ts new file mode 100644 index 0000000..67e02ab --- /dev/null +++ b/dashboard/backend/src/routes/meilisearch.ts @@ -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 { + 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; diff --git a/dashboard/frontend/src/locales/langs/en-us.ts b/dashboard/frontend/src/locales/langs/en-us.ts index 4dc635d..be57ad8 100644 --- a/dashboard/frontend/src/locales/langs/en-us.ts +++ b/dashboard/frontend/src/locales/langs/en-us.ts @@ -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', diff --git a/dashboard/frontend/src/locales/langs/zh-cn.ts b/dashboard/frontend/src/locales/langs/zh-cn.ts index 1eaa4da..c7332b1 100644 --- a/dashboard/frontend/src/locales/langs/zh-cn.ts +++ b/dashboard/frontend/src/locales/langs/zh-cn.ts @@ -239,6 +239,7 @@ const local: App.I18n.Schema = { system_users: '账号管理', toolbox: '工具箱', 'toolbox_luxsin-controller': 'luxsin 控制器', + meilisearch: 'meilisearch', // 官网管理 www: '官网管理', www_global: '全局配置', diff --git a/dashboard/frontend/src/router/elegant/imports.ts b/dashboard/frontend/src/router/elegant/imports.ts index 2707ce9..c319a41 100644 --- a/dashboard/frontend/src/router/elegant/imports.ts +++ b/dashboard/frontend/src/router/elegant/imports.ts @@ -23,6 +23,7 @@ export const views: Record Promise 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"), diff --git a/dashboard/frontend/src/router/elegant/routes.ts b/dashboard/frontend/src/router/elegant/routes.ts index d321f72..1fbee0f 100644 --- a/dashboard/frontend/src/router/elegant/routes.ts +++ b/dashboard/frontend/src/router/elegant/routes.ts @@ -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', diff --git a/dashboard/frontend/src/router/elegant/transform.ts b/dashboard/frontend/src/router/elegant/transform.ts index c058c85..ba953d3 100644 --- a/dashboard/frontend/src/router/elegant/transform.ts +++ b/dashboard/frontend/src/router/elegant/transform.ts @@ -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", diff --git a/dashboard/frontend/src/service/api/index.ts b/dashboard/frontend/src/service/api/index.ts index 5ae286b..74a6e1d 100644 --- a/dashboard/frontend/src/service/api/index.ts +++ b/dashboard/frontend/src/service/api/index.ts @@ -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'; diff --git a/dashboard/frontend/src/service/api/meilisearch.ts b/dashboard/frontend/src/service/api/meilisearch.ts new file mode 100644 index 0000000..78b77ee --- /dev/null +++ b/dashboard/frontend/src/service/api/meilisearch.ts @@ -0,0 +1,44 @@ +import { request } from '../request'; + +/** 获取 Meilisearch 连接信息与索引概况 */ +export function fetchGetMeilisearchInfo() { + return request({ + url: '/meilisearch/info', + method: 'get' + }); +} + +/** 分页获取文档列表(q 非空时按 brand_name/name 筛选) */ +export function fetchGetMeilisearchDocuments(params: { skip?: number; limit?: number; q?: string }) { + return request({ + url: '/meilisearch/documents', + method: 'get', + params + }); +} + +/** 新增文档 */ +export function fetchCreateMeilisearchDocument(data: Api.Meilisearch.Document) { + return request({ + url: '/meilisearch/documents', + method: 'post', + data + }); +} + +/** 更新文档(按主键覆盖) */ +export function fetchUpdateMeilisearchDocument(id: string | number, data: Api.Meilisearch.Document) { + return request({ + url: `/meilisearch/documents/${id}`, + method: 'put', + data + }); +} + +/** 删除文档 */ +export function fetchDeleteMeilisearchDocument(id: string | number) { + return request({ + url: `/meilisearch/documents/${id}`, + method: 'delete' + }); +} diff --git a/dashboard/frontend/src/typings/api/meilisearch.d.ts b/dashboard/frontend/src/typings/api/meilisearch.d.ts new file mode 100644 index 0000000..9cb3085 --- /dev/null +++ b/dashboard/frontend/src/typings/api/meilisearch.d.ts @@ -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; + + /** 文档分页列表 */ + 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; + } + } +} diff --git a/dashboard/frontend/src/typings/components.d.ts b/dashboard/frontend/src/typings/components.d.ts index ba7d237..8e2e459 100644 --- a/dashboard/frontend/src/typings/components.d.ts +++ b/dashboard/frontend/src/typings/components.d.ts @@ -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'] diff --git a/dashboard/frontend/src/typings/elegant-router.d.ts b/dashboard/frontend/src/typings/elegant-router.d.ts index d36cc94..5f426f5 100644 --- a/dashboard/frontend/src/typings/elegant-router.d.ts +++ b/dashboard/frontend/src/typings/elegant-router.d.ts @@ -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" diff --git a/dashboard/frontend/src/views/meilisearch/index.vue b/dashboard/frontend/src/views/meilisearch/index.vue new file mode 100644 index 0000000..159939a --- /dev/null +++ b/dashboard/frontend/src/views/meilisearch/index.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100755 index 0000000..4b3513c --- /dev/null +++ b/deploy/build.sh @@ -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 < 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[*]}"