初始提交:识流 AI 助手项目
微信自动回复机器人,基于截图+OCR识别消息,支持关键词规则和 AI(OpenAI/DeepSeek/Dify)自动回复。 技术栈:PySide6 + Flask + Vue3 + RapidOCR + SQLite 注:OCR大模型文件(.onnx / .pdiparams)不纳入版本控制,需单独下载。 🤖 Generated with [Qoder][https://qoder.com]
@@ -0,0 +1 @@
|
||||
function t(n,e,i,s){if(typeof e=="function"?n!==e||!s:!e.has(n))throw new TypeError("Cannot read private member from an object whose class did not declare it");return i==="m"?s:i==="a"?s.call(n):s?s.value:e.get(n)}function c(n,e,i,s,_){if(typeof e=="function"?n!==e||!0:!e.has(n))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(n,i),i}var a,r,o,l,u;const d="__TAURI_TO_IPC_KEY__";function w(n,e=!1){return window.__TAURI_INTERNALS__.transformCallback(n,e)}class g{constructor(e){a.set(this,void 0),r.set(this,0),o.set(this,[]),l.set(this,void 0),c(this,a,e||(()=>{})),this.id=w(i=>{const s=i.index;if("end"in i){s==t(this,r,"f")?this.cleanupCallback():c(this,l,s);return}const _=i.message;if(s==t(this,r,"f")){for(t(this,a,"f").call(this,_),c(this,r,t(this,r,"f")+1);t(this,r,"f")in t(this,o,"f");){const p=t(this,o,"f")[t(this,r,"f")];t(this,a,"f").call(this,p),delete t(this,o,"f")[t(this,r,"f")],c(this,r,t(this,r,"f")+1)}t(this,r,"f")===t(this,l,"f")&&this.cleanupCallback()}else t(this,o,"f")[s]=_})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(e){c(this,a,e)}get onmessage(){return t(this,a,"f")}[(a=new WeakMap,r=new WeakMap,o=new WeakMap,l=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}class f{constructor(e,i,s){this.plugin=e,this.event=i,this.channelId=s}async unregister(){return h(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function m(n,e,i){const s=new g(i);try{return await h(`plugin:${n}|register_listener`,{event:e,handler:s}),new f(n,e,s.id)}catch{return await h(`plugin:${n}|registerListener`,{event:e,handler:s}),new f(n,e,s.id)}}async function I(n){return h(`plugin:${n}|check_permissions`)}async function C(n){return h(`plugin:${n}|request_permissions`)}async function h(n,e={},i){return window.__TAURI_INTERNALS__.invoke(n,e,i)}function T(n,e="asset"){return window.__TAURI_INTERNALS__.convertFileSrc(n,e)}class k{get rid(){return t(this,u,"f")}constructor(e){u.set(this,void 0),c(this,u,e)}async close(){return h("plugin:resources|close",{rid:this.rid})}}u=new WeakMap;function E(){return!!(globalThis||window).isTauri}export{g as Channel,f as PluginListener,k as Resource,d as SERIALIZE_TO_IPC_FN,m as addPluginListener,I as checkPermissions,T as convertFileSrc,h as invoke,E as isTauri,C as requestPermissions,w as transformCallback};
|
||||
@@ -0,0 +1 @@
|
||||
:root{--bg: #f2f4f7;--panel: #ffffff;--line: #e5e7eb;--text: #111827;--muted: #6b7280;--brand: #22c1c3}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:linear-gradient(180deg,#f5f7fb,#eef2f7);color:var(--text);font-family:Inter,PingFang SC,Microsoft YaHei,sans-serif;overflow:hidden}button,input,select,textarea{font:inherit}.page-shell{height:100vh;overflow:hidden}.content-root{height:calc(100vh - 48px);overflow:hidden;display:flex;flex-direction:column}.content-scroll{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}.topbar{height:48px;background:#ffffffeb;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);position:sticky;top:0;z-index:20;-webkit-user-select:none;user-select:none}.drag-region{height:100%;width:100%;display:flex;align-items:center;justify-content:space-between;cursor:default}.inner{width:min(1320px,calc(100vw - 24px));margin:0 auto}.window-actions{display:flex;align-items:center;gap:4px}.win-btn{width:32px;height:28px;border-radius:8px;border:1px solid #e7ecf2;background:#fff;color:#4b5563;display:inline-flex;align-items:center;justify-content:center;font-size:14px;line-height:1;cursor:pointer}.win-btn:hover{background:#f5f7fb}.win-btn.close:hover{background:#fee2e2;color:#991b1b;border-color:#fecaca}.panel{background:var(--panel);border:1px solid #edf0f4;border-radius:14px;box-shadow:0 6px 18px #0f172a0a}.hero{background:linear-gradient(120deg,#22c1c326,#7c3aed14);border:1px solid #dff4f5}.tab-wrap{flex:1;min-height:0;display:flex;flex-direction:column;background:#fff;border:1px solid #e8ecf2;border-radius:12px;padding:8px 12px 0;box-shadow:0 6px 14px #0f172a08;overflow:hidden}.app-tabs,.app-tabs .n-tabs-nav-scroll-content,.app-tabs .n-tabs-pane-wrapper,.app-tabs .n-tab-pane,.app-tab-pane{min-height:0}.app-tabs{flex:1;min-height:0;display:flex;flex-direction:column}.app-tabs .n-tabs-nav{flex:0 0 auto}.app-tabs .n-tabs-pane-wrapper{flex:1;min-height:0}.app-tabs .n-tab-pane,.app-tab-pane{height:100%;display:flex;flex-direction:column}.app-tab-pane>*{flex:1;min-height:0}.label-title{font-weight:600;font-size:14px;color:#1f2937}.label-desc{font-size:12px;color:#6b7280;margin-top:2px}.row-line{border-bottom:1px dashed #edf1f5;padding:14px 0}.row-line:last-child{border-bottom:0}.compact-card .n-card-header{padding-bottom:8px!important}.compact-card .n-card__content{padding-top:0!important}.panel-view-shell{flex:1;min-height:0;display:flex;flex-direction:column;gap:16px}.logs-view-shell{flex:1;min-height:0;padding:12px 0}.log-panel,.log-panel .n-card__content{flex:1;min-height:0;height:100%;display:flex;flex-direction:column}.log-panel .n-card-header{flex:0 0 auto}.log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.log-toolbar-right{display:flex;align-items:center;gap:12px;min-width:0}.log-path{color:#6b7280;font-size:12px;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log-stream-wrap{height:clamp(320px,calc(100vh - 320px),680px);min-height:320px;max-height:680px;border:1px solid #e6ebf2;border-radius:12px;background:#0f172a;overflow-y:auto;overflow-x:hidden}.log-stream{margin:0;padding:14px 16px;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;font-family:Consolas,SFMono-Regular,Monaco,monospace}.log-line{white-space:pre-wrap}.log-line--error{color:#fca5a5}.log-line--warning{color:#fde68a}.log-line--info{color:#bfdbfe}.log-line--debug{color:#cbd5e1}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>识流 AI 助手</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C4rM3269.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Avv0D5es.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>识流 AI 助手</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ai-shiliu-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"backend:build": "scripts\\backend-build.bat",
|
||||
"release:prepare": "npm run build && npm run backend:build",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "npm run release:prepare && tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"naive-ui": "^2.43.1",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "ROOT=%~dp0..\.."
|
||||
for %%I in ("%ROOT%") do set "ROOT=%%~fI"
|
||||
set "PYTHON=%ROOT%\.venv\Scripts\python.exe"
|
||||
|
||||
if not exist "%PYTHON%" (
|
||||
echo Missing .venv\Scripts\python.exe
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
"%PYTHON%" -m pip show rapidocr-onnxruntime >nul 2>nul
|
||||
set "HAS_RAPIDOCR=%ERRORLEVEL%"
|
||||
"%PYTHON%" -m pip show onnxruntime >nul 2>nul
|
||||
set "HAS_ONNXRUNTIME=%ERRORLEVEL%"
|
||||
if not "%HAS_RAPIDOCR%"=="0" goto install_ocr
|
||||
if not "%HAS_ONNXRUNTIME%"=="0" goto install_ocr
|
||||
goto after_install
|
||||
|
||||
:install_ocr
|
||||
"%PYTHON%" -m pip install -r "%ROOT%\requirements.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
:after_install
|
||||
"%PYTHON%" -m PyInstaller --version >nul
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "RESOURCES=%ROOT%\frontend\src-tauri\resources"
|
||||
set "BACKEND_OUT=%RESOURCES%\backend"
|
||||
set "BUILD_ROOT=%ROOT%\.build\pyinstaller"
|
||||
set "STALE_ROOT=%ROOT%\.build\stale-resources"
|
||||
|
||||
if exist "%BACKEND_OUT%" (
|
||||
rmdir /s /q "%BACKEND_OUT%" 2>nul
|
||||
if exist "%BACKEND_OUT%" (
|
||||
if not exist "%STALE_ROOT%" mkdir "%STALE_ROOT%"
|
||||
set "STALE_NAME=backend-%DATE:~0,4%%DATE:~5,2%%DATE:~8,2%%TIME:~0,2%%TIME:~3,2%%TIME:~6,2%"
|
||||
set "STALE_NAME=!STALE_NAME: =0!"
|
||||
move "%BACKEND_OUT%" "%STALE_ROOT%\!STALE_NAME!" >nul
|
||||
)
|
||||
)
|
||||
|
||||
if not exist "%RESOURCES%" mkdir "%RESOURCES%"
|
||||
|
||||
"%PYTHON%" -m PyInstaller --noconfirm --clean --onedir --name backend --distpath "%RESOURCES%" --workpath "%BUILD_ROOT%\work" --specpath "%BUILD_ROOT%\spec" --paths "%ROOT%" --collect-all rapidocr_onnxruntime --collect-all onnxruntime --copy-metadata rapidocr-onnxruntime --copy-metadata onnxruntime --add-data "%ROOT%\app;app" "%ROOT%\backend_main.py"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
exit /b 0
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.2", features = ["devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,356 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::TcpStream,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Mutex,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Manager, RunEvent};
|
||||
|
||||
struct AppState {
|
||||
backend_child: Mutex<Option<Child>>,
|
||||
backend_bootstrap_error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
struct RuntimePaths {
|
||||
app_data_dir: PathBuf,
|
||||
log_root_dir: PathBuf,
|
||||
runtime_log_dir: PathBuf,
|
||||
bot_log_file: PathBuf,
|
||||
ocr_log_file: PathBuf,
|
||||
ocr_save_dir: PathBuf,
|
||||
blocked_row_cache_file: PathBuf,
|
||||
}
|
||||
|
||||
struct BackendLaunchPlan {
|
||||
program: PathBuf,
|
||||
args: Vec<PathBuf>,
|
||||
working_dir: PathBuf,
|
||||
envs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn project_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.expect("project root not found")
|
||||
}
|
||||
|
||||
fn backend_python() -> PathBuf {
|
||||
project_root().join(".venv").join("Scripts").join("python.exe")
|
||||
}
|
||||
|
||||
fn backend_entry() -> PathBuf {
|
||||
project_root().join("backend_main.py")
|
||||
}
|
||||
|
||||
fn normalize_mode(value: &str) -> String {
|
||||
let mode = value.trim().to_ascii_lowercase();
|
||||
match mode.as_str() {
|
||||
"release" | "prod" | "production" | "bundle" | "packaged" => "release".to_string(),
|
||||
_ => "dev".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_mode() -> String {
|
||||
std::env::var("OPENCLAW_BACKEND_MODE")
|
||||
.map(|value| normalize_mode(&value))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(debug_assertions) {
|
||||
"dev".to_string()
|
||||
} else {
|
||||
"release".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_addr() -> String {
|
||||
std::env::var("APP_HOST")
|
||||
.map(|host| {
|
||||
let port = std::env::var("APP_PORT").unwrap_or_else(|_| "5000".to_string());
|
||||
format!("{}:{}", host, port)
|
||||
})
|
||||
.unwrap_or_else(|_| "127.0.0.1:5000".to_string())
|
||||
}
|
||||
|
||||
fn is_backend_alive() -> bool {
|
||||
TcpStream::connect(backend_addr()).is_ok()
|
||||
}
|
||||
|
||||
fn wait_for_backend(child: &mut Child, timeout: Duration) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < timeout {
|
||||
if is_backend_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(format!("独立后台进程已退出,退出码: {}", status));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(format!("后台启动超时,未监听 {}", backend_addr()))
|
||||
}
|
||||
|
||||
fn ensure_dir(path: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {}", path.display(), err))
|
||||
}
|
||||
|
||||
fn resolve_runtime_paths(app: &AppHandle) -> Result<RuntimePaths, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|err| format!("获取应用数据目录失败: {}", err))?;
|
||||
let log_root_dir = app_data_dir.join("logs");
|
||||
let runtime_log_dir = log_root_dir.join("runtime");
|
||||
let bot_log_file = runtime_log_dir.join("bot").join("wechat_multi_chat_bot.log");
|
||||
let ocr_log_file = runtime_log_dir.join("ocr").join("wechat_ocr.log");
|
||||
let ocr_save_dir = log_root_dir.join("ocr_debug_images");
|
||||
let blocked_row_cache_file = log_root_dir.join("state").join("blocked_rows.json");
|
||||
|
||||
ensure_dir(&app_data_dir)?;
|
||||
ensure_dir(&runtime_log_dir.join("bot"))?;
|
||||
ensure_dir(&runtime_log_dir.join("ocr"))?;
|
||||
ensure_dir(&ocr_save_dir)?;
|
||||
ensure_dir(
|
||||
blocked_row_cache_file
|
||||
.parent()
|
||||
.ok_or_else(|| "状态缓存目录无效".to_string())?,
|
||||
)?;
|
||||
|
||||
Ok(RuntimePaths {
|
||||
app_data_dir,
|
||||
log_root_dir,
|
||||
runtime_log_dir,
|
||||
bot_log_file,
|
||||
ocr_log_file,
|
||||
ocr_save_dir,
|
||||
blocked_row_cache_file,
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_envs(app: &AppHandle, mode: &str) -> Result<HashMap<String, String>, String> {
|
||||
let paths = resolve_runtime_paths(app)?;
|
||||
let mut envs = HashMap::new();
|
||||
envs.insert("APP_NAME".to_string(), "AiShiliu".to_string());
|
||||
envs.insert("APP_DATA_DIR".to_string(), paths.app_data_dir.display().to_string());
|
||||
envs.insert("LOG_ROOT_DIR".to_string(), paths.log_root_dir.display().to_string());
|
||||
envs.insert(
|
||||
"RUNTIME_LOG_DIR".to_string(),
|
||||
paths.runtime_log_dir.display().to_string(),
|
||||
);
|
||||
envs.insert("BOT_LOG_FILE".to_string(), paths.bot_log_file.display().to_string());
|
||||
envs.insert("OCR_LOG_FILE".to_string(), paths.ocr_log_file.display().to_string());
|
||||
envs.insert("OCR_SAVE_DIR".to_string(), paths.ocr_save_dir.display().to_string());
|
||||
envs.insert(
|
||||
"BLOCKED_ROW_CACHE_FILE".to_string(),
|
||||
paths.blocked_row_cache_file.display().to_string(),
|
||||
);
|
||||
|
||||
envs.insert("OPENCLAW_BACKEND_MODE".to_string(), mode.to_string());
|
||||
envs.insert("OPENCLAW_RUNTIME_MODE".to_string(), mode.to_string());
|
||||
Ok(envs)
|
||||
}
|
||||
|
||||
fn release_backend_candidates(app: &AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("获取资源目录失败: {}", err))?;
|
||||
let exe_dir = std::env::current_exe()
|
||||
.map_err(|err| format!("获取主程序路径失败: {}", err))?
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "无法解析主程序目录".to_string())?;
|
||||
|
||||
Ok(vec![
|
||||
resource_dir.join("backend").join("backend.exe"),
|
||||
resource_dir.join("backend.exe"),
|
||||
exe_dir.join("resources").join("backend").join("backend.exe"),
|
||||
exe_dir.join("resources").join("backend.exe"),
|
||||
exe_dir.join("backend").join("backend.exe"),
|
||||
exe_dir.join("backend.exe"),
|
||||
])
|
||||
}
|
||||
|
||||
fn release_working_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("获取资源目录失败: {}", err))?;
|
||||
Ok(resource_dir)
|
||||
}
|
||||
|
||||
fn resolve_backend_launch_plan(app: &AppHandle) -> Result<BackendLaunchPlan, String> {
|
||||
let mode = backend_mode();
|
||||
let envs = runtime_envs(app, &mode)?;
|
||||
|
||||
if mode == "release" {
|
||||
let candidates = release_backend_candidates(app)?;
|
||||
let program = candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.exists())
|
||||
.ok_or_else(|| "未找到发布态后端程序 backend.exe".to_string())?;
|
||||
let working_dir = release_working_dir(app)?;
|
||||
return Ok(BackendLaunchPlan {
|
||||
program,
|
||||
args: Vec::new(),
|
||||
working_dir,
|
||||
envs,
|
||||
});
|
||||
}
|
||||
|
||||
let python = backend_python();
|
||||
if !python.exists() {
|
||||
return Err(format!("未找到后端解释器: {}", python.display()));
|
||||
}
|
||||
let entry = backend_entry();
|
||||
if !entry.exists() {
|
||||
return Err(format!("未找到后端入口: {}", entry.display()));
|
||||
}
|
||||
Ok(BackendLaunchPlan {
|
||||
program: python,
|
||||
args: vec![entry],
|
||||
working_dir: project_root(),
|
||||
envs,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_backend(app: &AppHandle) -> Result<(), String> {
|
||||
if is_backend_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let plan = resolve_backend_launch_plan(app)?;
|
||||
let mut command = Command::new(&plan.program);
|
||||
command.current_dir(&plan.working_dir);
|
||||
for arg in &plan.args {
|
||||
command.arg(arg);
|
||||
}
|
||||
for (key, value) in &plan.envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
command
|
||||
.env("OPENCLAW_PROJECT_VENV_REEXEC", "1")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|err| format!("启动后台失败: {}", err))?;
|
||||
if let Err(err) = wait_for_backend(&mut child, Duration::from_secs(20)) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(err);
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
let mut guard = state
|
||||
.backend_child
|
||||
.lock()
|
||||
.map_err(|_| "后台状态锁定失败".to_string())?;
|
||||
*guard = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ps_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn cleanup_orphan_backend_processes(app: &AppHandle, mode: &str) {
|
||||
let script = if mode == "release" {
|
||||
let targets = release_backend_candidates(app)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| ps_single_quote(&path.display().to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
format!("$targets=@({}); Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -and ($targets -contains $_.ExecutablePath) }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}", targets)
|
||||
} else {
|
||||
"Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq 'python.exe' -and $_.CommandLine -match 'backend_main.py' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }".to_string()
|
||||
};
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
fn terminate_backend(app: &AppHandle) {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
if let Ok(mut guard) = state.backend_child.lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup_orphan_backend_processes(app, &backend_mode());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_devtools(app: AppHandle) -> Result<(), String> {
|
||||
let webview = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "未找到主窗口".to_string())?;
|
||||
webview.open_devtools();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_backend_bootstrap_error(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let guard = state
|
||||
.backend_bootstrap_error
|
||||
.lock()
|
||||
.map_err(|_| "后台启动错误状态锁定失败".to_string())?;
|
||||
Ok(guard.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_backend(app: AppHandle) -> Result<(), String> {
|
||||
terminate_backend(&app);
|
||||
spawn_backend(&app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
backend_child: Mutex::new(None),
|
||||
backend_bootstrap_error: Mutex::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![open_devtools, get_backend_bootstrap_error, restart_backend])
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
if let Err(err) = spawn_backend(app.handle()) {
|
||||
if let Ok(mut guard) = app.state::<AppState>().backend_bootstrap_error.lock() {
|
||||
*guard = Some(err.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
|
||||
terminate_backend(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "识流 AI 助手",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.shiliu.aiassistant",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "识流 AI 助手",
|
||||
"width": 1280,
|
||||
"height": 920,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 760,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": [
|
||||
"resources/**/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"android": {
|
||||
"debugApplicationIdSuffix": ".debug"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"language": "zh-CN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NConfigProvider,
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NLayoutHeader,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
} from 'naive-ui'
|
||||
import AppTopBar from './components/AppTopBar.vue'
|
||||
import { messageColumns } from './constants/messageColumns'
|
||||
import { useBotStatus } from './composables/useBotStatus'
|
||||
import { useMessages } from './composables/useMessages'
|
||||
import { useSettings } from './composables/useSettings'
|
||||
import { useWindowBridge } from './composables/useWindowBridge'
|
||||
import MessagesView from './views/MessagesView.vue'
|
||||
import PanelView from './views/PanelView.vue'
|
||||
import RulesView from './views/RulesView.vue'
|
||||
import LogsView from './views/LogsView.vue'
|
||||
|
||||
|
||||
const message = window.$message
|
||||
const activeTab = ref('panel')
|
||||
const settingsTimer = ref(null)
|
||||
const messagesTimer = ref(null)
|
||||
const rulesViewRef = ref(null)
|
||||
|
||||
const { initWindowBridge, startMove, minimizeWindow, maximizeOrRestore, closeWindow, openDevtools, restartApp } = useWindowBridge()
|
||||
const { settings, loadSettings, saveSettings } = useSettings(message)
|
||||
const { loadingMessages, messages, loadMessages } = useMessages(message)
|
||||
|
||||
const {
|
||||
runtimeText,
|
||||
runtimeTagType,
|
||||
refreshBotStatus,
|
||||
connectBotStatusStream,
|
||||
startStatusHealthCheck,
|
||||
onListenerChange,
|
||||
closeStatusStream,
|
||||
applyBotStatus,
|
||||
} = useBotStatus(settings, () => loadSettings(applyBotStatus), message)
|
||||
|
||||
function clearPageTimers() {
|
||||
if (settingsTimer.value) {
|
||||
clearInterval(settingsTimer.value)
|
||||
settingsTimer.value = null
|
||||
}
|
||||
if (messagesTimer.value) {
|
||||
clearInterval(messagesTimer.value)
|
||||
messagesTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBackendBootstrapError() {
|
||||
if (!window.__TAURI_INTERNALS__) return
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const err = await invoke('get_backend_bootstrap_error')
|
||||
if (err) {
|
||||
message.error(`后端启动失败:${err}`)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(value) {
|
||||
activeTab.value = value
|
||||
if (value === 'rules') {
|
||||
rulesViewRef.value?.loadRules?.()
|
||||
}
|
||||
if (value === 'messages') {
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initWindowBridge()
|
||||
await checkBackendBootstrapError()
|
||||
await loadSettings(applyBotStatus)
|
||||
await refreshBotStatus().catch(() => {})
|
||||
connectBotStatusStream()
|
||||
startStatusHealthCheck()
|
||||
await loadMessages()
|
||||
settingsTimer.value = setInterval(() => loadSettings(applyBotStatus), 30000)
|
||||
messagesTimer.value = setInterval(() => {
|
||||
if (activeTab.value === 'messages') loadMessages()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPageTimers()
|
||||
closeStatusStream()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme-overrides="{
|
||||
common: { primaryColor: '#22c1c3', primaryColorHover: '#1ba8aa', primaryColorPressed: '#169295', borderRadius: '10px' }
|
||||
}">
|
||||
<n-layout class="page-shell">
|
||||
<n-layout-header class="topbar">
|
||||
<AppTopBar
|
||||
:runtime-text="runtimeText"
|
||||
:runtime-tag-type="runtimeTagType"
|
||||
:on-start-move="startMove"
|
||||
:on-minimize="minimizeWindow"
|
||||
:on-maximize-or-restore="maximizeOrRestore"
|
||||
:on-close="closeWindow"
|
||||
:on-open-devtools="openDevtools"
|
||||
:on-restart-app="restartApp"
|
||||
/>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content class="content-root" content-style="padding:16px 0 16px;height:100%;overflow:hidden;">
|
||||
<div class="inner content-scroll">
|
||||
<div class="panel hero p-4 mb-3">
|
||||
<div class="text-[15px] font-semibold text-slate-800 mb-1">管理后台</div>
|
||||
<div class="text-[13px] text-slate-600">组件化管理页面(已移除系统原生窗口栏)。</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<n-tabs class="app-tabs" type="segment" animated :value="activeTab" @update:value="onTabChange">
|
||||
<n-tab-pane class="app-tab-pane" name="panel" tab="面板">
|
||||
<PanelView
|
||||
:settings="settings"
|
||||
:on-listener-change="onListenerChange"
|
||||
:on-save-settings="payload => saveSettings(payload, applyBotStatus)"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="app-tab-pane" name="rules" tab="回复匹配配置">
|
||||
<RulesView ref="rulesViewRef" :message="message" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="app-tab-pane" name="logs" tab="运行日志">
|
||||
<LogsView />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="app-tab-pane" name="messages" tab="历史消息">
|
||||
<MessagesView :columns="messageColumns" :messages="messages" :loading="loadingMessages" :on-refresh="loadMessages" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getJson, postForm, postJson } from './http'
|
||||
|
||||
export function getBotStatus() {
|
||||
return getJson('/api/bot/status')
|
||||
}
|
||||
|
||||
export function getLogEvents(params = {}) {
|
||||
const query = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
query.set(key, String(value))
|
||||
})
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return getJson(`/api/logs/v2/events${suffix}`)
|
||||
}
|
||||
|
||||
export function getLogSummary(limit = 300) {
|
||||
return getJson(`/api/logs/v2/summary?limit=${encodeURIComponent(limit)}`)
|
||||
}
|
||||
|
||||
export function getLogTrace(traceId) {
|
||||
return getJson(`/api/logs/v2/trace/${encodeURIComponent(traceId)}`)
|
||||
}
|
||||
|
||||
export function getBotLogs(kind = 'bot', limit = 80) {
|
||||
return getLogEvents({ module: kind, size: limit, page: 1 })
|
||||
}
|
||||
|
||||
export function getLogEventJson(eventId) {
|
||||
return getJson(`/api/logs/v2/event/${encodeURIComponent(eventId)}`)
|
||||
}
|
||||
|
||||
export function clearLogs(module = '') {
|
||||
return postJson('/api/logs/v2/clear', { module })
|
||||
}
|
||||
|
||||
export function startBot() {
|
||||
return postForm('/api/bot/start', {})
|
||||
}
|
||||
|
||||
export function stopBot() {
|
||||
return postForm('/api/bot/stop', {})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { getJson, postForm } from './http'
|
||||
@@ -0,0 +1,57 @@
|
||||
const DEFAULT_BACKEND_ORIGIN = 'http://127.0.0.1:5000'
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return '/'
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function isAbsoluteUrl(url) {
|
||||
return /^https?:\/\//i.test(String(url || ''))
|
||||
}
|
||||
|
||||
function getConfiguredBackendOrigin() {
|
||||
const raw = String(window.__APP_BACKEND_URL || '').trim()
|
||||
if (!raw) return ''
|
||||
return raw.endsWith('/') ? raw.slice(0, -1) : raw
|
||||
}
|
||||
|
||||
export function isTauriRuntime() {
|
||||
return !!window.__TAURI_INTERNALS__
|
||||
}
|
||||
|
||||
export function resolveApiUrl(path) {
|
||||
if (isAbsoluteUrl(path)) return path
|
||||
const normalizedPath = normalizePath(path)
|
||||
const backendOrigin = getConfiguredBackendOrigin()
|
||||
if (backendOrigin) return `${backendOrigin}${normalizedPath}`
|
||||
if (isTauriRuntime()) return `${DEFAULT_BACKEND_ORIGIN}${normalizedPath}`
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
export async function getJson(url) {
|
||||
const resp = await fetch(resolveApiUrl(url))
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function postForm(url, payload) {
|
||||
const body = new URLSearchParams(payload)
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function postJson(url, payload) {
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {}),
|
||||
})
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export function createEventSource(url) {
|
||||
return new EventSource(resolveApiUrl(url))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getJson } from './http'
|
||||
|
||||
export function getRecentMessages(limit = 50) {
|
||||
return getJson(`/api/rules?action=messages_recent&limit=${limit}`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getJson, postForm } from './http'
|
||||
|
||||
export function getRules() {
|
||||
return getJson('/api/rules?action=list')
|
||||
}
|
||||
|
||||
export function createRule(payload) {
|
||||
return postForm('/api/rules', { action: 'create', ...payload })
|
||||
}
|
||||
|
||||
export function toggleRule(payload) {
|
||||
return postForm('/api/rules', { action: 'toggle', ...payload })
|
||||
}
|
||||
|
||||
export function deleteRule(id) {
|
||||
return postForm('/api/rules', { action: 'delete', id })
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getJson, postForm } from './http'
|
||||
|
||||
export function getSettings() {
|
||||
return getJson('/api/rules?action=settings_get')
|
||||
}
|
||||
|
||||
export function saveSettings(payload) {
|
||||
return postForm('/api/rules', { action: 'settings_set', ...payload })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import RuntimeBadge from './RuntimeBadge.vue'
|
||||
|
||||
defineProps({
|
||||
runtimeText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
runtimeTagType: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
onStartMove: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onMinimize: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onMaximizeOrRestore: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onOpenDevtools: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onRestartApp: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inner drag-region" data-tauri-drag-region @mousedown="onStartMove" @dblclick="onMaximizeOrRestore">
|
||||
<div class="flex items-center gap-3" data-tauri-drag-region>
|
||||
<div class="w-8 h-8 rounded-lg bg-cyan-100 text-cyan-700 flex items-center justify-center font-bold">识</div>
|
||||
<div class="text-[16px] font-semibold text-slate-800">识流 AI 助手</div>
|
||||
<RuntimeBadge :type="runtimeTagType" :text="runtimeText" />
|
||||
</div>
|
||||
<div class="window-actions" @mousedown.stop>
|
||||
<button class="win-btn" type="button" @click.stop="onOpenDevtools">调</button>
|
||||
<button class="win-btn" type="button" @click.stop="onRestartApp">重</button>
|
||||
<button class="win-btn" type="button" @click.stop="onMinimize">—</button>
|
||||
<button class="win-btn" type="button" @click.stop="onMaximizeOrRestore">□</button>
|
||||
<button class="win-btn close" type="button" @click.stop="onClose">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { NTag } from 'naive-ui'
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tag :type="type" :bordered="false" round>{{ text }}</n-tag>
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { getBotStatus, startBot, stopBot } from '../api/bot'
|
||||
import { createEventSource } from '../api/http'
|
||||
|
||||
export function useBotStatus(settings, loadSettings, message) {
|
||||
const statusEventSource = ref(null)
|
||||
const statusFallbackTimer = ref(null)
|
||||
const statusHealthTimer = ref(null)
|
||||
const statusConnecting = ref(false)
|
||||
const lastStatusStreamAt = ref(0)
|
||||
|
||||
const runtimeText = computed(() => {
|
||||
if (settings.listener_runtime_status === 'running') return '运行中'
|
||||
if (settings.listener_runtime_status === 'starting') return '启动中'
|
||||
if (settings.listener_runtime_status === 'stopping') return '停止中'
|
||||
if (settings.listener_runtime_status === 'error') return '异常'
|
||||
return '已停止'
|
||||
})
|
||||
|
||||
const runtimeTagType = computed(() => {
|
||||
if (settings.listener_runtime_status === 'running') return 'success'
|
||||
if (settings.listener_runtime_status === 'starting' || settings.listener_runtime_status === 'stopping') return 'warning'
|
||||
if (settings.listener_runtime_status === 'error') return 'error'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
function applyBotStatus(bot, fallbackEnabled) {
|
||||
const nextStatus = String(bot?.status || 'stopped').toLowerCase()
|
||||
settings.listener_runtime_status = nextStatus
|
||||
if (nextStatus === 'running' || nextStatus === 'starting') settings.listener_enabled = true
|
||||
else if (nextStatus === 'stopped' || nextStatus === 'error') settings.listener_enabled = false
|
||||
else settings.listener_enabled = !!fallbackEnabled
|
||||
|
||||
if (nextStatus === 'error') {
|
||||
const detail = String(bot?.last_error || '').trim().split('\n').slice(-1)[0] || ''
|
||||
if (detail && detail !== lastNotifiedError.value) {
|
||||
lastNotifiedError.value = detail
|
||||
message.error(`监听启动异常: ${detail}`)
|
||||
} else if (!detail && lastNotifiedError.value !== '__unknown__') {
|
||||
lastNotifiedError.value = '__unknown__'
|
||||
message.error('监听启动异常,请检查微信环境、日志与配置')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBotStatus() {
|
||||
const bot = await getBotStatus()
|
||||
if (bot.success) applyBotStatus(bot, settings.listener_enabled)
|
||||
}
|
||||
|
||||
function touchStatusStream() {
|
||||
lastStatusStreamAt.value = Date.now()
|
||||
}
|
||||
|
||||
function clearStatusFallbackTimer() {
|
||||
if (statusFallbackTimer.value) {
|
||||
clearInterval(statusFallbackTimer.value)
|
||||
statusFallbackTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatusHealthTimer() {
|
||||
if (statusHealthTimer.value) {
|
||||
clearInterval(statusHealthTimer.value)
|
||||
statusHealthTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function closeStatusStream({ reconnect = false } = {}) {
|
||||
clearStatusFallbackTimer()
|
||||
clearStatusHealthTimer()
|
||||
if (statusEventSource.value) {
|
||||
const source = statusEventSource.value
|
||||
statusEventSource.value = null
|
||||
source.onopen = null
|
||||
source.onerror = null
|
||||
source.close()
|
||||
}
|
||||
statusConnecting.value = false
|
||||
if (reconnect) {
|
||||
connectBotStatusStream(true)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStatusFallbackPolling() {
|
||||
if (statusFallbackTimer.value) return
|
||||
statusFallbackTimer.value = setInterval(() => {
|
||||
refreshBotStatus().catch(() => {})
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
function startStatusHealthCheck() {
|
||||
if (statusHealthTimer.value) return
|
||||
statusHealthTimer.value = setInterval(() => {
|
||||
const lastAt = lastStatusStreamAt.value || 0
|
||||
if (!statusEventSource.value) {
|
||||
connectBotStatusStream()
|
||||
return
|
||||
}
|
||||
if (lastAt && Date.now() - lastAt <= 45000) {
|
||||
return
|
||||
}
|
||||
ensureStatusFallbackPolling()
|
||||
closeStatusStream({ reconnect: true })
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
function connectBotStatusStream(force = false) {
|
||||
if (statusConnecting.value) return
|
||||
if (statusEventSource.value && !force) return
|
||||
if (statusEventSource.value && force) {
|
||||
closeStatusStream()
|
||||
}
|
||||
statusConnecting.value = true
|
||||
try {
|
||||
const source = createEventSource('/api/bot/status/stream')
|
||||
statusEventSource.value = source
|
||||
source.addEventListener('status', (event) => {
|
||||
touchStatusStream()
|
||||
try {
|
||||
const payload = JSON.parse(event.data || '{}')
|
||||
if (payload.success) {
|
||||
applyBotStatus(payload, settings.listener_enabled)
|
||||
}
|
||||
clearStatusFallbackTimer()
|
||||
} catch (_) {
|
||||
}
|
||||
})
|
||||
source.addEventListener('ping', () => {
|
||||
touchStatusStream()
|
||||
clearStatusFallbackTimer()
|
||||
})
|
||||
source.onopen = () => {
|
||||
statusConnecting.value = false
|
||||
touchStatusStream()
|
||||
clearStatusFallbackTimer()
|
||||
}
|
||||
source.onerror = () => {
|
||||
statusConnecting.value = false
|
||||
ensureStatusFallbackPolling()
|
||||
}
|
||||
} catch (_) {
|
||||
statusConnecting.value = false
|
||||
ensureStatusFallbackPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function onListenerChange(value) {
|
||||
try {
|
||||
settings.listener_enabled = !!value
|
||||
settings.listener_runtime_status = value ? 'starting' : 'stopping'
|
||||
const data = await (value ? startBot() : stopBot())
|
||||
if (!data.success) throw new Error(data.error || '操作失败')
|
||||
settings.listener_runtime_status = data.status || (value ? 'running' : 'stopped')
|
||||
settings.listener_enabled = data.status === 'running' || data.status === 'starting'
|
||||
await loadSettings()
|
||||
} catch (error) {
|
||||
message.error(`监听切换失败: ${error}`)
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeStatusStream()
|
||||
})
|
||||
|
||||
return {
|
||||
runtimeText,
|
||||
runtimeTagType,
|
||||
refreshBotStatus,
|
||||
connectBotStatusStream,
|
||||
startStatusHealthCheck,
|
||||
onListenerChange,
|
||||
closeStatusStream,
|
||||
applyBotStatus,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
import { getRecentMessages } from '../api/messages'
|
||||
|
||||
export function useMessages(message) {
|
||||
const loadingMessages = ref(false)
|
||||
const messages = ref([])
|
||||
|
||||
async function loadMessages(limit = 50) {
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const data = await getRecentMessages(limit)
|
||||
messages.value = data.data || []
|
||||
} catch (error) {
|
||||
message.error(`加载消息失败: ${error}`)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingMessages,
|
||||
messages,
|
||||
loadMessages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
||||
import { createRule, deleteRule, getRules, toggleRule } from '../api/rules'
|
||||
|
||||
export function useRulesManager(message) {
|
||||
const loadingRules = ref(false)
|
||||
const creatingRule = ref(false)
|
||||
const showCreateRuleModal = ref(false)
|
||||
const rules = ref([])
|
||||
const filters = reactive({ keyword: '', match_type: '', status: '' })
|
||||
const createForm = reactive({ keyword: '', match_type: 'contain', reply_text: '' })
|
||||
|
||||
const filteredRules = computed(() => {
|
||||
return rules.value.filter((row) => {
|
||||
if (filters.keyword && !String(row.keyword || '').toLowerCase().includes(filters.keyword.toLowerCase())) return false
|
||||
if (filters.match_type && row.match_type !== filters.match_type) return false
|
||||
if (filters.status !== '' && Number(row.is_active) !== Number(filters.status)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function loadRules() {
|
||||
loadingRules.value = true
|
||||
try {
|
||||
const data = await getRules()
|
||||
rules.value = data.data || []
|
||||
} catch (error) {
|
||||
message.error(`加载规则失败: ${error}`)
|
||||
} finally {
|
||||
loadingRules.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreateRule() {
|
||||
if (!createForm.keyword.trim() || !createForm.reply_text.trim()) {
|
||||
message.warning('请填写关键词和回复内容')
|
||||
return
|
||||
}
|
||||
creatingRule.value = true
|
||||
try {
|
||||
const data = await createRule({
|
||||
keyword: createForm.keyword.trim(),
|
||||
match_type: createForm.match_type,
|
||||
reply_text: createForm.reply_text.trim(),
|
||||
})
|
||||
if (!data.success) {
|
||||
message.error(data.message || '新增失败')
|
||||
return
|
||||
}
|
||||
message.success('新增成功')
|
||||
createForm.keyword = ''
|
||||
createForm.reply_text = ''
|
||||
showCreateRuleModal.value = false
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
message.error(`新增失败: ${error}`)
|
||||
} finally {
|
||||
creatingRule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRule(row) {
|
||||
try {
|
||||
const data = await toggleRule({
|
||||
id: String(row.id),
|
||||
is_active: Number(row.is_active) === 1 ? '0' : '1',
|
||||
})
|
||||
if (!data.success) {
|
||||
message.error(data.message || '切换失败')
|
||||
return
|
||||
}
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
message.error(`切换失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRule(row) {
|
||||
try {
|
||||
const data = await deleteRule(String(row.id))
|
||||
if (!data.success) {
|
||||
message.error(data.message || '删除失败')
|
||||
return
|
||||
}
|
||||
message.success('删除成功')
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
message.error(`删除失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.match_type = ''
|
||||
filters.status = ''
|
||||
loadRules()
|
||||
}
|
||||
|
||||
const ruleColumns = [
|
||||
{ title: '关键词', key: 'keyword', minWidth: 140 },
|
||||
{ title: '匹配方式', key: 'match_type', width: 120, render: (row) => row.match_type === 'equal' ? '完全匹配' : '包含' },
|
||||
{ title: '回复内容', key: 'reply_text', minWidth: 220 },
|
||||
{
|
||||
title: '状态', key: 'is_active', width: 110,
|
||||
render: (row) => h(NTag, { type: Number(row.is_active) === 1 ? 'success' : 'default', bordered: false, round: true }, {
|
||||
default: () => Number(row.is_active) === 1 ? '启用' : '暂停',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 190,
|
||||
render: (row) => h(NSpace, { size: 8 }, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', secondary: true, type: Number(row.is_active) === 1 ? 'warning' : 'success', onClick: () => handleToggleRule(row) }, { default: () => Number(row.is_active) === 1 ? '停用' : '启用' }),
|
||||
h(NButton, { size: 'small', secondary: true, type: 'error', onClick: () => handleDeleteRule(row) }, { default: () => '删除' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
loadingRules,
|
||||
creatingRule,
|
||||
showCreateRuleModal,
|
||||
filters,
|
||||
createForm,
|
||||
filteredRules,
|
||||
ruleColumns,
|
||||
loadRules,
|
||||
submitCreateRule,
|
||||
resetFilters,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { reactive } from 'vue'
|
||||
import { getSettings, saveSettings as saveSettingsApi } from '../api/settings'
|
||||
|
||||
export function useSettings(message) {
|
||||
const settings = reactive({
|
||||
auto_reply_enabled: false,
|
||||
listener_enabled: false,
|
||||
listener_runtime_status: 'stopped',
|
||||
full_auto_reply_enabled: false,
|
||||
reply_fallback_mode: 'ai',
|
||||
})
|
||||
|
||||
async function loadSettings(applyBotStatus) {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
if (!data.success) return
|
||||
settings.auto_reply_enabled = !!data.auto_reply_enabled
|
||||
settings.full_auto_reply_enabled = !!data.full_auto_reply_enabled
|
||||
settings.reply_fallback_mode = data.reply_fallback_mode || 'ai'
|
||||
applyBotStatus({ status: data.listener_runtime_status || 'stopped' }, data.listener_enabled)
|
||||
} catch (error) {
|
||||
message.error(`加载设置失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(payload, applyBotStatus) {
|
||||
try {
|
||||
const data = await saveSettingsApi(payload)
|
||||
if (!data.success) {
|
||||
message.error(data.message || '保存失败')
|
||||
return
|
||||
}
|
||||
await loadSettings(applyBotStatus)
|
||||
} catch (error) {
|
||||
message.error(`保存失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
let tauriWindow = null
|
||||
let tauriCore = null
|
||||
|
||||
async function ensureTauriModules() {
|
||||
if (tauriWindow && tauriCore) return true
|
||||
if (!window.__TAURI_INTERNALS__) return false
|
||||
try {
|
||||
const [{ getCurrentWindow }, { invoke }] = await Promise.all([
|
||||
import('@tauri-apps/api/window'),
|
||||
import('@tauri-apps/api/core'),
|
||||
])
|
||||
tauriWindow = getCurrentWindow()
|
||||
tauriCore = { invoke }
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function useWindowBridge() {
|
||||
const windowBridge = ref(null)
|
||||
const bridgeMode = ref('browser')
|
||||
|
||||
async function initWindowBridge() {
|
||||
if (window.qt && window.QWebChannel) {
|
||||
bridgeMode.value = 'qt'
|
||||
new window.QWebChannel(window.qt.webChannelTransport, (channel) => {
|
||||
windowBridge.value = channel.objects.windowBridge || null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (await ensureTauriModules()) {
|
||||
bridgeMode.value = 'tauri'
|
||||
return
|
||||
}
|
||||
bridgeMode.value = 'browser'
|
||||
}
|
||||
|
||||
async function invoke(methodName) {
|
||||
const bridge = windowBridge.value
|
||||
if (bridge && typeof bridge[methodName] === 'function') {
|
||||
bridge[methodName]()
|
||||
return
|
||||
}
|
||||
if (!(await ensureTauriModules())) return
|
||||
if (methodName === 'start_move') {
|
||||
await tauriWindow.startDragging()
|
||||
return
|
||||
}
|
||||
if (methodName === 'minimize') {
|
||||
await tauriWindow.minimize()
|
||||
return
|
||||
}
|
||||
if (methodName === 'maximize_or_restore') {
|
||||
const maximized = await tauriWindow.isMaximized()
|
||||
if (maximized) {
|
||||
await tauriWindow.unmaximize()
|
||||
} else {
|
||||
await tauriWindow.maximize()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (methodName === 'close_window') {
|
||||
await tauriWindow.close()
|
||||
return
|
||||
}
|
||||
if (methodName === 'open_devtools') {
|
||||
try {
|
||||
await tauriCore.invoke('open_devtools')
|
||||
} catch {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (methodName === 'restart_app') {
|
||||
try {
|
||||
await tauriCore.invoke('restart_backend')
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeMode,
|
||||
initWindowBridge,
|
||||
startMove: () => invoke('start_move'),
|
||||
minimizeWindow: () => invoke('minimize'),
|
||||
maximizeOrRestore: () => invoke('maximize_or_restore'),
|
||||
closeWindow: () => invoke('close_window'),
|
||||
openDevtools: () => invoke('open_devtools'),
|
||||
restartApp: () => invoke('restart_app'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatDateTime } from '../utils/datetime'
|
||||
|
||||
export const messageColumns = [
|
||||
{ title: '时间', key: 'created_at', width: 180, render: (row) => formatDateTime(row.created_at) },
|
||||
{ title: '微信用户', key: 'wx_nickname', width: 180 },
|
||||
{ title: '方向', key: 'direction', width: 90, render: (row) => row.direction === 'out' ? '发送' : '接收' },
|
||||
{ title: '内容', key: 'content', minWidth: 320 },
|
||||
{ title: '气泡方向', key: 'ocr_bubble_side', width: 100 },
|
||||
{ title: '置信度', key: 'ocr_confidence', width: 90 },
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
export const fallbackModeOptions = [
|
||||
{ label: 'AI兜底回复', value: 'ai' },
|
||||
{ label: '不回复', value: 'none' },
|
||||
]
|
||||
|
||||
export const matchTypeOptions = [
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '完全匹配', value: 'equal' },
|
||||
]
|
||||
|
||||
export const matchTypeFilterOptions = [
|
||||
{ label: '全部匹配方式', value: '' },
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '完全匹配', value: 'equal' },
|
||||
]
|
||||
|
||||
export const ruleStatusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '暂停', value: 0 },
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
function loadQtWebChannelScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.QWebChannel) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'qrc:///qtwebchannel/qwebchannel.js'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (window.qt && !window.QWebChannel) {
|
||||
try {
|
||||
await loadQtWebChannelScript()
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
const { message } = createDiscreteApi(['message'])
|
||||
window.$message = message
|
||||
createApp(App).mount('#app')
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
@@ -0,0 +1,299 @@
|
||||
:root {
|
||||
--bg: #f2f4f7;
|
||||
--panel: #ffffff;
|
||||
--line: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--brand: #22c1c3;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #f5f7fb 0%, #eef2f7 100%);
|
||||
color: var(--text);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-root {
|
||||
height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inner {
|
||||
width: min(1320px, calc(100vw - 24px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.window-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.win-btn {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e7ecf2;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.win-btn:hover {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.win-btn.close:hover {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid #edf0f4;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(120deg, rgba(34, 193, 195, 0.15), rgba(124, 58, 237, 0.08));
|
||||
border: 1px solid #dff4f5;
|
||||
}
|
||||
|
||||
.tab-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf2;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px 0;
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-tabs,
|
||||
.app-tabs .n-tabs-nav-scroll-content,
|
||||
.app-tabs .n-tabs-pane-wrapper,
|
||||
.app-tabs .n-tab-pane,
|
||||
.app-tab-pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-tabs .n-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-tabs .n-tabs-pane-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-tabs .n-tab-pane,
|
||||
.app-tab-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-tab-pane > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.label-desc {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.row-line {
|
||||
border-bottom: 1px dashed #edf1f5;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.row-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.compact-card .n-card-header {
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.compact-card .n-card__content {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.panel-view-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logs-view-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-panel .n-card__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-panel .n-card-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-path {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-stream-wrap {
|
||||
height: clamp(320px, calc(100vh - 320px), 680px);
|
||||
min-height: 320px;
|
||||
max-height: 680px;
|
||||
border: 1px solid #e6ebf2;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, "SFMono-Regular", Monaco, monospace;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.log-line--error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.log-line--warning {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.log-line--info {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.log-line--debug {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function formatDateTime(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return String(value)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { h, computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { NButton, NCard, NDataTable, NGi, NGrid, NInput, NModal, NSelect, NSpace } from 'naive-ui'
|
||||
import { clearLogs, getLogEventJson, getLogEvents, getLogSummary } from '../api/bot'
|
||||
|
||||
const pollTimer = ref(null)
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailJson = ref('')
|
||||
const keyword = ref('')
|
||||
const moduleValue = ref('')
|
||||
const domainValue = ref('')
|
||||
const levelValue = ref('')
|
||||
const onlyAbnormal = ref(false)
|
||||
const events = ref([])
|
||||
const summary = ref({ error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] })
|
||||
|
||||
const domainOptions = [
|
||||
{ label: '全部功能域', value: '' },
|
||||
{ label: '接口', value: 'api' },
|
||||
{ label: '机器人', value: 'bot' },
|
||||
{ label: 'OCR', value: 'ocr' },
|
||||
{ label: 'AI', value: 'ai' },
|
||||
{ label: '数据库', value: 'db' },
|
||||
{ label: '截图', value: 'capture' },
|
||||
]
|
||||
|
||||
const moduleOptions = [
|
||||
{ label: '全部模块', value: '' },
|
||||
{ label: '接口(api)', value: 'api' },
|
||||
{ label: '机器人(bot)', value: 'bot' },
|
||||
{ label: 'OCR(ocr)', value: 'ocr' },
|
||||
{ label: 'AI(ai)', value: 'ai' },
|
||||
{ label: '数据库(db)', value: 'db' },
|
||||
{ label: '截图(capture)', value: 'capture' },
|
||||
{ label: '审计(audit)', value: 'audit' },
|
||||
{ label: '错误(error)', value: 'error' },
|
||||
]
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '全部级别', value: '' },
|
||||
{ label: 'INFO', value: 'INFO' },
|
||||
{ label: 'WARNING', value: 'WARNING' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
]
|
||||
|
||||
const domainLabelMap = {
|
||||
api: '接口',
|
||||
bot: '机器人',
|
||||
ocr: 'OCR',
|
||||
ai: 'AI',
|
||||
db: '数据库',
|
||||
capture: '截图',
|
||||
}
|
||||
|
||||
const renderedDomainStats = computed(() => {
|
||||
const items = Array.isArray(summary.value?.domain_counts) ? summary.value.domain_counts : []
|
||||
return items
|
||||
.filter((x) => Number(x?.count || 0) > 0)
|
||||
.map((x) => `${domainLabelMap[x.domain] || x.domain}: ${x.count}`)
|
||||
.join(' | ')
|
||||
})
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
let rows = events.value
|
||||
if (domainValue.value) {
|
||||
rows = rows.filter((e) => String(e.domain || '') === domainValue.value)
|
||||
}
|
||||
if (onlyAbnormal.value) {
|
||||
rows = rows.filter((e) => {
|
||||
const lv = String(e.level || '').toUpperCase()
|
||||
const st = String(e.status || '').toLowerCase()
|
||||
return lv === 'ERROR' || lv === 'WARNING' || st === 'failed' || st === 'fail'
|
||||
})
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function formatLogTs(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '-'
|
||||
const d = new Date(raw)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return raw.replace('T', ' ').replace(/\.\d+([+-]\d{2}:?\d{2}|Z)?$/, '')
|
||||
}
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
|
||||
}
|
||||
|
||||
async function onViewJson(row) {
|
||||
detailVisible.value = true
|
||||
detailJson.value = '加载中...'
|
||||
const eventId = String(row?.event_id || '').trim()
|
||||
if (!eventId) {
|
||||
detailJson.value = JSON.stringify(row || {}, null, 2)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getLogEventJson(eventId)
|
||||
detailJson.value = JSON.stringify(res?.item || row || {}, null, 2)
|
||||
} catch {
|
||||
detailJson.value = JSON.stringify(row || {}, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearLogs() {
|
||||
await clearLogs(moduleValue.value || '')
|
||||
await refreshLogs()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', key: 'ts', width: 170, render: (row) => formatLogTs(row.ts) },
|
||||
{ title: '功能域', key: 'domain', width: 90 },
|
||||
{ title: '级别', key: 'level', width: 90 },
|
||||
{ title: '模块', key: 'module', width: 90 },
|
||||
{ title: '事件', key: 'event', width: 220 },
|
||||
{ title: '阶段', key: 'stage', width: 90 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: 'trace_id', key: 'trace_id', width: 180 },
|
||||
{ title: '原因', key: 'reason', width: 160 },
|
||||
{ title: '消息', key: 'message', minWidth: 260 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (row) => h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => onViewJson(row) }, { default: () => 'JSON' }),
|
||||
},
|
||||
]
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [eventRes, summaryRes] = await Promise.all([
|
||||
getLogEvents({ module: moduleValue.value, level: levelValue.value, keyword: keyword.value, page: 1, size: 200 }),
|
||||
getLogSummary(300),
|
||||
])
|
||||
events.value = Array.isArray(eventRes?.items) ? eventRes.items : []
|
||||
summary.value = summaryRes || { error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] }
|
||||
} catch {
|
||||
events.value = []
|
||||
summary.value = { error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
domainValue.value = ''
|
||||
moduleValue.value = ''
|
||||
levelValue.value = ''
|
||||
keyword.value = ''
|
||||
onlyAbnormal.value = false
|
||||
refreshLogs()
|
||||
}
|
||||
|
||||
function toggleAbnormal() {
|
||||
onlyAbnormal.value = !onlyAbnormal.value
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer.value) clearInterval(pollTimer.value)
|
||||
pollTimer.value = setInterval(() => {
|
||||
refreshLogs()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshLogs()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer.value) {
|
||||
clearInterval(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3">
|
||||
<n-card class="panel compact-card mb-3" title="筛选" size="small" :bordered="false">
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-gi :span="4"><n-select v-model:value="domainValue" :options="domainOptions" placeholder="功能域" /></n-gi>
|
||||
<n-gi :span="4"><n-select v-model:value="moduleValue" :options="moduleOptions" placeholder="模块" /></n-gi>
|
||||
<n-gi :span="4"><n-select v-model:value="levelValue" :options="levelOptions" placeholder="级别" /></n-gi>
|
||||
<n-gi :span="6"><n-input v-model:value="keyword" placeholder="关键词过滤" @keydown.enter="refreshLogs" /></n-gi>
|
||||
<n-gi :span="6" class="flex justify-end">
|
||||
<n-space>
|
||||
<n-button :type="onlyAbnormal ? 'error' : 'default'" @click="toggleAbnormal">{{ onlyAbnormal ? '仅异常:开' : '仅异常:关' }}</n-button>
|
||||
<n-button type="warning" @click="onClearLogs">清除日志</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="refreshLogs">搜索</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card class="panel compact-card mb-3" title="统计" size="small" :bordered="false">
|
||||
<div style="color: #8c8c8c; font-size: 12px;">
|
||||
<span>错误数: {{ summary.error_count || 0 }}</span>
|
||||
<span style="margin-left: 12px;">回退数: {{ summary.fallback_count || 0 }}</span>
|
||||
<span style="margin-left: 12px;">{{ renderedDomainStats || '域分布: 无' }}</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card class="panel" size="small" :bordered="false">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredEvents"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:max-height="560"
|
||||
size="small"
|
||||
striped
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<n-modal v-model:show="detailVisible" preset="card" title="日志JSON" style="width: 760px">
|
||||
<pre style="max-height: 520px; overflow: auto; margin: 0; font-size: 12px; white-space: pre-wrap; word-break: break-all;">{{ detailJson }}</pre>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { NButton, NCard, NDataTable } from 'naive-ui'
|
||||
|
||||
defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
onRefresh: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3">
|
||||
<n-card class="panel" size="small" :bordered="false">
|
||||
<template #header-extra><n-button @click="onRefresh">刷新</n-button></template>
|
||||
<n-data-table :columns="columns" :data="messages" :loading="loading" :pagination="{ pageSize: 10 }" :max-height="520" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { NButton, NCard, NRadioButton, NRadioGroup, NSelect, NSwitch } from 'naive-ui'
|
||||
import { getBotLogs } from '../api/bot'
|
||||
import { fallbackModeOptions } from '../constants/options'
|
||||
|
||||
const props = defineProps({
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
onListenerChange: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onSaveSettings: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const logKind = ref('bot')
|
||||
const logLines = ref([])
|
||||
const logPath = ref('')
|
||||
const logLoading = ref(false)
|
||||
const logError = ref('')
|
||||
const logTimer = ref(null)
|
||||
|
||||
const renderedLogLines = computed(() => {
|
||||
if (logLoading.value && !logLines.value.length) {
|
||||
return [{ text: '日志加载中...', level: 'info' }]
|
||||
}
|
||||
if (logError.value) {
|
||||
return [{ text: `日志加载失败:${logError.value}`, level: 'error' }]
|
||||
}
|
||||
if (!logLines.value.length) {
|
||||
return [{ text: '暂无日志输出', level: 'debug' }]
|
||||
}
|
||||
return logLines.value.map((line) => ({
|
||||
text: line,
|
||||
level: getLogLevel(line),
|
||||
}))
|
||||
})
|
||||
|
||||
function getLogLevel(line) {
|
||||
const text = String(line || '').toUpperCase()
|
||||
if (text.includes('ERROR')) return 'error'
|
||||
if (text.includes('WARNING') || text.includes('WARN')) return 'warning'
|
||||
if (text.includes('INFO')) return 'info'
|
||||
return 'debug'
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const data = await getBotLogs(logKind.value, 120)
|
||||
if (!data?.success) throw new Error(data?.message || '接口返回失败')
|
||||
logLines.value = Array.isArray(data.lines) ? data.lines : []
|
||||
logPath.value = data.path || ''
|
||||
logError.value = ''
|
||||
} catch (error) {
|
||||
logError.value = error?.message || String(error)
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setLogKind(value) {
|
||||
logKind.value = value
|
||||
}
|
||||
|
||||
function startLogPolling() {
|
||||
if (logTimer.value) clearInterval(logTimer.value)
|
||||
logTimer.value = setInterval(loadLogs, 2000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLogs()
|
||||
startLogPolling()
|
||||
})
|
||||
|
||||
watch(logKind, () => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (logTimer.value) {
|
||||
clearInterval(logTimer.value)
|
||||
logTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3 panel-view-shell">
|
||||
<n-card class="panel compact-card" title="监听与回复设置" size="small" :bordered="false">
|
||||
<div class="row-line flex items-center justify-between">
|
||||
<div><div class="label-title">监听</div><div class="label-desc">执行监听开始监听微信</div></div>
|
||||
<n-switch :value="props.settings.listener_enabled" @update:value="props.onListenerChange" />
|
||||
</div>
|
||||
<div class="row-line flex items-center justify-between">
|
||||
<div><div class="label-title">自动回复</div><div class="label-desc">开启后自动回复微信收到的信息</div></div>
|
||||
<n-switch :value="props.settings.auto_reply_enabled" @update:value="value => props.onSaveSettings({ auto_reply_enabled: value ? '1' : '0' })" />
|
||||
</div>
|
||||
<div class="row-line flex items-center justify-between">
|
||||
<div><div class="label-title">全量回复</div><div class="label-desc">开启后回复所有消息</div></div>
|
||||
<n-switch :value="props.settings.full_auto_reply_enabled" @update:value="value => props.onSaveSettings({ full_auto_reply_enabled: value ? '1' : '0' })" />
|
||||
</div>
|
||||
<div class="pt-3 max-w-[240px]">
|
||||
<n-select :options="fallbackModeOptions" :value="props.settings.reply_fallback_mode" @update:value="value => props.onSaveSettings({ reply_fallback_mode: value || 'ai' })" />
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { NButton, NCard, NDataTable, NGi, NGrid, NInput, NModal, NSelect, NSpace } from 'naive-ui'
|
||||
import { matchTypeFilterOptions, matchTypeOptions, ruleStatusOptions } from '../constants/options'
|
||||
import { useRulesManager } from '../composables/useRulesManager'
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
loadingRules,
|
||||
creatingRule,
|
||||
showCreateRuleModal,
|
||||
filters,
|
||||
createForm,
|
||||
filteredRules,
|
||||
ruleColumns,
|
||||
loadRules,
|
||||
submitCreateRule,
|
||||
resetFilters,
|
||||
} = useRulesManager(props.message)
|
||||
|
||||
onMounted(() => {
|
||||
loadRules()
|
||||
})
|
||||
|
||||
defineExpose({ loadRules })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3">
|
||||
<n-card class="panel compact-card mb-3" title="筛选" size="small" :bordered="false">
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-gi :span="6"><n-input v-model:value="filters.keyword" placeholder="关键词" /></n-gi>
|
||||
<n-gi :span="5"><n-select v-model:value="filters.match_type" :options="matchTypeFilterOptions" /></n-gi>
|
||||
<n-gi :span="5"><n-select v-model:value="filters.status" :options="ruleStatusOptions" /></n-gi>
|
||||
<n-gi :span="8" class="flex justify-end"><n-space><n-button type="primary" @click="loadRules">搜索</n-button><n-button @click="resetFilters">重置</n-button></n-space></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card class="panel compact-card mb-3" title="新增规则" size="small" :bordered="false">
|
||||
<div class="flex justify-end">
|
||||
<n-button type="primary" @click="showCreateRuleModal = true">新增回复规则</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-modal
|
||||
:show="showCreateRuleModal"
|
||||
preset="card"
|
||||
title="新增回复规则"
|
||||
class="w-[640px]"
|
||||
:mask-closable="false"
|
||||
@update:show="value => showCreateRuleModal = value"
|
||||
>
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-gi :span="24"><n-input v-model:value="createForm.keyword" placeholder="关键词" /></n-gi>
|
||||
<n-gi :span="24"><n-select v-model:value="createForm.match_type" :options="matchTypeOptions" /></n-gi>
|
||||
<n-gi :span="24"><n-input v-model:value="createForm.reply_text" type="textarea" :autosize="{ minRows: 3, maxRows: 6 }" placeholder="回复内容" /></n-gi>
|
||||
</n-grid>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="showCreateRuleModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="creatingRule" @click="submitCreateRule">确认新增</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-card class="panel" size="small" :bordered="false">
|
||||
<n-data-table :columns="ruleColumns" :data="filteredRules" :loading="loadingRules" :pagination="{ pageSize: 10 }" :max-height="360" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: 'localhost',
|
||||
port: 1420,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:5000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||