Browse Source

远程计划任务

李富豪 1 năm trước cách đây
mục cha
commit
c0fe339dbf

+ 28 - 0
Web/src/pages/page-web/projects/task/index.vue

@@ -0,0 +1,28 @@
+<template>
+  <a-menu v-model:selectedKeys="state.selectedKeys" mode="horizontal">
+    <a-menu-item :key="1">
+      任务列表
+    </a-menu-item>
+    <a-menu-item :key="2">
+      航线管理
+    </a-menu-item>
+  </a-menu>
+  <div v-if="state.selectedKeys[0] === 1">
+    <TaskList />
+  </div>
+  <div v-else-if="state.selectedKeys[0] === 2">
+    <WaylineList />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive } from 'vue';
+import TaskList from './taskList/index.vue'
+import WaylineList from './waylineList/index.vue'
+
+const state = reactive({
+  selectedKeys: [1],
+})
+</script>
+
+<style lang="scss" scoped></style>

+ 54 - 0
Web/src/pages/page-web/projects/task/taskList/components/CustomCell.vue

@@ -0,0 +1,54 @@
+<template>
+    <div class="customCell">
+        <template v-if="isEdit">
+            <div class="customCell-item">
+                <a-input v-model:value="record[fieldName]" />
+            </div>
+            <div class="customCell-item">
+                <a-input v-model:value="record.children[fieldName]" />
+            </div>
+        </template>
+        <template v-else>
+            <div class="customCell-item">
+                {{ record[fieldName] || '--' }}
+            </div>
+            <div class="customCell-item" v-if="record.children">
+                <div class="mt-5 ml0"
+                    style=" width:16px;height:16px;border-left:2px solid rgb(200,200,200);border-bottom:2px solid rgb(200,200,200);float:left;margin-right:5px;"
+                    v-if="showIcon">
+                </div>
+                <div>
+                    {{ record.children[fieldName] || '--' }}
+                </div>
+            </div>
+        </template>
+    </div>
+</template>
+
+<script lang="ts" setup>
+interface Props {
+    record: any,
+    fieldName: string,
+    showIcon?: boolean,
+    isEdit?: boolean,
+};
+
+const props = withDefaults(defineProps<Props>(), {
+    showIcon: false,
+    isEdit: false,
+});
+</script>
+
+<style lang="scss" scoped>
+.customCell {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    &-item {
+        height: 40px;
+        display: flex;
+        align-items: center;
+    }
+}
+</style>

+ 280 - 0
Web/src/pages/page-web/projects/task/taskList/components/FeedbackCreateModal.vue

@@ -0,0 +1,280 @@
+<template>
+    <a-modal :width="950" title="设备异常反馈" :maskClosable="false" :closable="false" okText="提交" v-model:visible="visible"
+        @ok="handleClickSubmit" @cancel="onClickClose">
+        <a-form ref="formRef" :model="formModel" :colon="false" :label-col="labelCol" :wrapper-col="wrapperCol">
+            <a-form-item label='异常描述' name="logs_info"
+                :rules="[{ required: true, message: '异常描述不能为空', whitespace: true }]">
+                <a-input style="width: 100%;" v-model:value="formModel.logs_info" placeholder="请输入异常描述" />
+            </a-form-item>
+            <a-form-item label='发生时间' name="happen_time" :rules="[{ required: true, message: '发生时间不能为空' }]">
+                <a-date-picker style="width: 100%;" showTime v-model:value="formModel.happen_time"
+                    placeholder="请选择发生时间" />
+            </a-form-item>
+            <a-form-item label='联系电话' name="contact_number">
+                <a-input style="width: 100%;" v-model:value="formModel.contact_number" placeholder="请输入联系电话" />
+            </a-form-item>
+            <a-form-item label='联系邮箱' name="contact_email">
+                <a-input style="width: 100%;" v-model:value="formModel.contact_email" placeholder="请输入联系邮箱" />
+            </a-form-item>
+            <a-form-item label='附件'>
+                <a-upload :action="getUploadPath()" method="POST" :headers="getHeaders()"
+                    :file-list="formModel.fileList" @change="handleChangeUpload">
+                    <a-button style="position: relative;">
+                        <div>
+                            上传附件
+                        </div>
+                        <div style="width: 400px;color:#8a8a8a;font-size: 12px;position: absolute;top: 6px;right: -410px;cursor:auto;"
+                            @click.stop>
+                            支持上传:图片、文档、视频、RAR 及 ZIP 等文件(至多上传 3 个文件)
+                        </div>
+                    </a-button>
+                </a-upload>
+            </a-form-item>
+        </a-form>
+        <div class="log-list">
+            <div class="log-list-item">
+                <a-table :columns="airportLogColumns" :scroll="{ x: '100%', y: 300 }"
+                    :data-source="state.airportLogList" :loading="state.listLoading" rowKey="boot_index"
+                    :row-selection="airportLogState.rowSelection" :pagination="false">
+                    <template #log="{ record }">
+                        <div style="font-size: 12px;">
+                            {{ getDateTime(record.start_time) }}
+                            <span style="margin: 0 5px;">
+                                -
+                            </span>
+                            {{ getDateTime(record.end_time) }}
+                        </div>
+                    </template>
+                    <template #size="{ record }">
+                        <div>
+                            {{ getLogSize(record.size) }} G
+                        </div>
+                    </template>
+                </a-table>
+            </div>
+            <div class="log-list-item">
+                <a-table :columns="droneLogColumns" :scroll="{ x: '100%', y: 300 }" :data-source="state.droneLogList"
+                    :loading="state.listLoading" rowKey="boot_index" :row-selection="droneLogState.rowSelection"
+                    :pagination="false">
+                    <template #log="{ record }">
+                        <div style="font-size: 12px;">
+                            {{ getDateTime(record.start_time) }}
+                            <span style="margin: 0 5px;">
+                                -
+                            </span>
+                            {{ getDateTime(record.end_time) }}
+                        </div>
+                    </template>
+                    <template #size="{ record }">
+                        <div>
+                            {{ getLogSize(record.size) }} G
+                        </div>
+                    </template>
+                </a-table>
+            </div>
+        </div>
+    </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, onMounted } from 'vue';
+import { message } from 'ant-design-vue';
+import { getUploadPath, apis, CreateDeviceFeedbackApiParams } from '/@/api/custom';
+import { getHeaders } from '/@/api/http/request';
+import moment from 'moment';
+
+interface Props {
+    sn: string,
+    visible: boolean,
+    onClickSubmit: (sn: string, data: CreateDeviceFeedbackApiParams) => Promise<any>,
+    onClickClose: () => void,
+};
+
+const props = withDefaults(defineProps<Props>(), {
+
+});
+
+const formRef = ref();
+
+const formModel = reactive({
+    logs_info: undefined,
+    happen_time: undefined,
+    contact_number: undefined,
+    contact_email: undefined,
+    fileList: [] as {
+        uid: string,
+        name: string,
+        url: string,
+    }[],
+})
+
+const labelCol = { span: 4 };
+
+const wrapperCol = { span: 18 };
+
+interface State {
+    listLoading: boolean,
+    airportLogList: {
+        boot_index: number,
+        start_time: number,
+        end_time: number,
+        size: number,
+    }[],
+    droneLogList: {
+        boot_index: number,
+        start_time: number,
+        end_time: number,
+        size: number,
+    }[],
+};
+
+const state: State = reactive({
+    listLoading: false,
+    airportLogList: [],
+    droneLogList: [],
+});
+
+const fetchList = async () => {
+    state.listLoading = true;
+    try {
+        const res = await apis.fetchDeviceLogList(props.sn, { domain_list: '0,3' });
+        const files = res.data.files;
+        const airportLog = files.find((item: any) => item.module === '3');
+        if (airportLog) {
+            state.airportLogList = airportLog.list;
+        }
+        const droneLog = files.find((item: any) => item.module === '0');
+        if (droneLog) {
+            state.droneLogList = droneLog.list;
+        }
+    } catch (error) {
+        console.error(error);
+    } finally {
+        state.listLoading = false;
+    }
+}
+
+const getDateTime = (timestamp: number) => {
+    const date = moment.unix(timestamp).format('YYYY-MM-DD HH:mm');
+    return date;
+}
+
+const getLogSize = (size: number) => {
+    // 将字节转换为GB
+    const gbSize = size / (1024 * 1024 * 1024);
+    // 保留一位小数
+    return parseFloat(gbSize.toFixed(1));
+};
+
+onMounted(async () => {
+    await fetchList();
+})
+
+const handleChangeUpload = (info: any) => {
+    const list = info.fileList.slice(-3);
+    const newFileList = list.map((file: any) => {
+        const data = file.response?.data;
+        return {
+            uid: data?.id || file.uid,
+            name: file.name,
+            url: data?.url || file.url,
+        }
+    });
+    formModel.fileList = newFileList;
+    if (info.file.status === 'done') {// 上传成功
+        const data = info.file.response;
+        if (data.code === 0) {
+            message.success('上传成功');
+        } else {
+            message.error(data.message);
+        }
+    }
+};
+
+const airportLogColumns = [
+    {
+        title: '机场日志',
+        dataIndex: 'log',
+        slots: { customRender: 'log' }
+    },
+    {
+        title: '文件大小',
+        dataIndex: 'size',
+        width: 100,
+        slots: { customRender: 'size' }
+    }
+]
+
+const droneLogColumns = [
+    {
+        title: '飞行器日志',
+        dataIndex: 'log',
+        slots: { customRender: 'log' }
+    },
+    {
+        title: '文件大小',
+        dataIndex: 'size',
+        width: 100,
+        slots: { customRender: 'size' }
+    }
+]
+
+const airportLogState = reactive({
+    selectedRows: [] as any[],
+    rowSelection: {
+        columnWidth: 40,
+        selectedRowKeys: [] as number[],
+        onChange: (selectedRowKeys: number[], selectedRows: any[]) => {
+            airportLogState.rowSelection.selectedRowKeys = selectedRowKeys;
+            airportLogState.selectedRows = selectedRows;
+        },
+    }
+})
+
+const droneLogState = reactive({
+    selectedRows: [] as any[],
+    rowSelection: {
+        columnWidth: 40,
+        selectedRowKeys: [] as number[],
+        onChange: (selectedRowKeys: number[], selectedRows: any[]) => {
+            droneLogState.rowSelection.selectedRowKeys = selectedRowKeys;
+            droneLogState.selectedRows = selectedRows;
+        },
+    }
+})
+
+// 点击提交
+const handleClickSubmit = () => {
+    formRef.value?.validateFields().then(async (values: any) => {
+        const list = [];
+        const airportLogSelectedList = airportLogState.selectedRows;
+        const droneLogSelectedList = droneLogState.selectedRows;
+        if (airportLogSelectedList.length) {
+            list.push({ list: airportLogSelectedList, module: '3' });
+        }
+        if (droneLogSelectedList.length) {
+            list.push({ list: droneLogSelectedList, module: '0' });
+        }
+        const data = {
+            ...values,
+            happen_time: moment(values.happenTime).valueOf(),
+            oss_ids: formModel.fileList.length ? formModel.fileList.map(item => item.uid).join(',') : undefined,
+            list: list.length ? list : undefined,
+        }
+        await props.onClickSubmit(props.sn, data);
+    }).catch((error: any) => {
+        console.error(error);
+    });
+}
+</script>
+
+<style lang="scss" scoped>
+.log-list {
+    display: flex;
+    justify-content: space-between;
+
+    &-item {
+        width: 48.5%;
+    }
+}
+</style>

+ 210 - 0
Web/src/pages/page-web/projects/task/taskList/components/FeedbackDetailModal.vue

@@ -0,0 +1,210 @@
+<template>
+    <a-modal :width="800" title="设备异常反馈" :maskClosable="false" :closable="false" :okButtonProps="{ hidden: true }"
+        cancelText="关闭" v-model:visible="visible" @cancel="onClickClose">
+        <a-spin :spinning="state.loading">
+            <a-form :colon="false" :label-col="labelCol" :wrapper-col="wrapperCol">
+                <a-form-item label='异常描述'>
+                    {{ state.info.logs_info || '--' }}
+                </a-form-item>
+                <a-form-item label='发生时间'>
+                    {{ state.info.happen_time || '--' }}
+                </a-form-item>
+                <a-form-item label='联系电话'>
+                    {{ state.info.contact_number || '--' }}
+                </a-form-item>
+                <a-form-item label='联系邮箱'>
+                    {{ state.info.contact_email || '--' }}
+                </a-form-item>
+            </a-form>
+            <div class="log-list">
+                <div class="log-list-item borderRight">
+                    <div class="log-list-item-title">
+                        机场日志
+                    </div>
+                    <div class="log-list-item-content">
+                        <div class="log-list-item-content-cell" v-for="item in state.info.airportLogList"
+                            :key="item.boot_index">
+                            <div>
+                                <div>
+                                    {{ getDateTime(item.start_time) }}
+                                    <span style="margin: 0 5px;">
+                                        -
+                                    </span>
+                                    {{ getDateTime(item.end_time) }}
+                                </div>
+                            </div>
+                            <div>
+                                {{ getLogSize(item.size) }} G
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="log-list-item borderLeft">
+                    <div class="log-list-item-title">
+                        飞行器日志
+                    </div>
+                    <div class="log-list-item-content">
+                        <div class="log-list-item-content-cell" v-for="item in state.info.droneLogList"
+                            :key="item.boot_index">
+                            <div>
+                                <div>
+                                    {{ getDateTime(item.start_time) }}
+                                    <span style="margin: 0 5px;">
+                                        -
+                                    </span>
+                                    {{ getDateTime(item.end_time) }}
+                                </div>
+                            </div>
+                            <div>
+                                {{ getLogSize(item.size) }} G
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </a-spin>
+    </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { reactive, onMounted } from 'vue';
+import { apis } from '/@/api/custom';
+import moment from 'moment';
+
+interface Props {
+    currentId: string,
+    visible: boolean,
+    onClickClose: () => void,
+};
+
+const props = withDefaults(defineProps<Props>(), {
+
+});
+
+interface State {
+    loading: boolean,
+    info: {
+        logs_info: string,
+        happen_time: string,
+        contact_number: string,
+        contact_email: string,
+        airportLogList: {
+            boot_index: number,
+            end_time: number,
+            size: number,
+            start_time: number,
+        }[],
+        droneLogList: {
+            boot_index: number,
+            end_time: number,
+            size: number,
+            start_time: number,
+        }[],
+    }
+}
+
+const state: State = reactive({
+    loading: false,
+    info: {
+        logs_info: '',
+        happen_time: '',
+        contact_number: '',
+        contact_email: '',
+        airportLogList: [],
+        droneLogList: [],
+    }
+})
+
+const labelCol = { style: { width: '100px', marginRight: '30px' } };
+
+const wrapperCol = { span: 18 };
+
+const fetchDetail = async () => {
+    state.loading = true;
+    try {
+        const res = await apis.fetchDeviceLogDetail({ logsId: props.currentId });
+        if (res.code !== 0) {
+            return;
+        }
+        const info = res.data;
+        const list = info.list || [];
+        const data = {
+            logs_info: info.logs_info,
+            happen_time: moment(info.happen_time).format('YYYY-MM-DD HH:mm:ss'),
+            contact_number: info.contact_number,
+            contact_email: info.contact_email,
+            airportLogList: [],
+            droneLogList: [],
+        }
+        const airportLog = list.find((item: any) => item.module === '3');
+        if (airportLog) {
+            data.airportLogList = airportLog.list;
+        }
+        const droneLog = list.find((item: any) => item.module === '0');
+        if (droneLog) {
+            data.droneLogList = droneLog.list;
+        }
+        state.info = data;
+    } catch (error) {
+        console.error(error);
+    } finally {
+        state.loading = false;
+    }
+}
+
+onMounted(async () => {
+    await fetchDetail();
+})
+
+const getDateTime = (timestamp: number) => {
+    const date = moment.unix(timestamp).format('YYYY-MM-DD HH:mm');
+    return date;
+}
+
+const getLogSize = (size: number) => {
+    // 将字节转换为GB
+    const gbSize = size / (1024 * 1024 * 1024);
+    // 保留一位小数
+    return parseFloat(gbSize.toFixed(1));
+};
+</script>
+
+<style lang="scss" scoped>
+.log-list {
+    border-top: 1px solid #f0f0f0;
+    display: flex;
+    justify-content: space-between;
+
+    .borderRight {
+        border-right: 1px solid #f0f0f0;
+    }
+
+    .borderLeft {
+        border-left: 1px solid #f0f0f0;
+    }
+
+    &-item {
+        width: 48%;
+        padding: 10px;
+        margin-top: 20px;
+
+        &-title {
+            font-weight: bold;
+        }
+
+        &-content {
+            width: 100%;
+            max-height: 300px;
+            padding: 0 10px;
+            overflow-y: auto;
+
+            &-cell {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                margin-top: 10px;
+            }
+        }
+    }
+}
+</style>

+ 269 - 0
Web/src/pages/page-web/projects/task/taskList/components/FeedbackDrawer.vue

@@ -0,0 +1,269 @@
+<template>
+    <a-drawer width="70%" title="设备异常反馈记录" :visible="visible" @close="onClose">
+        <div class="top">
+            <a-button type="primary" @click="() => state.createModalVisible = true">
+                新建异常反馈
+            </a-button>
+            <div>
+                <a-form ref="formRef" layout="inline" :model="formModel" :colon="false">
+                    <a-form-item name="rangeDate">
+                        <a-range-picker style="width: 250px;" valueFormat="YYYY-MM-DD"
+                            v-model:value="formModel.rangeDate" />
+                    </a-form-item>
+                    <a-form-item name="username">
+                        <a-input style="width: 200px;" placeholder="反馈人" v-model:value="formModel.username" />
+                    </a-form-item>
+                    <a-form-item name="search_info">
+                        <a-input style="width: 200px;" placeholder="设备SN、设备异常描述"
+                            v-model:value="formModel.search_info" />
+                    </a-form-item>
+                    <a-form-item>
+                        <a-button style="margin-right: 10px;" @click="handleClickSearch">
+                            <template #icon>
+                                <SearchOutlined />
+                            </template>
+                        </a-button>
+                        <a-button @click="handleClickReset">
+                            <template #icon>
+                                <ReloadOutlined />
+                            </template>
+                        </a-button>
+                    </a-form-item>
+                </a-form>
+            </div>
+        </div>
+        <div>
+            <a-table :scroll="{ x: '100%', y: 500 }" rowKey="logs_id" :loading="state.listLoading" :columns="columns"
+                :dataSource="state.list" :pagination="paginationConfig">
+                <!-- 上传状态 -->
+                <template #status="{ record }">
+                    <div v-if="record.status === 1">
+                        上传中
+                    </div>
+                    <div v-else-if="record.status === 2">
+                        成功
+                    </div>
+                    <div v-else-if="record.status === 3">
+                        取消
+                    </div>
+                    <div v-else-if="record.status === 4">
+                        失败
+                    </div>
+                </template>
+                <!-- 操作 -->
+                <template #operation="{ record }">
+                    <a-tooltip title="详情">
+                        <FileSearchOutlined style="color: #2d8cf0" @click="handleClickDetail(record.logs_id)" />
+                    </a-tooltip>
+                </template>
+            </a-table>
+        </div>
+    </a-drawer>
+    <!-- 异常反馈-信息弹出层 -->
+    <FeedbackCreateModal :sn="device.device_sn" :visible="state.createModalVisible"
+        :onClickSubmit="createModalOnClickSubmit" :onClickClose="() => state.createModalVisible = false"
+        v-if="state.createModalVisible" />
+    <!-- 异常反馈-详情弹出层 -->
+    <FeedbackDetailModal :currentId="state.currentId" :visible="state.detailModalVisible"
+        :onClickClose="() => state.detailModalVisible = false" v-if="state.detailModalVisible" />
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, onMounted } from 'vue';
+import { message } from 'ant-design-vue';
+import { SearchOutlined, ReloadOutlined, FileSearchOutlined } from '@ant-design/icons-vue';
+import FeedbackCreateModal from '../components/FeedbackCreateModal.vue';
+import FeedbackDetailModal from '../components/FeedbackDetailModal.vue';
+import { apis, CreateDeviceFeedbackApiParams } from '/@/api/custom';
+import { Device } from '/@/types/device';
+import moment from 'moment';
+
+interface Props {
+    visible: boolean,
+    device: Device,
+    onClose: () => void,
+};
+
+const props = withDefaults(defineProps<Props>(), {
+
+});
+
+const formRef = ref();
+
+const formModel = reactive({
+    rangeDate: undefined,
+    username: undefined,
+    search_info: undefined,
+})
+
+type Query = Partial<{
+    begin_time: number,
+    end_time: number,
+    username: number,
+    search_info: string,
+}>;
+
+interface State {
+    query?: Query,
+    listLoading: boolean,
+    list: any[],
+    currentId: string,
+    createModalVisible: boolean,
+    detailModalVisible: boolean,
+};
+
+const state: State = reactive({
+    query: undefined,
+    listLoading: false,
+    list: [],
+    currentId: '',
+    createModalVisible: false,
+    detailModalVisible: false,
+});
+
+const paginationConfig = reactive({
+    pageSizeOptions: ['20', '50', '100'],
+    showQuickJumper: true,
+    showSizeChanger: true,
+    pageSize: 20,
+    current: 1,
+    total: 0,
+    onChange: async (current: number) => {
+        paginationConfig.current = current;
+        await fetchList();
+    },
+    onShowSizeChange: async (current: number, pageSize: number) => {
+        paginationConfig.pageSize = pageSize;
+        await fetchList();
+    }
+})
+
+const fetchList = async () => {
+    state.listLoading = true;
+    try {
+        const res = await apis.fetchDeviceFeedbackRecordList(
+            props.device.device_sn,
+            {
+                ...state.query,
+                page: paginationConfig.current,
+                page_size: paginationConfig.pageSize
+            }
+        );
+        if (res.code === 0) {
+            paginationConfig.total = res.data.pagination.total
+            paginationConfig.current = res.data.pagination.page
+            paginationConfig.pageSize = res.data.pagination.page_size
+        }
+        state.list = res.data.list;
+    } catch (e) {
+        console.error(e);
+    } finally {
+        state.listLoading = false;
+    }
+}
+
+onMounted(async () => {
+    await fetchList();
+})
+
+const columns = [
+    {
+        title: '反馈时间',
+        dataIndex: 'create_time',
+        width: 200,
+        sorter: (a: any, b: any) => a.create_time.localeCompare(b.create_time),
+    },
+    {
+        title: '反馈人',
+        dataIndex: 'username',
+        width: 150,
+    },
+    {
+        title: '设备型号',
+        dataIndex: 'device_name',
+        width: 150,
+    },
+    {
+        title: '设备SN',
+        dataIndex: 'device_sn',
+        width: 250,
+        slots: { customRender: 'device_sn' }
+    },
+    {
+        title: '设备名称',
+        dataIndex: 'nick_name',
+        width: 150,
+        slots: { customRender: 'nick_name' },
+    },
+    {
+        title: '设备异常描述',
+        dataIndex: 'log_info',
+        width: 150,
+        ellipsis: true,
+    },
+    {
+        title: '上传状态',
+        dataIndex: 'status',
+        slots: { customRender: 'status' },
+        sorter: (a: any, b: any) => a.status.localeCompare(b.status),
+        width: 150,
+    },
+    {
+        title: '操作',
+        dataIndex: 'operation',
+        fixed: 'right',
+        width: 80,
+        slots: { customRender: 'operation' }
+    },
+]
+
+// 点击查询
+const handleClickSearch = async () => {
+    const values = formRef.value?.getFieldsValue();
+    const data = { ...values };
+    delete data.rangeDate;
+    if (values.rangeDate.length === 2) {
+        data.begin_time = moment(values.rangeDate[0]).valueOf();
+        data.end_time = moment(values.rangeDate[1]).valueOf();
+    }
+    state.query = data;
+    await fetchList();
+}
+
+// 点击重置
+const handleClickReset = async () => {
+    formRef.value?.resetFields();
+    const values = formRef.value?.getFieldsValue();
+    const data = { ...values };
+    delete data.rangeDate;
+    state.query = data;
+    await fetchList();
+}
+
+const createModalOnClickSubmit = async (sn: string, data: CreateDeviceFeedbackApiParams) => {
+    state.createModalVisible = false;
+    try {
+        const res = await apis.createDeviceFeedback(sn, data);
+        if (res.code === 0) {
+            await fetchList();
+            message.success('提交成功');
+        }
+    } catch (error) {
+        console.error(error);
+    }
+}
+
+// 点击详情
+const handleClickDetail = (id: string) => {
+    state.currentId = id;
+    state.detailModalVisible = true;
+}
+</script>
+
+<style lang="scss" scoped>
+.top {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 20px;
+}
+</style>

+ 186 - 0
Web/src/pages/page-web/projects/task/taskList/components/Search.vue

@@ -0,0 +1,186 @@
+<template>
+  <a-row style="margin-bottom: 20px;" justify="space-between">
+    <a-col>
+      <a-button style="margin-right: 10px;" @click="state.visible = true">
+        机场设备绑定码
+        <MenuOutlined />
+      </a-button>
+      <a-popover trigger="hover">
+        <template #content>
+          <div class="popover">
+            <div>
+              请使用遥控器连接飞行器后,在“Pilot2首页-云服务-三方云平台”绑定设备。
+            </div>
+            <div>
+              了解更多:
+              <span>
+                <a>
+                  <ReadOutlined />
+                  说明书
+                </a>
+              </span>
+            </div>
+          </div>
+        </template>
+        <a style="margin-right: 10px;">
+          如何绑定机场?
+        </a>
+      </a-popover>
+      <a-button type="primary" danger :disabled="!selectedRowKeys.length" @click="onClickDelete">
+        删除
+      </a-button>
+    </a-col>
+    <a-col>
+      <a-form ref="formRef" layout="inline" :model="formModel" :colon="false">
+        <!-- <a-form-item name="status">
+          <a-select style="width: 200px;" placeholder="请选择状态" v-model:value="formModel.status">
+            <a-select-option value="1">
+              设备空闲中
+            </a-select-option>
+            <a-select-option value="2">
+              设备已离线
+            </a-select-option>
+            <a-select-option value="3">
+              舱内关机
+            </a-select-option>
+            <a-select-option value="4">
+              离线
+            </a-select-option>
+          </a-select>
+        </a-form-item> -->
+        <a-form-item name="device_type">
+          <a-select style="width: 200px;" placeholder="请选择设备型号" v-model:value="formModel.device_type">
+            <a-select-option v-for="item in state.deviceModelList" :value="item.value">
+              {{ item.label }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item name="search_info">
+          <a-input style="width: 200px;" placeholder="设备SN、设备名称" v-model:value="formModel.search_info" />
+        </a-form-item>
+        <a-form-item>
+          <a-button style="margin-right: 10px;" @click="handleClickSearch">
+            <template #icon>
+              <SearchOutlined />
+            </template>
+          </a-button>
+          <a-button @click="handleClickReset">
+            <template #icon>
+              <ReloadOutlined />
+            </template>
+          </a-button>
+        </a-form-item>
+      </a-form>
+    </a-col>
+  </a-row>
+  <a-modal v-model:visible="state.visible" title="设备绑定码" :footer="null">
+    <div>
+      请在 Pilot2 机场部署流程-云服务配置中,以组织ID和设备绑定码来绑定机场设备。
+    </div>
+    <div class="modal-item">
+      <div class="modal-item-title">
+        项目名称
+      </div>
+      <div>
+        {{ state.deviceInfo.name }}
+      </div>
+    </div>
+    <div class="modal-item">
+      <div class="modal-item-title">
+        设备绑定码
+      </div>
+      <div>
+        {{ state.deviceInfo.code }}
+      </div>
+    </div>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, onMounted } from 'vue';
+import { ReadOutlined, MenuOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue';
+import { apis } from '/@/api/custom';
+
+interface Props {
+  selectedRowKeys: string[],
+  onClickDelete: () => Promise<any>,
+  onClickSearch: (query: any) => Promise<any>,
+  onClickReset: (query: any) => Promise<any>,
+};
+
+const props = withDefaults(defineProps<Props>(), {
+
+});
+
+const formRef = ref();
+
+const formModel = reactive({
+  device_type: undefined,
+  search_info: undefined,
+})
+
+interface State {
+  visible: boolean,
+  deviceInfo: {
+    name: string,
+    code: string,
+  },
+  deviceModelList: {
+    value: string,
+    label: string,
+  }[],
+};
+
+const state: State = reactive({
+  visible: false,
+  deviceInfo: {
+    name: '上海展域航空技术有限公司',
+    code: 'PB97VR',
+  },
+  deviceModelList: [],
+})
+
+onMounted(async () => {
+  try {
+    const res = await apis.fetchDeviceModel();
+    const list = res.data.map((item: any) => {
+      return {
+        value: item.device_value,
+        label: item.device_name,
+      }
+    })
+    state.deviceModelList = list;
+  } catch (e) {
+    console.error(e);
+  }
+})
+
+// 点击查询
+const handleClickSearch = async () => {
+  const values = formRef.value?.getFieldsValue();
+  await props.onClickSearch(values);
+}
+
+// 点击重置
+const handleClickReset = async () => {
+  formRef.value?.resetFields();
+  const values = formRef.value?.getFieldsValue();
+  await props.onClickReset(values);
+}
+</script>
+
+<style lang="scss" scoped>
+.popover {
+  width: 200px;
+}
+
+.modal-item {
+  display: flex;
+  margin-top: 20px;
+
+  &-title {
+    width: 100px;
+    font-weight: bold;
+  }
+}
+</style>

+ 372 - 0
Web/src/pages/page-web/projects/task/taskList/index.vue

@@ -0,0 +1,372 @@
+<template>
+  <div class="deviceList">
+    <Search :selectedRowKeys="state.selectedRowKeys" :onClickDelete="onClickBatchDelete" :onClickSearch="onClickSearch"
+      :onClickReset="onClickReset" />
+    <div class="deviceList-table">
+      <a-table :scroll="{ x: '100%', y: 500 }"  childrenColumnName="null" rowKey="device_sn"
+        :loading="state.listLoading" :columns="columns" :dataSource="state.list" @change="refreshData"
+        :rowClassName="rowClassName" :pagination="paginationConfig" :rowSelection="rowSelection">
+        <!-- 设备型号 -->
+        <template #device_name="{ record }">
+          <CustomCell :record="record" fieldName="device_name" :showIcon="true" />
+        </template>
+        <!-- 设备SN -->
+        <template #device_sn="{ record }">
+          <CustomCell :record="record" fieldName="device_sn" />
+        </template>
+        <!-- 设备名称 -->
+        <template #nickname="{ record }">
+          <CustomCell :record="record" fieldName="nickname" :isEdit="!!state.editableData[record.device_sn]" />
+        </template>
+        <!-- 固件版本 -->
+        <template #firmware_version="{ record }">
+          <CustomCell :record="record" fieldName="firmware_version" />
+        </template>
+        <!-- 固件升级 -->
+        <template #firmware_status="{ record }">
+          <DeviceFirmwareUpgrade :device="record" />
+        </template>
+        <!-- 当前状态 -->
+        <template #status="{ record }">
+          <CustomCell :record="record" fieldName="status_text" />
+        </template>
+        <!-- 加入项目时间 -->
+        <template #bound_time="{ record }">
+          <CustomCell :record="record" fieldName="bound_time" />
+        </template>
+        <!-- 最后在线时间 -->
+        <template #login_time="{ record }">
+          <CustomCell :record="record" fieldName="login_time" />
+        </template>
+        <!-- 操作 -->
+        <template #action="{ record }">
+          <!-- 编辑态操作 -->
+          <div v-if="state.editableData[record.device_sn]">
+            <a-tooltip title="确定">
+              <CheckOutlined style="color: #28d445;margin-right: 10px;" @click="onClickSave(record)" />
+            </a-tooltip>
+            <a-tooltip title="取消">
+              <CloseOutlined style="color: #e70102;" @click="() => delete state.editableData[record.device_sn]" />
+            </a-tooltip>
+          </div>
+          <!-- 非编辑态操作 -->
+          <div v-else class="flex-align-center flex-row" style="color: #2d8cf0">
+            <a-tooltip title="编辑">
+              <EditOutlined style="margin-right: 10px;" @click="onClickEdit(record)" />
+            </a-tooltip>
+            <a-tooltip title="删除">
+              <DeleteOutlined @click="onClickDelete(record)" />
+            </a-tooltip>
+            <a-dropdown v-if="record.domain === 3">
+              <EllipsisOutlined style="font-size: 20px;margin-left: 10px;" />
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item @click="onClickFeedback(record)">
+                    异常反馈
+                  </a-menu-item>
+                  <a-menu-item @click="onClickDeviceHms(record)">
+                    告警信息
+                  </a-menu-item>
+                  <!-- <a-menu-item>
+                    设备运维
+                  </a-menu-item> -->
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </div>
+        </template>
+      </a-table>
+    </div>
+  </div>
+  <!-- 异常反馈 -->
+  <FeedbackDrawer :visible="state.feedbackDrawerVisible" :device="state.currentDevice"
+    :onClose="() => state.feedbackDrawerVisible = false" v-if="state.feedbackDrawerVisible" />
+  <!-- 告警信息 -->
+  <DeviceHmsDrawer v-model:visible="state.deviceHmsDrawerVisible" :device="state.currentDevice" />
+</template>
+
+<script lang="ts" setup>
+import { reactive, onMounted, onUnmounted } from 'vue';
+import { Modal } from 'ant-design-vue';
+import { EditOutlined, CheckOutlined, CloseOutlined, DeleteOutlined, EllipsisOutlined } from '@ant-design/icons-vue';
+import Search from './components/Search.vue';
+import CustomCell from './components/CustomCell.vue';
+import DeviceFirmwareUpgrade from '/@/components/devices/device-upgrade/DeviceFirmwareUpgrade.vue';
+import FeedbackDrawer from './components/FeedbackDrawer.vue';
+import DeviceHmsDrawer from '/@/components/devices/device-hms/DeviceHmsDrawer.vue';
+import { getBindingDevices, updateDevice, unbindDevice } from '/@/api/manage';
+import { apis } from '/@/api/custom';
+import { getWorkspaceId } from '/@/utils/index';
+
+interface State {
+  workspaceId: string,
+  interval: number | null,
+  query: any,
+  listLoading: boolean,
+  list: any[],
+  selectedRowKeys: string[],
+  currentDevice: any,
+  editableData: {
+    [key: string]: any,
+  },
+  feedbackDrawerVisible: boolean,
+  deviceHmsDrawerVisible: boolean,
+};
+
+const state: State = reactive({
+  workspaceId: getWorkspaceId(),
+  interval: null,
+  query: undefined,
+  listLoading: false,
+  list: [],
+  selectedRowKeys: [],
+  currentDevice: {},
+  editableData: {},
+  feedbackDrawerVisible: false,
+  deviceHmsDrawerVisible: false,
+})
+
+const paginationConfig = reactive({
+  pageSizeOptions: ['20', '50', '100'],
+  showQuickJumper: true,
+  showSizeChanger: true,
+  pageSize: 20,
+  current: 1,
+  total: 0
+})
+
+const fetchList = async () => {
+  state.listLoading = true;
+  try {
+    const res = await getBindingDevices(state.workspaceId, {
+      ...state.query,
+      page: paginationConfig.current,
+      page_size: paginationConfig.pageSize,
+    });
+    state.list = res.data.list;
+    paginationConfig.total = res.data.pagination.total;
+    paginationConfig.current = res.data.pagination.page;
+    paginationConfig.pageSize = res.data.pagination.page_size;
+    await autoUpdateDeviceStatus()
+  } catch (e) {
+    console.error(e);
+  } finally {
+    state.listLoading = false;
+  }
+}
+
+// 自动更新设备状态
+const autoUpdateDeviceStatus = async () => {
+  if (state.list.length === 0) {
+    return;
+  }
+  const snList: string[] = [];
+  state.list.forEach(item => {
+    if (item.children) {
+      snList.push(item.children.device_sn);
+    }
+    snList.push(item.device_sn);
+  })
+  const data = {
+    snList: snList.join(','),
+  }
+  const res = await apis.fetchDeviceStatus(data);
+  const deviceStatusMap = new Map();
+  res.data.forEach((item: any) => {
+    deviceStatusMap.set(item.device_sn, item.status_text);
+  });
+  state.list.forEach(item => {
+    if (item.children) {
+      item.children.status_text = deviceStatusMap.get(item.children.device_sn);
+    }
+    item.status_text = deviceStatusMap.get(item.device_sn);
+  })
+}
+
+// 开始定时器
+const startAutoUpdate = () => {
+  state.interval = window.setInterval(autoUpdateDeviceStatus, 10000);
+};
+
+// 清除定时器
+const clearAutoUpdate = () => {
+  if (state.interval !== null) {
+    clearInterval(state.interval);
+    state.interval = null;
+  }
+};
+
+onMounted(async () => {
+  await fetchList();
+  startAutoUpdate(); // 页面加载完成后启动定时器
+});
+
+onUnmounted(() => {
+  clearAutoUpdate(); // 页面卸载前清除定时器
+});
+
+const columns = [
+  {
+    title: '设备型号',
+    dataIndex: 'device_name',
+    width: 150,
+    ellipsis: true,
+    sorter: (a: any, b: any) => a.device_name.localeCompare(b.device_name),
+    slots: { customRender: 'device_name' }
+  },
+  {
+    title: '设备SN',
+    dataIndex: 'device_sn',
+    width: 250,
+    ellipsis: true,
+    slots: { customRender: 'device_sn' }
+  },
+  {
+    title: '设备名称',
+    dataIndex: 'nickname',
+    width: 150,
+    ellipsis: true,
+    sorter: (a: any, b: any) => a.nickname.localeCompare(b.nickname),
+    slots: { customRender: 'nickname' }
+  },
+  {
+    title: '固件版本',
+    dataIndex: 'firmware_version',
+    width: 150,
+    ellipsis: true,
+    slots: { customRender: 'firmware_version' },
+  },
+  {
+    title: '固件升级',
+    dataIndex: 'firmware_status',
+    width: 150,
+    ellipsis: true,
+    slots: { customRender: 'firmware_status' },
+  },
+  {
+    title: '当前状态',
+    dataIndex: 'status',
+    width: 100,
+    ellipsis: true,
+    slots: { customRender: 'status' }
+  },
+  {
+    title: '加入项目时间',
+    dataIndex: 'bound_time',
+    width: 200,
+    sorter: (a: any, b: any) => a.bound_time.localeCompare(b.bound_time),
+    slots: { customRender: 'bound_time' },
+  },
+  {
+    title: '最后在线时间',
+    dataIndex: 'login_time',
+    width: 200,
+    sorter: (a: any, b: any) => a.login_time.localeCompare(b.login_time),
+    slots: { customRender: 'login_time' },
+  },
+  {
+    title: '操作',
+    dataIndex: 'actions',
+    fixed: 'right',
+    width: 100,
+    slots: { customRender: 'action' },
+  },
+]
+
+const rowClassName = (record: any, index: number) => {
+  const className = []
+  if ((index & 1) === 0) {
+    className.push('table-striped')
+  }
+  return className.toString().replaceAll(',', ' ')
+}
+
+const refreshData = async (page: any) => {
+  paginationConfig.current = page?.current!
+  paginationConfig.pageSize = page?.pageSize!
+  await fetchList();
+}
+
+const rowSelection = {
+  onChange: (selectedRowKeys: string[]) => {
+    state.selectedRowKeys = selectedRowKeys;
+  },
+}
+
+// 点击批量删除
+const onClickBatchDelete = async () => {
+  console.log(state.selectedRowKeys, '点击批量删除');
+}
+
+// 点击搜索
+const onClickSearch = async (query: any) => {
+  state.query = query;
+  await fetchList();
+}
+
+// 点击重置
+const onClickReset = async (query: any) => {
+  state.query = query;
+  await fetchList();
+}
+
+// 点击异常反馈
+const onClickFeedback = (record: any) => {
+  state.feedbackDrawerVisible = true;
+  state.currentDevice = record;
+}
+
+// 点击告警信息
+const onClickDeviceHms = (record: any) => {
+  state.deviceHmsDrawerVisible = true;
+  state.currentDevice = record;
+}
+
+// 点击编辑
+const onClickEdit = (record: any) => {
+  state.editableData[record.device_sn] = record;
+}
+
+// 点击保存
+const onClickSave = async (record: any) => {
+  delete state.editableData[record.device_sn];
+  await updateDevice({ nickname: record.nickname }, state.workspaceId, record.device_sn)
+  if (record.children) {
+    await updateDevice({ nickname: record.children.nickname }, state.workspaceId, record.children.device_sn)
+  }
+}
+
+// 点击删除
+const onClickDelete = (record: any) => {
+  Modal.confirm({
+    title: '提示',
+    content: `确定删除${record.device_name}吗?`,
+    onOk: async () => {
+      const res = await unbindDevice(record.device_sn);
+      if (res.code !== 0) {
+        return
+      }
+      await fetchList();
+    },
+  });
+}
+</script>
+
+<style lang="scss">
+.deviceList {
+  padding: 20px;
+}
+
+.ant-table {
+  border-top: 1px solid rgb(0, 0, 0, 0.06);
+  border-bottom: 1px solid rgb(0, 0, 0, 0.06);
+}
+
+.ant-table-tbody tr td {
+  border: 0;
+}
+
+.table-striped {
+  background-color: #f7f9fa;
+}
+</style>

+ 92 - 0
Web/src/pages/page-web/projects/task/waylineList/components/Search.vue

@@ -0,0 +1,92 @@
+<template>
+  <a-row style="margin-bottom: 20px;" justify="end">
+    <a-col>
+      <a-form ref="formRef" layout="inline" :model="formModel" :colon="false">
+        <a-form-item name="date">
+          <a-range-picker style="width: 250px;" v-model:value="formModel.date" />
+        </a-form-item>
+        <a-form-item name="status">
+          <a-select style="width: 200px;" placeholder="请选择上传状态" v-model:value="formModel.status">
+            <a-select-option :value="1">
+              上传中
+            </a-select-option>
+            <a-select-option :value="2">
+              成功
+            </a-select-option>
+            <a-select-option :value="3">
+              取消
+            </a-select-option>
+            <a-select-option :value="4">
+              失败
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item name="username">
+          <a-input style="width: 200px;" placeholder="反馈人" v-model:value="formModel.username" />
+        </a-form-item>
+        <a-form-item name="search_info">
+          <a-input style="width: 200px;" placeholder="设备SN、设备异常描述" v-model:value="formModel.search_info" />
+        </a-form-item>
+        <a-form-item>
+          <a-button style="margin-right: 10px;" @click="handleClickSearch">
+            <template #icon>
+              <SearchOutlined />
+            </template>
+          </a-button>
+          <a-button @click="handleClickReset">
+            <template #icon>
+              <ReloadOutlined />
+            </template>
+          </a-button>
+        </a-form-item>
+      </a-form>
+    </a-col>
+  </a-row>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive } from 'vue';
+import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue';
+import moment from 'moment';
+
+interface Props {
+  onClickSearch: (query: any) => Promise<any>,
+  onClickReset: (query: any) => Promise<any>,
+};
+
+const props = withDefaults(defineProps<Props>(), {
+
+});
+
+const formRef = ref();
+
+const formModel = reactive({
+  date: [],
+  status: undefined,
+  username: undefined,
+  search_info: undefined,
+})
+
+// 点击查询
+const handleClickSearch = async () => {
+  const values = formRef.value?.getFieldsValue();
+  const data = { ...values };
+  delete data.date;
+  if (values.date.length === 2) {
+    data.begin_time = moment(values.date[0]).valueOf();
+    data.end_time = moment(values.date[1]).valueOf();
+  }
+  await props.onClickSearch(data);
+}
+
+// 点击重置
+const handleClickReset = async () => {
+  formRef.value?.resetFields();
+  const values = formRef.value?.getFieldsValue();
+  const data = { ...values };
+  delete data.date;
+  await props.onClickReset(data);
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 207 - 0
Web/src/pages/page-web/projects/task/waylineList/index.vue

@@ -0,0 +1,207 @@
+<template>
+  <div class="feedbackRecord">
+    <Search :onClickSearch="onClickSearch" :onClickReset="onClickReset" />
+    <div class="feedbackRecord-table">
+      <a-table :scroll="{ x: '100%', y: 500 }" rowKey="logs_id" :loading="state.listLoading" :columns="columns"
+        @change="refreshData" :rowClassName="rowClassName" :dataSource="state.list" :pagination="paginationConfig">
+        <!-- 上传状态 -->
+        <template #status="{ record }">
+          <div v-if="record.status === 1">
+            上传中
+          </div>
+          <div v-else-if="record.status === 2">
+            成功
+          </div>
+          <div v-else-if="record.status === 3">
+            取消
+          </div>
+          <div v-else-if="record.status === 4">
+            失败
+          </div>
+        </template>
+        <!-- 操作 -->
+        <template #operation="{ record }">
+          <div class="editable-row-operations">
+            <div class="flex-align-center flex-row" style="color: #2d8cf0">
+              <a-tooltip title="详情">
+                <FileSearchOutlined style="margin-right: 10px;" @click="onClickDetail(record.logs_id)" />
+              </a-tooltip>
+            </div>
+          </div>
+        </template>
+      </a-table>
+    </div>
+    <!-- 异常反馈-详情弹出层 -->
+    <FeedbackDetailModal :currentId="state.currentId" :visible="state.drawerVisible"
+      :onClickClose="() => state.drawerVisible = false" v-if="state.drawerVisible" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, onMounted } from 'vue';
+import { FileSearchOutlined } from '@ant-design/icons-vue';
+import Search from './components/Search.vue';
+import FeedbackDetailModal from '../deviceList/components/FeedbackDetailModal.vue';
+import { apis } from '/@/api/custom/index';
+
+interface State {
+  query: any,
+  listLoading: boolean,
+  list: any[],
+  currentId: string,
+  drawerVisible: boolean,
+};
+
+const state: State = reactive({
+  query: undefined,
+  listLoading: false,
+  list: [],
+  currentId: '',
+  drawerVisible: false,
+});
+
+const paginationConfig = reactive({
+  pageSizeOptions: ['20', '50', '100'],
+  showQuickJumper: true,
+  showSizeChanger: true,
+  pageSize: 20,
+  current: 1,
+  total: 0
+})
+
+const fetchList = async () => {
+  state.listLoading = true;
+  try {
+    const res = await apis.fetchFeedbackRecordList({
+      ...state.query,
+      page: paginationConfig.current,
+      page_size: paginationConfig.pageSize
+    });
+    if (res.code === 0) {
+      paginationConfig.total = res.data.pagination.total
+      paginationConfig.current = res.data.pagination.page
+      paginationConfig.pageSize = res.data.pagination.page_size
+    }
+    state.list = res.data.list;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    state.listLoading = false;
+  }
+}
+
+onMounted(async () => {
+  await fetchList();
+})
+
+const columns = [
+  {
+    title: '反馈时间',
+    dataIndex: 'create_time',
+    width: 200,
+    sorter: (a: any, b: any) => a.create_time.localeCompare(b.create_time),
+  },
+  {
+    title: '反馈人',
+    dataIndex: 'user_name',
+    width: 150,
+  },
+  {
+    title: '设备型号',
+    dataIndex: 'device_name',
+    width: 150,
+    sorter: (a: any, b: any) => a.device_name.localeCompare(b.device_name),
+    slots: { customRender: 'device_name' }
+  },
+  {
+    title: '设备SN',
+    dataIndex: 'device_sn',
+    width: 250,
+    slots: { customRender: 'device_sn' }
+  },
+  {
+    title: '设备名称',
+    dataIndex: 'nick_name',
+    width: 150,
+    slots: { customRender: 'nick_name' },
+    sorter: (a: any, b: any) => a.nick_name.localeCompare(b.nick_name),
+  },
+  {
+    title: '固件版本',
+    dataIndex: 'firmware_version',
+    width: 150,
+    ellipsis: true,
+    slots: { customRender: 'firmware_version' }
+  },
+  {
+    title: '设备异常描述',
+    dataIndex: 'logs_info',
+    width: 150,
+    ellipsis: true,
+    customRender: ({ text }: any) => {
+      return text || '--';
+    }
+  },
+  {
+    title: '上传状态',
+    dataIndex: 'status',
+    slots: { customRender: 'status' },
+    sorter: (a: any, b: any) => a.status.localeCompare(b.status),
+    width: 150,
+  },
+  {
+    title: '操作',
+    dataIndex: 'operation',
+    fixed: 'right',
+    width: 80,
+    slots: { customRender: 'operation' }
+  },
+]
+
+const rowClassName = (record: any, index: number) => {
+  const className = []
+  if ((index & 1) === 0) {
+    className.push('table-striped')
+  }
+  return className.toString().replaceAll(',', ' ')
+}
+
+const refreshData = async (page: any) => {
+  paginationConfig.current = page?.current!
+  paginationConfig.pageSize = page?.pageSize!
+  await fetchList();
+}
+
+// 点击搜索
+const onClickSearch = async (query: any) => {
+  state.query = query;
+  await fetchList();
+}
+
+// 点击重置
+const onClickReset = async (query: any) => {
+  state.query = query;
+  await fetchList();
+}
+
+// 点击详情
+const onClickDetail = (id: string) => {
+  state.currentId = id;
+  state.drawerVisible = true;
+}
+</script>
+
+<style lang="scss">
+.feedbackRecord {
+  padding: 20px;
+}
+
+.ant-table {
+  border-top: 1px solid rgb(0, 0, 0, 0.06);
+  border-bottom: 1px solid rgb(0, 0, 0, 0.06);
+}
+
+.ant-table-tbody tr td {
+  border: 0;
+}
+</style>