Просмотр исходного кода

[Feat 0000]地图管理增加CAD图表页面

wangkeyi 1 месяц назад
Родитель
Сommit
8d252c494a

+ 20 - 14
src/views/system/cadFile/app.ts

@@ -6,7 +6,7 @@ import { useAppStore } from '/@/store/modules/app';
 import { message, Modal } from 'ant-design-vue';
 import { updatateGoaf } from './cad.api';
 import { createApp, h } from 'vue';
-import GoafPopup from './components/goafPopup.vue';
+import GoafPopup from './components/GoafPopup.vue';
 
 // ===================== 全局状态 =====================
 let map: Map | null = null;
@@ -37,11 +37,14 @@ const createMapApp = async () => {
 
   const style = env.darkTheme ? vjmap.openMapDarkStyle() : vjmap.openMapLightStyle();
   style.clipbounds = Math.pow(2, 6);
-  const httpDwgUrl = 'https://vjmap.com/static/assets/data/gym.dwg';
+  // const httpDwgUrl = 'https://vjmap.com/static/assets/data/gym.dwg';
+  const httpDwgUrl = './test.dwg';
   
   const res = await svc.openMap({
+    // mapid: env.mapId, // 地图ID,上传文件后获得的mapid
     version: env.version,
     fileid: httpDwgUrl,
+    // @ts-ignore
     mapopenway: env.mapopenway || vjmap.MapOpenWay.GeomRender,
     style,
   });
@@ -49,21 +52,25 @@ const createMapApp = async () => {
   if (res.error) {
     message.error(`地图加载失败:${res.error}`);
     console.error(res.error);
+    // return null;
   }
-
+  // 获取地图范围
   const mapExtent = GeoBounds.fromString(res.bounds);
+  // 根据地图范围建立几何投影坐标系
   prj = new GeoProjection(mapExtent);
-
-  map = new vjmap.Map({
-    container: containerId,
-    style: svc.rasterStyle(),
-    center: prj.toLngLat(mapExtent.center()),
-    zoom: 7,
+  // 地图对象
+   map = new vjmap.Map({
+    container: containerId, // DIV容器ID
+    style: svc.rasterStyle(), // 样式,这里是栅格样式
+    center: prj.toLngLat(mapExtent.center()), // 设置地图中心点
+    zoom: 8, // 设置地图缩放级别
     pitch: 0,
-    antialias: true,
-    renderWorldCopies: false,
+    antialias: true, // 反锯齿
+    renderWorldCopies: false, // 不显示多屏地图
   });
 
+  // 关联服务对象和投影对象
+  map.attach(svc, prj);
   await map.onLoad();
 
   const mapLayer = new MapThreeLayer(map, {
@@ -90,6 +97,7 @@ const createMapApp = async () => {
 // 对外导出地图初始化方法
 export const initMap2d = async () => {
   const app = await createMapApp();
+  console.info("地图初始化完成!");
   return app;
 };
 
@@ -420,11 +428,9 @@ const createMarkerElement = (goafItem: any, showInfo: boolean = false): HTMLDivE
     alarmLevelDiv.style.cssText = `
       font-size: 14px;
       color: #333;
-      margin-bottom: 2px;
       white-space: nowrap;
-      font-weight: 600;
     `;
-    alarmLevelDiv.textContent = goafItem.alarmLevel;
+    alarmLevelDiv.textContent = goafItem.alarmLevel ? `预警等级:${goafItem.alarmLevel}` : '正常';
     el.appendChild(alarmLevelDiv);
   }
 

+ 166 - 36
src/views/system/cadFile/cad.data.ts

@@ -1,51 +1,181 @@
-// 模拟传感器数据
-export const sensorMockData = [
+import { BasicColumn } from '/@/components/Table';
+import { FormSchema } from '/@/components/Table';
+import { h } from 'vue';
+import { Ref } from 'vue';
+import { isEmpty } from 'lodash-es';
+
+
+// 3. 生成动态表格列(接收动态状态映射)
+export function getColumns(): BasicColumn[] {
+  return [
+    {
+      title: '煤矿名称',
+      dataIndex: 'mineName',
+      width: 100,
+    },
+    {
+      title: '图纸名称',
+      dataIndex: 'mappingName',
+      width: 100,
+    },
+    {
+      title: '图纸分类',
+      dataIndex: 'mappingClass',
+      width: 100,
+    },
+    {
+      title: '图纸类型',
+      dataIndex: 'mappingType',
+      width: 100,
+    },
+    // {
+    //   title: '在线状态',
+    //   dataIndex: 'status',
+    //   width: 100,
+    //   customRender: ({ record }) => {
+    //     const status = String(record.status);
+    //     if (isEmpty(status)) {
+    //       return h('span', '-');
+    //     }
+    //     const text = status === '1' ? '在线' : '离线';
+    //     const textColor = status === '1' ? StatusColorEnum.green : StatusColorEnum.red;
+    //     return h('span', { style: { color: textColor } }, text);
+    //   },
+    // },
+    {
+      title: '更新时间',
+      dataIndex: 'updateTime',
+      width: 100,
+    },
+    {
+      title: '备注',
+      dataIndex: 'remark',
+      width: 100,
+    },
+  ];
+}
+
+// 4. 生成动态搜索表单配置
+export function getSearchFormSchema(): FormSchema[] {
+  return [
+    {
+      field: 'mineCode',
+      label: '煤矿名称',
+      component: 'MineCascader',
+      colProps: { span: 6 },
+    },
+    {
+      field: 'mappingName',
+      label: '图纸名称',
+      component: 'Input',
+      colProps: { span: 6 },
+    },
+    {
+      field: 'mappingType',
+      label: '图纸分类',
+      component: 'Select',
+      componentProps: {
+        options: [
+          { label: '采掘类', value: '0' },
+          { label: '机电类', value: '1' },
+          { label: '通防类', value: '2' },
+          { label: '地测及防治水类', value: '3' },
+        ],
+      },
+      colProps: { span: 6 },
+    },
+  ];
+}
+
+export const mockData = [
   {
-    goafId: 1,
-    devicePos: "密闭A",
-    type: "传感器1",
-    createTime: "2026-01-10 14:20:30"
+    mineCode: '610801006584',
+    mineName: '陕西益东矿业有限责任公司',
+    mappingName: '通风图1',
+    mappingClass: '1',
+    mappingType: '1',
+    updateTime: '2026-03-30 15:51:43',
+    remark: '1',
   },
   {
-    goafId: 2,
-    devicePos: "密闭B",
-    type: "传感器2",
-    createTime: "2026-01-11 09:15:22"
+    mineCode: '610801018251',
+    mineName: '神木县兴盛源矿业有限公司',
+    mappingName: '通风图2',
+    mappingClass: '2',
+    mappingType: '2',
+    updateTime: '2026-03-30 15:51:43',
+    remark: '2',
   },
   {
-    goafId: 3,
-    devicePos: "密闭C",
-    type: "传感器3",
-    createTime: "2026-01-12 16:08:15"
+    mineCode: '610801018252',
+    mineName: '神木市恒瑞源矿业有限公司',
+    mappingName: '通风图3',
+    mappingClass: '3',
+    mappingType: '3',
+    updateTime: '2026-03-30 15:51:43',
+    remark: '3',
   },
+];
+
+// 地图编辑弹框表单
+export const mapEditForm: FormSchema[] = [
   {
-    goafId: 4,
-    devicePos: "密闭D",
-    type: "传感器1",
-    createTime: "2026-01-13 11:30:45"
+    field: 'mineCodeList',
+    label: '煤矿名称',
+    component: 'MineCascader',
+    colProps: { span: 6 },
+    groupName: '常规查询',
   },
   {
-    goafId: 5,
-    devicePos: "密闭E",
-    type: "传感器2",
-    createTime: "2026-01-14 15:40:10"
+    field: 'mappingName',
+    label: '图纸名称',
+    component: 'Input',
+    colProps: { span: 6 },
+    groupName: '常规查询',
   },
   {
-    goafId: 6,
-    devicePos: "密闭F",
-    type: "传感器3",
-    createTime: "2026-01-15 10:05:50"
+    field: 'mappingClass',
+    label: '图纸分类',
+    component: 'Select',
+    componentProps: {
+      options: [
+        { label: '采掘类', value: '0' },
+        { label: '机电类', value: '1' },
+        { label: '通防类', value: '2' },
+        { label: '地测及防治水类', value: '3' },
+      ],
+    },
+    colProps: { span: 6 },
+    groupName: '常规查询',
   },
   {
-    goafId: 7,
-    devicePos: "密闭G",
-    type: "传感器1",
-    createTime: "2026-01-16 08:25:33"
+    field: 'mappingType',
+    label: '图纸类型',
+    component: 'Select',
+    componentProps: {
+      options: [
+        { label: '矿井地质图', value: '0' },
+        { label: '矿井充水性图', value: '1' },
+        { label: '通风系统图', value: '2' },
+        { label: '通风系统立体示意图', value: '3' },
+      ],
+    },
+    colProps: { span: 6 },
+    groupName: '常规查询',
   },
   {
-    goafId: 8,
-    devicePos: "密闭H",
-    type: "传感器2",
-    createTime: "2026-01-17 13:18:27"
-  }
-];
+    field: 'mappingAttach',
+    label: '附件',
+    component: 'Upload',
+
+    colProps: { span: 6 },
+    groupName: '常规查询',
+  },
+  {
+    field: 'remark',
+    label: '备注',
+    component: 'InputTextArea',
+    colProps: { span: 6 },
+    groupName: '常规查询',
+  },
+];

+ 194 - 0
src/views/system/cadFile/components/MapEditModal.vue

@@ -0,0 +1,194 @@
+<template>
+  <BasicModal
+    v-bind="$attrs"
+    @register="registerModal"
+    @ok="handleSubmit"
+    title="修改图纸管理"
+    width="900px"
+    :min-height="600"
+    :max-height="1000"
+    scrollable
+    centered
+    destroyOnClose
+    :bodyStyle="{ padding: '20px' }"
+  >
+    <a-form 
+      ref="formRef" 
+      :model="formData" 
+      :rules="formRules" 
+      layout="horizontal" 
+      :label-col="{ span: 4 }" 
+      :wrapper-col="{ span: 20 }"
+    >
+      <div class="edit-container">
+        <div class="mine-base-info">
+          <!-- 添加 key 强制重新渲染 -->
+          <a-form-item 
+            v-for="schema in mapEditForm" 
+            :key="schema.field + formKey" 
+            :name="schema.field" 
+            :label="schema.label"
+            :rules="schema.rules"
+          >
+            <!-- Upload 组件特殊处理 -->
+            <template v-if="schema.component === 'Upload'">
+              <a-upload
+                v-model:file-list="formData[schema.field]"
+                v-bind="schema.componentProps"
+              >
+                <a-button>
+                  <template #icon><UploadOutlined /></template>
+                  上传文件
+                </a-button>
+              </a-upload>
+            </template>
+            <!-- 其他组件动态渲染 -->
+            <template v-else>
+              <component
+                :is="getComponent(schema.component)"
+                :value="formData[schema.field]"
+                @update:value="handleUpdateValue(schema.field, $event)"
+                v-bind="schema.componentProps"
+                :placeholder="`请输入${schema.label}`"
+                style="width: 100%"
+              />
+            </template>
+          </a-form-item>
+        </div>
+      </div>
+    </a-form>
+  </BasicModal>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, nextTick } from 'vue';
+  import { BasicModal, useModalInner } from '/@/components/Modal';
+  import { mapEditForm } from '../cad.data';
+  import { Select, Input, Upload, message } from 'ant-design-vue';
+  import { UploadOutlined } from '@ant-design/icons-vue';
+  import MineCascader from '/@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
+  import type { FormInstance } from 'ant-design-vue/es/form';
+
+  // 表单 key(用于强制重新渲染)
+  const formKey = ref(0);
+
+  // 组件映射表(Upload 单独处理)
+  const componentMap: Record<string, any> = {
+    Input,
+    Select,
+    InputTextArea: Input.TextArea,
+    MineCascader,
+  };
+
+  // 定义事件发射
+  const emit = defineEmits(['success']);
+
+  // 表单实例
+  const formRef = ref<FormInstance>();
+  
+  // 表单数据
+  const formData = ref({
+    mineCodeList: '',
+    mappingName: '',
+    mappingClass: '',
+    mappingType: '',
+    mappingAttach: [] as any[],
+    remark: '',
+  });
+
+  // 表单规则
+  const formRules = computed(() => {
+    const rules: Record<string, any> = {};
+    mapEditForm.forEach((item) => {
+      if (item.rules) {
+        rules[item.field] = item.rules;
+      }
+    });
+    return rules;
+  });
+
+  // 获取组件
+  const getComponent = (componentName: string) => {
+    return componentMap[componentName] || Input;
+  };
+
+  // 处理值更新(替代 v-model)
+  const handleUpdateValue = (field: string, value: any) => {
+    formData.value[field as keyof typeof formData.value] = value;
+  };
+
+  // 注册模态框并初始化数据
+  const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
+    setModalProps({ confirmLoading: false });
+    // 先重置 formKey 强制重新渲染
+    formKey.value++;
+    
+    // 重置表单数据
+    await nextTick();
+    formData.value = {
+      mineCodeList: '',
+      mappingName: '',
+      mappingClass: '',
+      mappingType: '',
+      mappingAttach: [],
+      remark: '',
+    };
+    // 填充现有数据
+    if (data) {
+      const record = data;
+      console.log('当前记录数据:', record);
+      
+      // 使用 nextTick 确保数据填充后视图更新
+      await nextTick();
+      formData.value = {
+        mineCodeList: record.mineCode || record.mineCodeList || '',
+        mappingName: record.mappingName || '',
+        mappingClass: record.mappingClass || '',
+        mappingType: record.mappingType || '',
+        mappingAttach: record.mappingAttach || [],
+        remark: record.remark || '',
+      };
+      
+      console.log('填充后的表单数据:', formData.value);
+    }
+
+    // 重置表单校验状态
+    // if (formRef.value) {
+    //   await nextTick();
+    //   formRef.value.resetFields();
+    // }
+  });
+
+  // 提交表单处理
+  async function handleSubmit() {
+    try {
+      if (!formRef.value) return;
+      await formRef.value.validate();
+
+      setModalProps({ confirmLoading: true });
+      
+      const result = {
+        ...formData.value,
+        mineCode: formData.value.mineCodeList,
+      };
+      
+      console.log('最终提交数据:', result);
+      emit('success', result);
+      closeModal();
+    } catch (error: any) {
+      console.error('提交失败:', error);
+      message.error(error.message || '表单校验失败,请检查必填项');
+    } finally {
+      setModalProps({ confirmLoading: false });
+    }
+  }
+</script>
+
+<style scoped>
+  .mine-base-info {
+    padding: 12px 16px;
+    border: 1px solid #cad2e0;
+    border-radius: 5px;
+    background-color: #f8f9fc;
+  }
+</style>

+ 12 - 4
src/views/system/cadFile/components/SideDrawer.vue

@@ -13,7 +13,7 @@
     <div class="custom-drawer-body" v-if="visible">
       <div class="drawer-content">
         <!-- 搜索区域 -->
-        <div class="search-section">
+        <!-- <div class="search-section">
           <MineCascader
             v-model:value="selectedMineId"
             placeholder="请选择煤矿"
@@ -30,7 +30,7 @@
           <a-button @click="handleRefresh">
             刷新
           </a-button>
-        </div>
+        </div> -->
         <div class="search-section">
           <a-input
             v-model:value="searchValue"
@@ -86,6 +86,7 @@ import { getGoafList } from '../cad.api';
 
 interface Props {
   visible: boolean;
+  mineCode: string;
 }
 const props = defineProps<Props>();
 const emit = defineEmits<{
@@ -99,7 +100,7 @@ const pagination = ref({
   pageSize: 10,
   total: 0 // 初始化为0,由接口数据决定
 });
-const selectedMineId = ref("100008"); //这里暂时写死id,目前逻辑有问题
+const selectedMineId = ref("");
 // 新增:存储接口返回的密闭数据
 const goafList = ref<any[]>([]);
 
@@ -171,7 +172,7 @@ const handleGoafSearch = async () => {
     // 调用接口
     const res = await getGoafList({
       order: "desc",
-      mineCodeList: selectedMineId.value,
+      mineCode: selectedMineId.value,
     });
     goafList.value = res || [];
     pagination.value.current = 1; // 搜索后重置页码到第一页
@@ -241,6 +242,13 @@ const handleMineChange = (mineId: string) => {
   selectedMineId.value = mineId;
 };
 
+watch(() => props.mineCode, (newVal) => {
+  if (newVal) {
+    selectedMineId.value = newVal;
+  }
+}, { immediate: true });
+
+
 // 监听抽屉显隐:打开时刷新数据
 watch(() => props.visible, async (isVisible) => {
   if (isVisible) await handleRefresh(); // 异步数据加载

+ 138 - 53
src/views/system/cadFile/index.vue

@@ -1,66 +1,151 @@
+<!-- eslint-disable vue/multi-word-component-names -->
 <template>
-  <div id="map3dContainer"></div>
-  <!-- 操作按钮面板 -->
-  <div class="map-operation-panel">
-    <button @click="drawerVisible = true" class="btn">密闭列表</button>
-  </div>
+  <BasicTable @register="registerMapTable" :rowSelection="rowSelection" >
+    <template #resetBefore>
+      <a-button type="default" class="ml-8px" preIcon="mdi:download" @click="onExportXls"> 下载 </a-button>
+    </template>
+    <template #action="{ record }">
+      <button @click="handleGoToPageQuery(record, `/manage/mapView`)" class="action-btn" title="">
+        布点
+      </button>
+      <button @click="handleOpenModal(record)" class="action-btn" title="修改">
+        <SvgIcon name="edit" />
+      </button>
+      <!-- 删除按钮 -->
+      <Popconfirm
+        title="删除确认"
+        description="是否确认删除?"
+        okText="确认"
+        cancelText="取消"
+        @confirm="handleDeleteRecord(record)"
+        @cancel="handleCancel"
+        placement="top"
+      >
+        <button class="action-btn" title="删除">
+          <SvgIcon name="delete" />
+        </button>
+      </Popconfirm>
+    </template>
+  </BasicTable>
 
-    <SideDrawer v-model:visible="drawerVisible" />
+   <!-- 注册编辑弹框组件 -->
+  <MapEditModal @register="registerModal" @success="handleModalSuccess" />
 </template>
 
-<script lang="ts" setup>
-import { nextTick, onMounted,ref } from 'vue';
-// 直接导入app.ts中导出的方法
-import { initMap2d } from './app';
-import { message } from 'ant-design-vue';
-// 导入侧边弹框组件
-import SideDrawer from '../cadFile/components/SideDrawer.vue';
+<script setup lang="ts">
+  import { ref, nextTick, computed, onMounted } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { BasicTable } from '/@/components/Table';
+  import { SvgIcon } from '/@/components/Icon';
+  import { message, Popconfirm } from 'ant-design-vue';
+  // 引入动态列/表单配置函数 + 类型
+  import { getColumns, getSearchFormSchema, mockData} from './cad.data';
+  // import { getMineData } from '../basicInfo.api';
+  import { useListPage } from '/@/hooks/system/useListPage';
+  import { useIntervalFn } from '@vueuse/core';
+  import { useModal } from '/@/components/Modal';
+  import MineCascader from '/@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
+  import MapEditModal from './components/MapEditModal.vue';
 
-// 定义弹框显隐状态
-const drawerVisible = ref(false);
+  // 路由实例
+  const router = useRouter();
+   // 弹框注册
+  const [registerModal, { openModal }] = useModal();
 
-// 初始化地图(确保先加载地图,再调用其他方法)
-onMounted(() => {
-  nextTick(async () => {
-    await initMap2d();
-    message.success("地图初始化完成!");
+  // 生成动态列和搜索表单(computed响应式更新)
+  const columns = computed(() => getColumns());
+  const searchFormSchema = computed(() => getSearchFormSchema());
+  // ========== 表格注册 ==========
+  const { tableContext: mapManageTable, onExportXls } = useListPage({
+    tableProps: {
+      // api: getMineData, // 数据统计接口
+      dataSource: mockData,
+      columns, // 绑定动态列
+      rowKey: 'mineCode',
+      rowSelection: { 
+        type: 'checkbox' ,
+        onChange: (selectedRowKeys, selectedRows) => {
+          console.log('选中了:', selectedRowKeys, selectedRows);
+        },
+      },
+      formConfig: {
+        labelWidth: 120,
+        schemas: searchFormSchema.value, // 绑定动态搜索表单
+        showAdvancedButton: false,
+        schemaGroupNames: ['常规查询'],
+      },
+      showIndexColumn: false,
+      scroll: { x: 'max-content' },
+    },
+    exportConfig: {
+      url: '/ventanaly-province/province/mineData/exportMineData',
+      name: '矿山信息',
+      params: {},
+    },
   });
-});
+  const [registerMapTable, mapTable, {rowSelection} ] = mapManageTable;
+  const { pause, resume } = useIntervalFn(() => mapTable.reload({ silence: true }), 10000);
 
+    /**
+   * 打开弹框函数
+   * @param result 弹框数据
+   */
+  function handleOpenModal(record: any) {
+    openModal(true, { ...record });
+  }
 
-</script>
+  /**
+   * 弹框结果处理函数
+   * @param result 弹框数据
+   */
+  async function handleModalSuccess(result: any) {
+    if (result) {
+      message.success('操作成功!');
+      mapTable.reload();
+    }
+  }
+    /**
+   * 删除记录方法
+   * @param record 当前行数据
+   */
+  async function handleDeleteRecord(record: any) {
+    try {
+      // await deleteDataQuaQue({ id: record.id });
+      await nextTick();
+    } catch (error) {
+      console.error('删除失败:', error);
+    }
+  }
 
-<style scoped>
-#map3dContainer {
-  position: absolute;
-  background: transparent;
-  width: 100%;
-  height: 100%;
-  top: 0;
-  left: 0;
-}
+  function handleGoToPageQuery(record: any, path: string) {
+    const mineCode = record.mineCode;
+    router.push({
+      path,
+      state: { mineCode },
+    });
+  }
 
-/* 操作面板样式 */
-.map-operation-panel {
-  position: absolute;
-  top: 20px;
-  left: 20px;
-  z-index: 999;
-  display: flex;
-  gap: 8px;
-}
+  /**
+   * 气泡取消按钮通用回调
+   */
+  function handleCancel() {
+    // 取消操作,无逻辑(仅关闭气泡)
+  }
+  // ========== 初始化 ==========
+  onMounted(async () => {
+  
+  });
+</script>
 
-.btn {
-  padding: 8px 12px;
-  background: #409eff;
-  color: #fff;
-  border: none;
-  border-radius: 4px;
-  cursor: pointer;
-  transition: background 0.2s;
-}
+<style lang="less" scoped>
+  .action-btn {
+    height: 30px;
+    cursor: pointer;
+    margin-right: 10px;
+    background: none;
 
-.btn:hover {
-  background: #66b1ff;
-}
-</style>
+    &:last-child {
+      margin-right: 0;
+    }
+  }
+</style>

+ 68 - 0
src/views/system/cadFile/mapView/index.vue

@@ -0,0 +1,68 @@
+<template>
+  <div id="map3dContainer"></div>
+  <!-- 操作按钮面板 -->
+  <div class="map-operation-panel">
+    <button @click="drawerVisible = true" class="btn">密闭列表</button>
+  </div>
+
+    <SideDrawer v-model:visible="drawerVisible" :mineCode="mineCode"/>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted,ref } from 'vue';
+// 直接导入app.ts中导出的方法
+import { initMap2d } from '../app';
+import { message } from 'ant-design-vue';
+// 导入侧边弹框组件
+import SideDrawer from '../components/SideDrawer.vue';
+
+// 定义弹框显隐状态
+const drawerVisible = ref(false);
+const mineCode = ref('')
+
+// 初始化地图(确保先加载地图,再调用其他方法)
+onMounted(() => {
+  mineCode.value = history.state?.mineCode;
+  console.log('当前地图编号:', mineCode.value);
+  nextTick(async () => {
+    await initMap2d();
+  });
+});
+
+
+</script>
+
+<style scoped>
+#map3dContainer {
+  position: absolute;
+  background: transparent;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+}
+
+/* 操作面板样式 */
+.map-operation-panel {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  z-index: 999;
+  display: flex;
+  gap: 8px;
+}
+
+.btn {
+  padding: 8px 12px;
+  background: #409eff;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.btn:hover {
+  background: #66b1ff;
+}
+</style>