index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. <template>
  2. <div class="management-page">
  3. <div class="page-head">
  4. <div>
  5. <h1>{{ t('pages.webSearch.title') }}</h1>
  6. <p>{{ t('pages.webSearch.subtitle') }}</p>
  7. </div>
  8. </div>
  9. <el-card class="list-card">
  10. <div class="toolbar mb-16px">
  11. <div class="toolbar-left">
  12. <el-input
  13. v-model="keyword"
  14. clearable
  15. :placeholder="t('pages.webSearch.searchPlaceholder')"
  16. class="search-input"
  17. @keyup.enter="loadList(1)"
  18. >
  19. <template #prefix>
  20. <el-icon>
  21. <Search />
  22. </el-icon>
  23. </template>
  24. </el-input>
  25. <el-select
  26. v-model="provider"
  27. clearable
  28. :placeholder="t('pages.webSearch.provider')"
  29. style="width: 180px"
  30. @change="loadList(1)"
  31. >
  32. <el-option
  33. v-for="item in engineOptions"
  34. :key="item.id"
  35. :label="item.name"
  36. :value="item.id"
  37. />
  38. </el-select>
  39. <div class="toolbar-actions">
  40. <el-button type="primary" @click="loadList(1)">
  41. <el-icon><Search /></el-icon>
  42. {{ t('pages.webSearch.query') }}
  43. </el-button>
  44. <el-button @click="handleReset">
  45. <el-icon><RefreshRight /></el-icon>
  46. {{ t('common.reset') }}
  47. </el-button>
  48. </div>
  49. </div>
  50. <div class="toolbar-right">
  51. <el-button v-permission="'add'" type="primary" @click="openCreate">
  52. <el-icon>
  53. <Plus />
  54. </el-icon>
  55. {{ t('pages.webSearch.createWebSearch') }}
  56. </el-button>
  57. </div>
  58. </div>
  59. <div v-loading="loading" class="grid">
  60. <el-empty
  61. v-if="!list.length && !loading"
  62. class="empty"
  63. :description="t('pages.webSearch.noWebSearch')"
  64. />
  65. <div v-for="row in list" :key="row.id" class="card">
  66. <div class="card-head">
  67. <div class="flex items-center justify-between">
  68. <div class="title">{{ row.name || t('pages.webSearch.unnamedWebSearch') }}</div>
  69. <div class="actions" @click.stop>
  70. <el-dropdown>
  71. <span class="actions-trigger">
  72. <el-icon>
  73. <MoreFilled />
  74. </el-icon>
  75. </span>
  76. <template #dropdown>
  77. <el-dropdown-menu>
  78. <el-dropdown-item @click="openEditById(row.id)">
  79. <span v-permission="'edit'">{{ t('common.edit') }}</span>
  80. </el-dropdown-item>
  81. <el-dropdown-item @click="removeItem(row.id)" divided>
  82. <span v-permission="'del'" class="danger-text">{{ t('common.delete') }}</span>
  83. </el-dropdown-item>
  84. </el-dropdown-menu>
  85. </template>
  86. </el-dropdown>
  87. </div>
  88. </div>
  89. <div class="subtitle">{{ getEngineName(row.provider) }}</div>
  90. <div class="badge-row">
  91. <span class="badge">{{ getEngineName(row.provider) }}</span>
  92. <span class="badge subtle">{{
  93. row.is_default
  94. ? t('pages.webSearch.defaultConfig')
  95. : t('pages.webSearch.normalConfig')
  96. }}</span>
  97. </div>
  98. </div>
  99. <div class="card-footer">
  100. <el-button link type="primary" @click="checkItem(row.id)">{{
  101. t('pages.webSearch.testConnection')
  102. }}</el-button>
  103. </div>
  104. </div>
  105. </div>
  106. <div v-if="pagination.totalCount > 0" class="pagination">
  107. <el-pagination
  108. v-model:current-page="pagination.pageIndex"
  109. v-model:page-size="pagination.pageSize"
  110. background
  111. layout="total, prev, pager, next"
  112. :total="pagination.totalCount"
  113. @current-change="handlePageChange"
  114. @size-change="handleSizeChange"
  115. />
  116. </div>
  117. </el-card>
  118. <el-drawer
  119. v-model="drawerVisible"
  120. :title="
  121. currentId ? t('pages.webSearch.editWebSearch') : t('pages.webSearch.createWebSearchTitle')
  122. "
  123. direction="rtl"
  124. size="700px"
  125. >
  126. <el-form ref="formRef" :model="form" :rules="rules" label-position="top">
  127. <el-form-item :label="t('pages.webSearch.provider')" prop="provider">
  128. <el-select v-model="form.provider" style="width: 100%">
  129. <el-option
  130. v-for="item in engineOptions"
  131. :key="item.id"
  132. :label="item.name"
  133. :value="item.id"
  134. />
  135. </el-select>
  136. </el-form-item>
  137. <el-form-item :label="t('pages.webSearch.name')" prop="name">
  138. <el-input v-model="form.name" />
  139. </el-form-item>
  140. <el-form-item :label="t('pages.webSearch.description')" prop="description">
  141. <el-input v-model="form.description" type="textarea" :rows="2" />
  142. </el-form-item>
  143. <!-- 编辑不需要编辑api_key -->
  144. <el-form-item v-if="!currentId" label="API Key" prop="parameters.api_key">
  145. <el-input v-model="form.parameters.api_key" type="password" show-password />
  146. </el-form-item>
  147. <el-form-item v-if="currentId">
  148. <div class="credential-actions">
  149. <el-button type="primary" plain @click="updateCredentials(currentId)">
  150. {{ t('pages.webSearch.updateCredential') }}
  151. </el-button>
  152. <el-button
  153. v-if="hasCurrentCredential"
  154. type="danger"
  155. plain
  156. @click="deleteCredentials(currentId)"
  157. >
  158. {{ t('pages.webSearch.deleteCredential') }}
  159. </el-button>
  160. </div>
  161. </el-form-item>
  162. <div class="grid-2">
  163. <el-form-item :label="t('pages.webSearch.proxyUrl')" prop="parameters.proxy_url">
  164. <el-input v-model="form.parameters.proxy_url" />
  165. </el-form-item>
  166. <el-form-item :label="t('pages.webSearch.engineId')" prop="parameters.engine_id">
  167. <el-input v-model="form.parameters.engine_id" />
  168. </el-form-item>
  169. </div>
  170. <el-form-item :label="t('pages.webSearch.setDefault')" prop="is_default">
  171. <el-switch v-model="form.is_default" />
  172. </el-form-item>
  173. <el-form-item>
  174. <div class="check-box">
  175. <el-button :loading="checkLoading" @click="checkWithParameters">{{
  176. t('pages.webSearch.testConnection')
  177. }}</el-button>
  178. <el-alert
  179. v-if="checkMessage"
  180. :title="checkMessage"
  181. :type="checkSuccess ? 'success' : 'error'"
  182. :closable="false"
  183. show-icon
  184. />
  185. </div>
  186. </el-form-item>
  187. </el-form>
  188. <template #footer>
  189. <div class="drawer-footer">
  190. <el-button @click="drawerVisible = false">{{ t('common.cancel') }}</el-button>
  191. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{
  192. t('common.save')
  193. }}</el-button>
  194. </div>
  195. </template>
  196. </el-drawer>
  197. </div>
  198. </template>
  199. <script setup lang="ts">
  200. import { onMounted, reactive, ref } from 'vue'
  201. import { ElMessage, ElMessageBox } from 'element-plus'
  202. import { Plus, Search, MoreFilled, RefreshRight } from '@element-plus/icons-vue'
  203. import { resource } from '@repo/api-service'
  204. import { useI18n } from '@/composables/useI18n'
  205. const { t } = useI18n()
  206. type EngineResponse = Awaited<ReturnType<typeof resource.postWebSearchEngines>>
  207. type EngineItem = NonNullable<EngineResponse['result']>[number]
  208. const keyword = ref('')
  209. const provider = ref('')
  210. const loading = ref(false)
  211. const submitLoading = ref(false)
  212. const checkLoading = ref(false)
  213. const drawerVisible = ref(false)
  214. const currentId = ref('')
  215. const hasCurrentCredential = ref(false)
  216. const checkMessage = ref('')
  217. const checkSuccess = ref(false)
  218. const formRef = ref()
  219. const pagination = reactive({
  220. pageIndex: 1,
  221. pageSize: 20,
  222. totalCount: 0
  223. })
  224. const list = ref<any[]>([])
  225. const engineOptions = ref<EngineItem[]>([])
  226. const hasConfiguredCredential = (credential?: Record<string, { configured?: boolean }>) =>
  227. Object.values(credential || {}).some((item) => item?.configured === true)
  228. const form = reactive({
  229. provider: '',
  230. name: '',
  231. description: '',
  232. is_default: false,
  233. parameters: {
  234. proxy_url: '',
  235. api_key: '',
  236. engine_id: ''
  237. }
  238. })
  239. const rules = {
  240. provider: [
  241. { required: true, message: t('pages.webSearch.pleaseSelectProvider'), trigger: 'change' }
  242. ],
  243. name: [{ required: true, message: t('pages.webSearch.pleaseInputName'), trigger: 'blur' }]
  244. }
  245. function resetForm() {
  246. currentId.value = ''
  247. hasCurrentCredential.value = false
  248. checkMessage.value = ''
  249. checkSuccess.value = false
  250. form.provider = ''
  251. form.name = ''
  252. form.description = ''
  253. form.is_default = false
  254. form.parameters.proxy_url = ''
  255. form.parameters.api_key = ''
  256. form.parameters.engine_id = ''
  257. }
  258. function getEngineName(id?: string) {
  259. return (
  260. engineOptions.value.find((item) => item.id === id)?.name ||
  261. id ||
  262. t('pages.webSearch.noProvider')
  263. )
  264. }
  265. async function loadEngines() {
  266. const res = await resource.postWebSearchEngines({})
  267. if (res.isSuccess) {
  268. engineOptions.value = res.result || []
  269. }
  270. }
  271. async function loadList(pageIndex = 1) {
  272. loading.value = true
  273. try {
  274. const res = await resource.postWebSearchPageList({
  275. keyword: keyword.value,
  276. provider: provider.value,
  277. pageIndex,
  278. pageSize: pagination.pageSize
  279. })
  280. if (res.isSuccess && res.result) {
  281. list.value = (res.result.model || []) as any[]
  282. pagination.pageIndex = res.result.currentPage || pageIndex
  283. pagination.pageSize = res.result.pageSize || pagination.pageSize
  284. pagination.totalCount = res.result.totalCount || 0
  285. }
  286. } finally {
  287. loading.value = false
  288. }
  289. }
  290. function handlePageChange(page: number) {
  291. pagination.pageIndex = page
  292. loadList(page)
  293. }
  294. function handleSizeChange(size: number) {
  295. pagination.pageSize = size
  296. loadList(1)
  297. }
  298. function handleReset() {
  299. keyword.value = ''
  300. provider.value = ''
  301. loadList(1)
  302. }
  303. function openCreate() {
  304. resetForm()
  305. drawerVisible.value = true
  306. }
  307. async function openEditById(id: string) {
  308. const res = await resource.postWebSearchInfo({ id })
  309. if (!res.isSuccess || !res.result) {
  310. ElMessage.error(t('pages.webSearch.fetchDetailFailed'))
  311. return
  312. }
  313. resetForm()
  314. currentId.value = id
  315. form.provider = res.result.provider || ''
  316. form.name = res.result.name || ''
  317. form.description = res.result.description || ''
  318. form.is_default = !!res.result.is_default
  319. form.parameters.api_key = res.result.parameters?.api_key || ''
  320. hasCurrentCredential.value = hasConfiguredCredential((res.result as any).credential)
  321. drawerVisible.value = true
  322. }
  323. async function checkItem(id: string) {
  324. try {
  325. const res = await resource.postWebSearchCheckById({ id })
  326. if (res.isSuccess) {
  327. ElMessage.success(t('pages.webSearch.connectSuccess'))
  328. }
  329. } catch {
  330. ElMessage.error(t('pages.webSearch.connectFailed'))
  331. }
  332. }
  333. async function checkWithParameters() {
  334. checkLoading.value = true
  335. checkMessage.value = ''
  336. try {
  337. if (currentId.value) {
  338. const res = await resource.postWebSearchCheckById({ id: currentId.value })
  339. checkSuccess.value = !!res.isSuccess
  340. checkMessage.value = res.isSuccess
  341. ? t('pages.webSearch.connectSuccess')
  342. : t('pages.webSearch.connectFailed')
  343. } else {
  344. const res = await resource.postWebSearchCheckWithParameters({
  345. provider: form.provider,
  346. parameters: {
  347. api_key: form.parameters.api_key,
  348. proxy_url: form.parameters.proxy_url,
  349. engine_id: form.parameters.engine_id
  350. }
  351. })
  352. checkSuccess.value = !!res.isSuccess
  353. checkMessage.value = res.isSuccess
  354. ? t('pages.webSearch.connectSuccess')
  355. : t('pages.webSearch.connectFailed')
  356. }
  357. } catch {
  358. checkSuccess.value = false
  359. } finally {
  360. checkLoading.value = false
  361. }
  362. }
  363. async function handleSubmit() {
  364. await formRef.value?.validate(async (valid: boolean) => {
  365. if (!valid) return
  366. submitLoading.value = true
  367. try {
  368. const payload = {
  369. provider: form.provider,
  370. name: form.name,
  371. description: form.description,
  372. is_default: form.is_default,
  373. parameters: {
  374. proxy_url: form.parameters.proxy_url,
  375. api_key: form.parameters.api_key,
  376. engine_id: form.parameters.engine_id
  377. }
  378. }
  379. if (currentId.value) {
  380. const res = await resource.postWebSearchUpdate({ id: currentId.value, ...(payload as any) })
  381. if (!res?.isSuccess) return
  382. ElMessage.success(t('pages.webSearch.updateSuccess'))
  383. } else {
  384. const res = await resource.postWebSearchCreate(payload as any)
  385. if (!res?.isSuccess) return
  386. ElMessage.success(t('pages.webSearch.createSuccess'))
  387. }
  388. drawerVisible.value = false
  389. loadList(pagination.pageIndex)
  390. } catch {
  391. ElMessage.error(t('pages.webSearch.saveFailed'))
  392. } finally {
  393. submitLoading.value = false
  394. }
  395. })
  396. }
  397. async function removeItem(id: string) {
  398. try {
  399. await ElMessageBox.confirm(t('pages.webSearch.confirmDelete'), t('pages.webSearch.tip'), {
  400. type: 'warning'
  401. })
  402. const res = await resource.postWebSearchOpenApiDelete({ id })
  403. if (!res?.isSuccess) return
  404. ElMessage.success(t('pages.webSearch.deleteSuccess'))
  405. loadList(pagination.pageIndex)
  406. } catch {
  407. // ignore
  408. }
  409. }
  410. async function updateCredentials(id: string) {
  411. try {
  412. const { value } = await ElMessageBox.prompt(
  413. t('pages.webSearch.pleaseInputNewApiKey'),
  414. t('pages.webSearch.updateCredentialTitle'),
  415. {
  416. confirmButtonText: t('common.confirm'),
  417. cancelButtonText: t('common.cancel'),
  418. inputType: 'password',
  419. inputValidator: (value) => !!value?.trim() || t('pages.webSearch.pleaseInputApiKey')
  420. }
  421. )
  422. const res = await resource.postWebSearchCredentials({ id, api_key: value.trim() })
  423. if (!res?.isSuccess) return
  424. hasCurrentCredential.value = true
  425. ElMessage.success(t('pages.webSearch.credentialUpdateSuccess'))
  426. loadList(pagination.pageIndex)
  427. } catch (error) {
  428. if (error !== 'cancel' && error !== 'close') {
  429. ElMessage.error(t('pages.webSearch.credentialUpdateFailed'))
  430. }
  431. }
  432. }
  433. async function deleteCredentials(id: string) {
  434. try {
  435. await ElMessageBox.confirm(
  436. t('pages.webSearch.confirmDeleteCredential'),
  437. t('pages.webSearch.tip'),
  438. {
  439. type: 'warning'
  440. }
  441. )
  442. const res = await resource.postWebSearchDeleteCredentials({ id })
  443. if (!res?.isSuccess) return
  444. hasCurrentCredential.value = false
  445. ElMessage.success(t('pages.webSearch.credentialDeleteSuccess'))
  446. loadList(pagination.pageIndex)
  447. } catch (error) {
  448. if (error !== 'cancel' && error !== 'close') {
  449. ElMessage.error(t('pages.webSearch.credentialDeleteFailed'))
  450. }
  451. }
  452. }
  453. onMounted(async () => {
  454. await loadEngines()
  455. await loadList(1)
  456. })
  457. </script>
  458. <style scoped lang="less">
  459. .management-page {
  460. padding: 24px;
  461. min-height: 100%;
  462. box-sizing: border-box;
  463. }
  464. .page-head {
  465. margin-bottom: 20px;
  466. h1 {
  467. margin: 0;
  468. font-size: 28px;
  469. color: var(--text-primary);
  470. }
  471. p {
  472. margin: 6px 0 0;
  473. font-size: 14px;
  474. color: var(--text-secondary);
  475. }
  476. }
  477. .list-card {
  478. border-radius: 18px;
  479. margin-bottom: 16px;
  480. }
  481. .toolbar {
  482. display: flex;
  483. align-items: center;
  484. justify-content: space-between;
  485. gap: 12px;
  486. flex-wrap: wrap;
  487. }
  488. .toolbar-left,
  489. .toolbar-right {
  490. display: flex;
  491. align-items: center;
  492. gap: 10px;
  493. flex-wrap: wrap;
  494. }
  495. .toolbar-actions {
  496. display: flex;
  497. align-items: center;
  498. gap: 8px;
  499. .el-button + .el-button {
  500. margin-left: 0;
  501. }
  502. }
  503. .search-input {
  504. width: 280px;
  505. }
  506. .toolbar-meta {
  507. display: flex;
  508. align-items: center;
  509. gap: 10px;
  510. flex-wrap: wrap;
  511. }
  512. .pill {
  513. padding: 8px 12px;
  514. border-radius: 999px;
  515. border: 1px solid var(--border-light);
  516. background: var(--bg-container);
  517. font-size: 12px;
  518. color: var(--text-secondary);
  519. }
  520. .tip-box {
  521. padding: 14px 16px;
  522. border-radius: 16px;
  523. background: var(--bg-container);
  524. border: 1px solid var(--border-light);
  525. }
  526. .tip-title {
  527. font-weight: 700;
  528. color: var(--text-primary);
  529. margin-bottom: 10px;
  530. }
  531. .engine-tags {
  532. display: flex;
  533. flex-wrap: wrap;
  534. gap: 8px;
  535. }
  536. .grid {
  537. display: grid;
  538. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  539. gap: 16px;
  540. min-height: 200px;
  541. .empty {
  542. grid-column: span 4;
  543. }
  544. }
  545. .actions {
  546. display: flex;
  547. justify-content: flex-end;
  548. }
  549. .actions-trigger {
  550. width: 30px;
  551. height: 30px;
  552. border-radius: 999px;
  553. display: grid;
  554. place-items: center;
  555. color: var(--text-secondary);
  556. background: var(--bg-overlay);
  557. transition:
  558. background 0.2s ease,
  559. color 0.2s ease;
  560. }
  561. .actions-trigger:hover {
  562. background: var(--bg-container);
  563. color: var(--text-strong);
  564. }
  565. .danger-text {
  566. color: var(--el-color-danger);
  567. }
  568. .card {
  569. overflow: hidden;
  570. border-radius: 20px;
  571. border: 1px solid var(--border-light);
  572. background: var(--bg-base);
  573. box-shadow: var(--shadow-sm);
  574. padding: 16px;
  575. display: flex;
  576. flex-direction: column;
  577. gap: 14px;
  578. transition:
  579. transform 0.22s ease,
  580. box-shadow 0.22s ease,
  581. border-color 0.22s ease;
  582. }
  583. .card:hover {
  584. transform: translateY(-3px);
  585. border-color: var(--border-base);
  586. box-shadow: var(--shadow-md);
  587. }
  588. .card-head {
  589. padding: 14px 16px;
  590. border-radius: 18px;
  591. background: var(--card-bg-unselected);
  592. }
  593. .title {
  594. font-size: 18px;
  595. font-weight: 700;
  596. line-height: 1.25;
  597. color: var(--text-strong);
  598. word-break: break-word;
  599. }
  600. .subtitle {
  601. margin-top: 6px;
  602. font-size: 13px;
  603. color: var(--text-secondary);
  604. word-break: break-word;
  605. }
  606. .badge-row {
  607. margin-top: 10px;
  608. display: flex;
  609. flex-wrap: wrap;
  610. gap: 8px;
  611. }
  612. .badge {
  613. padding: 5px 10px;
  614. border-radius: 999px;
  615. background: var(--bg-overlay);
  616. font-size: 12px;
  617. color: var(--text-primary);
  618. border: 1px solid var(--border-light);
  619. }
  620. .badge.subtle {
  621. background: var(--text-strong);
  622. color: var(--bg-base);
  623. border-color: transparent;
  624. }
  625. .desc {
  626. min-height: 44px;
  627. color: var(--text-secondary);
  628. font-size: 14px;
  629. line-height: 1.65;
  630. display: -webkit-box;
  631. -webkit-line-clamp: 2;
  632. -webkit-box-orient: vertical;
  633. overflow: hidden;
  634. }
  635. .tags {
  636. display: flex;
  637. flex-wrap: wrap;
  638. gap: 8px;
  639. }
  640. .meta-list {
  641. display: grid;
  642. grid-template-columns: repeat(2, minmax(0, 1fr));
  643. gap: 10px;
  644. }
  645. .meta-item {
  646. padding: 10px 12px;
  647. border-radius: 14px;
  648. background: var(--bg-container);
  649. border: 1px solid var(--border-light);
  650. display: flex;
  651. flex-direction: column;
  652. gap: 4px;
  653. min-width: 0;
  654. }
  655. .meta-label {
  656. font-size: 12px;
  657. color: var(--text-tertiary);
  658. }
  659. .meta-value {
  660. font-size: 13px;
  661. color: var(--text-primary);
  662. word-break: break-word;
  663. }
  664. .card-footer {
  665. display: flex;
  666. justify-content: flex-end;
  667. gap: 6px;
  668. margin-top: auto;
  669. flex-wrap: wrap;
  670. }
  671. .pagination {
  672. display: flex;
  673. justify-content: flex-end;
  674. }
  675. .grid-2 {
  676. display: grid;
  677. grid-template-columns: repeat(2, minmax(0, 1fr));
  678. gap: 12px;
  679. }
  680. .drawer-footer {
  681. display: flex;
  682. justify-content: flex-end;
  683. gap: 10px;
  684. }
  685. .check-box {
  686. width: 100%;
  687. display: flex;
  688. flex-direction: column;
  689. gap: 10px;
  690. }
  691. .credential-actions {
  692. display: flex;
  693. gap: 8px;
  694. }
  695. @media (max-width: 768px) {
  696. .search-input {
  697. width: 100%;
  698. }
  699. .grid-2,
  700. .meta-list {
  701. grid-template-columns: 1fr;
  702. }
  703. }
  704. </style>