Browse Source

✨ feat: 增加售后服务功能

Pchen. 10 months ago
parent
commit
64a32a85d1

+ 64 - 0
src/api/workOrder.js

@@ -0,0 +1,64 @@
+import request from '../utils/request'
+
+const api = {
+  Order: '/Kefu/Order',
+  OrderDetail: '/Kefu/OrderDetail',
+  AdminOrder: '/Admin/Kefu/Order',
+  AdminOrderDetail: '/Admin/Kefu/OrderDetail',
+}
+
+export function createOrder(parameter) {
+  return request({
+    url: api.Order,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function closeOrder(parameter) {
+  return request({
+    url: api.Order,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function orderList(parameter) {
+  return request({
+    url: api.Order,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminReplyOrder(parameter) {
+  return request({
+    url: api.AdminOrder,
+    method: 'put',
+    data: parameter
+  })
+}
+
+export function adminOrderList(parameter) {
+  return request({
+    url: api.AdminOrder,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function orderDetail(parameter) {
+  return request({
+    url: api.OrderDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminOrderDetail(parameter) {
+  return request({
+    url: api.AdminOrderDetail,
+    method: 'get',
+    params: parameter
+  })
+}

+ 293 - 0
src/pages/service/admin/orderDetail.vue

@@ -0,0 +1,293 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['工单管理', '工单详情']" />
+        <a-card title="工单详情" :loading="loading">
+            <a-descriptions :data="info" :column="2" />
+            <div class="buttonGroup">
+                <a-button type="primary" size="large" :loading="buttonLoading" @click="CloseOrder()"
+                    :disabled="data.state === 2">{{ data.state === 2 ? '已关闭' :
+                        '关闭工单' }}</a-button>
+            </div>
+        </a-card>
+
+        <a-card title="沟通记录" style="margin-top: 15px; padding-bottom: 20px;">
+            <div class="message" v-for="(msg, index) in data.msg">
+                <a-divider v-if="index !== 0" />
+                <div class="head">
+                    <a-avatar :size="40">
+                        <img :src="data.userInfo[msg.uuid]?.avatar ?? ''" />
+                    </a-avatar>
+                    <div class="right">
+                        <div class="username">
+                            {{ data.userInfo[msg.uuid]?.username }}
+                            <a-tag color="gray" v-if="msg.type === 'system'">
+                                <template #icon>
+                                    <icon-desktop />
+                                </template>
+                                系统回复
+                            </a-tag>
+                            <a-tag color="orangered" v-else-if="msg.type === 'user'">
+                                <template #icon>
+                                    <icon-user />
+                                </template>
+                                用户回复
+                            </a-tag>
+                            <a-tag color="blue" v-else-if="msg.type === 'server'">
+                                <template #icon>
+                                    <icon-customer-service />
+                                </template>
+                                客服回复
+                            </a-tag>
+                        </div>
+                        <div class="time">
+                            {{ stramptoTime(msg.time) }}
+                        </div>
+                    </div>
+                </div>
+                <div class="content" :style="{ whiteSpace: 'pre-wrap' }">
+                    {{ msg.content }}
+                </div>
+                <div class="filebox">
+                    <a-upload :default-file-list="msg.files" :show-upload-button="false" :show-remove-button="false">
+                        <template #success-icon>
+                        </template>
+                    </a-upload>
+                </div>
+            </div>
+        </a-card>
+
+        <a-card title="回复工单" style="margin-top: 15px;" v-if="data.state !== 2">
+            <a-form :model="form" :rules="rules" layout="vertical" :style="{ width: '600px' }"
+                @submit-success="handleSubmit">
+                <a-form-item field="content" label="内容">
+                    <a-textarea v-model="form.content" placeholder="请详细说明您遇到的问题,详细的阐述有助于我们快速为您解决问题..." :max-length="200"
+                        :auto-size="{
+                            minRows: 6,
+                            maxRows: 20
+                        }" allow-clear show-word-limit />
+                </a-form-item>
+
+                <a-form-item field="files" label="上传附件">
+                    <a-upload action="/cloud/api.php" :file-list="form.files" @change="handleFileChange" />
+                </a-form-item>
+
+                <a-form-item>
+                    <a-button html-type="submit" :loading="formLoading">提交</a-button>
+                </a-form-item>
+            </a-form>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
+import { adminOrderDetail, closeOrder, adminReplyOrder } from '@/api/workOrder'
+import { Notification } from '@arco-design/web-vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+const loading = ref(false)
+const formLoading = ref(false)
+const buttonLoading = ref(false)
+const data = ref({})
+const info = ref([])
+
+const form = reactive({
+    content: '',
+    files: [],
+})
+
+const rules = {
+    content: [
+        {
+            required: true,
+            message: '请详细说明您遇到的问题',
+        },
+    ],
+}
+
+const handleFileChange = (fileList) => {
+    fileList.forEach(f => {
+        if (f.status === 'done' && f.response?.downurl) {
+            f.url = f.response?.viewurl ?? f.response.downurl
+        }
+    })
+
+    form.files = fileList // 更新到表单数据中
+}
+
+function getState(state) {
+    switch (state) {
+        case 0:
+            return '待处理'
+        case 1:
+            return '已回复'
+        case 2:
+            return '已关闭'
+    }
+    return '未知'
+}
+
+const handleSubmit = async () => {
+    try {
+        formLoading.value = true
+        const data = {
+            id: route.params.id,
+            ...form
+        }
+        const res = await adminReplyOrder(data)
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '回复工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+        Notification.success({
+            title: '回复工单成功!'
+        })
+        form.content = ''
+        form.files = []
+        getOrderDetail()
+    } catch (error) {
+        Notification.error({
+            title: '回复工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        formLoading.value = false
+    }
+}
+
+const CloseOrder = async () => {
+    try {
+        buttonLoading.value = true
+        const res = await closeOrder({ id: route.params.id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '关闭工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+        Notification.success({
+            title: '关闭工单成功!'
+        })
+        getOrderDetail()
+    } catch (error) {
+        Notification.error({
+            title: '关闭工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        buttonLoading.value = false
+    }
+}
+
+const getOrderDetail = async () => {
+    try {
+        const res = await adminOrderDetail({ id: route.params.id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取工单详情失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+
+        data.value = res.data
+
+        info.value = [
+            { label: '工单标题', value: res.data.title },
+            { label: '工单ID', value: res.data.id },
+            { label: '通知邮箱', value: res.data.email },
+            { label: '创建时间', value: stramptoTime(res.data.create_time) },
+            { label: '最后更新时间', value: stramptoTime(res.data.update_time) },
+            { label: '当前状态', value: getState(res.data.state) }
+        ]
+    } catch (error) {
+        Notification.error({
+            title: '获取路径数据失败!',
+            content: error.message || '请稍后再试'
+        })
+    }
+}
+
+let timer = null
+
+// 轮询
+const startPolling = () => {
+    if (!timer) {
+        timer = setInterval(async () => {
+            await getOrderDetail()
+        }, 2000)
+    }
+}
+
+// 停止轮询
+const stopPolling = () => {
+    if (timer) {
+        clearInterval(timer)
+        timer = null
+    }
+}
+
+onMounted(async () => {
+    loading.value = true
+    await getOrderDetail()
+    loading.value = false
+    startPolling()
+})
+
+// 组件销毁时停止轮询
+onUnmounted(() => {
+    stopPolling()
+})
+
+const stramptoTime = (time) => {
+    return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+</script>
+
+<style scoped lang="less">
+.container {
+    padding: 0 20px 20px 20px;
+
+    .buttonGroup {
+        display: flex;
+        justify-content: center;
+        gap: 20px;
+        margin: 15px;
+    }
+}
+
+.message {
+    display: flex;
+    flex-direction: column;
+
+    .head {
+        display: flex;
+
+        .right {
+            display: flex;
+            flex-direction: column;
+            margin-left: 10px;
+
+            .username {
+                font-size: 1.2em;
+                display: flex;
+                gap: 10px
+            }
+
+            .time {
+                font-size: 0.9em;
+                color: #777;
+            }
+        }
+    }
+
+    .content {
+        margin-top: 10px;
+    }
+
+    .filebox {
+        margin-top: -10px;
+        max-width: 500px;
+    }
+}
+</style>

+ 209 - 0
src/pages/service/admin/orderList.vue

@@ -0,0 +1,209 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['工单管理', '工单列表']" />
+
+        <a-card title="工单列表">
+            <a-row>
+                <a-col :flex="1">
+                    <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+                        label-align="left">
+                        <a-row :gutter="16">
+                            <a-col :span="8">
+                                <a-form-item field="id" label="工单ID">
+                                    <a-input-number v-model="queryData.id" placeholder="请输入工单ID" :step="1"
+                                        :precision="0" />
+                                </a-form-item>
+                            </a-col>
+
+                            <a-col :span="8">
+                                <a-form-item field="state" label="状态">
+                                    <a-select v-model="queryData.state" :options="state" placeholder="请选择路径状态"
+                                        :default-value="-1" />
+                                </a-form-item>
+                            </a-col>
+                        </a-row>
+                    </a-form>
+                </a-col>
+                <a-divider style="height: 84px" direction="vertical" />
+                <a-col :flex="'86px'" style="text-align: right">
+                    <a-space direction="vertical" :size="18">
+                        <a-button type="primary" @click="search">
+                            <template #icon>
+                                <icon-search />
+                            </template>
+                            搜索
+                        </a-button>
+                        <a-button @click="reset">
+                            <template #icon>
+                                <icon-refresh />
+                            </template>
+                            重置
+                        </a-button>
+                    </a-space>
+                </a-col>
+            </a-row>
+
+
+            <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
+                :pagination="{
+                    showPageSize: true,
+                    showJumper: true,
+                    showTotal: true,
+                    pageSize: pagination.pagesize,
+                    current: pagination.current,
+                    total: pagination.total
+                }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
+                <template #create_time="{ record }">
+                    {{ stramptoTime(record.create_time) }}
+                </template>
+                <template #update_time="{ record }">
+                    {{ stramptoTime(record.update_time) }}
+                </template>
+                <template #state="{ record }">
+                    <a-tag color="orangered" v-if="record.state === 0">
+                        <template #icon>
+                            <icon-schedule />
+                        </template>
+                        待处理
+                    </a-tag>
+                    <a-tag color="arcoblue" v-else-if="record.state === 1">
+                        <template #icon>
+                            <icon-customer-service />
+                        </template>
+                        已回复
+                    </a-tag>
+                    <a-tag color="green" v-else-if="record.state === 2">
+                        <template #icon>
+                            <icon-check-circle />
+                        </template>
+                        已关闭
+                    </a-tag>
+                </template>
+                <template #optional="{ record }">
+                    <a-button @click="$router.push(`/admin/service/orderDetail/${record.id}`)">查看详情</a-button>
+                </template>
+            </a-table>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { adminOrderList } from '@/api/workOrder'
+import { Notification } from '@arco-design/web-vue'
+
+const pagination = reactive({
+    total: 0,
+    current: 1,
+    pagesize: 20
+})
+
+const state = [
+    { label: '全部', value: -1 }, { label: '待处理', value: 0 }, { label: '已回复', value: 1 }, { label: '已关闭', value: 2 }
+]
+const queryData = reactive({
+    id: '',
+    state: -1
+})
+
+const search = () => {
+    pagination.current = 1
+    getOrderList()
+}
+
+const reset = () => {
+    pagination.current = 1
+    queryData.id = ''
+    queryData.state = -1
+    getOrderList()
+}
+
+const columns = [
+    {
+        title: '工单ID',
+        dataIndex: 'id',
+    },
+    {
+        title: '工单标题',
+        dataIndex: 'title',
+    }, {
+        title: '提醒邮箱',
+        dataIndex: 'email',
+    }, {
+        title: '创建时间',
+        slotName: 'create_time'
+    }, {
+        title: '最后更新时间',
+        slotName: 'update_time'
+    }, {
+        title: '状态',
+        slotName: 'state'
+    }, {
+        title: '操作',
+        slotName: 'optional'
+    }
+]
+
+const loading = ref(false)
+const data = ref([])
+
+const getOrderList = async () => {
+    try {
+        loading.value = true
+
+        const reqData = {
+            ...queryData,
+            pagesize: pagination.pagesize,
+            current: pagination.current
+        }
+
+        const res = await adminOrderList(reqData)
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+
+        data.value = res.data
+        pagination.total = res.pagination.total
+    } catch (error) {
+        Notification.error({
+            title: '获取工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+// 分页 - 页码变化
+const handlePageChange = (page) => {
+    pagination.current = page
+    getOrderList()
+}
+
+// 分页 - 每页条数变化
+const handlePageSizeChange = (size) => {
+    pagination.pagesize = size
+    pagination.current = 1 // 页大小变化后回到第一页
+    getOrderList()
+}
+
+onMounted(() => {
+    getOrderList()
+})
+
+const stramptoTime = (time) => {
+    return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+</script>
+
+<style scoped>
+.container {
+    padding: 0 20px 20px 20px;
+}
+
+.table {
+    margin-top: 15px;
+}
+</style>

+ 116 - 0
src/pages/service/createOrder.vue

@@ -0,0 +1,116 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['售后服务', '提交工单']" />
+
+        <a-card title="提交工单">
+            <a-form :model="form" :rules="rules" layout="vertical" :style="{ width: '600px' }"
+                @submit-success="handleSubmit">
+                <a-form-item field="title" label="标题">
+                    <a-input v-model="form.title" :max-length="25" allow-clear show-word-limit />
+                </a-form-item>
+                <a-form-item field="content" label="内容">
+                    <a-textarea v-model="form.content" placeholder="请详细说明您遇到的问题,详细的阐述有助于我们快速为您解决问题..." :max-length="200"
+                        :auto-size="{
+                            minRows: 6,
+                            maxRows: 20
+                        }" allow-clear show-word-limit />
+                </a-form-item>
+
+                <a-form-item field="files" label="上传附件">
+                    <a-upload action="/cloud/api.php" :file-list="form.files" @change="handleFileChange" />
+                </a-form-item>
+
+                <a-form-item field="email" label="通知邮箱">
+                    <a-input v-model="form.email" :max-length="30" allow-clear placeholder="请填写您的邮箱,客服回复后会通过邮件通知您" />
+                </a-form-item>
+
+                <a-form-item>
+                    <a-button html-type="submit" :loading="loading">提交</a-button>
+                </a-form-item>
+            </a-form>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import { createOrder } from '@/api/workOrder'
+import { useRouter } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+
+const loading = ref(false)
+const router = useRouter()
+
+const rules = {
+    title: [
+        {
+            required: true,
+            message: '请输入工单标题',
+        },
+    ],
+    content: [
+        {
+            required: true,
+            message: '请详细说明您遇到的问题',
+        },
+    ],
+    email: [
+        {
+            type: 'email',
+            required: true,
+            message: '请填写正确的通知邮箱',
+        }
+    ]
+}
+
+const form = reactive({
+    title: '',
+    content: '',
+    email: '',
+    files: [],
+})
+
+const handleSubmit = async () => {
+    try {
+        loading.value = true
+        const res = await createOrder(form)
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '提交工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+        Notification.success({
+            title: '提交工单成功!'
+        })
+        router.push(`/service/orderDetail/${res.data}`)
+    } catch (error) {
+        Notification.error({
+            title: '提交工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+const handleFileChange = (fileList) => {
+    fileList.forEach(f => {
+        if (f.status === 'done' && f.response?.downurl) {
+            f.url = f.response?.viewurl ?? f.response.downurl
+        }
+    })
+
+    form.files = fileList // 更新到表单数据中
+}
+
+</script>
+
+<style scoped>
+.container {
+    padding: 0 20px 20px 20px;
+}
+
+.table {
+    margin-top: 15px;
+}
+</style>

+ 293 - 0
src/pages/service/orderDetail.vue

@@ -0,0 +1,293 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['售后服务', '工单详情']" />
+        <a-card title="工单详情" :loading="loading">
+            <a-descriptions :data="info" :column="2" />
+            <div class="buttonGroup">
+                <a-button type="primary" size="large" :loading="buttonLoading" @click="CloseOrder()"
+                    :disabled="data.state === 2">{{ data.state === 2 ? '已关闭' :
+                        '关闭工单' }}</a-button>
+            </div>
+        </a-card>
+
+        <a-card title="沟通记录" style="margin-top: 15px; padding-bottom: 20px;">
+            <div class="message" v-for="(msg, index) in data.msg">
+                <a-divider v-if="index !== 0" />
+                <div class="head">
+                    <a-avatar :size="40">
+                        <img :src="data.userInfo[msg.uuid]?.avatar ?? ''" />
+                    </a-avatar>
+                    <div class="right">
+                        <div class="username">
+                            {{ data.userInfo[msg.uuid]?.username }}
+                            <a-tag color="gray" v-if="msg.type === 'system'">
+                                <template #icon>
+                                    <icon-desktop />
+                                </template>
+                                系统回复
+                            </a-tag>
+                            <a-tag color="orangered" v-else-if="msg.type === 'user'">
+                                <template #icon>
+                                    <icon-user />
+                                </template>
+                                用户回复
+                            </a-tag>
+                            <a-tag color="blue" v-else-if="msg.type === 'server'">
+                                <template #icon>
+                                    <icon-customer-service />
+                                </template>
+                                客服回复
+                            </a-tag>
+                        </div>
+                        <div class="time">
+                            {{ stramptoTime(msg.time) }}
+                        </div>
+                    </div>
+                </div>
+                <div class="content" :style="{ whiteSpace: 'pre-wrap' }">
+                    {{ msg.content }}
+                </div>
+                <div class="filebox">
+                    <a-upload :default-file-list="msg.files" :show-upload-button="false" :show-remove-button="false">
+                        <template #success-icon>
+                        </template>
+                    </a-upload>
+                </div>
+            </div>
+        </a-card>
+
+        <a-card title="回复工单" style="margin-top: 15px;" v-if="data.state !== 2">
+            <a-form :model="form" :rules="rules" layout="vertical" :style="{ width: '600px' }"
+                @submit-success="handleSubmit">
+                <a-form-item field="content" label="内容">
+                    <a-textarea v-model="form.content" placeholder="请详细说明您遇到的问题,详细的阐述有助于我们快速为您解决问题..." :max-length="200"
+                        :auto-size="{
+                            minRows: 6,
+                            maxRows: 20
+                        }" allow-clear show-word-limit />
+                </a-form-item>
+
+                <a-form-item field="files" label="上传附件">
+                    <a-upload action="/cloud/api.php" :file-list="form.files" @change="handleFileChange" />
+                </a-form-item>
+
+                <a-form-item>
+                    <a-button html-type="submit" :loading="formLoading">提交</a-button>
+                </a-form-item>
+            </a-form>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
+import { orderDetail, closeOrder, createOrder } from '@/api/workOrder'
+import { Notification } from '@arco-design/web-vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+const loading = ref(false)
+const formLoading = ref(false)
+const buttonLoading = ref(false)
+const data = ref({})
+const info = ref([])
+
+const form = reactive({
+    content: '',
+    files: [],
+})
+
+const rules = {
+    content: [
+        {
+            required: true,
+            message: '请详细说明您遇到的问题',
+        },
+    ],
+}
+
+const handleFileChange = (fileList) => {
+    fileList.forEach(f => {
+        if (f.status === 'done' && f.response?.downurl) {
+            f.url = f.response?.viewurl ?? f.response.downurl
+        }
+    })
+
+    form.files = fileList // 更新到表单数据中
+}
+
+function getState(state) {
+    switch (state) {
+        case 0:
+            return '待处理'
+        case 1:
+            return '已回复'
+        case 2:
+            return '已关闭'
+    }
+    return '未知'
+}
+
+const handleSubmit = async () => {
+    try {
+        formLoading.value = true
+        const data = {
+            id: route.params.id,
+            ...form
+        }
+        const res = await createOrder(data)
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '回复工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+        Notification.success({
+            title: '回复工单成功!'
+        })
+        form.content = ''
+        form.files = []
+        getOrderDetail()
+    } catch (error) {
+        Notification.error({
+            title: '回复工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        formLoading.value = false
+    }
+}
+
+const CloseOrder = async () => {
+    try {
+        buttonLoading.value = true
+        const res = await closeOrder({ id: route.params.id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '关闭工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+        Notification.success({
+            title: '关闭工单成功!'
+        })
+        getOrderDetail()
+    } catch (error) {
+        Notification.error({
+            title: '关闭工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        buttonLoading.value = false
+    }
+}
+
+const getOrderDetail = async () => {
+    try {
+        const res = await orderDetail({ id: route.params.id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取工单详情失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+
+        data.value = res.data
+
+        info.value = [
+            { label: '工单标题', value: res.data.title },
+            { label: '工单ID', value: res.data.id },
+            { label: '通知邮箱', value: res.data.email },
+            { label: '创建时间', value: stramptoTime(res.data.create_time) },
+            { label: '最后更新时间', value: stramptoTime(res.data.update_time) },
+            { label: '当前状态', value: getState(res.data.state) }
+        ]
+    } catch (error) {
+        Notification.error({
+            title: '获取路径数据失败!',
+            content: error.message || '请稍后再试'
+        })
+    }
+}
+
+let timer = null
+
+// 轮询
+const startPolling = () => {
+    if (!timer) {
+        timer = setInterval(async () => {
+            await getOrderDetail()
+        }, 2000)
+    }
+}
+
+// 停止轮询
+const stopPolling = () => {
+    if (timer) {
+        clearInterval(timer)
+        timer = null
+    }
+}
+
+onMounted(async () => {
+    loading.value = true
+    await getOrderDetail()
+    loading.value = false
+    startPolling()
+})
+
+// 组件销毁时停止轮询
+onUnmounted(() => {
+    stopPolling()
+})
+
+const stramptoTime = (time) => {
+    return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+</script>
+
+<style scoped lang="less">
+.container {
+    padding: 0 20px 20px 20px;
+
+    .buttonGroup {
+        display: flex;
+        justify-content: center;
+        gap: 20px;
+        margin: 15px;
+    }
+}
+
+.message {
+    display: flex;
+    flex-direction: column;
+
+    .head {
+        display: flex;
+
+        .right {
+            display: flex;
+            flex-direction: column;
+            margin-left: 10px;
+
+            .username {
+                font-size: 1.2em;
+                display: flex;
+                gap: 10px
+            }
+
+            .time {
+                font-size: 0.9em;
+                color: #777;
+            }
+        }
+    }
+
+    .content {
+        margin-top: 10px;
+    }
+
+    .filebox {
+        margin-top: -10px;
+        max-width: 500px;
+    }
+}
+</style>

+ 153 - 0
src/pages/service/orderList.vue

@@ -0,0 +1,153 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['售后服务', '工单列表']" />
+
+        <a-card title="工单列表">
+            <a-button type="primary" size="large" @click="$router.push('/service/createOrder')">
+                <template #icon>
+                    <icon-plus />
+                </template>
+                提交工单
+            </a-button>
+
+            <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
+                :pagination="{
+                    showPageSize: true,
+                    showJumper: true,
+                    showTotal: true,
+                    pageSize: pagination.pagesize,
+                    current: pagination.current,
+                    total: pagination.total
+                }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
+                <template #create_time="{ record }">
+                    {{ stramptoTime(record.create_time) }}
+                </template>
+                <template #update_time="{ record }">
+                    {{ stramptoTime(record.update_time) }}
+                </template>
+                <template #state="{ record }">
+                    <a-tag color="orangered" v-if="record.state === 0">
+                        <template #icon>
+                            <icon-schedule />
+                        </template>
+                        待处理
+                    </a-tag>
+                    <a-tag color="arcoblue" v-else-if="record.state === 1">
+                        <template #icon>
+                            <icon-customer-service />
+                        </template>
+                        已回复
+                    </a-tag>
+                    <a-tag color="green" v-else-if="record.state === 2">
+                        <template #icon>
+                            <icon-check-circle />
+                        </template>
+                        已关闭
+                    </a-tag>
+                </template>
+                <template #optional="{ record }">
+                    <a-button @click="$router.push(`/service/orderDetail/${record.id}`)">查看详情</a-button>
+                </template>
+            </a-table>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { orderList } from '@/api/workOrder'
+import { Notification } from '@arco-design/web-vue'
+
+const pagination = reactive({
+    total: 0,
+    current: 1,
+    pagesize: 20
+})
+
+const columns = [
+    {
+        title: '工单ID',
+        dataIndex: 'id',
+    }, 
+    {
+        title: '工单标题',
+        dataIndex: 'title',
+    }, {
+        title: '提醒邮箱',
+        dataIndex: 'email',
+    }, {
+        title: '创建时间',
+        slotName: 'create_time'
+    }, {
+        title: '最后更新时间',
+        slotName: 'update_time'
+    }, {
+        title: '状态',
+        slotName: 'state'
+    }, {
+        title: '操作',
+        slotName: 'optional'
+    }
+]
+
+const loading = ref(false)
+const data = ref([])
+
+const getOrderList = async () => {
+    try {
+        loading.value = true
+
+        const reqData = {
+            pagesize: pagination.pagesize,
+            current: pagination.current
+        }
+        const res = await orderList(reqData)
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取工单失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+
+        data.value = res.data
+        pagination.total = res.pagination.total
+    } catch (error) {
+        Notification.error({
+            title: '获取工单失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+// 分页 - 页码变化
+const handlePageChange = (page) => {
+    pagination.current = page
+    getOrderList()
+}
+
+// 分页 - 每页条数变化
+const handlePageSizeChange = (size) => {
+    pagination.pagesize = size
+    pagination.current = 1 // 页大小变化后回到第一页
+    getOrderList()
+}
+
+onMounted(() => {
+    getOrderList()
+})
+
+const stramptoTime = (time) => {
+    return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+</script>
+
+<style scoped>
+.container {
+    padding: 0 20px 20px 20px;
+}
+
+.table {
+    margin-top: 15px;
+}
+</style>

+ 67 - 1
src/router/index.js

@@ -27,7 +27,7 @@ const routes = [
         redirect: '/store/goodsList',
         component: DEFAULT_LAYOUT,
         meta: {
-            title: 'Forge商城',
+            title: '商城',
             icon: 'icon-gift'
         },
         children: [
@@ -104,6 +104,43 @@ const routes = [
             }
         ]
     },
+    {
+        path: "/service",
+        name: 'service',
+        redirect: '/service/orderList',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '售后服务',
+            icon: 'icon-customer-service'
+        },
+        children: [
+            {
+                path: 'orderList',
+                name: 'service.orderList',
+                component: () => import('../pages/service/orderList.vue'),
+                meta: {
+                    title: '工单列表'
+                }
+            },
+            {
+                path: 'createOrder',
+                name: 'service.createOrder',
+                component: () => import('../pages/service/createOrder.vue'),
+                meta: {
+                    title: '提交工单'
+                }
+            },
+            {
+                path: 'orderDetail/:id',
+                name: 'service.orderDetail',
+                component: () => import('../pages/service/orderDetail.vue'),
+                meta: {
+                    title: '工单详情',
+                    hideInMenu: true
+                }
+            }
+        ]
+    },
     {
         path: "/user",
         name: "user",
@@ -123,6 +160,35 @@ const routes = [
                 },
             }
         ]
+    },
+        {
+        path: "/admin/service",
+        name: 'admin.service',
+        redirect: '/admin/service/orderList',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '工单管理',
+            icon: 'icon-check-square'
+        },
+        children: [
+            {
+                path: 'orderList',
+                name: 'admin.service.orderList',
+                component: () => import('../pages/service/admin/orderList.vue'),
+                meta: {
+                    title: '工单列表'
+                }
+            },
+            {
+                path: 'orderDetail/:id',
+                name: 'admin.service.orderDetail',
+                component: () => import('../pages/service/admin/orderDetail.vue'),
+                meta: {
+                    title: '工单详情',
+                    hideInMenu: true
+                }
+            }
+        ]
     },
     {
         path: "/path",

+ 9 - 0
vite.config.js

@@ -14,5 +14,14 @@ export default defineConfig({
     alias: {
       '@': path.resolve(__dirname, 'src')
     }
+  },
+  server: {
+    proxy: {
+      '/cloud': {
+        target: 'https://lepao-cloud.cthc.top',
+        changeOrigin: true,
+        rewrite: path => path.replace(/^\/cloud/, '')
+      }
+    }
   }
 })