App.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. <script setup lang="ts">
  2. import { computed, onMounted, ref, watch } from 'vue'
  3. import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
  4. import { currentUser, defaultRouteFor, isAdmin, logout } from './auth'
  5. const route = useRoute()
  6. const router = useRouter()
  7. const navOpen = ref(false)
  8. const sidebarCollapsed = ref(false)
  9. const currentTitle = computed(() => (route.meta.title as string) || 'SupplyAgent')
  10. const SIDEBAR_STORAGE_KEY = 'supply-agent-sidebar-collapsed'
  11. const adminNavItems = [
  12. { to: '/', label: '供给总览', icon: '◫', admin: true },
  13. { to: '/demand-map', label: '全局需求地图', icon: '⌁', admin: false },
  14. { to: '/video-discovery', label: '需求汇总', icon: '▷', admin: false },
  15. { to: '/pipeline-runs', label: '定时任务', icon: '◷', admin: true },
  16. { to: '/llm-billing', label: 'LLM 费用', icon: '$', admin: true },
  17. { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
  18. { to: '/find-agent-framework', label: '寻找 Agent 框架', icon: '⌬', admin: false },
  19. { to: '/find-agent-records', label: '找视频记录', icon: '⌕', admin: false },
  20. { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },
  21. ]
  22. const navItems = computed(() =>
  23. adminNavItems.filter((item) => isAdmin.value || !item.admin),
  24. )
  25. const appHome = computed(() =>
  26. currentUser.value ? defaultRouteFor(currentUser.value) : '/login',
  27. )
  28. async function signOut() {
  29. await logout()
  30. navOpen.value = false
  31. await router.replace('/login')
  32. }
  33. onMounted(() => {
  34. sidebarCollapsed.value = window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
  35. })
  36. watch(sidebarCollapsed, (collapsed) => {
  37. window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(collapsed))
  38. })
  39. </script>
  40. <template>
  41. <div v-if="route.meta.layout === 'auth'" class="auth-shell">
  42. <RouterView />
  43. </div>
  44. <div v-else class="app-shell">
  45. <aside class="sidebar" :class="{ open: navOpen, collapsed: sidebarCollapsed }">
  46. <RouterLink class="brand" :to="appHome" @click="navOpen = false">
  47. <span class="brand-mark">S</span>
  48. <span class="brand-copy">
  49. <strong>SupplyAgent</strong>
  50. <small>CONTENT INTELLIGENCE</small>
  51. </span>
  52. </RouterLink>
  53. <nav class="side-nav">
  54. <span class="nav-section">工作台</span>
  55. <RouterLink
  56. v-for="item in navItems"
  57. :key="item.to"
  58. :to="item.to"
  59. :class="{ active: route.path === item.to }"
  60. active-class=""
  61. exact-active-class=""
  62. :title="sidebarCollapsed ? item.label : undefined"
  63. @click="navOpen = false"
  64. >
  65. <span class="nav-icon">{{ item.icon }}</span>
  66. <span class="nav-label">{{ item.label }}</span>
  67. <span class="nav-arrow">›</span>
  68. </RouterLink>
  69. </nav>
  70. <div class="sidebar-info">
  71. <div class="system-badge">
  72. <span class="pulse" />
  73. <span>
  74. <strong>系统服务</strong>
  75. <small>FastAPI · 8080</small>
  76. </span>
  77. </div>
  78. <p>将需求信号转化为可执行的内容供给。</p>
  79. </div>
  80. <button
  81. class="collapse-button"
  82. type="button"
  83. :aria-label="sidebarCollapsed ? '展开导航栏' : '收起导航栏'"
  84. :aria-expanded="!sidebarCollapsed"
  85. :title="sidebarCollapsed ? '展开导航栏' : '收起导航栏'"
  86. @click="sidebarCollapsed = !sidebarCollapsed"
  87. >
  88. {{ sidebarCollapsed ? '›' : '‹' }}
  89. </button>
  90. </aside>
  91. <div class="shell-content">
  92. <header class="topbar">
  93. <button class="menu-button" type="button" aria-label="打开导航" @click="navOpen = !navOpen">
  94. </button>
  95. <div>
  96. <span class="breadcrumb">SUPPLYAGENT /</span>
  97. <strong>{{ currentTitle }}</strong>
  98. </div>
  99. <div class="topbar-meta">
  100. <span class="environment"><i /> PRODUCTION</span>
  101. <span class="clock">Asia / Shanghai</span>
  102. <button class="account-button" type="button" title="退出登录" @click="signOut">
  103. <span>{{ currentUser?.display_name?.slice(0, 1) || 'U' }}</span>
  104. <span class="account-copy">
  105. <strong>{{ currentUser?.display_name || currentUser?.username }}</strong>
  106. <small>{{ isAdmin ? '管理员 · 点击退出' : '普通用户 · 点击退出' }}</small>
  107. </span>
  108. </button>
  109. </div>
  110. </header>
  111. <main class="main">
  112. <RouterView />
  113. </main>
  114. </div>
  115. <button
  116. v-if="navOpen"
  117. class="nav-backdrop"
  118. type="button"
  119. aria-label="关闭导航"
  120. @click="navOpen = false"
  121. />
  122. </div>
  123. </template>
  124. <style scoped>
  125. .auth-shell {
  126. min-height: 100vh;
  127. }
  128. .app-shell {
  129. display: flex;
  130. height: 100vh;
  131. overflow: hidden;
  132. background: #f5f6f8;
  133. }
  134. .sidebar {
  135. position: relative;
  136. display: flex;
  137. width: 222px;
  138. height: 100%;
  139. padding: 20px 14px 18px;
  140. flex-direction: column;
  141. flex-shrink: 0;
  142. border-right: 1px solid #e4e6eb;
  143. background: rgba(250, 250, 251, 0.96);
  144. backdrop-filter: blur(12px);
  145. transition: width 0.22s ease, padding 0.22s ease;
  146. }
  147. .brand {
  148. display: flex;
  149. align-items: center;
  150. gap: 11px;
  151. padding: 4px 8px 24px;
  152. color: #202739;
  153. text-decoration: none;
  154. }
  155. .brand-mark {
  156. display: grid;
  157. width: 35px;
  158. height: 35px;
  159. place-items: center;
  160. border-radius: 11px;
  161. background: linear-gradient(145deg, #6a6adb, #4f4fbf);
  162. color: #fff;
  163. font-size: 17px;
  164. font-weight: 800;
  165. box-shadow: 0 9px 20px rgba(91, 91, 214, 0.25);
  166. }
  167. .brand-copy strong,
  168. .brand-copy small {
  169. display: block;
  170. }
  171. .brand-copy strong {
  172. font-size: 14px;
  173. letter-spacing: -0.02em;
  174. }
  175. .brand-copy small {
  176. margin-top: 2px;
  177. color: #9a9fad;
  178. font-size: 7px;
  179. font-weight: 800;
  180. letter-spacing: 0.14em;
  181. }
  182. .side-nav {
  183. display: flex;
  184. flex-direction: column;
  185. gap: 4px;
  186. }
  187. .nav-section {
  188. margin: 0 10px 8px;
  189. color: #a1a6b1;
  190. font-size: 9px;
  191. font-weight: 700;
  192. letter-spacing: 0.12em;
  193. }
  194. .side-nav a {
  195. display: grid;
  196. min-height: 42px;
  197. grid-template-columns: 27px 1fr auto;
  198. align-items: center;
  199. padding: 0 10px;
  200. border-radius: 9px;
  201. color: #666e7e;
  202. font-size: 11px;
  203. font-weight: 600;
  204. text-decoration: none;
  205. transition: background 0.16s, color 0.16s;
  206. }
  207. .side-nav a:hover {
  208. background: #f0f1f5;
  209. color: #343b4b;
  210. }
  211. .side-nav a.active {
  212. background: #ececff;
  213. color: #5555cb;
  214. }
  215. .nav-icon {
  216. color: #8d94a3;
  217. font-size: 16px;
  218. }
  219. .side-nav a.active .nav-icon {
  220. color: #5b5bd6;
  221. }
  222. .nav-arrow {
  223. color: #abb0bb;
  224. font-size: 17px;
  225. }
  226. .collapse-button {
  227. position: absolute;
  228. z-index: 2;
  229. top: 72px;
  230. right: -13px;
  231. display: grid;
  232. width: 26px;
  233. height: 26px;
  234. place-items: center;
  235. padding: 0 0 2px;
  236. border: 1px solid #dfe2e8;
  237. border-radius: 8px;
  238. background: #fff;
  239. color: #7f8695;
  240. font-size: 17px;
  241. line-height: 1;
  242. box-shadow: 0 5px 15px rgba(36, 43, 62, 0.09);
  243. cursor: pointer;
  244. transition: border-color 0.16s, color 0.16s, transform 0.16s;
  245. }
  246. .collapse-button:hover {
  247. border-color: #7777d8;
  248. color: #5b5bd6;
  249. transform: scale(1.05);
  250. }
  251. .sidebar-info {
  252. margin-top: auto;
  253. padding: 14px 10px 4px;
  254. border-top: 1px solid #e8eaee;
  255. }
  256. .system-badge {
  257. display: flex;
  258. align-items: center;
  259. gap: 10px;
  260. }
  261. .pulse {
  262. width: 8px;
  263. height: 8px;
  264. border-radius: 50%;
  265. background: #2ab282;
  266. box-shadow: 0 0 0 4px rgba(42, 178, 130, 0.1);
  267. }
  268. .system-badge strong,
  269. .system-badge small {
  270. display: block;
  271. }
  272. .system-badge strong {
  273. color: #555c6c;
  274. font-size: 10px;
  275. }
  276. .system-badge small {
  277. margin-top: 2px;
  278. color: #a0a6b1;
  279. font-size: 8px;
  280. }
  281. .sidebar-info p {
  282. margin: 12px 0 0;
  283. color: #a2a7b2;
  284. font-size: 9px;
  285. line-height: 1.6;
  286. }
  287. .shell-content {
  288. display: flex;
  289. min-width: 0;
  290. flex: 1;
  291. flex-direction: column;
  292. }
  293. .topbar {
  294. display: flex;
  295. height: 56px;
  296. align-items: center;
  297. justify-content: space-between;
  298. padding: 0 26px;
  299. flex-shrink: 0;
  300. border-bottom: 1px solid #e6e8ed;
  301. background: rgba(255, 255, 255, 0.82);
  302. backdrop-filter: blur(12px);
  303. }
  304. .topbar > div:first-of-type {
  305. display: flex;
  306. align-items: center;
  307. gap: 6px;
  308. }
  309. .breadcrumb {
  310. color: #a1a6b2;
  311. font-size: 9px;
  312. font-weight: 800;
  313. letter-spacing: 0.1em;
  314. }
  315. .topbar strong {
  316. color: #535a69;
  317. font-size: 10px;
  318. }
  319. .topbar-meta {
  320. display: flex;
  321. align-items: center;
  322. gap: 17px;
  323. color: #9ba1ad;
  324. font-size: 8px;
  325. font-weight: 700;
  326. letter-spacing: 0.08em;
  327. }
  328. .account-button {
  329. display: flex;
  330. height: 38px;
  331. align-items: center;
  332. gap: 8px;
  333. padding: 0 10px 0 6px;
  334. border: 1px solid #e1e4ea;
  335. border-radius: 10px;
  336. background: #fff;
  337. color: #545c6b;
  338. cursor: pointer;
  339. }
  340. .account-button > span:first-child {
  341. display: grid;
  342. width: 26px;
  343. height: 26px;
  344. place-items: center;
  345. border-radius: 8px;
  346. background: #ececff;
  347. color: #5b5cc9;
  348. font-size: 11px;
  349. font-weight: 800;
  350. }
  351. .account-copy {
  352. text-align: left;
  353. }
  354. .account-copy strong,
  355. .account-copy small {
  356. display: block;
  357. }
  358. .account-copy strong {
  359. color: #444c5d;
  360. font-size: 10px;
  361. }
  362. .account-copy small {
  363. margin-top: 1px;
  364. color: #8c93a0;
  365. font-size: 8px;
  366. }
  367. .environment {
  368. display: inline-flex;
  369. align-items: center;
  370. gap: 6px;
  371. }
  372. .environment i {
  373. width: 6px;
  374. height: 6px;
  375. border-radius: 50%;
  376. background: #2ab282;
  377. }
  378. .menu-button {
  379. display: none;
  380. width: 34px;
  381. height: 34px;
  382. border: 1px solid #e0e3e9;
  383. border-radius: 8px;
  384. background: #fff;
  385. color: #5f6675;
  386. cursor: pointer;
  387. }
  388. .main {
  389. min-height: 0;
  390. flex: 1;
  391. padding: 22px 24px 28px;
  392. overflow: auto;
  393. }
  394. .nav-backdrop {
  395. display: none;
  396. }
  397. @media (min-width: 821px) {
  398. .sidebar.collapsed {
  399. width: 72px;
  400. padding-right: 10px;
  401. padding-left: 10px;
  402. }
  403. .sidebar.collapsed .brand {
  404. justify-content: center;
  405. padding-right: 0;
  406. padding-left: 0;
  407. }
  408. .sidebar.collapsed .brand-copy,
  409. .sidebar.collapsed .nav-section,
  410. .sidebar.collapsed .nav-label,
  411. .sidebar.collapsed .nav-arrow,
  412. .sidebar.collapsed .system-badge > span:last-child,
  413. .sidebar.collapsed .sidebar-info p {
  414. display: none;
  415. }
  416. .sidebar.collapsed .side-nav a {
  417. display: grid;
  418. grid-template-columns: 1fr;
  419. justify-items: center;
  420. padding: 0;
  421. }
  422. .sidebar.collapsed .nav-icon {
  423. font-size: 18px;
  424. }
  425. .sidebar.collapsed .sidebar-info {
  426. display: flex;
  427. justify-content: center;
  428. padding-right: 0;
  429. padding-left: 0;
  430. }
  431. .sidebar.collapsed .system-badge {
  432. justify-content: center;
  433. }
  434. }
  435. @media (max-width: 820px) {
  436. .sidebar {
  437. position: fixed;
  438. z-index: 20;
  439. top: 0;
  440. left: 0;
  441. transform: translateX(-100%);
  442. transition: transform 0.2s ease;
  443. }
  444. .sidebar.open {
  445. transform: translateX(0);
  446. }
  447. .menu-button {
  448. display: block;
  449. }
  450. .collapse-button {
  451. display: none;
  452. }
  453. .topbar {
  454. justify-content: flex-start;
  455. gap: 14px;
  456. padding: 0 14px;
  457. }
  458. .topbar-meta {
  459. margin-left: auto;
  460. }
  461. .clock {
  462. display: none;
  463. }
  464. .environment,
  465. .account-copy {
  466. display: none;
  467. }
  468. .account-button {
  469. padding: 0 5px;
  470. }
  471. .main {
  472. padding: 14px;
  473. }
  474. .nav-backdrop {
  475. position: fixed;
  476. z-index: 15;
  477. inset: 0;
  478. display: block;
  479. border: 0;
  480. background: rgba(21, 27, 40, 0.35);
  481. backdrop-filter: blur(2px);
  482. }
  483. }
  484. /* Readability and contrast */
  485. .brand-copy small {
  486. color: #626b7a;
  487. font-size: 10px;
  488. }
  489. .nav-section {
  490. color: #667085;
  491. font-size: 11px;
  492. }
  493. .side-nav a {
  494. color: #4b5565;
  495. font-size: 13px;
  496. }
  497. .nav-icon,
  498. .nav-arrow {
  499. color: #687386;
  500. }
  501. .system-badge strong {
  502. color: #374151;
  503. font-size: 12px;
  504. }
  505. .system-badge small,
  506. .sidebar-info p {
  507. color: #687386;
  508. font-size: 11px;
  509. }
  510. .breadcrumb {
  511. color: #667085;
  512. font-size: 11px;
  513. }
  514. .topbar strong {
  515. color: #374151;
  516. font-size: 12px;
  517. }
  518. .topbar-meta {
  519. color: #667085;
  520. font-size: 10px;
  521. }
  522. </style>