mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-11-04 00:40:21 +00:00
Merge upstream admin updates and fix dashboard pointer and group picker interactions
This commit is contained in:
commit
a04161683b
16 files changed
+2301
-2363
No files matched your search
@@ -59,7 +59,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.22.2
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: admin-frontend/package-lock.json
|
cache-dependency-path: admin-frontend/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22.19.0
|
node-version: 22.22.2
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -31,10 +31,29 @@
|
|||||||
"react/rules-of-hooks": "error",
|
"react/rules-of-hooks": "error",
|
||||||
"typescript/no-explicit-any": "off",
|
"typescript/no-explicit-any": "off",
|
||||||
"react/only-export-components": "off",
|
"react/only-export-components": "off",
|
||||||
|
// React Compiler lint stays opt-in until the compiler is adopted.
|
||||||
|
"react/capitalized-calls": "off",
|
||||||
|
"react/error-boundaries": "off",
|
||||||
|
"react/exhaustive-effect-dependencies": "off",
|
||||||
|
"react/globals": "off",
|
||||||
|
"react/hooks": "off",
|
||||||
"react/immutability": "off",
|
"react/immutability": "off",
|
||||||
"react/set-state-in-effect": "off",
|
|
||||||
"react/incompatible-library": "off",
|
"react/incompatible-library": "off",
|
||||||
"react/refs": "off"
|
"react/invariant": "off",
|
||||||
|
"react/memo-dependencies": "off",
|
||||||
|
"react/no-deriving-state-in-effects": "off",
|
||||||
|
"react/preserve-manual-memoization": "off",
|
||||||
|
"react/purity": "off",
|
||||||
|
"react/refs": "off",
|
||||||
|
"react/rule-suppression": "off",
|
||||||
|
"react/set-state-in-effect": "off",
|
||||||
|
"react/set-state-in-render": "off",
|
||||||
|
"react/static-components": "off",
|
||||||
|
"react/syntax": "off",
|
||||||
|
"react/todo": "off",
|
||||||
|
"react/unsupported-syntax": "off",
|
||||||
|
"react/use-memo": "off",
|
||||||
|
"react/void-use-memo": "off"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+782
-846
File diff suppressed because it is too large.
Load diff
+43
-43
@@ -17,24 +17,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.9.1",
|
"@hookform/resolvers": "^5.9.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||||
"@radix-ui/react-avatar": "^1.2.6",
|
"@radix-ui/react-avatar": "^1.3.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.4.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.3.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.17",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.22",
|
"@radix-ui/react-navigation-menu": "^1.4.1",
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
"@radix-ui/react-popover": "^1.3.1",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
"@radix-ui/react-scroll-area": "^1.4.0",
|
||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.4.1",
|
||||||
"@radix-ui/react-separator": "^1.1.15",
|
"@radix-ui/react-separator": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.5.0",
|
||||||
"@radix-ui/react-switch": "^1.3.7",
|
"@radix-ui/react-switch": "^1.4.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.21",
|
"@radix-ui/react-tabs": "^1.2.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tanstack/react-table": "^9.1.2",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/luxon": "^3.7.5",
|
"@types/luxon": "^3.7.6",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -42,46 +42,46 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"framer-motion": "^13.1.1",
|
"framer-motion": "^14.2.0",
|
||||||
"i18next": "^26.4.0",
|
"i18next": "^26.4.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jotai-zustand": "^0.6.0",
|
"jotai-zustand": "^0.6.0",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.56.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.3.0",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.2",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.3.0",
|
||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.90.0",
|
||||||
"react-i18next": "^17.0.12",
|
"react-i18next": "^17.0.16",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-virtuoso": "^4.18.12",
|
"react-virtuoso": "^4.18.19",
|
||||||
"simple-icons": "^16.28.0",
|
"simple-icons": "^16.35.0",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"swr": "^2.5.1",
|
"swr": "^2.5.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.6.7",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.64.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/node": "^26.3.0",
|
"@types/node": "^26.6.5",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
"autoprefixer": "^10.5.4",
|
"autoprefixer": "^10.6.1",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.1.2",
|
||||||
"oxlint": "1.80.0",
|
"oxlint": "1.88.0",
|
||||||
"postcss": "8.5.26",
|
"postcss": "8.5.30",
|
||||||
"swagger-typescript-api": "^13.12.6",
|
"swagger-typescript-api": "^13.13.0",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"typescript": "~7.0.2",
|
"typescript": "~7.0.2",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.3.4",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^5.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1156
-1465
File diff suppressed because it is too large.
Load diff
@@ -100,6 +100,11 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:not(:disabled):not([aria-disabled="true"]),
|
||||||
|
[role="button"]:not([aria-disabled="true"]):not([data-disabled]) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -164,6 +164,8 @@
|
|||||||
"ServerNotificationDays": "Server Notification Days",
|
"ServerNotificationDays": "Server Notification Days",
|
||||||
"FullIPNotification": "Show Full IP Address in Notification Messages",
|
"FullIPNotification": "Show Full IP Address in Notification Messages",
|
||||||
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
|
"EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)",
|
||||||
|
"OnlineStatsDays": "Online stats range (days)",
|
||||||
|
"OnlineStatsDaysHint": "Retention and query window for per-server online periods; sessions outside the range are removed by the daily cleanup job.",
|
||||||
"EditService": "Edit Service",
|
"EditService": "Edit Service",
|
||||||
"CreateService": "Create Service",
|
"CreateService": "Create Service",
|
||||||
"EditTask": "Edit Task",
|
"EditTask": "Edit Task",
|
||||||
|
|||||||
@@ -171,6 +171,8 @@
|
|||||||
"ServerNotificationDays": "VPS到期提醒天数",
|
"ServerNotificationDays": "VPS到期提醒天数",
|
||||||
"FullIPNotification": "在通知消息中显示完整的 IP 地址",
|
"FullIPNotification": "在通知消息中显示完整的 IP 地址",
|
||||||
"EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)",
|
"EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)",
|
||||||
|
"OnlineStatsDays": "在线统计范围(天)",
|
||||||
|
"OnlineStatsDaysHint": "设备在线时间段的统计与保留窗口,超出范围的记录会在每日清理时删除。",
|
||||||
"LoginFailed": "登录失败",
|
"LoginFailed": "登录失败",
|
||||||
"BruteForceAttackingToken": "暴力攻击令牌",
|
"BruteForceAttackingToken": "暴力攻击令牌",
|
||||||
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
|
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
|
||||||
|
|||||||
@@ -152,8 +152,8 @@
|
|||||||
"ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。",
|
"ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。",
|
||||||
"ConfigTLS": "Agent 使用 TLS 連線",
|
"ConfigTLS": "Agent 使用 TLS 連線",
|
||||||
"CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器",
|
"CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器",
|
||||||
"WebRealIPHeader": "前端真實IP請求頭",
|
"WebRealIPHeader": "前端真實 IP 請求標頭",
|
||||||
"AgentRealIPHeader": "Agent真實IP請求頭",
|
"AgentRealIPHeader": "Agent 真實 IP 請求標頭",
|
||||||
"UseDirectConnectingIP": "使用直連 IP",
|
"UseDirectConnectingIP": "使用直連 IP",
|
||||||
"IPChangeNotification": "IP變更通知",
|
"IPChangeNotification": "IP變更通知",
|
||||||
"FullIPNotification": "在通知訊息中顯示完整的 IP 位址",
|
"FullIPNotification": "在通知訊息中顯示完整的 IP 位址",
|
||||||
@@ -224,6 +224,8 @@
|
|||||||
"StatusCancelled": "已取消"
|
"StatusCancelled": "已取消"
|
||||||
},
|
},
|
||||||
"EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)",
|
"EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)",
|
||||||
|
"OnlineStatsDays": "線上統計範圍(天)",
|
||||||
|
"OnlineStatsDaysHint": "裝置線上時間段的統計與保留視窗,超出範圍的紀錄會在每日清理時刪除。",
|
||||||
"ApiTokens": "API Token",
|
"ApiTokens": "API Token",
|
||||||
"CreateApiToken": "建立 API token",
|
"CreateApiToken": "建立 API token",
|
||||||
"CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。",
|
"CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。",
|
||||||
@@ -249,5 +251,71 @@
|
|||||||
"Copied": "已複製到剪貼簿",
|
"Copied": "已複製到剪貼簿",
|
||||||
"Scopes": "範圍",
|
"Scopes": "範圍",
|
||||||
"InvalidUsernameOrPassword": "使用者名稱或密碼無效",
|
"InvalidUsernameOrPassword": "使用者名稱或密碼無效",
|
||||||
"NetworkError": "網路錯誤"
|
"NetworkError": "網路錯誤",
|
||||||
|
"Terminal": "終端機",
|
||||||
|
"Config": "設定",
|
||||||
|
"Add": "新增",
|
||||||
|
"Delete": "刪除",
|
||||||
|
"AdvancedJSON": "進階 JSON",
|
||||||
|
"Save": "儲存",
|
||||||
|
"PublicNote": {
|
||||||
|
"Label": "公開備註",
|
||||||
|
"Billing": "計費",
|
||||||
|
"Plan": "方案",
|
||||||
|
"StartDate": "開始日期",
|
||||||
|
"EndDate": "結束日期",
|
||||||
|
"AutoRenewal": "自動續約",
|
||||||
|
"Cycle": "週期",
|
||||||
|
"Amount": "金額",
|
||||||
|
"Bandwidth": "頻寬",
|
||||||
|
"TrafficVolume": "流量",
|
||||||
|
"TrafficType": "流量計算方式",
|
||||||
|
"IPv4": "IPv4",
|
||||||
|
"IPv6": "IPv6",
|
||||||
|
"NetworkRoute": "網路線路",
|
||||||
|
"Extra": "其他資訊",
|
||||||
|
"Enabled": "已啟用",
|
||||||
|
"Disabled": "已停用",
|
||||||
|
"Inbound": "連入流量",
|
||||||
|
"Both": "雙向流量",
|
||||||
|
"Day": "天",
|
||||||
|
"Week": "週",
|
||||||
|
"Month": "月",
|
||||||
|
"Year": "年",
|
||||||
|
"NoExpiry": "不會到期",
|
||||||
|
"SetNoExpiry": "設定為不會到期",
|
||||||
|
"CancelNoExpiry": "取消不會到期",
|
||||||
|
"Free": "免費",
|
||||||
|
"PayAsYouGo": "按用量計費",
|
||||||
|
"CommaSeparated": "多個項目請以逗號分隔",
|
||||||
|
"Has": "有",
|
||||||
|
"None": "無",
|
||||||
|
"CustomFields": "自訂欄位",
|
||||||
|
"ClearDate": "清除日期",
|
||||||
|
"Clear": "清除",
|
||||||
|
"RawText": "原始文字"
|
||||||
|
},
|
||||||
|
"Validation": {
|
||||||
|
"InvalidDate": "日期無效",
|
||||||
|
"MustBe0Or1": "必須為 0 或 1",
|
||||||
|
"MustBeDayWeekMonthYear": "必須為 Day/Week/Month/Year",
|
||||||
|
"MustBe1Or2": "必須為 1 或 2",
|
||||||
|
"DigitsOnly": "只能輸入數字",
|
||||||
|
"InvalidForm": "表單無效",
|
||||||
|
"InvalidJSON": "JSON 無效"
|
||||||
|
},
|
||||||
|
"AlertRules": {
|
||||||
|
"CoverAllServers": "監控所有伺服器",
|
||||||
|
"IgnoreAllSelectSpecific": "忽略所有伺服器,僅選取特定伺服器",
|
||||||
|
"IgnoreHint": "{{server}} ID:true/false",
|
||||||
|
"IgnoreExample": "例如:{\"1\": true, \"2\": false}"
|
||||||
|
},
|
||||||
|
"Search": "搜尋...",
|
||||||
|
"Format": "格式化",
|
||||||
|
"Formatted": "已格式化",
|
||||||
|
"Paste": "貼上",
|
||||||
|
"CopiedToClipboard": "已複製到剪貼簿",
|
||||||
|
"ClipboardWriteFailed": "寫入剪貼簿失敗",
|
||||||
|
"PastedFromClipboard": "已從剪貼簿貼上",
|
||||||
|
"ClipboardReadFailed": "讀取剪貼簿失敗"
|
||||||
}
|
}
|
||||||
+30
-1
@@ -41,6 +41,7 @@ const settingFormSchema = z.object({
|
|||||||
ignored_ip_notification: asOptionalField(z.string()),
|
ignored_ip_notification: asOptionalField(z.string()),
|
||||||
ip_change_notification_group_id: z.coerce.number().int().min(0),
|
ip_change_notification_group_id: z.coerce.number().int().min(0),
|
||||||
cover: z.coerce.number().int().min(1),
|
cover: z.coerce.number().int().min(1),
|
||||||
|
online_stats_days: z.coerce.number().int().min(7).max(3650),
|
||||||
site_name: z.string().min(1),
|
site_name: z.string().min(1),
|
||||||
language: z.string().min(2),
|
language: z.string().min(2),
|
||||||
user_template: z.string().min(1),
|
user_template: z.string().min(1),
|
||||||
@@ -96,6 +97,9 @@ export default function SettingsPage() {
|
|||||||
defaultValues: config
|
defaultValues: config
|
||||||
? {
|
? {
|
||||||
...config.config,
|
...config.config,
|
||||||
|
// 兼容尚未包含 online_stats_days 的旧后端:字段缺省时回退
|
||||||
|
// 默认值,避免必填校验拦截其他设置的提交
|
||||||
|
online_stats_days: config.config?.online_stats_days ?? 7,
|
||||||
user_template:
|
user_template:
|
||||||
config.config?.user_template ||
|
config.config?.user_template ||
|
||||||
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] ||
|
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] ||
|
||||||
@@ -104,6 +108,7 @@ export default function SettingsPage() {
|
|||||||
: {
|
: {
|
||||||
ip_change_notification_group_id: 0,
|
ip_change_notification_group_id: 0,
|
||||||
cover: 1,
|
cover: 1,
|
||||||
|
online_stats_days: 7,
|
||||||
site_name: "",
|
site_name: "",
|
||||||
language: "",
|
language: "",
|
||||||
user_template: "user-dist",
|
user_template: "user-dist",
|
||||||
@@ -118,7 +123,11 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.config) {
|
if (config?.config) {
|
||||||
form.reset(config?.config)
|
// 与 defaultValues 同一兼容口径:旧后端缺字段时回退默认值
|
||||||
|
form.reset({
|
||||||
|
...config.config,
|
||||||
|
online_stats_days: config.config?.online_stats_days ?? 7,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [config?.config, form])
|
}, [config?.config, form])
|
||||||
|
|
||||||
@@ -805,6 +814,26 @@ export default function SettingsPage() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="online_stats_days"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("OnlineStatsDays")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={7}
|
||||||
|
max={3650}
|
||||||
|
placeholder="7"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>{t("OnlineStatsDaysHint")}</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button type="submit">{t("Confirm")}</Button>
|
<Button type="submit">{t("Confirm")}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { MemoryRouter } from "react-router-dom"
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string) => k,
|
||||||
|
i18n: { language: "en", changeLanguage: vi.fn() },
|
||||||
|
}),
|
||||||
|
initReactI18next: { type: "3rdParty", init: () => undefined },
|
||||||
|
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useAuth", () => ({
|
||||||
|
useAuth: () => ({ profile: { id: 1, role: 0 }, loading: false }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useNotfication", () => ({
|
||||||
|
useNotification: () => ({ notifierGroup: [] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// h.data 可在每个用例中替换,覆盖"配置带/不带 online_stats_days"两种后端形态
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
site_name: "Nezha",
|
||||||
|
language: "zh-CN",
|
||||||
|
user_template: "user-dist",
|
||||||
|
cover: 1,
|
||||||
|
ip_change_notification_group_id: 0,
|
||||||
|
online_stats_days: 30,
|
||||||
|
},
|
||||||
|
frontend_templates: [] as unknown[],
|
||||||
|
},
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useSetting", () => ({
|
||||||
|
default: () => ({ data: h.data, mutate: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/api/settings", () => ({
|
||||||
|
updateSettings: (...args: unknown[]) => h.updateSettings(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.updateSettings.mockReset()
|
||||||
|
h.updateSettings.mockResolvedValue(undefined)
|
||||||
|
h.data.config.online_stats_days = 30
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renderSettings() {
|
||||||
|
const { default: SettingsPage } = await import("@/routes/settings")
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SettingsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitWithDays(value: string) {
|
||||||
|
const input = screen.getByPlaceholderText("7")
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value } })
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Confirm/i }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each(["6", "3651"])("rejects out-of-range online_stats_days (%s)", async (value) => {
|
||||||
|
await renderSettings()
|
||||||
|
await submitWithDays(value)
|
||||||
|
expect(h.updateSettings).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects non-integer online_stats_days", async () => {
|
||||||
|
await renderSettings()
|
||||||
|
await submitWithDays("7.5")
|
||||||
|
expect(h.updateSettings).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("legacy backend without online_stats_days falls back to 7 and still submits", async () => {
|
||||||
|
// 模拟旧后端:配置里没有该字段(delete 掉而非置 undefined 字面量)
|
||||||
|
const legacy = h.data.config as Record<string, unknown>
|
||||||
|
delete legacy.online_stats_days
|
||||||
|
|
||||||
|
await renderSettings()
|
||||||
|
|
||||||
|
// 初始化与 reset 后表单都应回退默认值,而不是把 undefined 交给必填校验
|
||||||
|
const input = screen.getByPlaceholderText("7") as HTMLInputElement
|
||||||
|
expect(input.value).toBe("7")
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Confirm/i }))
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(h.updateSettings).toHaveBeenCalled())
|
||||||
|
|
||||||
|
const payload = h.updateSettings.mock.calls[0][0] as Record<string, unknown>
|
||||||
|
expect(payload.online_stats_days).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("valid submit carries the field in the payload", async () => {
|
||||||
|
await renderSettings()
|
||||||
|
await submitWithDays("90")
|
||||||
|
await waitFor(() => expect(h.updateSettings).toHaveBeenCalled())
|
||||||
|
expect(h.updateSettings.mock.calls[0][0]).toMatchObject({ online_stats_days: 90 })
|
||||||
|
})
|
||||||
@@ -35,6 +35,7 @@ const validConfig = {
|
|||||||
user_template: "user-dist",
|
user_template: "user-dist",
|
||||||
cover: 1,
|
cover: 1,
|
||||||
ip_change_notification_group_id: 0,
|
ip_change_notification_group_id: 0,
|
||||||
|
online_stats_days: 7,
|
||||||
},
|
},
|
||||||
frontend_templates: [],
|
frontend_templates: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -726,6 +726,8 @@ export interface ModelSetting {
|
|||||||
oauth2_providers: string[]
|
oauth2_providers: string[]
|
||||||
/** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */
|
/** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */
|
||||||
reserved_hosts: string
|
reserved_hosts: string
|
||||||
|
/** 设备在线时间段统计范围(天),最小 7,最大 3650 */
|
||||||
|
online_stats_days: number
|
||||||
site_name: string
|
site_name: string
|
||||||
/** 用于前端判断生成的安装命令是否启用 TLS */
|
/** 用于前端判断生成的安装命令是否启用 TLS */
|
||||||
tls: boolean
|
tls: boolean
|
||||||
@@ -752,6 +754,8 @@ export interface ModelSettingForm {
|
|||||||
ip_change_notification_group_id: number
|
ip_change_notification_group_id: number
|
||||||
/** @minLength 2 */
|
/** @minLength 2 */
|
||||||
language: string
|
language: string
|
||||||
|
/** 设备在线时间段统计范围(天),最小 7,最大 3650 */
|
||||||
|
online_stats_days?: number
|
||||||
/** @minLength 1 */
|
/** @minLength 1 */
|
||||||
site_name: string
|
site_name: string
|
||||||
tls?: boolean
|
tls?: boolean
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
|
||||||
|
// Exercise real Radix portals/focus/scroll locks without a running dashboard.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => localStorage.setItem("language", "en"))
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname
|
||||||
|
let data: unknown = []
|
||||||
|
if (path === "/api/v1/profile") data = { id: 1, username: "admin", role: 0 }
|
||||||
|
if (path === "/api/v1/setting") {
|
||||||
|
data = { config: { site_name: "Test", language: "en_US" }, version: "test" }
|
||||||
|
}
|
||||||
|
if (path === "/api/v1/server") {
|
||||||
|
data = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `Server ${String(i + 1).padStart(2, "0")}`,
|
||||||
|
display_index: i,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (path === "/api/v1/server-group") {
|
||||||
|
data = [{ group: { id: 1, name: "Test group" }, servers: [] }]
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: { success: true, data } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dashboard buttons advertise clickability", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard")
|
||||||
|
const row = page.locator("tbody tr").filter({ hasText: "Server 01" })
|
||||||
|
const editButton = row.locator('button:not([role="checkbox"])').first()
|
||||||
|
await expect(editButton).toHaveCSS("cursor", "pointer")
|
||||||
|
await expect(row.getByRole("checkbox")).toHaveCSS("cursor", "pointer")
|
||||||
|
await row.getByRole("checkbox").click()
|
||||||
|
await expect(row.getByRole("checkbox")).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("server group picker scrolls, selects, searches and submits inside its dialog", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard/server-group")
|
||||||
|
const row = page.locator("tbody tr").filter({ hasText: "Test group" })
|
||||||
|
await row.locator('button:not([role="checkbox"])').first().click()
|
||||||
|
const editor = page.getByRole("dialog", { name: "Edit Server Group", exact: true })
|
||||||
|
await editor.getByText("Select options", { exact: true }).click()
|
||||||
|
const list = page.locator("[cmdk-list]")
|
||||||
|
await expect(list).toBeVisible()
|
||||||
|
await expect(list).toHaveCSS("pointer-events", "auto")
|
||||||
|
await list.hover()
|
||||||
|
await page.mouse.wheel(0, 400)
|
||||||
|
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||||
|
await page.getByRole("option", { name: "Server 20", exact: true }).click()
|
||||||
|
await expect(editor.getByText("Server 20", { exact: true })).toBeVisible()
|
||||||
|
await page.getByPlaceholder("Search...").fill("Server 60")
|
||||||
|
await page.getByRole("option", { name: "Server 60", exact: true }).click()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(list).not.toBeVisible()
|
||||||
|
await expect(editor).toBeVisible()
|
||||||
|
await expect(editor.getByText("Server 60", { exact: true })).toBeVisible()
|
||||||
|
const saved = page.waitForRequest((request) =>
|
||||||
|
request.url().endsWith("/api/v1/server-group/1") && request.method() === "PATCH",
|
||||||
|
)
|
||||||
|
await editor.getByRole("button", { name: "Confirm", exact: true }).click()
|
||||||
|
expect((await saved).postDataJSON()).toEqual({ name: "Test group", servers: [20, 60] })
|
||||||
|
await expect(editor).not.toBeVisible()
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["@playwright/test"]
|
"types": ["@playwright/test", "node"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user