mirror of
https://github.com/EasyTier/EasyTier.git
synced 2025-09-27 21:12:09 +08:00
web improve (#1047)
Some checks failed
EasyTier Core / pre_job (push) Has been cancelled
EasyTier GUI / pre_job (push) Has been cancelled
EasyTier Mobile / pre_job (push) Has been cancelled
EasyTier Test / pre_job (push) Has been cancelled
EasyTier Core / build_web (push) Has been cancelled
EasyTier Core / build (freebsd-13.2-x86_64, 13.2, ubuntu-22.04, x86_64-unknown-freebsd) (push) Has been cancelled
EasyTier Core / build (linux-aarch64, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-arm, ubuntu-22.04, arm-unknown-linux-musleabi) (push) Has been cancelled
EasyTier Core / build (linux-armhf, ubuntu-22.04, arm-unknown-linux-musleabihf) (push) Has been cancelled
EasyTier Core / build (linux-armv7, ubuntu-22.04, armv7-unknown-linux-musleabi) (push) Has been cancelled
EasyTier Core / build (linux-armv7hf, ubuntu-22.04, armv7-unknown-linux-musleabihf) (push) Has been cancelled
EasyTier Core / build (linux-mips, ubuntu-22.04, mips-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-mipsel, ubuntu-22.04, mipsel-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (macos-aarch64, macos-latest, aarch64-apple-darwin) (push) Has been cancelled
EasyTier Core / build (macos-x86_64, macos-latest, x86_64-apple-darwin) (push) Has been cancelled
EasyTier Core / build (windows-arm64, windows-latest, aarch64-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / build (windows-i686, windows-latest, i686-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / build (windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / core-result (push) Has been cancelled
EasyTier Core / magisk_build (push) Has been cancelled
EasyTier GUI / build-gui (linux-aarch64, aarch64-unknown-linux-gnu, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Has been cancelled
EasyTier GUI / build-gui (linux-x86_64, x86_64-unknown-linux-gnu, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Has been cancelled
EasyTier GUI / build-gui (macos-aarch64, aarch64-apple-darwin, macos-latest, aarch64-apple-darwin) (push) Has been cancelled
EasyTier GUI / build-gui (macos-x86_64, x86_64-apple-darwin, macos-latest, x86_64-apple-darwin) (push) Has been cancelled
EasyTier GUI / build-gui (windows-arm64, aarch64-pc-windows-msvc, windows-latest, aarch64-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / build-gui (windows-i686, i686-pc-windows-msvc, windows-latest, i686-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / build-gui (windows-x86_64, x86_64-pc-windows-msvc, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / gui-result (push) Has been cancelled
EasyTier Mobile / build-mobile (android, ubuntu-22.04, android) (push) Has been cancelled
EasyTier Mobile / mobile-result (push) Has been cancelled
EasyTier Test / test (push) Has been cancelled
Some checks failed
EasyTier Core / pre_job (push) Has been cancelled
EasyTier GUI / pre_job (push) Has been cancelled
EasyTier Mobile / pre_job (push) Has been cancelled
EasyTier Test / pre_job (push) Has been cancelled
EasyTier Core / build_web (push) Has been cancelled
EasyTier Core / build (freebsd-13.2-x86_64, 13.2, ubuntu-22.04, x86_64-unknown-freebsd) (push) Has been cancelled
EasyTier Core / build (linux-aarch64, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-arm, ubuntu-22.04, arm-unknown-linux-musleabi) (push) Has been cancelled
EasyTier Core / build (linux-armhf, ubuntu-22.04, arm-unknown-linux-musleabihf) (push) Has been cancelled
EasyTier Core / build (linux-armv7, ubuntu-22.04, armv7-unknown-linux-musleabi) (push) Has been cancelled
EasyTier Core / build (linux-armv7hf, ubuntu-22.04, armv7-unknown-linux-musleabihf) (push) Has been cancelled
EasyTier Core / build (linux-mips, ubuntu-22.04, mips-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-mipsel, ubuntu-22.04, mipsel-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (macos-aarch64, macos-latest, aarch64-apple-darwin) (push) Has been cancelled
EasyTier Core / build (macos-x86_64, macos-latest, x86_64-apple-darwin) (push) Has been cancelled
EasyTier Core / build (windows-arm64, windows-latest, aarch64-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / build (windows-i686, windows-latest, i686-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / build (windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / core-result (push) Has been cancelled
EasyTier Core / magisk_build (push) Has been cancelled
EasyTier GUI / build-gui (linux-aarch64, aarch64-unknown-linux-gnu, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Has been cancelled
EasyTier GUI / build-gui (linux-x86_64, x86_64-unknown-linux-gnu, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Has been cancelled
EasyTier GUI / build-gui (macos-aarch64, aarch64-apple-darwin, macos-latest, aarch64-apple-darwin) (push) Has been cancelled
EasyTier GUI / build-gui (macos-x86_64, x86_64-apple-darwin, macos-latest, x86_64-apple-darwin) (push) Has been cancelled
EasyTier GUI / build-gui (windows-arm64, aarch64-pc-windows-msvc, windows-latest, aarch64-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / build-gui (windows-i686, i686-pc-windows-msvc, windows-latest, i686-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / build-gui (windows-x86_64, x86_64-pc-windows-msvc, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / gui-result (push) Has been cancelled
EasyTier Mobile / build-mobile (android, ubuntu-22.04, android) (push) Has been cancelled
EasyTier Mobile / mobile-result (push) Has been cancelled
EasyTier Test / test (push) Has been cancelled
This commit is contained in:
@@ -15,8 +15,6 @@
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
@@ -218,7 +218,7 @@ const bool_flags: BoolFlag[] = [
|
||||
|
||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
|
||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions"
|
||||
:virtual-scroller-options="{ itemSize: 38 }" class="grow" dropdown :complete-on-focus="true"
|
||||
class="grow" dropdown :complete-on-focus="false"
|
||||
@complete="searchPresetPublicServers" />
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -203,3 +203,115 @@ event:
|
||||
DhcpIpv4Changed: DHCP IPv4地址更改
|
||||
DhcpIpv4Conflicted: DHCP IPv4地址冲突
|
||||
PortForwardAdded: 端口转发添加
|
||||
|
||||
web:
|
||||
login:
|
||||
title: 登录
|
||||
username: 用户名
|
||||
password: 密码
|
||||
submit: 登录
|
||||
register: 注册
|
||||
remember_me: 记住我
|
||||
api_host: API主机
|
||||
captcha: 验证码
|
||||
back_to_login: 返回登录
|
||||
login: 登录
|
||||
|
||||
register:
|
||||
title: 注册
|
||||
username: 用户名
|
||||
password: 密码
|
||||
confirm_password: 确认密码
|
||||
submit: 注册
|
||||
login: 返回登录
|
||||
|
||||
main:
|
||||
dashboard: 仪表盘
|
||||
device_list: 设备列表
|
||||
device_management: 设备管理
|
||||
login_page: 登录页面
|
||||
settings: 设置
|
||||
logout: 退出登录
|
||||
language: 语言
|
||||
change_password: 修改密码
|
||||
|
||||
device:
|
||||
list: 设备列表
|
||||
management: 设备管理
|
||||
add: 添加设备
|
||||
delete: 删除设备
|
||||
refresh: 刷新
|
||||
status: 状态
|
||||
online: 在线
|
||||
offline: 离线
|
||||
last_seen: 最后在线
|
||||
no_devices: 未找到设备
|
||||
sort_by: 排序依据
|
||||
sort_direction: 排序方向
|
||||
show_detailed_view: 显示详情
|
||||
hide_detailed_view: 隐藏详情
|
||||
sort_by_hostname: 主机名
|
||||
sort_by_public_ip: 公网IP
|
||||
sort_by_version: 版本
|
||||
sort_by_networks: 网络数量
|
||||
sort_direction_asc: 当前升序,点击切换为降序
|
||||
sort_direction_desc: 当前降序,点击切换为升序
|
||||
hostname: 主机名
|
||||
public_ip: 公网IP
|
||||
networks: 网络数量
|
||||
last_report: 最后在线
|
||||
version: 版本
|
||||
machine_id: 机器ID
|
||||
|
||||
device_management:
|
||||
edit_network: 编辑网络
|
||||
export_config: 导出配置
|
||||
delete_network: 删除网络
|
||||
network: 网络
|
||||
select_network: 选择网络
|
||||
create_network: 创建网络
|
||||
cancel_creation: 取消创建
|
||||
more_actions: 更多操作
|
||||
edit_as_file: 编辑为文件
|
||||
import_config: 导入配置
|
||||
create_new: 创建新网络
|
||||
network_status: 网络状态
|
||||
network_configuration: 网络配置
|
||||
loading_network_configuration: 加载网络配置
|
||||
no_network_selected: 未选择网络
|
||||
select_existing_network_or_create_new: 选择现有网络实例或创建新网络以管理网络设置
|
||||
disable_network: 禁用网络
|
||||
|
||||
network:
|
||||
title: 网络
|
||||
create: 创建网络
|
||||
delete: 删除网络
|
||||
start: 启动网络
|
||||
stop: 停止网络
|
||||
config: 网络配置
|
||||
status: 网络状态
|
||||
import: 导入配置
|
||||
export: 导出配置
|
||||
|
||||
common:
|
||||
confirm: 确认
|
||||
cancel: 取消
|
||||
save: 保存
|
||||
delete: 删除
|
||||
edit: 编辑
|
||||
refresh: 刷新
|
||||
loading: 加载中...
|
||||
error: 错误
|
||||
success: 成功
|
||||
warning: 警告
|
||||
info: 提示
|
||||
|
||||
settings:
|
||||
title: 设置
|
||||
change_password: 修改密码
|
||||
old_password: 旧密码
|
||||
new_password: 新密码
|
||||
confirm_password: 确认新密码
|
||||
language: 语言
|
||||
theme: 主题
|
||||
logout: 退出登录
|
@@ -203,3 +203,115 @@ event:
|
||||
DhcpIpv4Changed: DhcpIpv4Changed
|
||||
DhcpIpv4Conflicted: DhcpIpv4Conflicted
|
||||
PortForwardAdded: PortForwardAdded
|
||||
|
||||
web:
|
||||
login:
|
||||
title: Login
|
||||
username: Username
|
||||
password: Password
|
||||
submit: Login
|
||||
register: Register
|
||||
remember_me: Remember Me
|
||||
api_host: API Host
|
||||
captcha: Captcha
|
||||
back_to_login: Back to Login
|
||||
login: Login
|
||||
|
||||
register:
|
||||
title: Register
|
||||
username: Username
|
||||
password: Password
|
||||
confirm_password: Confirm Password
|
||||
submit: Register
|
||||
login: Back to Login
|
||||
|
||||
main:
|
||||
dashboard: Dashboard
|
||||
device_list: Device List
|
||||
device_management: Device Management
|
||||
login_page: Login Page
|
||||
settings: Settings
|
||||
logout: Logout
|
||||
language: Language
|
||||
change_password: Change Password
|
||||
|
||||
device:
|
||||
list: Device List
|
||||
management: Device Management
|
||||
add: Add Device
|
||||
delete: Delete Device
|
||||
refresh: Refresh
|
||||
status: Status
|
||||
online: Online
|
||||
offline: Offline
|
||||
last_seen: Last Seen
|
||||
no_devices: No Devices Found
|
||||
sort_by: Sort By
|
||||
sort_direction: Sort Direction
|
||||
show_detailed_view: Show Details
|
||||
hide_detailed_view: Hide Details
|
||||
sort_by_hostname: Hostname
|
||||
sort_by_public_ip: Public IP
|
||||
sort_by_version: Version
|
||||
sort_by_networks: Network Count
|
||||
sort_direction_asc: Currently ascending, click to switch to descending
|
||||
sort_direction_desc: Currently descending, click to switch to ascending
|
||||
hostname: Hostname
|
||||
public_ip: Public IP
|
||||
networks: Network Count
|
||||
last_report: Last Seen
|
||||
version: Version
|
||||
machine_id: Machine ID
|
||||
|
||||
device_management:
|
||||
edit_network: Edit Network
|
||||
export_config: Export Config
|
||||
delete_network: Delete Network
|
||||
network: Network
|
||||
select_network: Select Network
|
||||
create_network: Create Network
|
||||
cancel_creation: Cancel Creation
|
||||
more_actions: More Actions
|
||||
edit_as_file: Edit as File
|
||||
import_config: Import Config
|
||||
create_new: Create New Network
|
||||
network_status: Network Status
|
||||
network_configuration: Network Configuration
|
||||
loading_network_configuration: Loading Network Configuration
|
||||
no_network_selected: No Network Selected
|
||||
select_existing_network_or_create_new: Select an existing network instance or create a new one to manage network settings
|
||||
disable_network: Disable Network
|
||||
|
||||
network:
|
||||
title: Network
|
||||
create: Create Network
|
||||
delete: Delete Network
|
||||
start: Start Network
|
||||
stop: Stop Network
|
||||
config: Network Config
|
||||
status: Network Status
|
||||
import: Import Config
|
||||
export: Export Config
|
||||
|
||||
common:
|
||||
confirm: Confirm
|
||||
cancel: Cancel
|
||||
save: Save
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
refresh: Refresh
|
||||
loading: Loading...
|
||||
error: Error
|
||||
success: Success
|
||||
warning: Warning
|
||||
info: Info
|
||||
|
||||
settings:
|
||||
title: Settings
|
||||
change_password: Change Password
|
||||
old_password: Old Password
|
||||
new_password: New Password
|
||||
confirm_password: Confirm New Password
|
||||
language: Language
|
||||
theme: Theme
|
||||
logout: Logout
|
@@ -22,6 +22,16 @@ export const availableLocales = Object.keys(localesMap)
|
||||
|
||||
const loadedLanguages: string[] = []
|
||||
|
||||
export function toggleLanguage() {
|
||||
const currentLang = localStorage.getItem('lang') || 'en'
|
||||
const newLang = currentLang === 'en' ? 'cn' : 'en'
|
||||
loadLanguageAsync(newLang)
|
||||
}
|
||||
|
||||
export function getCurrentLanguage() {
|
||||
return localStorage.getItem('lang') || 'en'
|
||||
}
|
||||
|
||||
function setI18nLanguage(lang: Locale) {
|
||||
i18n.global.locale.value = lang as any
|
||||
localStorage.setItem('lang', lang)
|
||||
@@ -56,4 +66,6 @@ export default {
|
||||
i18n,
|
||||
localesMap,
|
||||
loadLanguageAsync,
|
||||
toggleLanguage,
|
||||
getCurrentLanguage,
|
||||
}
|
||||
|
@@ -18,8 +18,6 @@
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
@@ -1,14 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/easytier.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EasyTier Dashboard</title>
|
||||
<script src="/api_meta.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/easytier.png" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>EasyTier Dashboard</title>
|
||||
<script src="/api_meta.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -16,7 +16,9 @@
|
||||
"primevue": "4.3.3",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "4"
|
||||
"vue-router": "4",
|
||||
"vue-i18n": "^9.9.1",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.6",
|
||||
|
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
import { onMounted } from 'vue';
|
||||
import { Toast, DynamicDialog } from 'primevue';
|
||||
|
||||
onMounted(async () => {
|
||||
await I18nUtils.loadLanguageAsync('cn')
|
||||
await I18nUtils.loadLanguageAsync(localStorage.getItem('lang') || 'en')
|
||||
});
|
||||
|
||||
</script>
|
||||
|
211
easytier-web/frontend/src/components/DeviceDetails.vue
Normal file
211
easytier-web/frontend/src/components/DeviceDetails.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { Utils } from 'easytier-frontend-lib';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
// 定义组件接收的 props
|
||||
defineProps<{
|
||||
device: Utils.DeviceInfo;
|
||||
// 可以传入额外的样式类
|
||||
containerClass?: string;
|
||||
// 是否使用紧凑布局
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['device-details', containerClass, { 'compact': compact }]">
|
||||
<div class="detail-item hostname">
|
||||
<div class="detail-label">{{ t('web.device.hostname') }}</div>
|
||||
<div class="detail-value">{{ device.hostname }}</div>
|
||||
</div>
|
||||
<div class="detail-item public-ip">
|
||||
<div class="detail-label">{{ t('web.device.public_ip') }}</div>
|
||||
<div class="detail-value">{{ device.public_ip }}</div>
|
||||
</div>
|
||||
<div class="detail-item running-networks">
|
||||
<div class="detail-label">{{ t('web.device.networks') }}</div>
|
||||
<div class="detail-value">{{ device.running_network_count }}</div>
|
||||
</div>
|
||||
<div class="detail-item last-report">
|
||||
<div class="detail-label">{{ t('web.device.last_report') }}</div>
|
||||
<div class="detail-value">{{ device.report_time }}</div>
|
||||
</div>
|
||||
<div class="detail-item version">
|
||||
<div class="detail-label">{{ t('web.device.version') }}</div>
|
||||
<div class="detail-value">{{ device.easytier_version }}</div>
|
||||
</div>
|
||||
<div class="detail-item machine-id">
|
||||
<div class="detail-label">{{ t('web.device.machine_id') }}</div>
|
||||
<div class="detail-value">
|
||||
<span class="machine-id-value" :title="device.machine_id">{{ device.machine_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 基础布局 */
|
||||
.device-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* 标准布局的详情项样式 */
|
||||
.detail-item {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--surface-border, #e9ecef);
|
||||
padding-bottom: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-item:hover {
|
||||
background-color: var(--surface-hover, rgba(245, 247, 250, 0.5));
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #334155);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 紧凑布局样式 */
|
||||
.device-details.compact {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.compact .detail-item {
|
||||
padding: 0.3rem 0.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 40% 60%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compact .detail-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-label::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: #3b82f6;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-color-secondary, #475569);
|
||||
word-break: break-all;
|
||||
padding-left: 1rem;
|
||||
line-height: 1.4;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* 紧凑布局的标签和值样式 */
|
||||
.compact .detail-label::before {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.compact .detail-value {
|
||||
padding-left: 0.3rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 特定字段的样式 */
|
||||
.hostname .detail-label::before {
|
||||
background-color: #3b82f6;
|
||||
/* 蓝色 */
|
||||
}
|
||||
|
||||
.public-ip .detail-label::before {
|
||||
background-color: #10b981;
|
||||
/* 绿色 */
|
||||
}
|
||||
|
||||
.running-networks .detail-label::before {
|
||||
background-color: #f59e0b;
|
||||
/* 橙色 */
|
||||
}
|
||||
|
||||
.last-report .detail-label::before {
|
||||
background-color: #8b5cf6;
|
||||
/* 紫色 */
|
||||
}
|
||||
|
||||
.version .detail-label::before {
|
||||
background-color: #ec4899;
|
||||
/* 粉色 */
|
||||
}
|
||||
|
||||
.machine-id .detail-label::before {
|
||||
background-color: #6b7280;
|
||||
/* 灰色 */
|
||||
}
|
||||
|
||||
/* 机器ID特殊样式 */
|
||||
.machine-id-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--surface-ground, #f1f5f9);
|
||||
color: var(--text-color, #1f2937);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 紧凑布局下的机器ID样式 */
|
||||
.compact .machine-id-value {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.detail-item {
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-item:hover {
|
||||
background-color: var(--surface-hover, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.machine-id-value {
|
||||
background-color: var(--surface-ground, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
border-color: var(--surface-border, #334155);
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,13 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Button, Column, DataTable, Drawer, ProgressSpinner, useToast } from 'primevue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { Button, Drawer, ProgressSpinner, useToast, InputSwitch, Popover, Dropdown, Toolbar } from 'primevue';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Api, Utils } from 'easytier-frontend-lib';
|
||||
import DeviceDetails from './DeviceDetails.vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
declare const window: Window & typeof globalThis;
|
||||
|
||||
// 注册 Tooltip 指令
|
||||
const vTooltip = Tooltip;
|
||||
|
||||
const props = defineProps({
|
||||
api: Api.ApiClient,
|
||||
});
|
||||
|
||||
const detailPopover = ref();
|
||||
const selectedDevice = ref<Utils.DeviceInfo | null>(null);
|
||||
// 从 localStorage 读取显示详情状态,默认为 false
|
||||
const showDetailedView = ref(localStorage.getItem('deviceList.showDetailedView') === 'true');
|
||||
|
||||
// 监听显示详情状态变化,保存到 localStorage
|
||||
watch(showDetailedView, (newValue) => {
|
||||
localStorage.setItem('deviceList.showDetailedView', newValue.toString());
|
||||
});
|
||||
|
||||
const api = props.api;
|
||||
|
||||
const deviceList = ref<Array<Utils.DeviceInfo> | undefined>(undefined);
|
||||
@@ -47,10 +67,14 @@ const periodFunc = new Utils.PeriodicTask(async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
periodFunc.start();
|
||||
// 初始化屏幕尺寸相关变量
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
periodFunc.stop();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
const deviceManageVisible = computed<boolean>({
|
||||
@@ -66,45 +90,713 @@ const selectedDeviceHostname = computed<string | undefined>(() => {
|
||||
return deviceList.value?.find((device) => device.machine_id === selectedDeviceId.value)?.hostname;
|
||||
});
|
||||
|
||||
// 处理设备管理
|
||||
const handleDeviceManagement = (device: Utils.DeviceInfo) => {
|
||||
const instanceId = device.running_network_instances?.[0];
|
||||
router.push({
|
||||
name: 'deviceManagement',
|
||||
params: {
|
||||
deviceId: device.machine_id,
|
||||
instanceId: instanceId
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 显示设备详情
|
||||
const showDeviceDetails = (device: Utils.DeviceInfo, event: Event) => {
|
||||
selectedDevice.value = device;
|
||||
detailPopover.value.toggle(event);
|
||||
};
|
||||
|
||||
// 检查是否为桌面设备
|
||||
const isDesktop = ref(false);
|
||||
// 检查是否为多卡片视图(一行可以放置多个卡片)
|
||||
const isMultiCardView = ref(false);
|
||||
|
||||
// 抽屉布局相关
|
||||
const drawerWidth = computed(() => {
|
||||
return isDesktop.value ? 'w-3/5 min-w-96' : 'w-full';
|
||||
});
|
||||
|
||||
const drawerPosition = computed(() => {
|
||||
return isDesktop.value ? 'right' : 'bottom';
|
||||
});
|
||||
|
||||
const drawerHeight = computed(() => {
|
||||
return isDesktop.value ? undefined : '100%';
|
||||
});
|
||||
|
||||
// 排序相关
|
||||
const sortOptions = ref([
|
||||
{ name: () => t('web.device.sort_by_hostname'), value: 'hostname', icon: 'pi pi-home' },
|
||||
{ name: () => t('web.device.sort_by_version'), value: 'version', icon: 'pi pi-tag' },
|
||||
{ name: () => t('web.device.sort_by_networks'), value: 'networks', icon: 'pi pi-sitemap' }
|
||||
]);
|
||||
const selectedSortOption = ref(sortOptions.value[0]);
|
||||
// 排序方向 (true为升序,false为降序)
|
||||
const ascending = ref(true);
|
||||
|
||||
// 切换排序方向
|
||||
const toggleSortDirection = () => {
|
||||
ascending.value = !ascending.value;
|
||||
};
|
||||
|
||||
// 排序函数
|
||||
const sortDevices = (devices: Array<Utils.DeviceInfo> | undefined) => {
|
||||
if (!devices) return [];
|
||||
|
||||
const sortField = selectedSortOption.value.value;
|
||||
const direction = ascending.value ? 1 : -1;
|
||||
|
||||
return [...devices].sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'hostname':
|
||||
result = a.hostname.localeCompare(b.hostname);
|
||||
break;
|
||||
case 'version':
|
||||
result = a.easytier_version.localeCompare(b.easytier_version);
|
||||
break;
|
||||
case 'networks':
|
||||
result = a.running_network_count - b.running_network_count;
|
||||
break;
|
||||
}
|
||||
|
||||
return result * direction;
|
||||
});
|
||||
};
|
||||
|
||||
// 排序后的设备列表
|
||||
const sortedDeviceList = computed(() => {
|
||||
return sortDevices(deviceList.value);
|
||||
});
|
||||
|
||||
// 保存resize事件处理函数的引用,以便正确移除
|
||||
const handleResize = () => {
|
||||
isDesktop.value = window.innerWidth >= 768;
|
||||
// 当容器宽度足够放置两个或更多卡片时,视为多卡片视图
|
||||
isMultiCardView.value = window.innerWidth >= 650;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
/* 卡片容器 */
|
||||
.card-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
/* 确保子元素的绝对定位相对于此容器 */
|
||||
}
|
||||
|
||||
/* 设备卡片样式 */
|
||||
.device-card {
|
||||
border: 1px solid var(--surface-border, #e5e7eb);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--surface-card, white);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
color: var(--text-color, #1f2937);
|
||||
}
|
||||
|
||||
.device-details-popover {
|
||||
min-width: 280px;
|
||||
max-width: 350px;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
/* Popover 样式 */
|
||||
:deep(.device-popover.p-popover) {
|
||||
min-width: 320px;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--card-shadow, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--surface-border, #e5e7eb);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-content) {
|
||||
padding: 0;
|
||||
background-color: var(--surface-card, #ffffff);
|
||||
color: var(--text-color, #334155);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-arrow) {
|
||||
background-color: var(--surface-card, #ffffff);
|
||||
border-color: var(--surface-border, #e5e7eb);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-header) {
|
||||
background-color: var(--surface-section, #f8fafc);
|
||||
border-bottom: 1px solid var(--surface-border, #e2e8f0);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-header-close) {
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-header-close:hover) {
|
||||
background-color: var(--surface-hover, rgba(0, 0, 0, 0.04));
|
||||
color: var(--text-color, #334155);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.device-popover.p-popover) {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.25);
|
||||
border-color: var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-content) {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-arrow) {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
border-color: var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-header) {
|
||||
background-color: var(--surface-section, #0f172a);
|
||||
border-bottom: 1px solid var(--surface-border, #1e293b);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-header-close) {
|
||||
color: var(--text-color-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
:deep(.device-popover .p-popover-header-close:hover) {
|
||||
background-color: var(--surface-hover, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
background-color: var(--surface-section, #0f172a);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--surface-section, #f8fafc);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--surface-border, #e2e8f0);
|
||||
color: var(--text-color, #334155);
|
||||
}
|
||||
|
||||
/* 卡片内详情样式 */
|
||||
.card-details {
|
||||
background-color: var(--surface-ground, #f9fafb);
|
||||
}
|
||||
|
||||
/* 卡片内详情内容的特定样式 */
|
||||
:deep(.card-details-content) {
|
||||
padding: 0.15rem 0.1rem;
|
||||
}
|
||||
|
||||
/* 卡片中的紧凑详情内容 */
|
||||
:deep(.card-details-content) {
|
||||
padding: 0.15rem 0.1rem;
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-label) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-value) {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.card-details-content .detail-item) {
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-item:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-item:hover) {
|
||||
background-color: var(--surface-hover, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-label) {
|
||||
color: var(--text-color, #e2e8f0);
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-value) {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.card-details-content .detail-item) {
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-item:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-item:hover) {
|
||||
background-color: var(--surface-hover, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-label) {
|
||||
color: var(--text-color, #e2e8f0);
|
||||
}
|
||||
|
||||
:deep(.card-details-content .detail-value) {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保卡片在暗黑模式下有足够的对比度 */
|
||||
:deep(.device-card) {
|
||||
background-color: var(--surface-card, white);
|
||||
border-color: var(--surface-border, #e5e7eb);
|
||||
}
|
||||
|
||||
:deep(.card-header) {
|
||||
color: var(--text-color, #1f2937);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--text-color, #1f2937);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
background-color: var(--primary-color, #3b82f6);
|
||||
color: #ffffff;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
background-color: var(--surface-card);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
box-shadow: var(--card-shadow, 0 1px 3px rgba(0, 0, 0, 0.05));
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sort-controls:hover {
|
||||
box-shadow: var(--card-shadow, 0 2px 5px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.sort-dropdown {
|
||||
min-width: 6rem;
|
||||
max-width: 9rem;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sort-direction-btn {
|
||||
font-size: 1rem;
|
||||
width: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
}
|
||||
|
||||
/* 暗黑模式样式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sort-controls {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sort-controls:hover {
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:deep(.device-card) {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
border-color: var(--surface-border, #334155);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:deep(.card-header) {
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
background-color: var(--primary-color, #4f46e5);
|
||||
}
|
||||
|
||||
:deep(.card-details) {
|
||||
background-color: var(--surface-ground, #0f172a);
|
||||
border-top: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
}
|
||||
|
||||
/* Popover 详情内容的特定样式 */
|
||||
:deep(.popover-details-content) {
|
||||
padding: 0.25rem 0.2rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Popover 中的紧凑详情内容 */
|
||||
:deep(.popover-details-content) {
|
||||
padding: 0.25rem 0.2rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-label) {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-value) {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .machine-id-value) {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.popover-details-content .detail-item) {
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-item:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-item:hover) {
|
||||
background-color: var(--surface-hover, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-label) {
|
||||
color: var(--text-color, #e2e8f0);
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-value) {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.popover-details-content .detail-item) {
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-item:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-item:hover) {
|
||||
background-color: var(--surface-hover, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-label) {
|
||||
color: var(--text-color, #e2e8f0);
|
||||
}
|
||||
|
||||
:deep(.popover-details-content .detail-value) {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端卡片样式 */
|
||||
@media (max-width: 768px) {
|
||||
.card-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 抽屉响应式样式 */
|
||||
:deep(.p-drawer) {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.p-drawer.p-drawer-bottom) {
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:deep(.p-drawer.p-drawer-bottom .p-drawer-header) {
|
||||
padding-top: 1rem;
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
}
|
||||
|
||||
:deep(.p-drawer.p-drawer-bottom .p-drawer-content) {
|
||||
padding-bottom: 2rem;
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
}
|
||||
|
||||
/* 底部抽屉的拖动指示器 */
|
||||
:deep(.p-drawer.p-drawer-bottom .p-drawer-header::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4rem;
|
||||
height: 4px;
|
||||
background-color: var(--surface-border);
|
||||
border-radius: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.p-drawer.p-drawer-bottom) {
|
||||
box-shadow: 0 -4px 12px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-fab-close-btn {
|
||||
/* 适配移动和桌面端,防止被内容遮挡 */
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.drawer-fab-close-btn:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
/* 排序控件在小屏幕下单独一行 */
|
||||
.sort-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sort-controls-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具栏样式优化 */
|
||||
:deep(.p-dropdown) {
|
||||
background: transparent;
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown:hover) {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
:deep(.p-dropdown-panel) {
|
||||
.p-dropdown-items .p-dropdown-item {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-inputswitch) {
|
||||
.p-inputswitch-slider {
|
||||
background: var(--surface-200);
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保所有按钮大小一致 */
|
||||
:deep(.p-button.p-button-icon-only) {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div v-if="deviceList === undefined" class="w-full flex justify-center">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 标题和工具栏 -->
|
||||
<div class="text-xl font-bold">
|
||||
<h1>{{ t('web.device.list') }}</h1>
|
||||
</div>
|
||||
|
||||
<DataTable :value="deviceList" tableStyle="min-width: 50rem" :metaKeySelection="true" sortField="hostname"
|
||||
:sortOrder="-1" v-if="deviceList !== undefined">
|
||||
<template #header>
|
||||
<div class="text-xl font-bold">Device List</div>
|
||||
</template>
|
||||
|
||||
<Column field="hostname" header="Hostname" sortable style="width: 180px"></Column>
|
||||
<Column field="public_ip" header="Public IP" style="width: 150px"></Column>
|
||||
<Column field="running_network_count" header="Running Network Count" sortable style="width: 150px"></Column>
|
||||
<Column field="report_time" header="Report Time" sortable style="width: 150px"></Column>
|
||||
<Column field="easytier_version" header="EasyTier Version" sortable style="width: 150px"></Column>
|
||||
<Column class="w-24 !text-end">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-cog"
|
||||
@click="router.push({ name: 'deviceManagement', params: { deviceId: data.machine_id, instanceId: data.running_network_instances[0] } })"
|
||||
severity="secondary" rounded></Button>
|
||||
<Toolbar class="mb-4 p-3 gap-4 surface-0 border-1 surface-border rounded-md">
|
||||
<template #start>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="sort-by" class="text-sm text-500 hidden sm:block">{{ t('web.device.sort_by') }}:</label>
|
||||
<Dropdown id="sort-by" v-model="selectedSortOption" :options="sortOptions" optionLabel="name"
|
||||
class="sort-dropdown text-sm !min-w-[120px] sm:!min-w-[140px]" panelClass="text-sm">
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="[slotProps.value.icon, 'text-600']"></i>
|
||||
<span class="text-600">{{ slotProps.value.name() }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="[slotProps.option.icon, 'text-600']"></i>
|
||||
<span>{{ slotProps.option.name() }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Button :icon="ascending ? 'pi pi-sort-amount-up' : 'pi pi-sort-amount-down'" severity="secondary"
|
||||
text rounded class="sort-direction-btn min-w-[2.5rem] h-[2.5rem]"
|
||||
v-tooltip.top="ascending ? t('web.device.sort_direction_asc') : t('web.device.sort_direction_desc')"
|
||||
@click="toggleSortDirection" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #end>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="hidden sm:block border-r-1 surface-border h-4 mr-2"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="detailed-view" class="text-sm text-500 hidden sm:block">{{
|
||||
t('web.device.show_detailed_view') }}</label>
|
||||
<InputSwitch id="detailed-view" v-model="showDetailedView" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<Button icon="pi pi-refresh" label="Reload" severity="info" @click="loadDevices" />
|
||||
<div v-if="deviceList === undefined" class="w-full flex justify-center">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-if="deviceList !== undefined">
|
||||
<!-- 卡片视图 (适用于所有屏幕尺寸) -->
|
||||
<div class="card-container">
|
||||
<div v-for="device in sortedDeviceList" :key="device.machine_id" class="device-card">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header">
|
||||
<!-- 上部区域:设备名称和版本徽章 -->
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<!-- 设备名称 -->
|
||||
<div class="font-semibold truncate card-title" :title="device.hostname">{{ device.hostname
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- 版本徽章 -->
|
||||
<div class="text-xs version-badge" v-tooltip="`EasyTier ${device.easytier_version}`">
|
||||
v{{ device.easytier_version.split('-')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下部区域:IP地址和操作按钮 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<!-- IP地址 -->
|
||||
<div class="text-sm truncate card-subtitle max-w-[60%]" :title="device.public_ip">
|
||||
{{ device.public_ip }}
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 网络数量徽章 -->
|
||||
<span v-tooltip="t('web.device.network_count')"
|
||||
class="inline-flex items-center justify-center w-6 h-6 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
{{ device.running_network_count }}
|
||||
</span>
|
||||
|
||||
<!-- 详情按钮 -->
|
||||
<Button v-tooltip="t('web.device.show_detailed_view')" icon="pi pi-info-circle"
|
||||
severity="info" text rounded class="w-9 h-9" v-if="!showDetailedView"
|
||||
@click="showDeviceDetails(device, $event)" />
|
||||
|
||||
<!-- 设置按钮 -->
|
||||
<Button icon="pi pi-cog" @click="handleDeviceManagement(device)" severity="secondary"
|
||||
rounded class="w-9 h-9" :title="`Manage ${device.hostname}`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情区域 - 当开启详情显示时展示 -->
|
||||
<div v-if="showDetailedView" class="card-details border-t border-gray-200 fade-in">
|
||||
<DeviceDetails :device="device" containerClass="card-details-content" :compact="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Drawer v-model:visible="deviceManageVisible" :header="`Manage ${selectedDeviceHostname}`" position="right"
|
||||
:baseZIndex=1000 class="w-3/5 min-w-96">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :api="api" :deviceList="deviceList" @update="loadDevices" />
|
||||
</RouterView>
|
||||
</Drawer>
|
||||
<!-- 全局设备详情 Popover -->
|
||||
<Popover ref="detailPopover" :showCloseIcon="true" :closeOnEscape="true" :autoHide="false" appendTo="body"
|
||||
class="device-popover">
|
||||
<template v-if="selectedDevice">
|
||||
<div class="popover-header">
|
||||
<i class="pi pi-info-circle mr-2"></i>
|
||||
<span class="font-bold">设备详情</span>
|
||||
</div>
|
||||
<div class="device-details-popover">
|
||||
<DeviceDetails :device="selectedDevice" containerClass="popover-details-content" :compact="true" />
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<Drawer v-model:visible="deviceManageVisible" :position="drawerPosition"
|
||||
:header="`Manage ${selectedDeviceHostname}`" :baseZIndex=1000 class="" :class="drawerWidth"
|
||||
:style="{ height: drawerHeight }">
|
||||
<template #container="{ closeCallback }">
|
||||
<div style="position: relative; height: 100%;" class="device-manage-drawer">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :api="api" :deviceList="deviceList" @update="loadDevices" />
|
||||
</RouterView>
|
||||
<Button icon="pi pi-times" rounded severity="danger"
|
||||
class="fixed z-50 right-6 bottom-6 shadow-lg drawer-fab-close-btn"
|
||||
style="width: 3.2rem; height: 3.2rem; font-size: 1.5rem;" @click="closeCallback" />
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Toolbar, IftaLabel, Select, Button, ConfirmPopup, Dialog, useConfirm, useToast, Divider } from 'primevue';
|
||||
import { IftaLabel, Select, Button, ConfirmPopup, useConfirm, useToast, Divider, Menu } from 'primevue';
|
||||
import { NetworkTypes, Status, Utils, Api, ConfigEditDialog } from 'easytier-frontend-lib';
|
||||
import { watch, computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
api: Api.ApiClient;
|
||||
@@ -34,6 +37,7 @@ const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
|
||||
const isEditing = ref(false);
|
||||
const showCreateNetworkDialog = ref(false);
|
||||
const showConfigEditDialog = ref(false);
|
||||
const isCreatingNetwork = ref(false); // Flag to indicate if we're in network creation mode
|
||||
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
|
||||
|
||||
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
|
||||
@@ -162,13 +166,19 @@ const createNewNetwork = async () => {
|
||||
}
|
||||
emits('update');
|
||||
showCreateNetworkDialog.value = false;
|
||||
isCreatingNetwork.value = false; // Exit creation mode after successful network creation
|
||||
}
|
||||
|
||||
const newNetwork = () => {
|
||||
newNetworkConfig.value = NetworkTypes.DEFAULT_NETWORK_CONFIG();
|
||||
newNetworkConfig.value.hostname = deviceInfo.value?.hostname;
|
||||
isEditing.value = false;
|
||||
showCreateNetworkDialog.value = true;
|
||||
// showCreateNetworkDialog.value = true; // Old dialog approach
|
||||
isCreatingNetwork.value = true; // Switch to creation mode instead
|
||||
}
|
||||
|
||||
const cancelNetworkCreation = () => {
|
||||
isCreatingNetwork.value = false;
|
||||
}
|
||||
|
||||
const editNetwork = async () => {
|
||||
@@ -183,7 +193,8 @@ const editNetwork = async () => {
|
||||
let ret = await props.api?.get_network_config(deviceId.value, instanceId.value);
|
||||
console.debug("editNetwork", ret);
|
||||
newNetworkConfig.value = ret;
|
||||
showCreateNetworkDialog.value = true;
|
||||
// showCreateNetworkDialog.value = true; // Old dialog approach
|
||||
isCreatingNetwork.value = true; // Switch to creation mode instead
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
@@ -310,6 +321,33 @@ const saveConfig = async (tomlConfig: string): Promise<void> => {
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式屏幕宽度
|
||||
const screenWidth = ref(window.innerWidth);
|
||||
const updateScreenWidth = () => {
|
||||
screenWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
// 菜单引用和菜单项
|
||||
const menuRef = ref();
|
||||
const actionMenu = ref([
|
||||
{
|
||||
label: t('web.device_management.edit_network'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: () => editNetwork()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.export_config'),
|
||||
icon: 'pi pi-download',
|
||||
command: () => exportConfig()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.delete_network'),
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-error',
|
||||
command: () => confirmDeleteNetwork(new Event('click'))
|
||||
}
|
||||
]);
|
||||
|
||||
let periodFunc = new Utils.PeriodicTask(async () => {
|
||||
try {
|
||||
await Promise.all([loadNetworkInstanceIds(), loadDeviceInfo()]);
|
||||
@@ -320,75 +358,253 @@ let periodFunc = new Utils.PeriodicTask(async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
periodFunc.start();
|
||||
|
||||
// 添加屏幕尺寸监听
|
||||
window.addEventListener('resize', updateScreenWidth);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
periodFunc.stop();
|
||||
|
||||
// 移除屏幕尺寸监听
|
||||
window.removeEventListener('resize', updateScreenWidth);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
<Dialog v-if="!networkIsDisabled" v-model:visible="showCreateNetworkDialog" modal
|
||||
:header="!isEditing ? 'Create New Network' : 'Edit Network'" :style="{ width: '55rem' }">
|
||||
<div class="flex flex-col">
|
||||
<div class="w-11/12 self-center space-x-2">
|
||||
<Button @click="showConfigEditDialog = true" icon="pi pi-pen-to-square" label="Edit File" iconPos="right" />
|
||||
<Button @click="importConfig" icon="pi pi-file-import" label="Import" iconPos="right" />
|
||||
<div class="device-management">
|
||||
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
|
||||
<!-- 网络选择和操作按钮始终在同一行 -->
|
||||
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
|
||||
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
|
||||
<!-- 网络选择 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<IftaLabel class="w-full">
|
||||
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" class="w-full"
|
||||
inputId="dd-inst-id" :placeholder="t('web.device_management.select_network')"
|
||||
:pt="{ root: { class: 'network-select-container' } }" />
|
||||
<label class="network-label mr-2 font-medium" for="dd-inst-id">{{
|
||||
t('web.device_management.network') }}</label>
|
||||
</IftaLabel>
|
||||
</div>
|
||||
|
||||
<!-- 简化的按钮区域 - 无论屏幕大小都显示 -->
|
||||
<div class="flex gap-2 shrink-0 button-container items-center">
|
||||
<!-- Create/Cancel button based on state -->
|
||||
<Button v-if="!isCreatingNetwork" @click="newNetwork" icon="pi pi-plus"
|
||||
:label="screenWidth > 640 ? t('web.device_management.create_new') : undefined"
|
||||
:class="['create-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
|
||||
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
|
||||
:tooltip="screenWidth <= 640 ? t('web.device_management.create_network') : undefined"
|
||||
tooltipOptions="{ position: 'bottom' }" severity="primary" />
|
||||
|
||||
<Button v-else @click="cancelNetworkCreation" icon="pi pi-times"
|
||||
:label="screenWidth > 640 ? t('web.device_management.cancel_creation') : undefined"
|
||||
:class="['cancel-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
|
||||
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
|
||||
:tooltip="screenWidth <= 640 ? t('web.device_management.cancel_creation') : undefined"
|
||||
tooltipOptions="{ position: 'bottom' }" severity="secondary" />
|
||||
|
||||
<!-- More actions menu -->
|
||||
<Menu ref="menuRef" :model="actionMenu" :popup="true" />
|
||||
<Button v-if="!isCreatingNetwork && selectedInstanceId" icon="pi pi-ellipsis-v"
|
||||
class="p-button-rounded flex items-center justify-center" severity="help"
|
||||
style="width: 3rem !important; height: 3rem !important; font-size: 1.2rem"
|
||||
@click="menuRef.toggle($event)" :aria-label="t('web.device_management.more_actions')"
|
||||
:tooltip="t('web.device_management.more_actions')" tooltipOptions="{ position: 'bottom' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
|
||||
</Dialog>
|
||||
<ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
|
||||
:cur-network="disabledNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" />
|
||||
<ConfigEditDialog v-else v-model:visible="showConfigEditDialog" :cur-network="newNetworkConfig"
|
||||
:generate-config="generateConfig" :save-config="saveConfig" />
|
||||
|
||||
<Toolbar>
|
||||
<template #start>
|
||||
<IftaLabel>
|
||||
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" inputId="dd-inst-id"
|
||||
placeholder="Select Instance" />
|
||||
<label class="mr-3" for="dd-inst-id">Network</label>
|
||||
</IftaLabel>
|
||||
</template>
|
||||
<!-- Main Content Area -->
|
||||
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
|
||||
<!-- Network Creation Form -->
|
||||
<div v-if="isCreatingNetwork" class="network-creation-container">
|
||||
<div class="network-creation-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-plus-circle text-primary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ isEditing ? t('web.device_management.edit_network') :
|
||||
t('web.device_management.create_network') }}</h2>
|
||||
</div>
|
||||
|
||||
<template #end>
|
||||
<div class="gap-x-3 flex">
|
||||
<Button @click="confirmDeleteNetwork($event)" icon="pi pi-minus" severity="danger" label="Delete"
|
||||
iconPos="right" />
|
||||
<Button @click="exportConfig" icon="pi pi-file-export" severity="help" label="Export" iconPos="right" />
|
||||
<Button @click="editNetwork" icon="pi pi-pen-to-square" label="Edit" iconPos="right" severity="info" />
|
||||
<Button @click="newNetwork" icon="pi pi-plus" label="Create" iconPos="right" />
|
||||
<div class="w-full flex gap-2 flex-wrap justify-start mb-3">
|
||||
<Button @click="showConfigEditDialog = true" icon="pi pi-file-edit"
|
||||
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
|
||||
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
|
||||
iconPos="left" severity="help" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<Divider />
|
||||
<!-- Network Status (for running networks) -->
|
||||
<div v-else-if="needShowNetworkStatus" class="network-status-container">
|
||||
<div class="network-status-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-chart-line text-primary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- For running network, show the status -->
|
||||
<div v-if="needShowNetworkStatus">
|
||||
<Status v-bind:cur-network-inst="curNetworkInfo" v-if="needShowNetworkStatus">
|
||||
</Status>
|
||||
<Divider />
|
||||
<div class="text-center">
|
||||
<Button @click="updateNetworkState(true)" label="Disable Network" severity="warn" />
|
||||
<Status v-bind:cur-network-inst="curNetworkInfo" class="mb-4"></Status>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<Button @click="updateNetworkState(true)" :label="t('web.device_management.disable_network')"
|
||||
severity="warning" icon="pi pi-power-off" iconPos="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Configuration (for disabled networks) -->
|
||||
<div v-else-if="networkIsDisabled" class="network-config-container">
|
||||
<div class="network-config-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-cog text-secondary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.network_configuration') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="disabledNetworkConfig" class="mb-4">
|
||||
<Config :cur-network="disabledNetworkConfig" @run-network="updateNetworkState(false)" />
|
||||
</div>
|
||||
<div v-else class="network-loading-placeholder text-center py-8">
|
||||
<i class="pi pi-spin pi-spinner text-3xl text-primary mb-3"></i>
|
||||
<div class="text-xl text-secondary">{{ t('web.device_management.loading_network_configuration') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state flex flex-col items-center py-12">
|
||||
<i class="pi pi-sitemap text-5xl text-secondary mb-4 opacity-50"></i>
|
||||
<div class="text-xl text-center font-medium mb-3">{{ t('web.device_management.no_network_selected') }}
|
||||
</div>
|
||||
<p class="text-secondary text-center mb-6 max-w-md">
|
||||
{{ t('web.device_management.select_existing_network_or_create_new') }}
|
||||
</p>
|
||||
<Button @click="newNetwork" :label="t('web.device_management.create_network')" icon="pi pi-plus"
|
||||
iconPos="left" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- For disabled network, show the config -->
|
||||
<div v-if="networkIsDisabled">
|
||||
<Config :cur-network="disabledNetworkConfig" @run-network="updateNetworkState(false)"
|
||||
v-if="disabledNetworkConfig" />
|
||||
<div v-else>
|
||||
<div class="text-center text-xl"> Network is disabled, Loading config... </div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Keep only the config edit dialogs -->
|
||||
<ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
|
||||
:cur-network="disabledNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" />
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 place-content-center h-full" v-if="!selectedInstanceId">
|
||||
<div class="text-center text-xl"> Select or create a network instance to manage </div>
|
||||
<ConfigEditDialog v-else v-model:visible="showConfigEditDialog" :cur-network="newNetworkConfig"
|
||||
:generate-config="generateConfig" :save-config="saveConfig" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-management {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.network-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.button-container {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
font-weight: 600;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
/* 菜单样式定制 */
|
||||
:deep(.p-menu) {
|
||||
min-width: 12rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem) {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem-link) {
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem-icon) {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem.p-error .p-menuitem-text,
|
||||
.p-menu .p-menuitem.p-error .p-menuitem-icon) {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem:hover.p-error .p-menuitem-link) {
|
||||
background-color: var(--red-50);
|
||||
}
|
||||
|
||||
/* 按钮图标样式 */
|
||||
:deep(.p-button-icon-only) {
|
||||
width: 2.5rem !important;
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
:deep(.p-button-icon-only .p-button-icon) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 网络选择相关样式 */
|
||||
.network-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.network-select-container) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode adaptations */
|
||||
:deep(.bg-surface-50) {
|
||||
background-color: var(--surface-50, #f8fafc);
|
||||
}
|
||||
|
||||
:deep(.bg-surface-0) {
|
||||
background-color: var(--surface-card, #ffffff);
|
||||
}
|
||||
|
||||
:deep(.text-primary) {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
:deep(.text-secondary) {
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.bg-surface-50) {
|
||||
background-color: var(--surface-ground, #0f172a);
|
||||
}
|
||||
|
||||
:deep(.bg-surface-0) {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design for mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
.network-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.network-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* 在小屏幕上缩短网络标签文本 */
|
||||
.network-label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -3,8 +3,11 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { Api } from 'easytier-frontend-lib';
|
||||
import {getInitialApiHost, cleanAndLoadApiHosts, saveApiHost} from "../modules/api-host"
|
||||
import { Api, I18nUtils } from 'easytier-frontend-lib';
|
||||
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
isRegistering: boolean;
|
||||
@@ -74,57 +77,68 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<Card class="w-full max-w-md p-6">
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-semibold text-center">{{ isRegistering ? 'Register' : 'Login' }}
|
||||
<h2 class="text-2xl font-semibold text-center">{{ isRegistering ? t('web.login.register') :
|
||||
t('web.login.login') }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="p-field mb-4">
|
||||
<label for="api-host" class="block text-sm font-medium">Api Host</label>
|
||||
<label for="api-host" class="block text-sm font-medium">{{ t('web.login.api_host') }}</label>
|
||||
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
|
||||
@complete="apiHostSearch" class="w-full" />
|
||||
</div>
|
||||
<form v-if="!isRegistering" @submit.prevent="onSubmit" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="username" class="block text-sm font-medium">Username</label>
|
||||
<label for="username" class="block text-sm font-medium">{{ t('web.login.username') }}</label>
|
||||
<InputText id="username" v-model="username" required class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="password" class="block text-sm font-medium">Password</label>
|
||||
<label for="password" class="block text-sm font-medium">{{ t('web.login.password') }}</label>
|
||||
<Password id="password" v-model="password" required toggleMask :feedback="false" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Login" type="submit" class="w-full" />
|
||||
<Button :label="t('web.login.login')" type="submit" class="w-full" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Register" type="button" class="w-full"
|
||||
<Button :label="t('web.login.register')" type="button" class="w-full"
|
||||
@click="saveApiHost(apiHost); $router.replace({ name: 'register' })" severity="secondary" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="register-username" class="block text-sm font-medium">Username</label>
|
||||
<label for="register-username" class="block text-sm font-medium">{{ t('web.login.username')
|
||||
}}</label>
|
||||
<InputText id="register-username" v-model="registerUsername" required class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="register-password" class="block text-sm font-medium">Password</label>
|
||||
<label for="register-password" class="block text-sm font-medium">{{ t('web.login.password')
|
||||
}}</label>
|
||||
<Password id="register-password" v-model="registerPassword" required toggleMask
|
||||
:feedback="false" class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="captcha" class="block text-sm font-medium">Captcha</label>
|
||||
<label for="captcha" class="block text-sm font-medium">{{ t('web.login.captcha') }}</label>
|
||||
<InputText id="captcha" v-model="captcha" required class="w-full" />
|
||||
<img :src="captchaSrc" alt="Captcha" class="mt-2 mb-2" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Register" type="submit" class="w-full" />
|
||||
<Button :label="t('web.login.register')" type="submit" class="w-full" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Back to Login" type="button" class="w-full"
|
||||
<Button :label="t('web.login.back_to_login')" type="button" class="w-full"
|
||||
@click="saveApiHost(apiHost); $router.replace({ name: 'login' })" severity="secondary" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button icon="pi pi-language" type="button" class="rounded-full absolute top-4 right-4 z-10"
|
||||
style="box-shadow: 0 2px 8px rgba(0,0,0,0.08);" severity="contrast"
|
||||
@click="I18nUtils.toggleLanguage" :aria-label="t('web.main.language')"
|
||||
:v-tooltip="t('web.main.language')" />
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Api, I18nUtils } from 'easytier-frontend-lib'
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, onUnmounted, nextTick } from 'vue';
|
||||
import { Button, TieredMenu } from 'primevue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useDialog } from 'primevue/usedialog';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import Icon from '../assets/easytier.png'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const api = computed<Api.ApiClient | undefined>(() => {
|
||||
@@ -21,14 +23,10 @@ const api = computed<Api.ApiClient | undefined>(() => {
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
onMounted(async () => {
|
||||
await I18nUtils.loadLanguageAsync('cn')
|
||||
});
|
||||
|
||||
const userMenu = ref();
|
||||
const userMenuItems = ref([
|
||||
{
|
||||
label: 'Change Password',
|
||||
label: t('web.main.change_password'),
|
||||
icon: 'pi pi-key',
|
||||
command: () => {
|
||||
console.log('File');
|
||||
@@ -45,7 +43,7 @@ const userMenuItems = ref([
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Logout',
|
||||
label: t('web.main.logout'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: async () => {
|
||||
try {
|
||||
@@ -59,18 +57,58 @@ const userMenuItems = ref([
|
||||
])
|
||||
|
||||
const forceShowSideBar = ref(false)
|
||||
const sidebarRef = ref<HTMLElement>()
|
||||
const toggleButtonRef = ref<HTMLElement>()
|
||||
|
||||
// 处理点击外部区域关闭侧边栏
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// 如果侧边栏是隐藏的,不需要处理
|
||||
if (!forceShowSideBar.value) return;
|
||||
|
||||
// 检查点击是否在侧边栏内部或切换按钮上
|
||||
const isClickInsideSidebar = sidebarRef.value?.contains(target);
|
||||
const isClickOnToggleButton = toggleButtonRef.value?.contains(target);
|
||||
|
||||
// 如果点击在侧边栏外部且不在切换按钮上,则关闭侧边栏
|
||||
if (!isClickInsideSidebar && !isClickOnToggleButton) {
|
||||
forceShowSideBar.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 切换侧边栏显示状态
|
||||
const toggleSidebar = () => {
|
||||
forceShowSideBar.value = !forceShowSideBar.value;
|
||||
};
|
||||
|
||||
// 点击背景遮罩关闭侧边栏
|
||||
const closeSidebar = () => {
|
||||
forceShowSideBar.value = false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// 等待 DOM 渲染完成后添加事件监听器
|
||||
await nextTick();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar -->
|
||||
<template>
|
||||
<nav class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<nav
|
||||
class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 top-navbar">
|
||||
<div class="px-3 py-3 lg:px-5 lg:pl-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-start rtl:justify-end">
|
||||
<div class="sm:hidden">
|
||||
<Button type="button" aria-haspopup="true" icon="pi pi-list" variant="text" size="large"
|
||||
severity="contrast" @click="forceShowSideBar = !forceShowSideBar" />
|
||||
<Button ref="toggleButtonRef" type="button" aria-haspopup="true" icon="pi pi-list"
|
||||
variant="text" size="large" severity="contrast" @click="toggleSidebar" />
|
||||
</div>
|
||||
<a href="https://easytier.top" class="flex ms-2 md:me-24">
|
||||
<img :src="Icon" class="h-9 me-3" alt="FlowBite Logo" />
|
||||
@@ -79,52 +117,27 @@ const forceShowSideBar = ref(false)
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="language-switch">
|
||||
<Button icon="pi pi-language" @click="I18nUtils.toggleLanguage" rounded severity="contrast" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center ms-3">
|
||||
<div>
|
||||
<Button type="button" @click="userMenu.toggle($event)" aria-haspopup="true"
|
||||
aria-controls="user-menu" icon="pi pi-user" raised rounded />
|
||||
<TieredMenu ref="userMenu" id="user-menu" :model="userMenuItems" popup />
|
||||
</div>
|
||||
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
|
||||
id="dropdown-user">
|
||||
<div class="px-4 py-3" role="none">
|
||||
<p class="text-sm text-gray-900 dark:text-white" role="none">
|
||||
Neil Sims
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-gray-300" role="none">
|
||||
neil.sims@flowbite.com
|
||||
</p>
|
||||
</div>
|
||||
<ul class="py-1" role="none">
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Dashboard</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Earnings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Sign out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<aside id="logo-sidebar"
|
||||
<!-- 背景遮罩 - 只在侧边栏显示时显示 -->
|
||||
<div v-if="forceShowSideBar" class="fixed inset-0 z-30 bg-black bg-opacity-50 sm:hidden" @click="closeSidebar">
|
||||
</div>
|
||||
|
||||
<aside ref="sidebarRef" id="logo-sidebar"
|
||||
class="fixed top-1 left-0 z-40 w-64 h-screen pt-20 transition-transform bg-white border-r border-gray-201 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
|
||||
:class="{ '-translate-x-full': !forceShowSideBar }" aria-label="Sidebar">
|
||||
<div class="h-full px-3 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
|
||||
@@ -133,21 +146,21 @@ const forceShowSideBar = ref(false)
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
|
||||
severity="contrast" @click="router.push({ name: 'dashboard' })">
|
||||
<i class="pi pi-chart-pie text-xl"></i>
|
||||
<span class="mb-0.5">DashBoard</span>
|
||||
<span class="mb-0.5">{{ t('web.main.dashboard') }}</span>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
|
||||
severity="contrast" @click="router.push({ name: 'deviceList' })">
|
||||
<i class="pi pi-server text-xl"></i>
|
||||
<span class="mb-0.5">Devices</span>
|
||||
<span class="mb-0.5">{{ t('web.main.device_list') }}</span>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
|
||||
severity="contrast" @click="router.push({ name: 'login' })">
|
||||
<i class="pi pi-sign-in text-xl"></i>
|
||||
<span class="mb-0.5">Login Page</span>
|
||||
<span class="mb-0.5">{{ t('web.main.login_page') }}</span>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -155,7 +168,7 @@ const forceShowSideBar = ref(false)
|
||||
</aside>
|
||||
|
||||
<div class="p-4 sm:ml-64">
|
||||
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700 mt-14">
|
||||
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :api="api" />
|
||||
|
@@ -6,6 +6,7 @@ import EasytierFrontendLib from 'easytier-frontend-lib'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import MainPage from './components/MainPage.vue'
|
||||
@@ -78,7 +79,12 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
createApp(App).use(PrimeVue,
|
||||
const app = createApp(App)
|
||||
|
||||
// Use i18n
|
||||
app.use(I18nUtils.i18n)
|
||||
|
||||
app.use(PrimeVue,
|
||||
{
|
||||
theme: {
|
||||
preset: Aura,
|
||||
|
@@ -10,11 +10,11 @@
|
||||
}
|
||||
|
||||
.p-password {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-password>input {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -23,11 +23,30 @@
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* 顶部导航栏安全区适配(如有 .top-navbar 类) */
|
||||
.top-navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 全屏内容适配移动端浏览器可视区 */
|
||||
.fullscreen-content {
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 如导航栏类名不同,请将 .top-navbar 替换为实际类名 */
|
||||
|
||||
.device-manage-drawer {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||
// import { viteSingleFile } from "vite-plugin-singlefile"
|
||||
|
||||
const WEB_BASE_URL = process.env.WEB_BASE_URL || '';
|
||||
@@ -8,7 +9,7 @@ const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:11211';
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: WEB_BASE_URL,
|
||||
plugins: [vue(),/* viteSingleFile() */],
|
||||
plugins: [vue(), ViteYaml(),/* viteSingleFile() */],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -144,6 +144,9 @@ importers:
|
||||
|
||||
easytier-web/frontend:
|
||||
dependencies:
|
||||
'@modyfi/vite-plugin-yaml':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(rollup@4.24.3)(vite@5.4.10(@types/node@22.8.6))
|
||||
'@primevue/themes':
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
@@ -165,6 +168,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.12
|
||||
version: 3.5.12(typescript@5.6.3)
|
||||
vue-i18n:
|
||||
specifier: ^9.9.1
|
||||
version: 9.14.4(vue@3.5.12(typescript@5.6.3))
|
||||
vue-router:
|
||||
specifier: '4'
|
||||
version: 4.4.5(vue@3.5.12(typescript@5.6.3))
|
||||
@@ -907,6 +913,10 @@ packages:
|
||||
resolution: {integrity: sha512-GG428DkrrWCMhxRMRQZjuS7zmSUzarYcaHJqG9VB8dXAxw4iQDoKVQ7ChJRB6ZtsCsX3Jse1PEUlHrJiyQrOTg==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/core-base@9.14.4':
|
||||
resolution: {integrity: sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@10.0.4':
|
||||
resolution: {integrity: sha512-AFbhEo10DP095/45EauinQJ5hJ3rJUmuuqltGguvc3WsvezZN+g8qNHLGWKu60FHQVizMrQY7VJ+zVlBXlQQkQ==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -915,6 +925,10 @@ packages:
|
||||
resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@9.14.4':
|
||||
resolution: {integrity: sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@10.0.4':
|
||||
resolution: {integrity: sha512-ukFn0I01HsSgr3VYhYcvkTCLS7rGa0gw4A4AMpcy/A9xx/zRJy7PS2BElMXLwUazVFMAr5zuiTk3MQeoeGXaJg==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -923,6 +937,10 @@ packages:
|
||||
resolution: {integrity: sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@9.14.4':
|
||||
resolution: {integrity: sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@5.2.0':
|
||||
resolution: {integrity: sha512-pmRiPY2Nj9mmSrixT69aO45XxGUr5fDBy/IIw4ajLlDTJm5TSmQKA5YNdsH0uxVDCPWy5tlQrF18hkDwI7UJvg==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -3776,6 +3794,12 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-i18n@9.14.4:
|
||||
resolution: {integrity: sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-resize@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
|
||||
peerDependencies:
|
||||
@@ -4397,6 +4421,11 @@ snapshots:
|
||||
'@intlify/message-compiler': 10.0.4
|
||||
'@intlify/shared': 10.0.4
|
||||
|
||||
'@intlify/core-base@9.14.4':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 9.14.4
|
||||
'@intlify/shared': 9.14.4
|
||||
|
||||
'@intlify/message-compiler@10.0.4':
|
||||
dependencies:
|
||||
'@intlify/shared': 10.0.4
|
||||
@@ -4407,10 +4436,17 @@ snapshots:
|
||||
'@intlify/shared': 12.0.0-alpha.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/message-compiler@9.14.4':
|
||||
dependencies:
|
||||
'@intlify/shared': 9.14.4
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/shared@10.0.4': {}
|
||||
|
||||
'@intlify/shared@12.0.0-alpha.2': {}
|
||||
|
||||
'@intlify/shared@9.14.4': {}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@5.2.0(@vue/compiler-dom@3.5.12)(eslint@9.14.0(jiti@2.4.0))(rollup@4.24.3)(typescript@5.6.3)(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.14.0(jiti@2.4.0))
|
||||
@@ -7767,6 +7803,13 @@ snapshots:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.12(typescript@5.6.3)
|
||||
|
||||
vue-i18n@9.14.4(vue@3.5.12(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 9.14.4
|
||||
'@intlify/shared': 9.14.4
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.12(typescript@5.6.3)
|
||||
|
||||
vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.3)
|
||||
|
Reference in New Issue
Block a user