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

This commit is contained in:
Sijie.Sun
2025-06-24 09:09:52 +08:00
committed by GitHub
parent 760a1e6306
commit ae4a158e36
18 changed files with 1628 additions and 176 deletions

View File

@@ -15,8 +15,6 @@
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;

View File

@@ -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>

View File

@@ -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: 退出登录

View File

@@ -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

View File

@@ -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,
}

View File

@@ -18,8 +18,6 @@
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;

View File

@@ -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>

View File

@@ -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",

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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
View File

@@ -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)