1
This commit is contained in:
@@ -1,3 +1,9 @@
|
|||||||
# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001
|
# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001
|
||||||
# 生产或直连后端时可设为完整地址,例如:
|
|
||||||
# VITE_API_BASE_URL=http://127.0.0.1:8001/api/v1
|
# VITE_API_BASE_URL=http://127.0.0.1:8001/api/v1
|
||||||
|
|
||||||
|
# Tauri Rust 请求后端基址(拉取 appConfig、脚本等)
|
||||||
|
# AICLIENT_API_BASE=http://127.0.0.1:8001
|
||||||
|
|
||||||
|
# 应用配置在 pythonbackend 的 MySQL 表 desktop_configs(name/value),
|
||||||
|
# 登录后自动同步;Node 流水线通过环境变量 AICLIENT_CFG_<NAME> 读取。
|
||||||
|
# 常用键示例:LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIG(JSON)
|
||||||
|
|||||||
180
src-tauri/src/app_config.rs
Normal file
180
src-tauri/src/app_config.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
//! 全局应用配置:登录后从 `GET /api/v1/appConfig` 拉取 `desktop_configs` 键值表,
|
||||||
|
//! 原样缓存;启动 Node 时写入环境变量 `AICLIENT_CFG_<规范化键名>`。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Node 子进程环境变量前缀,与 `scripts/nodejs/desktop_config.js` 一致。
|
||||||
|
pub const NODE_ENV_PREFIX: &str = "AICLIENT_CFG_";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppConfigError {
|
||||||
|
#[error("拉取配置失败: {0}")]
|
||||||
|
Fetch(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Inner {
|
||||||
|
entries: HashMap<String, String>,
|
||||||
|
last_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
inner: Arc<RwLock<Inner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_base() -> String {
|
||||||
|
std::env::var("AICLIENT_API_BASE").unwrap_or_else(|_| "http://127.0.0.1:8001".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将库表 `name` 转为 Node 环境变量名(不含前缀),如 `llm.api_key` → `LLM_API_KEY`。
|
||||||
|
pub fn normalize_config_key(name: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut prev_underscore = false;
|
||||||
|
for c in name.trim().chars() {
|
||||||
|
if c.is_ascii_alphanumeric() {
|
||||||
|
out.push(c.to_ascii_uppercase());
|
||||||
|
prev_underscore = false;
|
||||||
|
} else if !prev_underscore {
|
||||||
|
out.push('_');
|
||||||
|
prev_underscore = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while out.starts_with('_') {
|
||||||
|
out.remove(0);
|
||||||
|
}
|
||||||
|
while out.ends_with('_') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完整环境变量名:`AICLIENT_CFG_LLM_API_KEY`
|
||||||
|
pub fn node_env_var_name(db_name: &str) -> String {
|
||||||
|
format!("{}{}", NODE_ENV_PREFIX, normalize_config_key(db_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(Inner::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn entries(&self) -> HashMap<String, String> {
|
||||||
|
self.inner.read().await.entries.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, name: &str) -> Option<String> {
|
||||||
|
self.inner.read().await.entries.get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn last_message(&self) -> String {
|
||||||
|
self.inner.read().await.last_message.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear(&self) {
|
||||||
|
let mut g = self.inner.write().await;
|
||||||
|
g.entries.clear();
|
||||||
|
g.last_message.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供 Node 子进程 `Command::env` 使用。
|
||||||
|
pub async fn env_for_node(&self) -> HashMap<String, String> {
|
||||||
|
let g = self.inner.read().await;
|
||||||
|
g.entries
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| (node_env_var_name(name), value.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_after_auth(&self, token: &str) -> Result<(), AppConfigError> {
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
self.clear().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match fetch_from_server(token).await {
|
||||||
|
Ok(map) => {
|
||||||
|
let mut g = self.inner.write().await;
|
||||||
|
g.entries = map;
|
||||||
|
g.last_message.clear();
|
||||||
|
log::info!(
|
||||||
|
target: "app_config",
|
||||||
|
"应用配置已更新,共 {} 项",
|
||||||
|
g.entries.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let mut g = self.inner.write().await;
|
||||||
|
g.last_message = e.to_string();
|
||||||
|
log::warn!(target: "app_config", "拉取应用配置失败: {}", e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_from_server(token: &str) -> Result<HashMap<String, String>, AppConfigError> {
|
||||||
|
let base = api_base().trim_end_matches('/').to_string();
|
||||||
|
let url = format!("{base}/api/v1/appConfig");
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token.trim()))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = res.status();
|
||||||
|
let body = res
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
|
||||||
|
|
||||||
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
return Err(AppConfigError::Fetch("未认证或令牌已失效".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let v: Value =
|
||||||
|
serde_json::from_str(&body).map_err(|e| AppConfigError::Fetch(format!("响应非 JSON: {e}")))?;
|
||||||
|
|
||||||
|
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
let msg = v
|
||||||
|
.get("message")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or("未知错误");
|
||||||
|
return Err(AppConfigError::Fetch(msg.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = v
|
||||||
|
.get("data")
|
||||||
|
.ok_or_else(|| AppConfigError::Fetch("响应缺少 data".into()))?;
|
||||||
|
|
||||||
|
match data {
|
||||||
|
Value::Object(obj) => {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for (k, val) in obj {
|
||||||
|
let s = match val {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Null => String::new(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
map.insert(k.clone(), s);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
_ => Err(AppConfigError::Fetch("data 应为 JSON 对象".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use std::sync::Mutex;
|
|||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub role_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
|
|||||||
19
src-tauri/src/commands/app_config.rs
Normal file
19
src-tauri/src/commands/app_config.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppConfig;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
) -> Result<HashMap<String, String>, String> {
|
||||||
|
Ok(app_config.entries().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config_last_message(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
Ok(app_config.last_message().await)
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppConfig;
|
||||||
use crate::auth_session::{AuthSession, AuthUser};
|
use crate::auth_session::{AuthSession, AuthUser};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sync_auth_session(
|
pub async fn sync_auth_session(
|
||||||
session: State<'_, AuthSession>,
|
session: State<'_, AuthSession>,
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
token: String,
|
token: String,
|
||||||
user: Option<AuthUser>,
|
user: Option<AuthUser>,
|
||||||
) {
|
) -> Result<(), String> {
|
||||||
session.set(token, user);
|
session.set(token.clone(), user);
|
||||||
|
if let Err(e) = app_config.refresh_after_auth(&token).await {
|
||||||
|
log::debug!(target: "auth", "refresh app config: {}", e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod app_config;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod nodejs;
|
pub mod nodejs;
|
||||||
pub mod quickjs;
|
pub mod quickjs;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use serde_json::Value;
|
|||||||
use tauri::{AppHandle, Emitter, Window};
|
use tauri::{AppHandle, Emitter, Window};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::app_config::AppConfig;
|
||||||
use crate::js_runtime::PipelineEvent;
|
use crate::js_runtime::PipelineEvent;
|
||||||
use crate::nodejs;
|
use crate::nodejs;
|
||||||
|
|
||||||
@@ -80,13 +81,15 @@ fn spawn_event_pump(
|
|||||||
pub async fn run_nodejs_script(
|
pub async fn run_nodejs_script(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
window: Window,
|
window: Window,
|
||||||
|
app_config: tauri::State<'_, AppConfig>,
|
||||||
script_name: String,
|
script_name: String,
|
||||||
params: Value,
|
params: Value,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
||||||
let pump = spawn_event_pump(app, window, script_name.clone(), rx);
|
let pump = spawn_event_pump(app, window, script_name.clone(), rx);
|
||||||
|
let config_env = app_config.env_for_node().await;
|
||||||
|
|
||||||
let result = nodejs::run_node_script(script_name, params, tx)
|
let result = nodejs::run_node_script(script_name, params, tx, config_env)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
@@ -99,13 +102,15 @@ pub async fn run_nodejs_script(
|
|||||||
pub async fn run_nodejs_script_source(
|
pub async fn run_nodejs_script_source(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
window: Window,
|
window: Window,
|
||||||
|
app_config: tauri::State<'_, AppConfig>,
|
||||||
script_source: String,
|
script_source: String,
|
||||||
params: Value,
|
params: Value,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
||||||
let pump = spawn_event_pump(app, window, "<debug-source>".to_string(), rx);
|
let pump = spawn_event_pump(app, window, "<debug-source>".to_string(), rx);
|
||||||
|
let config_env = app_config.env_for_node().await;
|
||||||
|
|
||||||
let result = nodejs::run_node_script_source(script_source, params, tx)
|
let result = nodejs::run_node_script_source(script_source, params, tx, config_env)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod app_config;
|
||||||
pub mod asr;
|
pub mod asr;
|
||||||
pub mod auth_session;
|
pub mod auth_session;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
@@ -16,6 +17,7 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(auth_session::AuthSession::default())
|
.manage(auth_session::AuthSession::default())
|
||||||
|
.manage(app_config::AppConfig::new())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
@@ -26,6 +28,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::auth::sync_auth_session,
|
commands::auth::sync_auth_session,
|
||||||
|
commands::app_config::get_app_config,
|
||||||
|
commands::app_config::get_app_config_last_message,
|
||||||
commands::quickjs::run_quickjs_script,
|
commands::quickjs::run_quickjs_script,
|
||||||
commands::quickjs::run_quickjs_script_source,
|
commands::quickjs::run_quickjs_script_source,
|
||||||
commands::quickjs::list_quickjs_scripts,
|
commands::quickjs::list_quickjs_scripts,
|
||||||
|
|||||||
@@ -8,16 +8,21 @@
|
|||||||
//! - 进度 / 日志由用户脚本通过 `globalThis.__native.emitProgress(s)`
|
//! - 进度 / 日志由用户脚本通过 `globalThis.__native.emitProgress(s)`
|
||||||
//! / `__native.log(level, msg, fields)` 写到 stderr,Rust 这边解析后
|
//! / `__native.log(level, msg, fields)` 写到 stderr,Rust 这边解析后
|
||||||
//! 转成 `PipelineEvent` 推给前端;
|
//! 转成 `PipelineEvent` 推给前端;
|
||||||
//! - 用户脚本的最终结果通过赋值 / `return` 的方式(约定函数名为
|
//! - 用户脚本通过 `require('./xxx.js')` 引用同目录模块;Rust 在启动前
|
||||||
//! `globalThis.__nodejsMain(params)`)返回,runner 把 JSON.stringify 后
|
//! 按 require 图从服务端递归拉取并写入临时 bundle,Node 原生 CommonJS
|
||||||
//! 的结果以 `__RESULT__<json>` 这一行写到 stdout,Rust 抓回来反序列化。
|
//! `require` 加载(比 ESM `import` 更简单,与 `.cjs` runner 一致);
|
||||||
|
//! - 用户脚本的最终结果通过 `globalThis.__nodejsMain(params)` 返回,runner
|
||||||
|
//! 把 JSON.stringify 后的结果以 `__RESULT__<json>` 写到 stdout。
|
||||||
//!
|
//!
|
||||||
//! 这套协议有意保持极小:跟 QuickJS 的 `globalThis.__quickjsMain` / `__native`
|
//! 这套协议有意保持极小:跟 QuickJS 的 `globalThis.__quickjsMain` / `__native`
|
||||||
//! 看起来完全对称,方便用户在两种运行时之间迁移脚本。
|
//! 看起来完全对称,方便用户在两种运行时之间迁移脚本。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
@@ -27,6 +32,15 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::js_runtime::PipelineEvent;
|
use crate::js_runtime::PipelineEvent;
|
||||||
|
|
||||||
|
/// 与后端 `nodejs_scripts._SAFE_NAME` 一致:仅允许扁平 `.js` 文件名。
|
||||||
|
static RE_SAFE_SCRIPT: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*\.js$").unwrap());
|
||||||
|
|
||||||
|
static RE_REQUIRE: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r#"require\s*\(\s*['"]([^'"]+\.js)['"]\s*\)"#).unwrap());
|
||||||
|
|
||||||
|
const INLINE_ENTRY: &str = "__inline_entry__.js";
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum NodeError {
|
pub enum NodeError {
|
||||||
#[error("node.exe 未找到(请检查 resources/nodejs/ 是否存在)")]
|
#[error("node.exe 未找到(请检查 resources/nodejs/ 是否存在)")]
|
||||||
@@ -76,91 +90,168 @@ pub fn locate_node() -> Option<PathBuf> {
|
|||||||
Some(PathBuf::from(exe_name()))
|
Some(PathBuf::from(exe_name()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用于设置 `cwd`:让用户脚本里的 `require('xxx')` 能解析到
|
/// 用于 `NODE_PATH`:让用户脚本里的 `require('puppeteer')` 等解析到
|
||||||
/// `resources/nodejs/node_modules/` 内置依赖。
|
/// `resources/nodejs/node_modules/`(需在该目录执行 `npm install`)。
|
||||||
pub fn nodejs_dir() -> Option<PathBuf> {
|
pub fn nodejs_dir() -> Option<PathBuf> {
|
||||||
locate_node().and_then(|p| p.parent().map(|x| x.to_path_buf()))
|
locate_node().and_then(|p| p.parent().map(|x| x.to_path_buf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Runner 模板
|
// Runner 模板(文件模式:入口脚本与 require 依赖均落盘,Node 原生 require)
|
||||||
//
|
|
||||||
// HEADER 安装 __native 桥,开始 IIFE 异步函数;
|
|
||||||
// 用户脚本原样夹在中间(通常用户脚本里会赋值 globalThis.__nodejsMain);
|
|
||||||
// FOOTER 调用 __nodejsMain(params)、捕获异常、写 __RESULT__。
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const RUNNER_HEADER: &str = r#"
|
fn build_file_runner(entry_script: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
// ---- aiclient auto-generated nodejs runner (do not edit) ----
|
// ---- aiclient auto-generated nodejs runner (do not edit) ----
|
||||||
"use strict";
|
"use strict";
|
||||||
(function () {
|
(function () {{
|
||||||
function emit(obj) {
|
function emit(obj) {{
|
||||||
try {
|
try {{
|
||||||
process.stderr.write("__EVT__" + JSON.stringify(obj) + "\n");
|
process.stderr.write("__EVT__" + JSON.stringify(obj) + "\n");
|
||||||
} catch (_) {}
|
}} catch (_) {{}}
|
||||||
}
|
}}
|
||||||
globalThis.__native = {
|
globalThis.__native = {{
|
||||||
emitProgress(status) {
|
emitProgress(status) {{
|
||||||
emit({ type: "progress", status: String(status) });
|
emit({{ type: "progress", status: String(status) }});
|
||||||
},
|
}},
|
||||||
log(level, msg, fields) {
|
log(level, msg, fields) {{
|
||||||
emit({
|
emit({{
|
||||||
type: "log",
|
type: "log",
|
||||||
level: String(level || "info"),
|
level: String(level || "info"),
|
||||||
msg: String(msg),
|
msg: String(msg),
|
||||||
fields: fields == null ? null : fields,
|
fields: fields == null ? null : fields,
|
||||||
});
|
}});
|
||||||
},
|
}},
|
||||||
};
|
}};
|
||||||
})();
|
}})();
|
||||||
|
|
||||||
(async () => {
|
(async () => {{
|
||||||
// 1) read params JSON from stdin (one-shot)
|
|
||||||
let raw = "";
|
let raw = "";
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
for await (const chunk of process.stdin) raw += chunk;
|
for await (const chunk of process.stdin) raw += chunk;
|
||||||
const params = raw ? JSON.parse(raw) : null;
|
const params = raw ? JSON.parse(raw) : null;
|
||||||
|
|
||||||
// 2) inline user script begins ↓↓↓
|
require("./{entry_script}");
|
||||||
"#;
|
|
||||||
|
|
||||||
const RUNNER_FOOTER: &str = r#"
|
if (typeof globalThis.__nodejsMain !== "function") {{
|
||||||
// ↑↑↑ inline user script ends
|
|
||||||
|
|
||||||
// 3) dispatch to __nodejsMain(params)
|
|
||||||
if (typeof globalThis.__nodejsMain !== "function") {
|
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
"__EVT__" + JSON.stringify({
|
"__EVT__" + JSON.stringify({{
|
||||||
type: "log",
|
type: "log",
|
||||||
level: "error",
|
level: "error",
|
||||||
msg: "脚本未注册 globalThis.__nodejsMain",
|
msg: "脚本未注册 globalThis.__nodejsMain",
|
||||||
}) + "\n"
|
}}) + "\n"
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}}
|
||||||
try {
|
try {{
|
||||||
const result = await globalThis.__nodejsMain(params);
|
const result = await globalThis.__nodejsMain(params);
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"__RESULT__" + JSON.stringify(result === undefined ? null : result) + "\n"
|
"__RESULT__" + JSON.stringify(result === undefined ? null : result) + "\n"
|
||||||
);
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (e) {
|
}} catch (e) {{
|
||||||
const msg = (e && (e.stack || e.message)) || String(e);
|
const msg = (e && (e.stack || e.message)) || String(e);
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
"__EVT__" + JSON.stringify({ type: "log", level: "error", msg }) + "\n"
|
"__EVT__" + JSON.stringify({{ type: "log", level: "error", msg }}) + "\n"
|
||||||
);
|
);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}}
|
||||||
})();
|
}})();
|
||||||
"#;
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_runner(user_source: &str) -> String {
|
fn normalize_script_name(name: &str) -> Option<String> {
|
||||||
let mut s = String::with_capacity(
|
let base = Path::new(name.trim())
|
||||||
RUNNER_HEADER.len() + user_source.len() + RUNNER_FOOTER.len(),
|
.file_name()
|
||||||
);
|
.and_then(|s| s.to_str())?;
|
||||||
s.push_str(RUNNER_HEADER);
|
if RE_SAFE_SCRIPT.is_match(base) {
|
||||||
s.push_str(user_source);
|
Some(base.to_string())
|
||||||
s.push_str(RUNNER_FOOTER);
|
} else {
|
||||||
s
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_require_deps(source: &str) -> Vec<String> {
|
||||||
|
RE_REQUIRE
|
||||||
|
.captures_iter(source)
|
||||||
|
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从入口脚本名出发,按 `require('*.js')` 递归拉取服务端模块。
|
||||||
|
async fn resolve_script_bundle(entry: &str) -> Result<HashMap<String, String>, NodeError> {
|
||||||
|
let entry = normalize_script_name(entry)
|
||||||
|
.ok_or_else(|| NodeError::FetchScript(format!("无效的入口脚本名: {entry}")))?;
|
||||||
|
|
||||||
|
let mut modules: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut stack = vec![entry];
|
||||||
|
|
||||||
|
while let Some(name) = stack.pop() {
|
||||||
|
if modules.contains_key(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source = fetch_node_script_source(&name).await?;
|
||||||
|
for dep in extract_require_deps(&source) {
|
||||||
|
if let Some(dep_name) = normalize_script_name(&dep) {
|
||||||
|
if !modules.contains_key(&dep_name) {
|
||||||
|
stack.push(dep_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modules.insert(name, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调试源码:仅拉取其中 require 到的服务端模块,入口为内存写入的 `__inline_entry__.js`。
|
||||||
|
async fn resolve_script_bundle_for_inline(
|
||||||
|
inline_source: &str,
|
||||||
|
) -> Result<HashMap<String, String>, NodeError> {
|
||||||
|
let mut modules: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut stack: Vec<String> = extract_require_deps(inline_source)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| normalize_script_name(&d))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
while let Some(name) = stack.pop() {
|
||||||
|
if modules.contains_key(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source = fetch_node_script_source(&name).await?;
|
||||||
|
for dep in extract_require_deps(&source) {
|
||||||
|
if let Some(dep_name) = normalize_script_name(&dep) {
|
||||||
|
if !modules.contains_key(&dep_name) {
|
||||||
|
stack.push(dep_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modules.insert(name, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.insert(INLINE_ENTRY.to_string(), inline_source.to_string());
|
||||||
|
Ok(modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_script_bundle(
|
||||||
|
modules: &HashMap<String, String>,
|
||||||
|
entry: &str,
|
||||||
|
) -> Result<(PathBuf, PathBuf), NodeError> {
|
||||||
|
let tmp_dir = std::env::temp_dir().join("aiclient-node");
|
||||||
|
tokio::fs::create_dir_all(&tmp_dir).await?;
|
||||||
|
let bundle_dir = tmp_dir.join(format!("bundle-{}", Uuid::new_v4()));
|
||||||
|
tokio::fs::create_dir_all(&bundle_dir).await?;
|
||||||
|
|
||||||
|
for (name, source) in modules {
|
||||||
|
tokio::fs::write(bundle_dir.join(name), source).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runner_path = bundle_dir.join("_runner.cjs");
|
||||||
|
let runner_src = build_file_runner(entry);
|
||||||
|
tokio::fs::write(&runner_path, runner_src).await?;
|
||||||
|
|
||||||
|
Ok((bundle_dir, runner_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -247,46 +338,59 @@ pub async fn list_scripts() -> Result<Vec<String>, NodeError> {
|
|||||||
// 公开入口
|
// 公开入口
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 按脚本名运行:去后端拉源码 → 包装 runner → 启动 node → 收集结果。
|
/// 按脚本名运行:去后端拉入口及 require 依赖 → 落盘 → 启动 node → 收集结果。
|
||||||
pub async fn run_node_script(
|
pub async fn run_node_script(
|
||||||
script_name: String,
|
script_name: String,
|
||||||
params: Value,
|
params: Value,
|
||||||
events: UnboundedSender<PipelineEvent>,
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
config_env: HashMap<String, String>,
|
||||||
) -> Result<Value, NodeError> {
|
) -> Result<Value, NodeError> {
|
||||||
let user_source = fetch_node_script_source(&script_name).await?;
|
let entry = normalize_script_name(&script_name)
|
||||||
run_inline_source(user_source, params, events).await
|
.ok_or_else(|| NodeError::FetchScript(format!("无效的脚本名称: {script_name}")))?;
|
||||||
|
let modules = resolve_script_bundle(&entry).await?;
|
||||||
|
run_script_bundle(&entry, modules, params, events, config_env).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 调试入口:直接传源码字符串,跳过后端拉取。
|
/// 调试入口:直接传源码;其中 `require('*.js')` 仍从服务端拉取依赖。
|
||||||
pub async fn run_node_script_source(
|
pub async fn run_node_script_source(
|
||||||
source: String,
|
source: String,
|
||||||
params: Value,
|
params: Value,
|
||||||
events: UnboundedSender<PipelineEvent>,
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
config_env: HashMap<String, String>,
|
||||||
) -> Result<Value, NodeError> {
|
) -> Result<Value, NodeError> {
|
||||||
run_inline_source(source, params, events).await
|
let modules = resolve_script_bundle_for_inline(&source).await?;
|
||||||
|
run_script_bundle(INLINE_ENTRY, modules, params, events, config_env).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_inline_source(
|
async fn run_script_bundle(
|
||||||
user_source: String,
|
entry: &str,
|
||||||
|
modules: HashMap<String, String>,
|
||||||
params: Value,
|
params: Value,
|
||||||
events: UnboundedSender<PipelineEvent>,
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
config_env: HashMap<String, String>,
|
||||||
) -> Result<Value, NodeError> {
|
) -> Result<Value, NodeError> {
|
||||||
let node = locate_node().ok_or(NodeError::NotFound)?;
|
let node = locate_node().ok_or(NodeError::NotFound)?;
|
||||||
let cwd = nodejs_dir().unwrap_or_else(std::env::temp_dir);
|
let node_modules_cwd = nodejs_dir().unwrap_or_else(std::env::temp_dir);
|
||||||
|
|
||||||
// 写到 OS 临时目录而非 resources/,避免污染只读资源
|
let (bundle_dir, script_path) = write_script_bundle(&modules, entry).await?;
|
||||||
let tmp_dir = std::env::temp_dir().join("aiclient-node");
|
|
||||||
tokio::fs::create_dir_all(&tmp_dir).await?;
|
|
||||||
let script_path = tmp_dir.join(format!("script-{}.cjs", Uuid::new_v4()));
|
|
||||||
tokio::fs::write(&script_path, build_runner(&user_source)).await?;
|
|
||||||
|
|
||||||
let mut child = Command::new(&node)
|
let mut cmd = Command::new(&node);
|
||||||
.arg(&script_path)
|
cmd.arg(&script_path)
|
||||||
.current_dir(&cwd)
|
.current_dir(&bundle_dir)
|
||||||
|
.env(
|
||||||
|
"NODE_PATH",
|
||||||
|
node_modules_cwd
|
||||||
|
.join("node_modules")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped());
|
||||||
.spawn()?;
|
for (key, value) in config_env {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
|
||||||
// 把 params JSON 灌进 stdin
|
// 把 params JSON 灌进 stdin
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
@@ -377,7 +481,7 @@ async fn run_inline_source(
|
|||||||
let stderr_tail = stderr_task.await.unwrap_or_default();
|
let stderr_tail = stderr_task.await.unwrap_or_default();
|
||||||
let result_line = stdout_task.await.unwrap_or(None);
|
let result_line = stdout_task.await.unwrap_or(None);
|
||||||
|
|
||||||
let _ = tokio::fs::remove_file(&script_path).await;
|
let _ = tokio::fs::remove_dir_all(&bundle_dir).await;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(NodeError::NonZero {
|
return Err(NodeError::NonZero {
|
||||||
|
|||||||
58
src/api/adminCardKeys.js
Normal file
58
src/api/adminCardKeys.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { apiRequest } from "./client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
*/
|
||||||
|
function adminRequest(token, path, options = {}) {
|
||||||
|
return apiRequest(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {{ page?: number, pageSize?: number, status?: string }} [params]
|
||||||
|
*/
|
||||||
|
export function listAdminCardKeysApi(token, { page = 1, pageSize = 20, status = "all" } = {}) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return adminRequest(token, `/admin/card-keys?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function createAdminCardKeysApi(token, payload) {
|
||||||
|
return adminRequest(token, "/admin/card-keys", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {number} cardId
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function updateAdminCardKeyApi(token, cardId, payload) {
|
||||||
|
return adminRequest(token, `/admin/card-keys/${cardId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} token @param {number} cardId */
|
||||||
|
export function deleteAdminCardKeyApi(token, cardId) {
|
||||||
|
return adminRequest(token, `/admin/card-keys/${cardId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
62
src/api/adminDesktopConfigs.js
Normal file
62
src/api/adminDesktopConfigs.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { apiRequest } from "./client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
*/
|
||||||
|
function adminRequest(token, path, options = {}) {
|
||||||
|
return apiRequest(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {{ page?: number, pageSize?: number, keyword?: string }} [params]
|
||||||
|
*/
|
||||||
|
export function listAdminDesktopConfigsApi(
|
||||||
|
token,
|
||||||
|
{ page = 1, pageSize = 20, keyword = "" } = {},
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
});
|
||||||
|
const kw = keyword?.trim();
|
||||||
|
if (kw) query.set("keyword", kw);
|
||||||
|
return adminRequest(token, `/admin/desktop-configs?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function createAdminDesktopConfigApi(token, payload) {
|
||||||
|
return adminRequest(token, "/admin/desktop-configs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {number} configId
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function updateAdminDesktopConfigApi(token, configId, payload) {
|
||||||
|
return adminRequest(token, `/admin/desktop-configs/${configId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} token @param {number} configId */
|
||||||
|
export function deleteAdminDesktopConfigApi(token, configId) {
|
||||||
|
return adminRequest(token, `/admin/desktop-configs/${configId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
57
src/api/adminUsers.js
Normal file
57
src/api/adminUsers.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { apiRequest } from "./client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
*/
|
||||||
|
function adminRequest(token, path, options = {}) {
|
||||||
|
return apiRequest(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {{ page?: number, pageSize?: number }} [params]
|
||||||
|
*/
|
||||||
|
export function listAdminUsersApi(token, { page = 1, pageSize = 20 } = {}) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
});
|
||||||
|
return adminRequest(token, `/admin/users?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function createAdminUserApi(token, payload) {
|
||||||
|
return adminRequest(token, "/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function updateAdminUserApi(token, userId, payload) {
|
||||||
|
return adminRequest(token, `/admin/users/${userId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} token @param {number} userId */
|
||||||
|
export function deleteAdminUserApi(token, userId) {
|
||||||
|
return adminRequest(token, `/admin/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ export async function apiRequest(path, options = {}) {
|
|||||||
return { ok: false, message: "未登录或登录已过期" };
|
return { ok: false, message: "未登录或登录已过期" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: body?.detail || "无权限执行此操作",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: body?.message || `请求失败(${res.status})`,
|
message: body?.message || `请求失败(${res.status})`,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const baseMenuItems = [
|
const baseMenuItems = [
|
||||||
{ name: "home", label: "首页", to: "/", icon: "home" },
|
{ name: "home", label: "首页", to: "/", icon: "home" },
|
||||||
@@ -17,13 +19,24 @@ const baseMenuItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
if (import.meta.env.DEV) {
|
const items = [...baseMenuItems];
|
||||||
return [...baseMenuItems, { name: "debug", label: "调试", to: "/debug", icon: "debug" }];
|
if (auth.isAdmin) {
|
||||||
|
items.push({ name: "admin", label: "管理", to: "/admin", icon: "admin" });
|
||||||
}
|
}
|
||||||
return baseMenuItems;
|
if (auth.isAgent) {
|
||||||
|
items.push({ name: "agent", label: "代理", to: "/agent", icon: "agent" });
|
||||||
|
}
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
items.push({ name: "debug", label: "调试", to: "/debug", icon: "debug" });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeName = computed(() => route.name);
|
const activeName = computed(() => {
|
||||||
|
if (route.path.startsWith("/admin")) return "admin";
|
||||||
|
if (route.path.startsWith("/agent")) return "agent";
|
||||||
|
return route.name;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -72,6 +85,14 @@ const activeName = computed(() => route.name);
|
|||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.2-1.2 2.2M12 17h.01" stroke-linecap="round" />
|
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.2-1.2 2.2M12 17h.01" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg v-else-if="item.icon === 'admin'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M12 3l8 4v6c0 5-3.5 8-8 8s-8-3-8-8V7l8-4z" stroke-linejoin="round" />
|
||||||
|
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="item.icon === 'agent'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="8" r="3" />
|
||||||
|
<path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
<svg v-else-if="item.icon === 'debug'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg v-else-if="item.icon === 'debug'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M12 6v2M12 16v2M6 12h2M16 12h2" stroke-linecap="round" />
|
<path d="M12 6v2M12 16v2M6 12h2M16 12h2" stroke-linecap="round" />
|
||||||
<circle cx="12" cy="12" r="4" />
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
|||||||
30
src/components/admin/AdminSubNav.vue
Normal file
30
src/components/admin/AdminSubNav.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ name: "admin-overview", label: "概览", to: "/admin" },
|
||||||
|
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
||||||
|
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
||||||
|
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeName = computed(() => route.name);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
||||||
|
<p class="admin-sub-nav__title">管理后台</p>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.name"
|
||||||
|
:to="item.to"
|
||||||
|
class="admin-sub-nav__item"
|
||||||
|
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,12 @@ import { storeToRefs } from "pinia";
|
|||||||
import { useWorkflowStore, executeVideoRewrite } from "../../stores/workflow.js";
|
import { useWorkflowStore, executeVideoRewrite } from "../../stores/workflow.js";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
const { videoLinkModalVisible, videoShareText, videoRewriting } = storeToRefs(workflow);
|
const {
|
||||||
|
videoLinkModalVisible,
|
||||||
|
videoShareText,
|
||||||
|
videoRewriting,
|
||||||
|
rewriteProgress,
|
||||||
|
} = storeToRefs(workflow);
|
||||||
|
|
||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
@@ -12,8 +17,7 @@ const tipItems = [
|
|||||||
"已支持抖音分享文本、抖音视频链接、抖音短链接",
|
"已支持抖音分享文本、抖音视频链接、抖音短链接",
|
||||||
"支持直接视频文件 URL(如 .mp4、.mov 等)",
|
"支持直接视频文件 URL(如 .mp4、.mov 等)",
|
||||||
"快手、小红书、B站、视频号分享链接目前会提示暂不支持",
|
"快手、小红书、B站、视频号分享链接目前会提示暂不支持",
|
||||||
"使用 FUNASR 进行语音识别",
|
"流程:解析链接 → 下载视频 → 提取音频 → 语音识别 → AI 仿写",
|
||||||
"根据设置的仿写提示词生成仿写文案",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
@@ -26,6 +30,8 @@ async function onRewrite() {
|
|||||||
const result = await executeVideoRewrite(workflow);
|
const result = await executeVideoRewrite(workflow);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
feedback.value = { severity: "error", message: result.message };
|
feedback.value = { severity: "error", message: result.message };
|
||||||
|
} else {
|
||||||
|
feedback.value = { severity: "success", message: result.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -49,6 +55,15 @@ async function onRewrite() {
|
|||||||
{{ feedback.message }}
|
{{ feedback.message }}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="videoRewriting && rewriteProgress"
|
||||||
|
severity="info"
|
||||||
|
:closable="false"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
{{ rewriteProgress }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block font-medium text-slate-200">
|
<label class="mb-2 block font-medium text-slate-200">
|
||||||
请粘贴视频分享文本或视频链接
|
请粘贴视频分享文本或视频链接
|
||||||
|
|||||||
69
src/config/videoPipeline.js
Normal file
69
src/config/videoPipeline.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 流水线配置校验:优先使用 Rust 缓存的 desktop_configs(get_app_config)。
|
||||||
|
* 实际密钥由 Node 子进程通过环境变量 AICLIENT_CFG_* 读取,无需在前端 params 传递。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
/** @returns {Promise<{ ok: true } | { ok: false, message: string }>} */
|
||||||
|
export async function ensureAppConfigReady() {
|
||||||
|
try {
|
||||||
|
const map = await invoke("get_app_config");
|
||||||
|
if (map && typeof map === "object" && Object.keys(map).length > 0) {
|
||||||
|
const apiKey =
|
||||||
|
map.LLM_API_KEY ||
|
||||||
|
map.llm_api_key ||
|
||||||
|
map["llm.api_key"];
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "服务端配置缺少 LLM_API_KEY,请在 desktop_configs 表中维护",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const serverMsg = await invoke("get_app_config_last_message");
|
||||||
|
if (serverMsg) {
|
||||||
|
return { ok: false, message: String(serverMsg) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "未获取到应用配置,请先登录并确保 desktop_configs 表有数据",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "请在 Tauri 桌面端登录后使用(配置由服务端下发)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOUYIN_RE = /douyin\.com|iesdouyin\.com|v\.douyin\.com/i;
|
||||||
|
const DIRECT_VIDEO_RE = /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i;
|
||||||
|
|
||||||
|
/** @returns {{ ok: true, value: string } | { ok: false, message: string }} */
|
||||||
|
export function validateShareText(text) {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (!raw) {
|
||||||
|
return { ok: false, message: "请粘贴视频分享文本或视频链接" };
|
||||||
|
}
|
||||||
|
const urls = raw.match(/https?:\/\/[^\s\u4e00-\u9fa5]+/gi) || [];
|
||||||
|
if (urls.some((u) => DIRECT_VIDEO_RE.test(u))) {
|
||||||
|
return { ok: true, value: raw };
|
||||||
|
}
|
||||||
|
if (DOUYIN_RE.test(raw) || urls.some((u) => DOUYIN_RE.test(u))) {
|
||||||
|
return { ok: true, value: raw };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
"请输入抖音分享文本、抖音视频链接,或直接视频文件链接(mp4/mov/webm/mkv/avi)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_REWRITE_CONFIG = {
|
||||||
|
style: "casual",
|
||||||
|
length: "medium",
|
||||||
|
keepHashtags: true,
|
||||||
|
keepEmoji: true,
|
||||||
|
};
|
||||||
@@ -19,6 +19,9 @@ import Tab from "primevue/tab";
|
|||||||
import TabPanels from "primevue/tabpanels";
|
import TabPanels from "primevue/tabpanels";
|
||||||
import TabPanel from "primevue/tabpanel";
|
import TabPanel from "primevue/tabpanel";
|
||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
|
import DataTable from "primevue/datatable";
|
||||||
|
import Column from "primevue/column";
|
||||||
|
import Tag from "primevue/tag";
|
||||||
|
|
||||||
import DashboardCard from "./components/dashboard/DashboardCard.vue";
|
import DashboardCard from "./components/dashboard/DashboardCard.vue";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
@@ -59,6 +62,9 @@ app.component("TabPanels", TabPanels);
|
|||||||
app.component("TabPanel", TabPanel);
|
app.component("TabPanel", TabPanel);
|
||||||
app.component("DashboardCard", DashboardCard);
|
app.component("DashboardCard", DashboardCard);
|
||||||
app.component("Dialog", Dialog);
|
app.component("Dialog", Dialog);
|
||||||
|
app.component("DataTable", DataTable);
|
||||||
|
app.component("Column", Column);
|
||||||
|
app.component("Tag", Tag);
|
||||||
|
|
||||||
useAuthStore(pinia).hydrateRustSession();
|
useAuthStore(pinia).hydrateRustSession();
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +1,317 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
|
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../stores/auth";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const placeholder = () => import("../views/PlaceholderView.vue");
|
const placeholder = () => import("../views/PlaceholderView.vue");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const mainChildren = [
|
const mainChildren = [
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "",
|
path: "",
|
||||||
|
|
||||||
name: "home",
|
name: "home",
|
||||||
|
|
||||||
component: () => import("../views/HomeView.vue"),
|
component: () => import("../views/HomeView.vue"),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "avatar",
|
path: "avatar",
|
||||||
|
|
||||||
name: "avatar",
|
name: "avatar",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "形象" },
|
meta: { title: "形象" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "material",
|
path: "material",
|
||||||
|
|
||||||
name: "material",
|
name: "material",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "素材" },
|
meta: { title: "素材" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "voice",
|
path: "voice",
|
||||||
|
|
||||||
name: "voice",
|
name: "voice",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "声音" },
|
meta: { title: "声音" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "compose",
|
path: "compose",
|
||||||
|
|
||||||
name: "compose",
|
name: "compose",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "合成" },
|
meta: { title: "合成" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "extract",
|
path: "extract",
|
||||||
|
|
||||||
name: "extract",
|
name: "extract",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "提取" },
|
meta: { title: "提取" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "design",
|
path: "design",
|
||||||
|
|
||||||
name: "design",
|
name: "design",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "设计" },
|
meta: { title: "设计" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "model",
|
path: "model",
|
||||||
|
|
||||||
name: "model",
|
name: "model",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "模型" },
|
meta: { title: "模型" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "help",
|
path: "help",
|
||||||
|
|
||||||
name: "help",
|
name: "help",
|
||||||
|
|
||||||
component: placeholder,
|
component: placeholder,
|
||||||
|
|
||||||
meta: { title: "帮助" },
|
meta: { title: "帮助" },
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "admin",
|
||||||
|
|
||||||
|
component: () => import("../views/AdminView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "管理", requiresRole: ROLE_ADMIN },
|
||||||
|
|
||||||
|
redirect: { name: "admin-overview" },
|
||||||
|
|
||||||
|
children: [
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "",
|
||||||
|
|
||||||
|
name: "admin-overview",
|
||||||
|
|
||||||
|
component: () => import("../views/admin/AdminOverviewView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "概览" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "users",
|
||||||
|
|
||||||
|
name: "admin-users",
|
||||||
|
|
||||||
|
component: () => import("../views/admin/AdminUsersView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "用户管理" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "desktop-config",
|
||||||
|
|
||||||
|
name: "admin-desktop-config",
|
||||||
|
|
||||||
|
component: () => import("../views/admin/AdminDesktopConfigView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "桌面配置" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "card-keys",
|
||||||
|
|
||||||
|
name: "admin-card-keys",
|
||||||
|
|
||||||
|
component: () => import("../views/admin/AdminCardKeysView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "卡密管理" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "agent",
|
||||||
|
|
||||||
|
name: "agent",
|
||||||
|
|
||||||
|
component: () => import("../views/AgentView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "代理", requiresRole: ROLE_AGENT },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|
||||||
mainChildren.push({
|
mainChildren.push({
|
||||||
|
|
||||||
path: "debug",
|
path: "debug",
|
||||||
|
|
||||||
name: "debug",
|
name: "debug",
|
||||||
|
|
||||||
component: () => import("../views/DebugView.vue"),
|
component: () => import("../views/DebugView.vue"),
|
||||||
|
|
||||||
meta: { title: "调试", devOnly: true },
|
meta: { title: "调试", devOnly: true },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "/",
|
path: "/",
|
||||||
|
|
||||||
component: () => import("../layouts/MainLayout.vue"),
|
component: () => import("../layouts/MainLayout.vue"),
|
||||||
|
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
|
|
||||||
children: mainChildren,
|
children: mainChildren,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "/login",
|
path: "/login",
|
||||||
|
|
||||||
name: "login",
|
name: "login",
|
||||||
|
|
||||||
component: () => import("../views/LoginView.vue"),
|
component: () => import("../views/LoginView.vue"),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "/register",
|
path: "/register",
|
||||||
|
|
||||||
name: "register",
|
name: "register",
|
||||||
|
|
||||||
component: () => import("../views/RegisterView.vue"),
|
component: () => import("../views/RegisterView.vue"),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
|
|
||||||
redirect: "/login",
|
redirect: "/login",
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
||||||
routes,
|
routes,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
|
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (requiresAuth && !auth.isAuthenticated) {
|
if (requiresAuth && !auth.isAuthenticated) {
|
||||||
|
|
||||||
return { name: "login" };
|
return { name: "login" };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) {
|
if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) {
|
||||||
|
|
||||||
return { name: "home" };
|
return { name: "home" };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const requiredRole = to.matched
|
||||||
|
|
||||||
|
.map((record) => record.meta.requiresRole)
|
||||||
|
|
||||||
|
.find((role) => role != null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (requiredRole != null && auth.roleId !== requiredRole) {
|
||||||
|
|
||||||
|
return { name: "home" };
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { loginApi, logoutApi, registerApi } from "../api/auth.js";
|
|||||||
const TOKEN_KEY = "aiclient_token";
|
const TOKEN_KEY = "aiclient_token";
|
||||||
const USER_KEY = "aiclient_current_user";
|
const USER_KEY = "aiclient_current_user";
|
||||||
|
|
||||||
|
/** 管理员 */
|
||||||
|
export const ROLE_ADMIN = 1;
|
||||||
|
/** 代理 */
|
||||||
|
export const ROLE_AGENT = 2;
|
||||||
|
|
||||||
async function syncAuthSessionToRust(token, user) {
|
async function syncAuthSessionToRust(token, user) {
|
||||||
try {
|
try {
|
||||||
await invoke("sync_auth_session", {
|
await invoke("sync_auth_session", {
|
||||||
@@ -29,6 +34,7 @@ function applyTokenResponse(store, res, fallbackMessage) {
|
|||||||
store.setSession(data.access_token, {
|
store.setSession(data.access_token, {
|
||||||
id: data.user.id,
|
id: data.user.id,
|
||||||
username: data.user.username,
|
username: data.user.username,
|
||||||
|
role_id: data.user.role_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, message: res.message || fallbackMessage };
|
return { ok: true, message: res.message || fallbackMessage };
|
||||||
@@ -49,6 +55,9 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
isAuthenticated: (state) => Boolean(state.token),
|
isAuthenticated: (state) => Boolean(state.token),
|
||||||
|
roleId: (state) => state.currentUser?.role_id ?? null,
|
||||||
|
isAdmin: (state) => state.currentUser?.role_id === ROLE_ADMIN,
|
||||||
|
isAgent: (state) => state.currentUser?.role_id === ROLE_AGENT,
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1,238 +1,486 @@
|
|||||||
import { defineStore, acceptHMRUpdate } from "pinia";
|
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
ensureAppConfigReady,
|
||||||
|
|
||||||
|
validateShareText,
|
||||||
|
|
||||||
|
DEFAULT_REWRITE_CONFIG,
|
||||||
|
|
||||||
|
} from "../config/videoPipeline.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const PIPELINE_SCRIPT = "douyin_pipeline.js";
|
||||||
|
const NODEJS_EVENT = "nodejs:event";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const defaultPublishPlatforms = () => [
|
const defaultPublishPlatforms = () => [
|
||||||
|
|
||||||
{ label: "*音", checked: false, account: null },
|
{ label: "*音", checked: false, account: null },
|
||||||
|
|
||||||
{ label: "*手", checked: false, account: null },
|
{ label: "*手", checked: false, account: null },
|
||||||
|
|
||||||
{ label: "*频号", checked: false, account: null },
|
{ label: "*频号", checked: false, account: null },
|
||||||
|
|
||||||
{ label: "*红书", checked: false, account: null },
|
{ label: "*红书", checked: false, account: null },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const useWorkflowStore = defineStore("workflow", {
|
export const useWorkflowStore = defineStore("workflow", {
|
||||||
|
|
||||||
state: () => ({
|
state: () => ({
|
||||||
|
|
||||||
// 01 IP 深度学习
|
// 01 IP 深度学习
|
||||||
|
|
||||||
ipMode: "ipBrain",
|
ipMode: "ipBrain",
|
||||||
|
|
||||||
ipBrainModalVisible: false,
|
ipBrainModalVisible: false,
|
||||||
|
|
||||||
ipBrainProfileUrl: "",
|
ipBrainProfileUrl: "",
|
||||||
|
|
||||||
ipBrainCollecting: false,
|
ipBrainCollecting: false,
|
||||||
|
|
||||||
ipBenchmarks: [],
|
ipBenchmarks: [],
|
||||||
|
|
||||||
ipTopics: [],
|
ipTopics: [],
|
||||||
|
|
||||||
ipBrainSelectedBenchmarkId: null,
|
ipBrainSelectedBenchmarkId: null,
|
||||||
|
|
||||||
videoLinkModalVisible: false,
|
videoLinkModalVisible: false,
|
||||||
|
|
||||||
videoShareText: "",
|
videoShareText: "",
|
||||||
|
|
||||||
videoRewriting: false,
|
videoRewriting: false,
|
||||||
|
|
||||||
|
/** 流水线进度文案(来自 quickjs:event progress) */
|
||||||
|
|
||||||
|
rewriteProgress: "",
|
||||||
|
|
||||||
recognizedContent: "",
|
recognizedContent: "",
|
||||||
|
|
||||||
|
/** 自定义仿写提示词(可选,对应 zhenqianba rewritePrompt) */
|
||||||
|
|
||||||
|
rewritePrompt: "",
|
||||||
|
|
||||||
videoType: "口播文案",
|
videoType: "口播文案",
|
||||||
|
|
||||||
copyType: "人设型",
|
copyType: "人设型",
|
||||||
|
|
||||||
industryPersona: "",
|
industryPersona: "",
|
||||||
|
|
||||||
productBusiness: "",
|
productBusiness: "",
|
||||||
|
|
||||||
sellingPrice: "",
|
sellingPrice: "",
|
||||||
|
|
||||||
otherRequirements: "",
|
otherRequirements: "",
|
||||||
|
|
||||||
targetWords: 300,
|
targetWords: 300,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 视频文案编辑
|
// 视频文案编辑
|
||||||
|
|
||||||
scriptContent: "",
|
scriptContent: "",
|
||||||
|
|
||||||
editLanguage: "中文(普通话)",
|
editLanguage: "中文(普通话)",
|
||||||
|
|
||||||
editWordCount: 300,
|
editWordCount: 300,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 02 音视频生成
|
// 02 音视频生成
|
||||||
|
|
||||||
voiceEmotion: "自然",
|
voiceEmotion: "自然",
|
||||||
|
|
||||||
speechRate: 1,
|
speechRate: 1,
|
||||||
|
|
||||||
voiceLanguage: "中文(普通话)",
|
voiceLanguage: "中文(普通话)",
|
||||||
|
|
||||||
avatarSelect: null,
|
avatarSelect: null,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 03 视频编辑
|
// 03 视频编辑
|
||||||
|
|
||||||
pipInPicture: false,
|
pipInPicture: false,
|
||||||
|
|
||||||
autoCutBreath: false,
|
autoCutBreath: false,
|
||||||
|
|
||||||
greenScreen: false,
|
greenScreen: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 04 标题标签关键词
|
// 04 标题标签关键词
|
||||||
|
|
||||||
titleGenerated: "",
|
titleGenerated: "",
|
||||||
|
|
||||||
tagsGenerated: "",
|
tagsGenerated: "",
|
||||||
|
|
||||||
keywordTab: "0",
|
keywordTab: "0",
|
||||||
|
|
||||||
keywordsFocus: "",
|
keywordsFocus: "",
|
||||||
|
|
||||||
keywordsDescribe: "",
|
keywordsDescribe: "",
|
||||||
|
|
||||||
keywordsAction: "",
|
keywordsAction: "",
|
||||||
|
|
||||||
keywordsEmotion: "",
|
keywordsEmotion: "",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 05 字幕和音乐
|
// 05 字幕和音乐
|
||||||
|
|
||||||
autoSubtitle: false,
|
autoSubtitle: false,
|
||||||
|
|
||||||
smartSubtitle: true,
|
smartSubtitle: true,
|
||||||
|
|
||||||
bgmEnabled: false,
|
bgmEnabled: false,
|
||||||
|
|
||||||
bgmVolume: 30,
|
bgmVolume: 30,
|
||||||
|
|
||||||
subtitleTemplate: null,
|
subtitleTemplate: null,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 07 视频发布
|
// 07 视频发布
|
||||||
|
|
||||||
publishPlatforms: defaultPublishPlatforms(),
|
publishPlatforms: defaultPublishPlatforms(),
|
||||||
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
|
|
||||||
ipModes: () => [
|
ipModes: () => [
|
||||||
|
|
||||||
{ label: "IP学习", value: "ipBrain" },
|
{ label: "IP学习", value: "ipBrain" },
|
||||||
|
|
||||||
{ label: "视频学习", value: "videoWrite" },
|
{ label: "视频学习", value: "videoWrite" },
|
||||||
|
|
||||||
{ label: "爆款文案", value: "customPrompt" },
|
{ label: "爆款文案", value: "customPrompt" },
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
videoTypes: () => ["口播文案", "剧情文案", "带货文案"],
|
videoTypes: () => ["口播文案", "剧情文案", "带货文案"],
|
||||||
|
|
||||||
copyTypes: () => ["人设型", "卖点型", "故事型"],
|
copyTypes: () => ["人设型", "卖点型", "故事型"],
|
||||||
|
|
||||||
voiceEmotions: () => ["自然", "热情", "沉稳"],
|
voiceEmotions: () => ["自然", "热情", "沉稳"],
|
||||||
|
|
||||||
languages: () => ["中文(普通话)", "中文(粤语)", "English"],
|
languages: () => ["中文(普通话)", "中文(粤语)", "English"],
|
||||||
|
|
||||||
avatarOptions: () => [{ label: "请选择形象", value: null }],
|
avatarOptions: () => [{ label: "请选择形象", value: null }],
|
||||||
|
|
||||||
keywordCountFocus: (state) => countLines(state.keywordsFocus),
|
keywordCountFocus: (state) => countLines(state.keywordsFocus),
|
||||||
|
|
||||||
keywordCountDescribe: (state) => countLines(state.keywordsDescribe),
|
keywordCountDescribe: (state) => countLines(state.keywordsDescribe),
|
||||||
|
|
||||||
keywordCountAction: (state) => countLines(state.keywordsAction),
|
keywordCountAction: (state) => countLines(state.keywordsAction),
|
||||||
|
|
||||||
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
|
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
|
||||||
|
|
||||||
ipBenchmarkCount: (state) => state.ipBenchmarks.length,
|
ipBenchmarkCount: (state) => state.ipBenchmarks.length,
|
||||||
|
|
||||||
selectedBenchmark: (state) =>
|
selectedBenchmark: (state) =>
|
||||||
|
|
||||||
state.ipBenchmarks.find((b) => b.id === state.ipBrainSelectedBenchmarkId) ?? null,
|
state.ipBenchmarks.find((b) => b.id === state.ipBrainSelectedBenchmarkId) ?? null,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|
||||||
openIpBrainDialog() {
|
openIpBrainDialog() {
|
||||||
|
|
||||||
this.ipBrainModalVisible = true;
|
this.ipBrainModalVisible = true;
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
closeIpBrainDialog() {
|
closeIpBrainDialog() {
|
||||||
|
|
||||||
this.ipBrainModalVisible = false;
|
this.ipBrainModalVisible = false;
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async startIpBrainCollect() {
|
async startIpBrainCollect() {
|
||||||
|
|
||||||
const url = this.ipBrainProfileUrl?.trim();
|
const url = this.ipBrainProfileUrl?.trim();
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
|
||||||
return { ok: false, message: "请输入账号主页或分享链接" };
|
return { ok: false, message: "请输入账号主页或分享链接" };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.ipBenchmarks.length >= 5) {
|
if (this.ipBenchmarks.length >= 5) {
|
||||||
|
|
||||||
return { ok: false, message: "最多添加 5 个对标账号" };
|
return { ok: false, message: "最多添加 5 个对标账号" };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.ipBrainCollecting = true;
|
this.ipBrainCollecting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// TODO: 对接后端采集 API / Tauri 浏览器自动化
|
// TODO: 对接后端采集 API / Tauri 浏览器自动化
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const benchmark = {
|
const benchmark = {
|
||||||
|
|
||||||
id: `bm_${Date.now()}`,
|
id: `bm_${Date.now()}`,
|
||||||
|
|
||||||
url,
|
url,
|
||||||
|
|
||||||
label: truncateUrl(url),
|
label: truncateUrl(url),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ipBenchmarks.push(benchmark);
|
this.ipBenchmarks.push(benchmark);
|
||||||
|
|
||||||
this.ipBrainSelectedBenchmarkId = benchmark.id;
|
this.ipBrainSelectedBenchmarkId = benchmark.id;
|
||||||
|
|
||||||
this.ipTopics = Array.from({ length: 5 }, (_, i) => ({
|
this.ipTopics = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
|
||||||
id: `topic_${Date.now()}_${i}`,
|
id: `topic_${Date.now()}_${i}`,
|
||||||
|
|
||||||
title: `示例视频标题 ${i + 1}(待对接真实采集)`,
|
title: `示例视频标题 ${i + 1}(待对接真实采集)`,
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.ipBrainProfileUrl = "";
|
this.ipBrainProfileUrl = "";
|
||||||
|
|
||||||
this.ipBrainModalVisible = false;
|
this.ipBrainModalVisible = false;
|
||||||
|
|
||||||
return { ok: true, message: "采集完成" };
|
return { ok: true, message: "采集完成" };
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
this.ipBrainCollecting = false;
|
this.ipBrainCollecting = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
selectBenchmark(id) {
|
selectBenchmark(id) {
|
||||||
|
|
||||||
this.ipBrainSelectedBenchmarkId = id;
|
this.ipBrainSelectedBenchmarkId = id;
|
||||||
|
|
||||||
this.ipTopics = [];
|
this.ipTopics = [];
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
openVideoLinkDialog() {
|
openVideoLinkDialog() {
|
||||||
|
|
||||||
this.videoLinkModalVisible = true;
|
this.videoLinkModalVisible = true;
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
closeVideoLinkDialog() {
|
closeVideoLinkDialog() {
|
||||||
|
|
||||||
this.videoLinkModalVisible = false;
|
this.videoLinkModalVisible = false;
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async startVideoRewrite() {
|
async startVideoRewrite() {
|
||||||
|
|
||||||
return executeVideoRewrite(this);
|
return executeVideoRewrite(this);
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
resetAll() {
|
resetAll() {
|
||||||
|
|
||||||
this.$reset();
|
this.$reset();
|
||||||
|
|
||||||
this.publishPlatforms = defaultPublishPlatforms();
|
this.publishPlatforms = defaultPublishPlatforms();
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
clearData() {
|
clearData() {
|
||||||
|
|
||||||
this.resetAll();
|
this.resetAll();
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
export async function processDouyinShare(shareText) {
|
|
||||||
const result = await invoke("run_quickjs_script", {
|
|
||||||
scriptName: "douyin_pipeline.js",
|
|
||||||
params: {
|
/**
|
||||||
shareText: shareText ?? "",
|
* 一键仿写:Node.js douyin_pipeline(对齐 zhenqianba ipAgent:processDouyinShare)
|
||||||
modelConfig: {
|
* @param {ReturnType<typeof useWorkflowStore>} store
|
||||||
providerId: "openai",
|
*/
|
||||||
modelId: "gpt-4o-mini",
|
|
||||||
apiUrl: "https://api.openai.com", // 或 dashscope/openrouter 等任意兼容端点
|
|
||||||
apiKey: "sk-...",
|
|
||||||
asrMode: "online",
|
|
||||||
aliyunApiKey: "sk-dashscope-...",
|
|
||||||
ossConfig: {
|
|
||||||
accessKeyId: "...",
|
|
||||||
accessKeySecret: "...",
|
|
||||||
bucket: "my-bucket",
|
|
||||||
region: "oss-cn-hangzhou",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rewriteConfig: { style: "幽默口播", tone: "活泼" },
|
|
||||||
customPrompt: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
export async function executeVideoRewrite(store) {
|
export async function executeVideoRewrite(store) {
|
||||||
const text = store.videoShareText?.trim();
|
const parsed = validateShareText(store.videoShareText);
|
||||||
if (!text) {
|
if (!parsed.ok) {
|
||||||
return { ok: false, message: "请粘贴视频分享文本或视频链接" };
|
return { ok: false, message: parsed.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsupported = detectUnsupportedPlatform(text);
|
const unsupported = detectUnsupportedPlatform(parsed.value);
|
||||||
if (unsupported) {
|
if (unsupported) {
|
||||||
return { ok: false, message: unsupported };
|
return { ok: false, message: unsupported };
|
||||||
}
|
}
|
||||||
|
|
||||||
store.videoRewriting = true;
|
const cfgReady = await ensureAppConfigReady();
|
||||||
try {
|
if (!cfgReady.ok) {
|
||||||
// TODO: 对接 FUNASR 语音识别与仿写 API
|
return { ok: false, message: cfgReady.message };
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
}
|
||||||
|
|
||||||
|
store.videoRewriting = true;
|
||||||
|
store.rewriteProgress = "正在解析和仿写...";
|
||||||
|
|
||||||
|
let unlisten = null;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
unlisten = await listen(NODEJS_EVENT, (e) => {
|
||||||
|
const p = e.payload;
|
||||||
|
if (p?.type === "progress" && typeof p.status === "string") {
|
||||||
|
store.rewriteProgress = p.status;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* 非 Tauri 环境 */
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await invoke("run_nodejs_script", {
|
||||||
|
scriptName: PIPELINE_SCRIPT,
|
||||||
|
params: {
|
||||||
|
shareText: parsed.value,
|
||||||
|
rewriteConfig: { ...DEFAULT_REWRITE_CONFIG },
|
||||||
|
customPrompt: store.rewritePrompt?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || result.success !== true) {
|
||||||
|
const err =
|
||||||
|
(result && (result.error || result.message)) ||
|
||||||
|
"解析或仿写失败,请检查链接与模型配置";
|
||||||
|
return { ok: false, message: String(err) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = result.original || {};
|
||||||
|
const rewritten = result.rewritten || {};
|
||||||
|
|
||||||
|
store.recognizedContent = original.description || "";
|
||||||
|
store.scriptContent =
|
||||||
|
rewritten.fullContent || rewritten.description || "";
|
||||||
|
if (rewritten.title) {
|
||||||
|
store.titleGenerated = rewritten.title;
|
||||||
|
}
|
||||||
|
if (original.hashtags?.length) {
|
||||||
|
store.tagsGenerated = original.hashtags.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
store.recognizedContent =
|
|
||||||
"【示例识别结果】这里是 FUNASR 语音识别后的原始文案内容,待对接后端后将显示真实结果。\n\n" +
|
|
||||||
`来源:${truncateUrl(text, 48)}`;
|
|
||||||
store.videoShareText = "";
|
store.videoShareText = "";
|
||||||
store.videoLinkModalVisible = false;
|
store.videoLinkModalVisible = false;
|
||||||
return { ok: true, message: "仿写完成" };
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "仿写完成,已为您生成新文案",
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||||
|
return { ok: false, message: `仿写失败: ${msg}` };
|
||||||
} finally {
|
} finally {
|
||||||
|
if (typeof unlisten === "function") {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
store.videoRewriting = false;
|
store.videoRewriting = false;
|
||||||
|
store.rewriteProgress = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const UNSUPPORTED_PLATFORM_RULES = [
|
const UNSUPPORTED_PLATFORM_RULES = [
|
||||||
|
|
||||||
{ pattern: /kuaishou\.com|ksurl\.cn|gifshow\.com/i, name: "快手" },
|
{ pattern: /kuaishou\.com|ksurl\.cn|gifshow\.com/i, name: "快手" },
|
||||||
|
|
||||||
{ pattern: /xiaohongshu\.com|xhslink\.com/i, name: "小红书" },
|
{ pattern: /xiaohongshu\.com|xhslink\.com/i, name: "小红书" },
|
||||||
|
|
||||||
{ pattern: /bilibili\.com|b23\.tv/i, name: "B站" },
|
{ pattern: /bilibili\.com|b23\.tv/i, name: "B站" },
|
||||||
|
|
||||||
{ pattern: /channels\.weixin\.qq\.com|finder\.video\.qq\.com/i, name: "视频号" },
|
{ pattern: /channels\.weixin\.qq\.com|finder\.video\.qq\.com/i, name: "视频号" },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function detectUnsupportedPlatform(text) {
|
function detectUnsupportedPlatform(text) {
|
||||||
|
|
||||||
for (const { pattern, name } of UNSUPPORTED_PLATFORM_RULES) {
|
for (const { pattern, name } of UNSUPPORTED_PLATFORM_RULES) {
|
||||||
|
|
||||||
if (pattern.test(text)) {
|
if (pattern.test(text)) {
|
||||||
|
|
||||||
return `${name}分享链接暂不支持,请使用抖音分享文本或视频链接`;
|
return `${name}分享链接暂不支持,请使用抖音分享文本或视频链接`;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function truncateUrl(url, max = 36) {
|
function truncateUrl(url, max = 36) {
|
||||||
|
|
||||||
if (url.length <= max) return url;
|
if (url.length <= max) return url;
|
||||||
|
|
||||||
return `${url.slice(0, max)}…`;
|
return `${url.slice(0, max)}…`;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function countLines(text) {
|
function countLines(text) {
|
||||||
|
|
||||||
if (!text?.trim()) return 0;
|
if (!text?.trim()) return 0;
|
||||||
|
|
||||||
return text.split("\n").filter((line) => line.trim()).length;
|
return text.split("\n").filter((line) => line.trim()).length;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
|
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useWorkflowStore, import.meta.hot));
|
import.meta.hot.accept(acceptHMRUpdate(useWorkflowStore, import.meta.hot));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,126 @@ body {
|
|||||||
var(--color-bg-base);
|
var(--color-bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 管理后台嵌套布局 */
|
||||||
|
.admin-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sub-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 200px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
gap: 4px;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: rgba(10, 12, 20, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sub-nav__title {
|
||||||
|
margin: 0 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sub-nav__item {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sub-nav__item:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sub-nav__item--active {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
box-shadow: inset 2px 0 0 #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page__title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users__message {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users__table {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-keys__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-keys__status-select {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-keys__serial {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-keys__serial code {
|
||||||
|
color: #e2e8f0;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-desktop-config__search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-desktop-config__value {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
/* 首页工作台 */
|
/* 首页工作台 */
|
||||||
.custom-scrollbar {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
12
src/views/AdminView.vue
Normal file
12
src/views/AdminView.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup>
|
||||||
|
import AdminSubNav from "../components/admin/AdminSubNav.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<AdminSubNav />
|
||||||
|
<div class="admin-layout__content custom-scrollbar">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
src/views/AgentView.vue
Normal file
14
src/views/AgentView.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col items-center justify-center gap-3 p-8">
|
||||||
|
<h1 class="gradient-text text-xl font-semibold">代理工作台</h1>
|
||||||
|
<p class="text-sm text-text-muted">
|
||||||
|
当前用户:{{ auth.currentUser?.username }}(代理)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
369
src/views/admin/AdminCardKeysView.vue
Normal file
369
src/views/admin/AdminCardKeysView.vue
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useAuthStore } from "../../stores/auth.js";
|
||||||
|
import {
|
||||||
|
listAdminCardKeysApi,
|
||||||
|
createAdminCardKeysApi,
|
||||||
|
updateAdminCardKeyApi,
|
||||||
|
deleteAdminCardKeyApi,
|
||||||
|
} from "../../api/adminCardKeys.js";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
{ label: "未使用", value: "unused" },
|
||||||
|
{ label: "已激活", value: "used" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cards = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const statusFilter = ref("all");
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const dialogMode = ref("create");
|
||||||
|
const editingId = ref(null);
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
serial_number: "",
|
||||||
|
duration_days: 30,
|
||||||
|
count: 1,
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(emptyForm());
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
dialogMode.value === "create" ? "生成卡密" : "编辑卡密",
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||||
|
const editingActivatedAt = ref(null);
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCards() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await listAdminCardKeysApi(auth.token, {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
});
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "加载卡密列表失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = res.data;
|
||||||
|
cards.value = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||||
|
if (typeof data?.page === "number") page.value = data.page;
|
||||||
|
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPage(event) {
|
||||||
|
page.value = event.page + 1;
|
||||||
|
pageSize.value = event.rows;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange() {
|
||||||
|
page.value = 1;
|
||||||
|
loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = "create";
|
||||||
|
editingId.value = null;
|
||||||
|
editingActivatedAt.value = null;
|
||||||
|
form.value = emptyForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row) {
|
||||||
|
dialogMode.value = "edit";
|
||||||
|
editingId.value = row.id;
|
||||||
|
editingActivatedAt.value = row.activated_at;
|
||||||
|
form.value = {
|
||||||
|
serial_number: row.serial_number,
|
||||||
|
duration_days: row.duration_days,
|
||||||
|
count: 1,
|
||||||
|
remark: row.remark || "",
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySerial(serial) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(serial);
|
||||||
|
showMessage("success", "序列号已复制");
|
||||||
|
} catch {
|
||||||
|
showMessage("warn", "复制失败,请手动复制");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCard() {
|
||||||
|
if (form.value.duration_days < 1) {
|
||||||
|
showMessage("warn", "时长至少 1 天");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (dialogMode.value === "create") {
|
||||||
|
const payload = {
|
||||||
|
duration_days: form.value.duration_days,
|
||||||
|
count: form.value.count,
|
||||||
|
remark: form.value.remark.trim() || null,
|
||||||
|
};
|
||||||
|
const serial = form.value.serial_number.trim();
|
||||||
|
if (serial) {
|
||||||
|
if (form.value.count !== 1) {
|
||||||
|
saving.value = false;
|
||||||
|
showMessage("warn", "指定序列号时数量只能为 1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.serial_number = serial.toUpperCase();
|
||||||
|
}
|
||||||
|
res = await createAdminCardKeysApi(auth.token, payload);
|
||||||
|
} else {
|
||||||
|
res = await updateAdminCardKeyApi(auth.token, editingId.value, {
|
||||||
|
duration_days: isActivated.value ? undefined : form.value.duration_days,
|
||||||
|
remark: form.value.remark.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "保存失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = false;
|
||||||
|
showMessage("success", res.message || "保存成功");
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCard(row) {
|
||||||
|
if (row.activated_at) {
|
||||||
|
showMessage("warn", "已激活卡密不能删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(`确定删除卡密「${row.serial_number}」?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const res = await deleteAdminCardKeyApi(auth.token, row.id);
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "删除失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("success", res.message || "已删除");
|
||||||
|
if (cards.value.length === 1 && page.value > 1) {
|
||||||
|
page.value -= 1;
|
||||||
|
}
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, onStatusChange);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCards();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page admin-card-keys">
|
||||||
|
<div class="admin-users__header">
|
||||||
|
<div>
|
||||||
|
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
||||||
|
<p class="text-sm text-text-muted">生成、查看与管理 VIP 激活卡密</p>
|
||||||
|
</div>
|
||||||
|
<Button label="生成卡密" @click="openCreateDialog" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="feedback.message"
|
||||||
|
:severity="feedback.severity"
|
||||||
|
:closable="true"
|
||||||
|
class="admin-users__message"
|
||||||
|
@close="feedback.message = ''"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div class="admin-card-keys__toolbar">
|
||||||
|
<label class="text-sm text-text-muted">状态筛选</label>
|
||||||
|
<Select
|
||||||
|
v-model="statusFilter"
|
||||||
|
:options="STATUS_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="admin-card-keys__status-select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="cards"
|
||||||
|
:loading="loading"
|
||||||
|
lazy
|
||||||
|
paginator
|
||||||
|
:rows="pageSize"
|
||||||
|
:total-records="total"
|
||||||
|
:first="(page - 1) * pageSize"
|
||||||
|
:rows-per-page-options="[10, 20, 50]"
|
||||||
|
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||||
|
current-page-report-template="共 {totalRecords} 条"
|
||||||
|
striped-rows
|
||||||
|
size="small"
|
||||||
|
class="admin-users__table"
|
||||||
|
data-key="id"
|
||||||
|
@page="onPage"
|
||||||
|
>
|
||||||
|
<Column field="serial_number" header="序列号" style="min-width: 11rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-card-keys__serial">
|
||||||
|
<code class="text-xs">{{ data.serial_number }}</code>
|
||||||
|
<Button
|
||||||
|
label="复制"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="copySerial(data.serial_number)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
||||||
|
<Column field="created_at" header="创建时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDateTime(data.created_at) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="activated_at" header="激活时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.activated_at"
|
||||||
|
:value="formatDateTime(data.activated_at)"
|
||||||
|
severity="success"
|
||||||
|
/>
|
||||||
|
<Tag v-else value="未使用" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="username" header="使用者">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.username || "—" }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="remark" header="备注">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.remark || "—" }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="操作" style="width: 9rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-users__actions">
|
||||||
|
<Button
|
||||||
|
label="编辑"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="openEditDialog(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="删除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
:disabled="!!data.activated_at"
|
||||||
|
@click="removeCard(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<template #empty>
|
||||||
|
<p class="py-6 text-center text-sm text-text-muted">暂无卡密</p>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:header="dialogTitle"
|
||||||
|
modal
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<template v-if="dialogMode === 'create'">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||||
|
<InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">生成数量</label>
|
||||||
|
<InputNumber v-model="form.count" :min="1" :max="100" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">自定义序列号(可选,仅 1 张)</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.serial_number"
|
||||||
|
class="w-full font-mono uppercase"
|
||||||
|
placeholder="留空则自动生成"
|
||||||
|
:disabled="form.count > 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">序列号</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="form.serial_number"
|
||||||
|
class="w-full font-mono"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.duration_days"
|
||||||
|
:min="1"
|
||||||
|
:max="3650"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="isActivated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">备注</label>
|
||||||
|
<Textarea v-model="form.remark" rows="2" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||||
|
<Button label="保存" :loading="saving" @click="saveCard" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
325
src/views/admin/AdminDesktopConfigView.vue
Normal file
325
src/views/admin/AdminDesktopConfigView.vue
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useAuthStore } from "../../stores/auth.js";
|
||||||
|
import {
|
||||||
|
listAdminDesktopConfigsApi,
|
||||||
|
createAdminDesktopConfigApi,
|
||||||
|
updateAdminDesktopConfigApi,
|
||||||
|
deleteAdminDesktopConfigApi,
|
||||||
|
} from "../../api/adminDesktopConfigs.js";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const configs = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const keyword = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const dialogMode = ref("create");
|
||||||
|
const editingId = ref(null);
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
mark: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(emptyForm());
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
dialogMode.value === "create" ? "新建配置" : "编辑配置",
|
||||||
|
);
|
||||||
|
|
||||||
|
const valuePreview = computed(() => {
|
||||||
|
const v = form.value.value || "";
|
||||||
|
if (v.length <= 120) return v;
|
||||||
|
return `${v.slice(0, 120)}…`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateValue(value, max = 48) {
|
||||||
|
if (!value) return "—";
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return `${value.slice(0, max)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigs() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await listAdminDesktopConfigsApi(auth.token, {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
keyword: keyword.value,
|
||||||
|
});
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "加载配置失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = res.data;
|
||||||
|
configs.value = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||||
|
if (typeof data?.page === "number") page.value = data.page;
|
||||||
|
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPage(event) {
|
||||||
|
page.value = event.page + 1;
|
||||||
|
pageSize.value = event.rows;
|
||||||
|
await loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
page.value = 1;
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = "create";
|
||||||
|
editingId.value = null;
|
||||||
|
form.value = emptyForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row) {
|
||||||
|
dialogMode.value = "edit";
|
||||||
|
editingId.value = row.id;
|
||||||
|
form.value = {
|
||||||
|
name: row.name,
|
||||||
|
value: row.value,
|
||||||
|
mark: row.mark || "",
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyName(name) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(name);
|
||||||
|
showMessage("success", "键名已复制");
|
||||||
|
} catch {
|
||||||
|
showMessage("warn", "复制失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const name = form.value.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
showMessage("warn", "配置键名不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
value: form.value.value,
|
||||||
|
mark: form.value.mark.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (dialogMode.value === "create") {
|
||||||
|
res = await createAdminDesktopConfigApi(auth.token, payload);
|
||||||
|
} else {
|
||||||
|
res = await updateAdminDesktopConfigApi(auth.token, editingId.value, {
|
||||||
|
value: payload.value,
|
||||||
|
mark: payload.mark,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "保存失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = false;
|
||||||
|
showMessage("success", res.message || "保存成功");
|
||||||
|
await loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeConfig(row) {
|
||||||
|
const ok = window.confirm(`确定删除配置「${row.name}」?桌面端需重新登录后生效。`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const res = await deleteAdminDesktopConfigApi(auth.token, row.id);
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "删除失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("success", res.message || "已删除");
|
||||||
|
if (configs.value.length === 1 && page.value > 1) {
|
||||||
|
page.value -= 1;
|
||||||
|
}
|
||||||
|
await loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfigs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page admin-desktop-config">
|
||||||
|
<div class="admin-users__header">
|
||||||
|
<div>
|
||||||
|
<h1 class="admin-page__title gradient-text">桌面配置</h1>
|
||||||
|
<p class="text-sm text-text-muted">
|
||||||
|
管理 desktop_configs 键值,下发至桌面端 Node 环境变量(AICLIENT_CFG_*)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button label="新建配置" @click="openCreateDialog" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="feedback.message"
|
||||||
|
:severity="feedback.severity"
|
||||||
|
:closable="true"
|
||||||
|
class="admin-users__message"
|
||||||
|
@close="feedback.message = ''"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div class="admin-card-keys__toolbar">
|
||||||
|
<InputText
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="按键名搜索,如 LLM_API_KEY"
|
||||||
|
class="admin-desktop-config__search"
|
||||||
|
@keyup.enter="onSearch"
|
||||||
|
/>
|
||||||
|
<Button label="搜索" severity="secondary" @click="onSearch" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="configs"
|
||||||
|
:loading="loading"
|
||||||
|
lazy
|
||||||
|
paginator
|
||||||
|
:rows="pageSize"
|
||||||
|
:total-records="total"
|
||||||
|
:first="(page - 1) * pageSize"
|
||||||
|
:rows-per-page-options="[10, 20, 50]"
|
||||||
|
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||||
|
current-page-report-template="共 {totalRecords} 条"
|
||||||
|
striped-rows
|
||||||
|
size="small"
|
||||||
|
class="admin-users__table"
|
||||||
|
data-key="id"
|
||||||
|
@page="onPage"
|
||||||
|
>
|
||||||
|
<Column field="name" header="键名" style="min-width: 10rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-card-keys__serial">
|
||||||
|
<code class="text-xs">{{ data.name }}</code>
|
||||||
|
<Button label="复制" size="small" severity="secondary" text @click="copyName(data.name)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="value" header="值">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="admin-desktop-config__value" :title="data.value">
|
||||||
|
{{ truncateValue(data.value) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="mark" header="备注">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.mark || "—" }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="updated_at" header="更新时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDateTime(data.updated_at) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="操作" style="width: 9rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-users__actions">
|
||||||
|
<Button
|
||||||
|
label="编辑"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="openEditDialog(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="删除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
@click="removeConfig(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<template #empty>
|
||||||
|
<p class="py-6 text-center text-sm text-text-muted">暂无配置</p>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:header="dialogTitle"
|
||||||
|
modal
|
||||||
|
:style="{ width: '32rem' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">键名 (name)</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.name"
|
||||||
|
class="w-full font-mono uppercase"
|
||||||
|
placeholder="如 LLM_API_KEY"
|
||||||
|
:disabled="dialogMode === 'edit'"
|
||||||
|
/>
|
||||||
|
<p v-if="dialogMode === 'edit'" class="text-xs text-text-muted">
|
||||||
|
键名创建后不可修改,避免环境变量映射错乱
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">值 (value)</label>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.value"
|
||||||
|
rows="6"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
placeholder="支持长文本或 JSON,如 OSS_CONFIG"
|
||||||
|
/>
|
||||||
|
<p v-if="valuePreview && form.value.length > 120" class="text-xs text-text-muted">
|
||||||
|
预览:{{ valuePreview }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">备注 (mark)</label>
|
||||||
|
<InputText v-model="form.mark" class="w-full" placeholder="可选说明" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||||
|
<Button label="保存" :loading="saving" @click="saveConfig" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
src/views/admin/AdminOverviewView.vue
Normal file
14
src/views/admin/AdminOverviewView.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from "../../stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page">
|
||||||
|
<h1 class="admin-page__title gradient-text">概览</h1>
|
||||||
|
<p class="text-sm text-text-muted">
|
||||||
|
欢迎,{{ auth.currentUser?.username }}。在此查看系统运行概况。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
364
src/views/admin/AdminUsersView.vue
Normal file
364
src/views/admin/AdminUsersView.vue
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
|
||||||
|
import {
|
||||||
|
listAdminUsersApi,
|
||||||
|
createAdminUserApi,
|
||||||
|
updateAdminUserApi,
|
||||||
|
deleteAdminUserApi,
|
||||||
|
} from "../../api/adminUsers.js";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ label: "普通用户", value: 0 },
|
||||||
|
{ label: "管理员", value: 1 },
|
||||||
|
{ label: "代理", value: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLE_LABELS = {
|
||||||
|
0: "普通用户",
|
||||||
|
[ROLE_ADMIN]: "管理员",
|
||||||
|
[ROLE_AGENT]: "代理",
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const dialogMode = ref("create");
|
||||||
|
const editingId = ref(null);
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
phone: "",
|
||||||
|
role_id: 0,
|
||||||
|
vip_end_time: "",
|
||||||
|
clear_vip_end_time: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(emptyForm());
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
dialogMode.value === "create" ? "新建用户" : "编辑用户",
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(roleId) {
|
||||||
|
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleSeverity(roleId) {
|
||||||
|
if (roleId === ROLE_ADMIN) return "info";
|
||||||
|
if (roleId === ROLE_AGENT) return "warn";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await listAdminUsersApi(auth.token, {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
});
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "加载用户列表失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = res.data;
|
||||||
|
users.value = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||||
|
if (typeof data?.page === "number") page.value = data.page;
|
||||||
|
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPage(event) {
|
||||||
|
page.value = event.page + 1;
|
||||||
|
pageSize.value = event.rows;
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = "create";
|
||||||
|
editingId.value = null;
|
||||||
|
form.value = emptyForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row) {
|
||||||
|
dialogMode.value = "edit";
|
||||||
|
editingId.value = row.id;
|
||||||
|
form.value = {
|
||||||
|
username: row.username,
|
||||||
|
password: "",
|
||||||
|
phone: row.phone || "",
|
||||||
|
role_id: row.role_id,
|
||||||
|
vip_end_time: toDatetimeLocalValue(row.vip_end_time),
|
||||||
|
clear_vip_end_time: false,
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
const payload = {
|
||||||
|
username: form.value.username.trim(),
|
||||||
|
phone: form.value.phone.trim() || null,
|
||||||
|
role_id: form.value.role_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.value.password.trim()) {
|
||||||
|
payload.password = form.value.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.clear_vip_end_time) {
|
||||||
|
payload.clear_vip_end_time = true;
|
||||||
|
} else if (form.value.vip_end_time) {
|
||||||
|
payload.vip_end_time = new Date(form.value.vip_end_time).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
if (!form.value.username.trim()) {
|
||||||
|
showMessage("warn", "用户名不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dialogMode.value === "create" && form.value.password.length < 6) {
|
||||||
|
showMessage("warn", "密码至少 6 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
|
||||||
|
const payload = buildPayload();
|
||||||
|
let res;
|
||||||
|
|
||||||
|
if (dialogMode.value === "create") {
|
||||||
|
if (!payload.password) {
|
||||||
|
saving.value = false;
|
||||||
|
showMessage("warn", "请设置初始密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res = await createAdminUserApi(auth.token, payload);
|
||||||
|
} else {
|
||||||
|
res = await updateAdminUserApi(auth.token, editingId.value, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "保存失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = false;
|
||||||
|
showMessage("success", res.message || "保存成功");
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(row) {
|
||||||
|
if (row.id === auth.currentUser?.id) {
|
||||||
|
showMessage("warn", "不能删除当前登录账号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const res = await deleteAdminUserApi(auth.token, row.id);
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "删除失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("success", res.message || "已删除");
|
||||||
|
if (users.value.length === 1 && page.value > 1) {
|
||||||
|
page.value -= 1;
|
||||||
|
}
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page admin-users">
|
||||||
|
<div class="admin-users__header">
|
||||||
|
<div>
|
||||||
|
<h1 class="admin-page__title gradient-text">用户管理</h1>
|
||||||
|
<p class="text-sm text-text-muted">创建、编辑与删除系统用户,分配角色与 VIP 到期时间</p>
|
||||||
|
</div>
|
||||||
|
<Button label="新建用户" @click="openCreateDialog" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="feedback.message"
|
||||||
|
:severity="feedback.severity"
|
||||||
|
:closable="true"
|
||||||
|
class="admin-users__message"
|
||||||
|
@close="feedback.message = ''"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="users"
|
||||||
|
:loading="loading"
|
||||||
|
lazy
|
||||||
|
paginator
|
||||||
|
:rows="pageSize"
|
||||||
|
:total-records="total"
|
||||||
|
:first="(page - 1) * pageSize"
|
||||||
|
:rows-per-page-options="[10, 20, 50]"
|
||||||
|
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||||
|
current-page-report-template="共 {totalRecords} 条"
|
||||||
|
striped-rows
|
||||||
|
size="small"
|
||||||
|
class="admin-users__table"
|
||||||
|
data-key="id"
|
||||||
|
@page="onPage"
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" style="width: 4rem" />
|
||||||
|
<Column field="username" header="用户名" />
|
||||||
|
<Column field="phone" header="手机号">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.phone || "—" }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="role_id" header="角色" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="vip_end_time" header="VIP 到期">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDateTime(data.vip_end_time) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="created_at" header="注册时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDateTime(data.created_at) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="操作" style="width: 9rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-users__actions">
|
||||||
|
<Button
|
||||||
|
label="编辑"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="openEditDialog(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="删除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
:disabled="data.id === auth.currentUser?.id"
|
||||||
|
@click="removeUser(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<template #empty>
|
||||||
|
<p class="py-6 text-center text-sm text-text-muted">暂无用户</p>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:header="dialogTitle"
|
||||||
|
modal
|
||||||
|
class="admin-users-dialog"
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">用户名</label>
|
||||||
|
<InputText v-model="form.username" class="w-full" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">
|
||||||
|
{{ dialogMode === "create" ? "密码" : "新密码(留空不修改)" }}
|
||||||
|
</label>
|
||||||
|
<Password
|
||||||
|
v-model="form.password"
|
||||||
|
:feedback="false"
|
||||||
|
toggle-mask
|
||||||
|
fluid
|
||||||
|
input-class="w-full"
|
||||||
|
:placeholder="dialogMode === 'create' ? '至少 6 位' : '留空则不修改'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">手机号(可选)</label>
|
||||||
|
<InputText v-model="form.phone" class="w-full" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">角色</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.role_id"
|
||||||
|
:options="ROLE_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">VIP 到期时间</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.vip_end_time"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="form.clear_vip_end_time"
|
||||||
|
/>
|
||||||
|
<label v-if="dialogMode === 'edit'" class="flex items-center gap-2 text-sm text-text-muted">
|
||||||
|
<Checkbox v-model="form.clear_vip_end_time" :binary="true" input-id="clear-vip" />
|
||||||
|
<span>清除 VIP 到期时间</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||||
|
<Button label="保存" :loading="saving" @click="saveUser" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user