代理模块修改
This commit is contained in:
@@ -320,3 +320,40 @@ appStore.toggleModule(moduleId, enabled)
|
|||||||
4. **避免循环依赖**:模块的 `index.ts` 只导出配置,不导入其他模块的 store
|
4. **避免循环依赖**:模块的 `index.ts` 只导出配置,不导入其他模块的 store
|
||||||
5. **进程配置的 executable**:通常留空,由模块组件在运行时根据用户设置确定实际路径
|
5. **进程配置的 executable**:通常留空,由模块组件在运行时根据用户设置确定实际路径
|
||||||
6. **内置模块**:设置 `builtin: true` 的模块不可被用户禁用,开关处于禁用状态
|
6. **内置模块**:设置 `builtin: true` 的模块不可被用户禁用,开关处于禁用状态
|
||||||
|
|
||||||
|
## 跨模块开发经验(代理模块沉淀)
|
||||||
|
|
||||||
|
以下要点来自代理模块开发,对后续涉及子进程管理、外部 API 交互、shadcn-vue 组件使用的模块同样适用。
|
||||||
|
|
||||||
|
### Tauri 命令与主线程
|
||||||
|
|
||||||
|
- **同步命令(`pub fn`)会阻塞主线程**:Tauri 的同步命令在主线程执行,其内部的 `std::thread::sleep`、磁盘 I/O、网络请求会阻塞所有 async 命令的调度。涉及等待/阻塞操作的命令必须声明为 `pub async fn`,并用 `tauri::async_runtime::spawn_blocking(|| { std::thread::sleep(...) }).await` 将阻塞操作放到线程池。
|
||||||
|
- **Windows 端口释放有延迟**:`child.kill()` + `child.wait()` 后 TCP 端口不会立即可用,需等待约 800ms 再重新绑定。重启类命令应预留此延迟。
|
||||||
|
- **`Mutex` 持锁期间禁止 sleep**:`ProcessManager::check_and_cleanup` 等持锁函数中不要执行长时间 sleep,否则会阻塞所有需要该锁的命令(如状态查询)。应先释放锁再 sleep,或移出临界区。
|
||||||
|
- **进程监控线程**:`start_monitoring_thread` 每 3 秒检查一次进程状态,崩溃时自动重启(可配置 `maxRestarts`)。前端通过监听 `process-status-changed` 事件更新 UI。
|
||||||
|
|
||||||
|
### 外部 API 交互
|
||||||
|
|
||||||
|
- **API 就绪轮询**:子进程 spawn 后 API 不会立即可用(需初始化配置、加载 geo 文件等)。前端应在请求前轮询健康检查接口(如 `/version`),500ms 间隔、10s 超时。
|
||||||
|
- **缓存配置避免频繁读盘**:后端 Manager 每次方法调用都从磁盘读 settings 会拖慢批量操作。建议在 Manager 内维护内存缓存,`save_settings` 时同步更新。
|
||||||
|
- **并发请求限流**:批量测速等场景不要一次性 `Promise.all` 全部请求,应分批(如每批 20 个),避免压垮子进程。
|
||||||
|
|
||||||
|
### shadcn-vue / reka-ui 注意事项
|
||||||
|
|
||||||
|
- **Select 禁止空字符串 value**:`<SelectItem value="">` 会触发警告并失效。使用哨兵值(如 `__default__`、`__all__`)替代空字符串,在 `@update:model-value` 回调中转回空值。
|
||||||
|
- **Select 双击问题**:reka-ui Select 的 DismissableLayer + closeAutoFocus 会导致连续点击两个 Select 时第一次点击仅关闭上一个、需第二次点击才打开下一个。当前未完美解决,建议同一界面避免放置过多相邻 Select。
|
||||||
|
- **Switch 使用 `model-value` / `update:model-value`**:reka-ui v2+ 的 Switch 不再使用 `checked` / `update:checked`。
|
||||||
|
- **Sonner toast 不可见**:通常是缺少 `vue-sonner/lib/index.css` 导入,而非 z-index 问题。确保在入口处导入该 CSS。
|
||||||
|
- **AlertDialog 替代 confirm()**:原生 `confirm()` 在 Tauri WebView 中样式不一致,使用 shadcn-vue AlertDialog 封装 Promise 化的 `showConfirm()` 函数,支持 destructive 样式。
|
||||||
|
|
||||||
|
### 数据一致性与自愈
|
||||||
|
|
||||||
|
- **配置文件与磁盘 reconcile**:settings.json 中的列表(如订阅 profiles)可能与磁盘文件不同步(用户手动删除、反序列化失败被 default 覆盖)。每次 `load_settings` 时应扫描磁盘补全缺失条目,并在 `currentProfile` 为 null 时自动指向第一个。
|
||||||
|
- **操作顺序防重复**:导入文件后写入 settings 时,若先写文件再 load_settings(含 reconcile 扫盘),reconcile 会扫到新文件添加一次,随后 push 又添加一次。应先 load_settings → 写文件 → push(加去重保护)。
|
||||||
|
- **子进程崩溃循环防护**:子进程因依赖文件损坏(如 geo 数据库)崩溃时,`restart_on_crash` 会反复重启。需配置备用下载源(如 jsdelivr 镜像)避免无代理时 GitHub 超时。
|
||||||
|
|
||||||
|
### UI 细节
|
||||||
|
|
||||||
|
- **瀑布流布局避免卡片等高撑开**:使用 `columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid` 替代 `grid`,让卡片按内容高度自然排列。
|
||||||
|
- **模块禁用清理系统状态**:模块 `onDisable` 钩子应清理系统级副作用(如系统代理注册表项),避免模块停用后遗留导致系统异常。
|
||||||
|
- **应用退出清理**:在 `lib.rs` 的 `quit_app` 命令和托盘退出事件中都要调用 `cleanup_on_exit` + `stop_all`,确保任何退出路径都清理干净。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
|
"opener:allow-reveal-item-in-dir",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
|||||||
+24
-6
@@ -8,11 +8,11 @@ use logger::{
|
|||||||
clear_logs, get_log_info, get_logs, log_message, LogManager,
|
clear_logs, get_log_info, get_logs, log_message, LogManager,
|
||||||
};
|
};
|
||||||
use mihomo_manager::{
|
use mihomo_manager::{
|
||||||
proxy_activate_profile, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile,
|
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile,
|
||||||
proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
||||||
proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
|
proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
|
||||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
||||||
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
|
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager,
|
||||||
};
|
};
|
||||||
use process_manager::{
|
use process_manager::{
|
||||||
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
|
||||||
@@ -25,8 +25,13 @@ fn greet(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn quit_app(state: tauri::State<'_, ProcessManager>) {
|
fn quit_app(
|
||||||
// 退出前停止所有子进程
|
state: tauri::State<'_, ProcessManager>,
|
||||||
|
mihomo: tauri::State<'_, MihomoManager>,
|
||||||
|
) {
|
||||||
|
// 退出前清理系统代理,避免遗留导致网络问题
|
||||||
|
mihomo.cleanup_on_exit();
|
||||||
|
// 停止所有子进程
|
||||||
state.stop_all();
|
state.stop_all();
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
@@ -52,6 +57,8 @@ pub fn run() {
|
|||||||
proxy_get_settings,
|
proxy_get_settings,
|
||||||
proxy_save_settings,
|
proxy_save_settings,
|
||||||
proxy_kernel_info,
|
proxy_kernel_info,
|
||||||
|
proxy_check_kernel_update,
|
||||||
|
proxy_update_kernel,
|
||||||
proxy_status,
|
proxy_status,
|
||||||
proxy_start,
|
proxy_start,
|
||||||
proxy_stop,
|
proxy_stop,
|
||||||
@@ -85,7 +92,8 @@ pub fn run() {
|
|||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from("."));
|
.unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
app.manage(MihomoManager::new(app_data_dir));
|
let mihomo = MihomoManager::new(app_data_dir);
|
||||||
|
app.manage(mihomo);
|
||||||
|
|
||||||
let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?;
|
let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?;
|
||||||
let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||||
@@ -103,7 +111,10 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
// 退出前停止所有子进程
|
// 退出前清理系统代理 + 停止所有子进程
|
||||||
|
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||||
|
mihomo.cleanup_on_exit();
|
||||||
|
}
|
||||||
if let Some(pm) = app.try_state::<ProcessManager>() {
|
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||||
pm.stop_all();
|
pm.stop_all();
|
||||||
}
|
}
|
||||||
@@ -129,6 +140,13 @@ pub fn run() {
|
|||||||
// 启动进程监控线程
|
// 启动进程监控线程
|
||||||
start_monitoring_thread(app.handle().clone());
|
start_monitoring_thread(app.handle().clone());
|
||||||
|
|
||||||
|
// 应用启动时自动启动 mihomo(如果用户在设置中开启了自动启动)
|
||||||
|
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||||
|
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||||
|
mihomo.auto_start_on_launch(app.handle(), &pm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -14,18 +14,44 @@ use crate::process_manager::{ProcessInfo, ProcessManager, StartProcessParams};
|
|||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProxySettings {
|
pub struct ProxySettings {
|
||||||
|
#[serde(default = "default_mixed_port")]
|
||||||
pub mixed_port: u16,
|
pub mixed_port: u16,
|
||||||
|
#[serde(default = "default_external_controller")]
|
||||||
pub external_controller: String,
|
pub external_controller: String,
|
||||||
|
#[serde(default)]
|
||||||
pub secret: String,
|
pub secret: String,
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
pub log_level: String,
|
pub log_level: String,
|
||||||
|
#[serde(default)]
|
||||||
pub allow_lan: bool,
|
pub allow_lan: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub system_proxy: bool,
|
pub system_proxy: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub auto_start: bool,
|
pub auto_start: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_system_proxy: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub current_profile: Option<String>,
|
pub current_profile: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub profiles: Vec<ProfileMeta>,
|
pub profiles: Vec<ProfileMeta>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_switch_enabled: bool,
|
||||||
|
#[serde(default = "default_auto_switch_interval")]
|
||||||
|
pub auto_switch_interval: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_switch_group: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_switch_region: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_mixed_port() -> u16 { 7890 }
|
||||||
|
fn default_external_controller() -> String { "127.0.0.1:9090".into() }
|
||||||
|
fn default_mode() -> String { "rule".into() }
|
||||||
|
fn default_log_level() -> String { "info".into() }
|
||||||
|
fn default_auto_switch_interval() -> u32 { 5 }
|
||||||
|
|
||||||
impl Default for ProxySettings {
|
impl Default for ProxySettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -37,8 +63,13 @@ impl Default for ProxySettings {
|
|||||||
allow_lan: false,
|
allow_lan: false,
|
||||||
system_proxy: false,
|
system_proxy: false,
|
||||||
auto_start: false,
|
auto_start: false,
|
||||||
|
auto_system_proxy: false,
|
||||||
current_profile: None,
|
current_profile: None,
|
||||||
profiles: Vec::new(),
|
profiles: Vec::new(),
|
||||||
|
auto_switch_enabled: false,
|
||||||
|
auto_switch_interval: 5,
|
||||||
|
auto_switch_group: String::new(),
|
||||||
|
auto_switch_region: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +77,17 @@ impl Default for ProxySettings {
|
|||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfileMeta {
|
pub struct ProfileMeta {
|
||||||
|
#[serde(default)]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
pub added_at: String,
|
pub added_at: String,
|
||||||
|
#[serde(default)]
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +99,15 @@ pub struct KernelInfo {
|
|||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KernelUpdateInfo {
|
||||||
|
pub current_version: Option<String>,
|
||||||
|
pub latest_version: String,
|
||||||
|
pub download_url: String,
|
||||||
|
pub has_update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProxyStatus {
|
pub struct ProxyStatus {
|
||||||
@@ -116,10 +162,68 @@ impl MihomoManager {
|
|||||||
|
|
||||||
// ---------- 设置 ----------
|
// ---------- 设置 ----------
|
||||||
pub fn load_settings(&self) -> ProxySettings {
|
pub fn load_settings(&self) -> ProxySettings {
|
||||||
fs::read_to_string(self.settings_path())
|
let mut settings = fs::read_to_string(self.settings_path())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str::<ProxySettings>(&s).ok())
|
.and_then(|s| serde_json::from_str::<ProxySettings>(&s).ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
// 恢复机制:扫描磁盘 profile 文件,补全 settings.profiles
|
||||||
|
// 防止 settings.json 损坏(如反序列化失败被 default 覆盖)导致订阅丢失
|
||||||
|
if self.reconcile_profiles(&mut settings) {
|
||||||
|
let _ = self.save_settings(&settings);
|
||||||
|
}
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描磁盘 profile 文件,补全 settings.profiles 中缺失的条目。
|
||||||
|
/// 返回 true 表示有变化需要保存。
|
||||||
|
fn reconcile_profiles(&self, settings: &mut ProxySettings) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let existing_ids: std::collections::HashSet<String> =
|
||||||
|
settings.profiles.iter().map(|p| p.id.clone()).collect();
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(self.profiles_dir()) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(id) = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if existing_ids.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
let updated_at = fs::metadata(&path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0))
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
settings.profiles.push(ProfileMeta {
|
||||||
|
added_at: updated_at.clone(),
|
||||||
|
id: id.clone(),
|
||||||
|
name: id,
|
||||||
|
url: String::new(),
|
||||||
|
updated_at,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 currentProfile 为 null 但有 profile,设置为第一个
|
||||||
|
if settings.current_profile.is_none() && !settings.profiles.is_empty() {
|
||||||
|
settings.current_profile = Some(settings.profiles[0].id.clone());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_settings(&self, settings: &ProxySettings) -> Result<(), String> {
|
pub fn save_settings(&self, settings: &ProxySettings) -> Result<(), String> {
|
||||||
@@ -165,6 +269,141 @@ impl MihomoManager {
|
|||||||
Ok(self.kernel_info())
|
Ok(self.kernel_info())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检查 GitHub 上的最新 mihomo 版本
|
||||||
|
pub async fn check_kernel_update(&self) -> Result<KernelUpdateInfo, String> {
|
||||||
|
let resp: serde_json::Value = self
|
||||||
|
.client
|
||||||
|
.get("https://api.github.com/repos/MetaCubeX/mihomo/releases/latest")
|
||||||
|
.header("User-Agent", "thing-app")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("请求 GitHub API 失败: {}", e))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("解析 GitHub 响应失败: {}", e))?;
|
||||||
|
|
||||||
|
let latest_version = resp
|
||||||
|
.get("tag_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 查找 windows amd64 zip 资产(非 compatible 版本)
|
||||||
|
let download_url = resp
|
||||||
|
.get("assets")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.and_then(|assets| {
|
||||||
|
assets.iter().find_map(|asset| {
|
||||||
|
let name = asset.get("name")?.as_str()?;
|
||||||
|
let url = asset.get("browser_download_url")?.as_str()?;
|
||||||
|
// 匹配 mihomo-windows-amd64-v*.zip,排除 compatible/arm64
|
||||||
|
if name.starts_with("mihomo-windows-amd64-")
|
||||||
|
&& name.ends_with(".zip")
|
||||||
|
&& !name.contains("compatible")
|
||||||
|
&& !name.contains("arm64")
|
||||||
|
{
|
||||||
|
Some(url.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok_or_else(|| "未找到适用的 Windows amd64 内核资产".to_string())?;
|
||||||
|
|
||||||
|
// 当前版本
|
||||||
|
let current = self.kernel_info().version;
|
||||||
|
let has_update = match ¤t {
|
||||||
|
Some(c) => {
|
||||||
|
// 简单比较:从当前版本字符串提取版本号
|
||||||
|
let cur_ver = c
|
||||||
|
.split_whitespace()
|
||||||
|
.find(|s| s.starts_with('v') && s.chars().filter(|c| *c == '.').count() >= 2)
|
||||||
|
.unwrap_or("");
|
||||||
|
cur_ver != latest_version && !latest_version.is_empty()
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(KernelUpdateInfo {
|
||||||
|
current_version: current,
|
||||||
|
latest_version,
|
||||||
|
download_url,
|
||||||
|
has_update,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载并安装内核更新
|
||||||
|
pub async fn update_kernel(&self) -> Result<KernelInfo, String> {
|
||||||
|
let info = self.check_kernel_update().await?;
|
||||||
|
let zip_path = self.cores_dir().join("mihomo-update.zip");
|
||||||
|
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
|
||||||
|
|
||||||
|
// 下载 zip
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&info.download_url)
|
||||||
|
.header("User-Agent", "thing-app")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("下载内核失败: {}", e))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("下载失败: HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("读取下载内容失败: {}", e))?;
|
||||||
|
fs::write(&zip_path, &bytes).map_err(|e| format!("保存 zip 失败: {}", e))?;
|
||||||
|
|
||||||
|
// 清理旧解压目录
|
||||||
|
if extract_dir.exists() {
|
||||||
|
fs::remove_dir_all(&extract_dir).ok();
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 用 PowerShell 解压
|
||||||
|
let output = std::process::Command::new("powershell")
|
||||||
|
.args([
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
&format!(
|
||||||
|
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
|
||||||
|
zip_path.to_string_lossy(),
|
||||||
|
extract_dir.to_string_lossy()
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("解压失败: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("解压失败: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找解压出的 mihomo.exe
|
||||||
|
let new_exe = extract_dir.join("mihomo.exe");
|
||||||
|
if !new_exe.exists() {
|
||||||
|
// 可能在不同子目录
|
||||||
|
return Err("解压后未找到 mihomo.exe".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份旧内核
|
||||||
|
let kernel = self.kernel_path();
|
||||||
|
if kernel.exists() {
|
||||||
|
let bak = self.cores_dir().join("mihomo.exe.bak");
|
||||||
|
fs::remove_file(&bak).ok();
|
||||||
|
fs::rename(&kernel, &bak).map_err(|e| format!("备份旧内核失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动新内核
|
||||||
|
fs::rename(&new_exe, &kernel).map_err(|e| format!("替换内核失败: {}", e))?;
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
fs::remove_file(&zip_path).ok();
|
||||||
|
fs::remove_dir_all(&extract_dir).ok();
|
||||||
|
|
||||||
|
Ok(self.kernel_info())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 配置生成 ----------
|
// ---------- 配置生成 ----------
|
||||||
/// 合并 profile + 控制器设置,生成运行时 config.yaml
|
/// 合并 profile + 控制器设置,生成运行时 config.yaml
|
||||||
pub fn generate_config(&self) -> Result<(), String> {
|
pub fn generate_config(&self) -> Result<(), String> {
|
||||||
@@ -200,6 +439,29 @@ impl MihomoManager {
|
|||||||
);
|
);
|
||||||
m.insert(YamlValue::String("allow-lan".into()), YamlValue::Bool(settings.allow_lan));
|
m.insert(YamlValue::String("allow-lan".into()), YamlValue::Bool(settings.allow_lan));
|
||||||
|
|
||||||
|
// 日志写入文件,便于排查问题
|
||||||
|
let log_file = self.logs_dir().join("mihomo.log");
|
||||||
|
m.insert(
|
||||||
|
YamlValue::String("log-file".into()),
|
||||||
|
YamlValue::String(log_file.to_string_lossy().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Geo 数据库下载源(使用 jsdelivr 国内可访问镜像,避免无代理时 GitHub 超时)
|
||||||
|
let mut geox = serde_yaml::Mapping::new();
|
||||||
|
geox.insert(
|
||||||
|
YamlValue::String("mmdb".into()),
|
||||||
|
YamlValue::String("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb".into()),
|
||||||
|
);
|
||||||
|
geox.insert(
|
||||||
|
YamlValue::String("geosite".into()),
|
||||||
|
YamlValue::String("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat".into()),
|
||||||
|
);
|
||||||
|
geox.insert(
|
||||||
|
YamlValue::String("asn".into()),
|
||||||
|
YamlValue::String("https://cdn.jsdelivr.net/gh/xishang0128/bdg@master/GeoLite2-ASN.mmdb".into()),
|
||||||
|
);
|
||||||
|
m.insert(YamlValue::String("geox-url".into()), YamlValue::Mapping(geox));
|
||||||
|
|
||||||
let yaml = serde_yaml::to_string(&value).map_err(|e| e.to_string())?;
|
let yaml = serde_yaml::to_string(&value).map_err(|e| e.to_string())?;
|
||||||
fs::write(self.config_path(), yaml).map_err(|e| e.to_string())?;
|
fs::write(self.config_path(), yaml).map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -231,8 +493,44 @@ impl MihomoManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
|
||||||
|
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
|
||||||
|
let settings = self.load_settings();
|
||||||
|
if !settings.auto_start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.prepare_for_start(app) {
|
||||||
|
Ok(params) => {
|
||||||
|
if let Err(e) = pm.start(params) {
|
||||||
|
eprintln!("[mihomo] 自动启动失败: {}", e);
|
||||||
|
} else if settings.auto_system_proxy {
|
||||||
|
// 启动成功后开启系统代理
|
||||||
|
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
||||||
|
let _ = set_system_proxy_windows(&addr);
|
||||||
|
let mut s = settings;
|
||||||
|
s.system_proxy = true;
|
||||||
|
let _ = self.save_settings(&s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[mihomo] 自动启动跳过: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用退出时清理:关闭系统代理
|
||||||
|
pub fn cleanup_on_exit(&self) {
|
||||||
|
let settings = self.load_settings();
|
||||||
|
if settings.system_proxy || settings.auto_system_proxy {
|
||||||
|
let _ = clear_system_proxy_windows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 订阅管理 ----------
|
// ---------- 订阅管理 ----------
|
||||||
pub async fn import_profile(&self, url: &str, name: &str) -> Result<ProfileMeta, String> {
|
pub async fn import_profile(&self, url: &str, name: &str) -> Result<ProfileMeta, String> {
|
||||||
|
// 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加)
|
||||||
|
let mut settings = self.load_settings();
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -259,8 +557,10 @@ impl MihomoManager {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
size: content.len() as u64,
|
size: content.len() as u64,
|
||||||
};
|
};
|
||||||
let mut settings = self.load_settings();
|
// 去重保护:避免 reconcile 已添加同 id(理论上不会,因为文件刚写入)
|
||||||
settings.profiles.push(meta.clone());
|
if !settings.profiles.iter().any(|p| p.id == id) {
|
||||||
|
settings.profiles.push(meta.clone());
|
||||||
|
}
|
||||||
if settings.current_profile.is_none() {
|
if settings.current_profile.is_none() {
|
||||||
settings.current_profile = Some(id);
|
settings.current_profile = Some(id);
|
||||||
}
|
}
|
||||||
@@ -540,6 +840,18 @@ pub fn proxy_kernel_info(
|
|||||||
state.prepare_kernel(&app)
|
state.prepare_kernel(&app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn proxy_check_kernel_update(
|
||||||
|
state: tauri::State<'_, MihomoManager>,
|
||||||
|
) -> Result<KernelUpdateInfo, String> {
|
||||||
|
state.check_kernel_update().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn proxy_update_kernel(state: tauri::State<'_, MihomoManager>) -> Result<KernelInfo, String> {
|
||||||
|
state.update_kernel().await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn proxy_status(pm: tauri::State<'_, ProcessManager>) -> ProxyStatus {
|
pub fn proxy_status(pm: tauri::State<'_, ProcessManager>) -> ProxyStatus {
|
||||||
match pm.get_status("proxy") {
|
match pm.get_status("proxy") {
|
||||||
@@ -572,12 +884,18 @@ pub fn proxy_stop(pm: tauri::State<'_, ProcessManager>) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn proxy_restart(
|
pub async fn proxy_restart(
|
||||||
state: tauri::State<'_, MihomoManager>,
|
state: tauri::State<'_, MihomoManager>,
|
||||||
pm: tauri::State<'_, ProcessManager>,
|
pm: tauri::State<'_, ProcessManager>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) -> Result<ProcessInfo, String> {
|
) -> Result<ProcessInfo, String> {
|
||||||
let _ = pm.stop("proxy");
|
let _ = pm.stop("proxy");
|
||||||
|
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟),在阻塞线程池中 sleep 避免阻塞主线程
|
||||||
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(800));
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("sleep 失败: {}", e))?;
|
||||||
let params = state.prepare_for_start(&app)?;
|
let params = state.prepare_for_start(&app)?;
|
||||||
pm.start(params)
|
pm.start(params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ impl ProcessManager {
|
|||||||
// 先终止旧进程
|
// 先终止旧进程
|
||||||
let _ = entry.child.kill();
|
let _ = entry.child.kill();
|
||||||
let _ = entry.child.wait();
|
let _ = entry.child.wait();
|
||||||
|
// 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟)
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(800));
|
||||||
|
|
||||||
// 重新启动
|
// 重新启动
|
||||||
let mut cmd = Command::new(&executable);
|
let mut cmd = Command::new(&executable);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"resources": ["binaries/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -108,6 +108,6 @@ onMounted(() => {
|
|||||||
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" />
|
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toaster position="bottom-right" rich-colors close-button />
|
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
|
||||||
|
import {
|
||||||
|
AccordionRoot,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AccordionRootProps>()
|
||||||
|
const emits = defineEmits<AccordionRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</AccordionRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AccordionContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AccordionContent } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionContent
|
||||||
|
data-slot="accordion-content"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
>
|
||||||
|
<div :class="cn('pt-0 pb-4', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AccordionItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AccordionItem, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionItem
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="accordion-item"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('border-b last:border-b-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</AccordionItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AccordionTriggerProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { ChevronDown } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AccordionHeader,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionHeader class="flex">
|
||||||
|
<AccordionTrigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<slot name="icon">
|
||||||
|
<ChevronDown
|
||||||
|
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</AccordionTrigger>
|
||||||
|
</AccordionHeader>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as Accordion } from "./Accordion.vue"
|
||||||
|
export { default as AccordionContent } from "./AccordionContent.vue"
|
||||||
|
export { default as AccordionItem } from "./AccordionItem.vue"
|
||||||
|
export { default as AccordionTrigger } from "./AccordionTrigger.vue"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||||
|
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogProps>()
|
||||||
|
const emits = defineEmits<AlertDialogEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogActionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogAction } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogAction>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogCancelProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogCancel } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogCancel
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<AlertDialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||||
|
/>
|
||||||
|
<AlertDialogContent
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
AlertDialogDescription,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogDescription
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { AlertDialogTitle } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTitle
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-lg font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||||
|
import { AlertDialogTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||||
|
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||||
|
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||||
|
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||||
|
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||||
|
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||||
|
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||||
|
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||||
|
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { AlertVariants } from "."
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { alertVariants } from "."
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
variant?: AlertVariants["variant"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
:class="cn(alertVariants({ variant }), props.class)"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
:class="cn('text-muted-foreground col-start-2 text-sm [&_p]:leading-relaxed', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Alert } from "./Alert.vue"
|
||||||
|
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||||
|
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||||
|
|
||||||
|
export const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { BadgeVariants } from "."
|
||||||
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { badgeVariants } from "."
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
variant?: BadgeVariants["variant"]
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: "span",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Badge } from "./Badge.vue"
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-1.5 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
destructive: "border-transparent bg-destructive text-white",
|
||||||
|
outline: "text-foreground border-border",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxRootEmits, ComboboxRootProps } from "reka-ui"
|
||||||
|
import { ComboboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxRootProps>()
|
||||||
|
const emits = defineEmits<ComboboxRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="combobox"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</ComboboxRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxAnchorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxAnchor, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxAnchor
|
||||||
|
data-slot="combobox-anchor"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('w-[200px]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxEmptyProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxEmpty } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxEmpty
|
||||||
|
data-slot="combobox-empty"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('py-6 text-center text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxEmpty>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxGroupProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxGroup, ComboboxLabel } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxGroupProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
heading?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxGroup
|
||||||
|
data-slot="combobox-group"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('overflow-hidden p-1 text-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{{ heading }}
|
||||||
|
</ComboboxLabel>
|
||||||
|
<slot />
|
||||||
|
</ComboboxGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxInputEmits, ComboboxInputProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { SearchIcon } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxInput, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxInputProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<ComboboxInputEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
class="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon class="size-4 shrink-0 opacity-50" />
|
||||||
|
<ComboboxInput
|
||||||
|
data-slot="command-input"
|
||||||
|
:class="cn(
|
||||||
|
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxInput>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxItemEmits, ComboboxItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxItem, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<ComboboxItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxItem
|
||||||
|
data-slot="combobox-item"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxItemIndicatorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxItemIndicator, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxItemIndicatorProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxItemIndicator
|
||||||
|
data-slot="combobox-item-indicator"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('ml-auto', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxContentEmits, ComboboxContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ComboboxContentProps & { class?: HTMLAttributes["class"] }>(), {
|
||||||
|
position: "popper",
|
||||||
|
align: "center",
|
||||||
|
sideOffset: 4,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<ComboboxContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxPortal>
|
||||||
|
<ComboboxContent
|
||||||
|
data-slot="combobox-list"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn('z-50 w-[200px] rounded-md border bg-popover text-popover-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxSeparatorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxSeparator } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxSeparator
|
||||||
|
data-slot="combobox-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border -mx-1 h-px', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxSeparator>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxTriggerProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxTrigger, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxTrigger
|
||||||
|
data-slot="combobox-trigger"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('', props.class)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComboboxViewportProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ComboboxViewport, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxViewport
|
||||||
|
data-slot="combobox-viewport"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxViewport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { default as Combobox } from "./Combobox.vue"
|
||||||
|
export { default as ComboboxAnchor } from "./ComboboxAnchor.vue"
|
||||||
|
export { default as ComboboxEmpty } from "./ComboboxEmpty.vue"
|
||||||
|
export { default as ComboboxGroup } from "./ComboboxGroup.vue"
|
||||||
|
export { default as ComboboxInput } from "./ComboboxInput.vue"
|
||||||
|
export { default as ComboboxItem } from "./ComboboxItem.vue"
|
||||||
|
export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue"
|
||||||
|
export { default as ComboboxList } from "./ComboboxList.vue"
|
||||||
|
export { default as ComboboxSeparator } from "./ComboboxSeparator.vue"
|
||||||
|
export { default as ComboboxTrigger } from "./ComboboxTrigger.vue"
|
||||||
|
export { default as ComboboxViewport } from "./ComboboxViewport.vue"
|
||||||
|
|
||||||
|
export { ComboboxCancel } from "reka-ui"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from "reka-ui"
|
||||||
|
import { DialogClose } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { X } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import DialogOverlay from "./DialogOverlay.vue"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="dialog-close"
|
||||||
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { DialogClose } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}>(), {
|
||||||
|
showCloseButton: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<DialogClose v-if="showCloseButton" as-child>
|
||||||
|
<Button variant="outline">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogOverlay } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { X } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from "reka-ui"
|
||||||
|
import { DialogTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Dialog } from "./Dialog.vue"
|
||||||
|
export { default as DialogClose } from "./DialogClose.vue"
|
||||||
|
export { default as DialogContent } from "./DialogContent.vue"
|
||||||
|
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||||
|
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||||
|
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||||
|
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||||
|
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||||
|
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||||
|
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||||
|
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectRootProps>()
|
||||||
|
const emits = defineEmits<SelectRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="select"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
SelectPortal,
|
||||||
|
SelectViewport,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
position: "popper",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<SelectContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
data-slot="select-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||||
|
position === 'popper'
|
||||||
|
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width) scroll-my-1')">
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectGroupProps } from "reka-ui"
|
||||||
|
import { SelectGroup } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectGroupProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectGroup
|
||||||
|
data-slot="select-group"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { Check } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItem
|
||||||
|
data-slot="select-item"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectItemIndicator>
|
||||||
|
<slot name="indicator-icon">
|
||||||
|
<Check class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectItemText>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemTextProps } from "reka-ui"
|
||||||
|
import { SelectItemText } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemTextProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItemText
|
||||||
|
data-slot="select-item-text"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectLabelProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { SelectLabel } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectLabel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollDownButtonProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { ChevronDown } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronDown class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollDownButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollUpButtonProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { ChevronUp } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronUp class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollUpButton>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectSeparatorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { SelectSeparator } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectSeparator
|
||||||
|
data-slot="select-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectTriggerProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { ChevronDown } from "@lucide/vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||||
|
{ size: "default" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectTrigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
:data-size="size"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDown class="size-4 opacity-50" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectValueProps } from "reka-ui"
|
||||||
|
import { SelectValue } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<SelectValueProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectValue
|
||||||
|
data-slot="select-value"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectValue>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { default as Select } from "./Select.vue"
|
||||||
|
export { default as SelectContent } from "./SelectContent.vue"
|
||||||
|
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||||
|
export { default as SelectItem } from "./SelectItem.vue"
|
||||||
|
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||||
|
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||||
|
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||||
|
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||||
|
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||||
|
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||||
|
export { default as SelectValue } from "./SelectValue.vue"
|
||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
// 导入模块注册入口 —— 副作用导入,注册所有模块到 moduleRegistry
|
// 导入模块注册入口 —— 副作用导入,注册所有模块到 moduleRegistry
|
||||||
import './modules'
|
import './modules'
|
||||||
|
|||||||
+799
-104
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,13 @@ export interface ProxySettings {
|
|||||||
allowLan: boolean
|
allowLan: boolean
|
||||||
systemProxy: boolean
|
systemProxy: boolean
|
||||||
autoStart: boolean
|
autoStart: boolean
|
||||||
|
autoSystemProxy: boolean
|
||||||
currentProfile: string | null
|
currentProfile: string | null
|
||||||
profiles: ProfileMeta[]
|
profiles: ProfileMeta[]
|
||||||
|
autoSwitchEnabled: boolean
|
||||||
|
autoSwitchInterval: number
|
||||||
|
autoSwitchGroup: string
|
||||||
|
autoSwitchRegion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileMeta {
|
export interface ProfileMeta {
|
||||||
@@ -35,6 +40,13 @@ export interface KernelInfo {
|
|||||||
version: string | null
|
version: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KernelUpdateInfo {
|
||||||
|
currentVersion: string | null
|
||||||
|
latestVersion: string
|
||||||
|
downloadUrl: string
|
||||||
|
hasUpdate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProxyStatus {
|
export interface ProxyStatus {
|
||||||
running: boolean
|
running: boolean
|
||||||
pid: number | null
|
pid: number | null
|
||||||
@@ -108,6 +120,20 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 等待 mihomo API 就绪(轮询 version 接口,最多等 10 秒) */
|
||||||
|
const waitForApi = async (timeoutMs = 10000): Promise<boolean> => {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
await invoke<MihomoVersion>('proxy_version')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取 mihomo 版本(仅运行时可用) */
|
/** 获取 mihomo 版本(仅运行时可用) */
|
||||||
const refreshVersion = async () => {
|
const refreshVersion = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -220,6 +246,16 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 内核更新 ----------
|
||||||
|
const checkKernelUpdate = async (): Promise<KernelUpdateInfo> => {
|
||||||
|
return await invoke<KernelUpdateInfo>('proxy_check_kernel_update')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateKernel = async () => {
|
||||||
|
await invoke('proxy_update_kernel')
|
||||||
|
await refreshKernel()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state
|
// state
|
||||||
kernel,
|
kernel,
|
||||||
@@ -234,6 +270,7 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
restart,
|
restart,
|
||||||
|
waitForApi,
|
||||||
refreshVersion,
|
refreshVersion,
|
||||||
// proxies
|
// proxies
|
||||||
loadProxies,
|
loadProxies,
|
||||||
@@ -251,6 +288,9 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
// system proxy
|
// system proxy
|
||||||
setSystemProxy,
|
setSystemProxy,
|
||||||
clearSystemProxy,
|
clearSystemProxy,
|
||||||
toggleSystemProxy
|
toggleSystemProxy,
|
||||||
|
// kernel update
|
||||||
|
checkKernelUpdate,
|
||||||
|
updateKernel
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@@ -165,3 +168,8 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
@apply antialiased;
|
@apply antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 确保 Sonner toast 始终在最上层 */
|
||||||
|
[data-sonner-toaster] {
|
||||||
|
z-index: 99999 !important;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user