NavigationTV.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <template>
  2. <div class="navigation-tv">
  3. <!-- 应用网格 -->
  4. <div class="apps-grid">
  5. <a
  6. v-for="(link, linkIndex) in sortedLinks(currentGroup.links)"
  7. :key="link.name"
  8. :href="link.autoLogin ? '#' : (link.url.startsWith('http') ? link.url : `http://${link.url}`)"
  9. target="_blank"
  10. rel="noopener noreferrer"
  11. class="app-tile"
  12. :style="getTileStyle(linkIndex)"
  13. :class="{ 'focused': focusedIndex === linkIndex }"
  14. @mouseenter="focusedIndex = linkIndex"
  15. @mouseleave="focusedIndex = -1"
  16. @click.prevent="handleLinkClick($event, link)"
  17. >
  18. <div class="tile-background" :style="getBackgroundStyle(link, linkIndex)"></div>
  19. <div class="tile-content">
  20. <div
  21. class="tile-name"
  22. :style="getFontSize(link.name)"
  23. >{{ link.name }}</div>
  24. </div>
  25. </a>
  26. </div>
  27. <!-- 底部导航栏 -->
  28. <div class="bottom-nav-bar">
  29. <div class="nav-title">
  30. <div class="logo-circle"></div>
  31. <div class="title-group">
  32. <h1 class="title">韫珠科技</h1>
  33. <p class="subtitle">内部导航系统</p>
  34. </div>
  35. </div>
  36. <div class="nav-tabs">
  37. <button
  38. v-for="(group, index) in sortedGroups"
  39. :key="group.name"
  40. :class="['nav-tab', { active: activeTabIndex === index }]"
  41. @click="switchTab(index)"
  42. >
  43. {{ group.name }}
  44. </button>
  45. </div>
  46. <div class="nav-info">
  47. <div class="time">{{ currentTime }}</div>
  48. </div>
  49. </div>
  50. </div>
  51. </template>
  52. <script setup>
  53. import { ref, computed, onMounted, onUnmounted } from 'vue'
  54. import navigationData from '../data/navigation.json'
  55. import { getAutoLoginUrl } from '../config/backend.js'
  56. const groups = ref([])
  57. const activeTabIndex = ref(0)
  58. const focusedIndex = ref(-1)
  59. const currentTime = ref('')
  60. const sortedGroups = computed(() => {
  61. return [...groups.value].sort((a, b) => (a.sort || 0) - (b.sort || 0))
  62. })
  63. const currentGroup = computed(() => {
  64. return sortedGroups.value[activeTabIndex.value] || { links: [] }
  65. })
  66. const sortedLinks = (links) => {
  67. if (!links) return []
  68. return [...links].sort((a, b) => (a.sort || 0) - (b.sort || 0))
  69. }
  70. const switchTab = (index) => {
  71. activeTabIndex.value = index
  72. focusedIndex.value = -1
  73. }
  74. const handleLinkClick = async (event, link) => {
  75. // 阻止默认行为
  76. event.preventDefault();
  77. event.stopPropagation();
  78. if (link.autoLogin) {
  79. console.log('========================================');
  80. console.log('[前端] 检测到自动登录链接');
  81. console.log('[前端] 链接名称:', link.name);
  82. console.log('[前端] 自动登录方式:', link.autoLoginMethod || 'backend');
  83. try {
  84. // 前端自动登录方式(推荐)
  85. if (link.autoLoginMethod === 'frontend') {
  86. console.log('[前端] 使用纯前端自动登录');
  87. // 动态导入自动登录工具
  88. const { autoLoginHomeAssistant } = await import('../utils/homeAssistantAuth.js');
  89. // 获取配置
  90. const baseUrl = link.url.startsWith('http') ? link.url : `http://${link.url}`;
  91. const username = link.autoLoginUsername || 'guest';
  92. const password = link.autoLoginPassword || 'guest-888';
  93. // 执行自动登录
  94. await autoLoginHomeAssistant(baseUrl, username, password);
  95. } else if (link.autoLoginMethod === 'direct') {
  96. // 直接跳转(利用 Trusted Networks)
  97. console.log('[前端] 直接跳转(Trusted Networks)');
  98. const targetUrl = link.url.startsWith('http') ? link.url : `http://${link.url}`;
  99. window.open(targetUrl, '_blank', 'noopener,noreferrer');
  100. } else {
  101. // 使用后端代理方式(原有方式)
  102. console.log('[前端] 使用后端代理自动登录');
  103. const autoLoginUrl = getAutoLoginUrl(link.autoLoginEndpoint);
  104. console.log('[前端] 完整自动登录URL:', autoLoginUrl);
  105. window.open(autoLoginUrl, '_blank', 'noopener,noreferrer');
  106. }
  107. } catch (error) {
  108. console.error('[前端] 处理自动登录时出错:', error);
  109. // 如果是 CORS 错误,提示用户
  110. if (error.message && (error.message.includes('CORS') || error.message.includes('Failed to fetch'))) {
  111. alert(
  112. '自动登录失败:跨域限制\n\n' +
  113. '由于浏览器安全策略,无法直接访问 Home Assistant API。\n\n' +
  114. '建议:\n' +
  115. '1. 使用 Trusted Networks 配置(推荐)\n' +
  116. '2. 或使用后端代理模式\n\n' +
  117. '现在将直接跳转到 Home Assistant 登录页面'
  118. );
  119. } else {
  120. alert('自动登录失败: ' + error.message + '\n\n将跳转到登录页面');
  121. }
  122. // 失败后直接跳转
  123. const targetUrl = link.url.startsWith('http') ? link.url : `http://${link.url}`;
  124. window.open(targetUrl, '_blank', 'noopener,noreferrer');
  125. }
  126. console.log('========================================');
  127. } else {
  128. // 普通链接
  129. console.log('[前端] 普通链接:', link.name, link.url);
  130. const targetUrl = link.url.startsWith('http') ? link.url : `http://${link.url}`;
  131. window.open(targetUrl, '_blank', 'noopener,noreferrer');
  132. }
  133. }
  134. const getFontSize = (name) => {
  135. if (!name) return { fontSize: '40px' }
  136. // 计算字符长度(中文算1.5个字符)
  137. let charCount = 0
  138. for (let i = 0; i < name.length; i++) {
  139. if (/[\u4e00-\u9fa5]/.test(name[i])) {
  140. charCount += 1.5
  141. } else {
  142. charCount += 1
  143. }
  144. }
  145. // 根据字符数动态调整字体大小
  146. // 字符少(1-4个字符):大字体 52-44px
  147. // 字符中等(5-8个字符):中等字体 40-28px
  148. // 字符多(9+个字符):小字体 26-20px
  149. let fontSize
  150. if (charCount <= 4) {
  151. fontSize = 52 - (charCount - 1) * 2.5
  152. } else if (charCount <= 8) {
  153. fontSize = 40 - (charCount - 4) * 3
  154. } else {
  155. fontSize = Math.max(20, 28 - (charCount - 8) * 0.8)
  156. }
  157. return {
  158. fontSize: `${fontSize}px`
  159. }
  160. }
  161. const getBackgroundStyle = (link, index) => {
  162. // 如果有背景图片,优先使用背景图片
  163. if (link.background && link.background.trim() !== '') {
  164. return {
  165. backgroundImage: `url(${link.background})`,
  166. backgroundSize: 'cover',
  167. backgroundPosition: 'center',
  168. backgroundColor: 'rgba(0, 0, 0, 0.3)'
  169. }
  170. }
  171. // 如果没有背景图片,使用随机渐变背景
  172. const gradients = [
  173. 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
  174. 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
  175. 'linear-gradient(135deg, #f6d365 0%, #fda085 100%)',
  176. 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
  177. 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
  178. 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
  179. 'linear-gradient(135deg, #30cfd0 0%, #330867 100%)',
  180. 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)',
  181. 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)',
  182. 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
  183. 'linear-gradient(135deg, #ff8a80 0%, #ea6100 100%)',
  184. 'linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%)',
  185. ]
  186. // 使用索引确保相同系统总是使用相同的渐变
  187. return {
  188. background: gradients[index % gradients.length]
  189. }
  190. }
  191. const getTileStyle = (index) => {
  192. return {
  193. animationDelay: `${index * 0.05}s`
  194. }
  195. }
  196. const updateTime = () => {
  197. const now = new Date()
  198. const year = now.getFullYear()
  199. const month = String(now.getMonth() + 1).padStart(2, '0')
  200. const day = String(now.getDate()).padStart(2, '0')
  201. const hours = String(now.getHours()).padStart(2, '0')
  202. const minutes = String(now.getMinutes()).padStart(2, '0')
  203. const seconds = String(now.getSeconds()).padStart(2, '0')
  204. currentTime.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
  205. }
  206. onMounted(() => {
  207. groups.value = navigationData.groups || []
  208. updateTime()
  209. const timeInterval = setInterval(updateTime, 1000)
  210. // 添加全屏样式类
  211. document.body.classList.add('tv-mode')
  212. const app = document.getElementById('app')
  213. if (app) {
  214. app.classList.add('tv-mode')
  215. }
  216. onUnmounted(() => {
  217. clearInterval(timeInterval)
  218. document.body.classList.remove('tv-mode')
  219. const app = document.getElementById('app')
  220. if (app) {
  221. app.classList.remove('tv-mode')
  222. }
  223. })
  224. })
  225. </script>
  226. <style scoped>
  227. .navigation-tv {
  228. position: fixed;
  229. top: 0;
  230. left: 0;
  231. right: 0;
  232. bottom: 0;
  233. background: #0f1419;
  234. background-image:
  235. radial-gradient(circle at 20% 30%, rgba(30, 144, 255, 0.1) 0%, transparent 50%),
  236. radial-gradient(circle at 80% 70%, rgba(70, 130, 180, 0.08) 0%, transparent 50%),
  237. linear-gradient(180deg, #0f1419 0%, #1a1f2e 100%);
  238. display: flex;
  239. flex-direction: column;
  240. padding: 0;
  241. overflow-y: auto;
  242. }
  243. .apps-grid {
  244. flex: 1;
  245. padding: 60px 40px 120px;
  246. display: grid;
  247. grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  248. gap: 20px;
  249. max-width: 100%;
  250. align-content: start;
  251. }
  252. .bottom-nav-bar {
  253. position: fixed;
  254. bottom: 0;
  255. left: 0;
  256. right: 0;
  257. background: rgba(26, 31, 53, 0.95);
  258. backdrop-filter: blur(20px);
  259. border-top: 1px solid rgba(255, 255, 255, 0.1);
  260. padding: 20px 40px;
  261. display: flex;
  262. justify-content: space-between;
  263. align-items: center;
  264. gap: 30px;
  265. z-index: 100;
  266. box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.2);
  267. }
  268. .nav-title {
  269. display: flex;
  270. align-items: center;
  271. gap: 20px;
  272. flex-shrink: 0;
  273. }
  274. .logo-circle {
  275. width: 60px;
  276. height: 60px;
  277. border-radius: 50%;
  278. background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1));
  279. backdrop-filter: blur(10px);
  280. border: 2px solid rgba(255, 255, 255, 0.5);
  281. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  282. }
  283. .title-group {
  284. display: flex;
  285. flex-direction: column;
  286. gap: 4px;
  287. }
  288. .title {
  289. font-size: 28px;
  290. font-weight: 700;
  291. color: #ffffff;
  292. margin: 0;
  293. text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
  294. letter-spacing: 1px;
  295. }
  296. .subtitle {
  297. font-size: 16px;
  298. font-weight: 400;
  299. color: rgba(255, 255, 255, 0.8);
  300. margin: 0;
  301. text-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
  302. }
  303. .nav-tabs {
  304. display: flex;
  305. gap: 30px;
  306. flex: 1;
  307. justify-content: center;
  308. }
  309. .nav-tab {
  310. background: transparent;
  311. border: none;
  312. color: rgba(255, 255, 255, 0.6);
  313. font-size: 24px;
  314. font-weight: 500;
  315. padding: 12px 0;
  316. cursor: pointer;
  317. position: relative;
  318. transition: all 0.3s ease;
  319. font-family: inherit;
  320. }
  321. .nav-tab:hover {
  322. color: rgba(255, 255, 255, 0.9);
  323. }
  324. .nav-tab.active {
  325. color: #ffffff;
  326. font-weight: 600;
  327. }
  328. .nav-tab.active::after {
  329. content: '';
  330. position: absolute;
  331. bottom: -22px;
  332. left: 0;
  333. right: 0;
  334. height: 3px;
  335. background: #3498db;
  336. border-radius: 2px;
  337. }
  338. .nav-info {
  339. display: flex;
  340. align-items: center;
  341. color: rgba(255, 255, 255, 0.9);
  342. font-size: 16px;
  343. flex-shrink: 0;
  344. }
  345. .time {
  346. font-weight: 500;
  347. letter-spacing: 0.5px;
  348. }
  349. .app-tile {
  350. position: relative;
  351. aspect-ratio: 1;
  352. border-radius: 12px;
  353. overflow: hidden;
  354. text-decoration: none;
  355. color: inherit;
  356. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  357. animation: tileFadeIn 0.4s ease-out both;
  358. cursor: pointer;
  359. }
  360. @keyframes tileFadeIn {
  361. from {
  362. opacity: 0;
  363. transform: scale(0.9);
  364. }
  365. to {
  366. opacity: 1;
  367. transform: scale(1);
  368. }
  369. }
  370. .app-tile:hover,
  371. .app-tile.focused {
  372. transform: scale(1.08) translateY(-5px);
  373. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
  374. z-index: 10;
  375. }
  376. .tile-background {
  377. position: absolute;
  378. top: 0;
  379. left: 0;
  380. right: 0;
  381. bottom: 0;
  382. transition: all 0.3s ease;
  383. }
  384. .tile-background::after {
  385. content: '';
  386. position: absolute;
  387. top: 0;
  388. left: 0;
  389. right: 0;
  390. bottom: 0;
  391. background: rgba(0, 0, 0, 0.4);
  392. transition: background 0.3s ease;
  393. }
  394. .app-tile:hover .tile-background,
  395. .app-tile.focused .tile-background {
  396. transform: scale(1.05);
  397. }
  398. .app-tile:hover .tile-background::after,
  399. .app-tile.focused .tile-background::after {
  400. background: rgba(0, 0, 0, 0.3);
  401. }
  402. .tile-content {
  403. position: relative;
  404. z-index: 1;
  405. display: flex;
  406. align-items: center;
  407. justify-content: center;
  408. height: 100%;
  409. width: 100%;
  410. padding: 20px;
  411. }
  412. .tile-name {
  413. color: #ffffff;
  414. font-weight: 700;
  415. text-align: center;
  416. line-height: 1.2;
  417. text-shadow: 0 3px 8px rgba(0, 0, 0, 0.5);
  418. word-break: break-word;
  419. letter-spacing: 1px;
  420. width: 100%;
  421. }
  422. /* 响应式设计 */
  423. @media (max-width: 768px) {
  424. .apps-grid {
  425. padding: 40px 20px 140px;
  426. grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  427. gap: 15px;
  428. }
  429. .bottom-nav-bar {
  430. flex-direction: column;
  431. gap: 15px;
  432. padding: 15px 20px;
  433. align-items: flex-start;
  434. }
  435. .nav-title {
  436. width: 100%;
  437. justify-content: center;
  438. }
  439. .nav-tabs {
  440. width: 100%;
  441. flex-wrap: wrap;
  442. gap: 15px;
  443. justify-content: flex-start;
  444. }
  445. .nav-tab {
  446. font-size: 18px;
  447. padding: 8px 0;
  448. }
  449. .nav-tab.active::after {
  450. bottom: -15px;
  451. }
  452. .nav-info {
  453. width: 100%;
  454. justify-content: flex-end;
  455. }
  456. .title {
  457. font-size: 24px;
  458. }
  459. .subtitle {
  460. font-size: 14px;
  461. }
  462. .logo-circle {
  463. width: 50px;
  464. height: 50px;
  465. }
  466. }
  467. @media (min-width: 1200px) {
  468. .apps-grid {
  469. grid-template-columns: repeat(6, 1fr);
  470. }
  471. }
  472. </style>