Browse Source

feat: 改造售后服务用户端界面

工单模板快捷提交、卡片列表、沟通详情气泡样式,并统一居中布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 3 weeks ago
parent
commit
19b32136a8

+ 14 - 0
src/components/service/WorkOrderStateTag.vue

@@ -0,0 +1,14 @@
+<template>
+  <a-tag :color="meta.color" size="small">{{ meta.label }}</a-tag>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { getWorkOrderStateMeta } from '@/utils/workOrderTemplates'
+
+const props = defineProps({
+  state: { type: Number, required: true }
+})
+
+const meta = computed(() => getWorkOrderStateMeta(props.state))
+</script>

+ 106 - 0
src/components/service/WorkOrderTemplatePicker.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="template-picker">
+    <div class="template-picker__head">
+      <h3 class="template-picker__title">选择问题类型</h3>
+      <p class="template-picker__desc">点击模板将自动填充标题与内容,您可继续修改后提交</p>
+    </div>
+    <div class="template-grid">
+      <button
+        v-for="item in WORK_ORDER_TEMPLATES"
+        :key="item.id"
+        type="button"
+        class="template-card"
+        :class="{ 'template-card--active': modelValue === item.id }"
+        @click="select(item)"
+      >
+        <span class="template-card__icon">{{ item.icon }}</span>
+        <span class="template-card__label">{{ item.label }}</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { WORK_ORDER_TEMPLATES } from '@/utils/workOrderTemplates'
+
+defineProps({
+  modelValue: { type: String, default: '' }
+})
+
+const emit = defineEmits(['update:modelValue', 'select'])
+
+const select = (item) => {
+  emit('update:modelValue', item.id)
+  emit('select', item)
+}
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.template-picker {
+  margin-bottom: 24px;
+
+  &__head {
+    margin-bottom: 16px;
+  }
+
+  &__title {
+    margin: 0 0 6px;
+    font-size: 1rem;
+    font-weight: 600;
+    color: @store-primary;
+  }
+
+  &__desc {
+    margin: 0;
+    font-size: 0.85rem;
+    color: @store-text-muted;
+  }
+}
+
+.template-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
+  gap: 12px;
+}
+
+.template-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 16px 10px;
+  min-height: 96px;
+  border: 2px solid @store-card-border;
+  border-radius: @store-radius-sm;
+  background: @store-card-bg;
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
+
+  &:hover {
+    border-color: fade(@store-accent, 50%);
+    transform: translateY(-2px);
+    box-shadow: @store-shadow;
+  }
+
+  &--active {
+    border-color: @store-accent;
+    background: fade(@store-accent, 8%);
+    box-shadow: 0 0 0 2px fade(@store-accent, 15%);
+  }
+
+  &__icon {
+    font-size: 1.75rem;
+    line-height: 1;
+  }
+
+  &__label {
+    font-size: 0.85rem;
+    font-weight: 500;
+    color: @store-primary;
+    text-align: center;
+  }
+}
+</style>

+ 147 - 96
src/pages/service/createOrder.vue

@@ -1,39 +1,77 @@
 <template>
-    <div class="container">
-        <Breadcrumb />
-
-        <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="通知邮箱">
-                    <EmailAutoComplete v-model="form.email" :max-length="30" allow-clear placeholder="请填写您的邮箱,客服回复后会通过邮件通知您" />
-                </a-form-item>
-
-                <a-alert v-if="!hasPermission('action.service.createOrder')" type="warning" style="margin-bottom: 16px;">
-                    当前账号暂无发起工单权限
-                </a-alert>
-
-                <a-form-item>
-                    <a-button html-type="submit" :loading="loading" :disabled="!hasPermission('action.service.createOrder')">提交</a-button>
-                </a-form-item>
-            </a-form>
-        </a-card>
+  <div class="store-page service-page">
+    <div class="service-page__inner">
+      <Breadcrumb />
+
+      <header class="page-header">
+        <div>
+          <h1 class="store-section-title">提交工单</h1>
+          <p class="store-section-desc">选择常见问题模板,或自行填写后提交,客服将尽快回复</p>
+        </div>
+        <a-button type="outline" @click="$router.push('/service/orderList')">
+          <template #icon><icon-list /></template>
+          我的工单
+        </a-button>
+      </header>
+
+      <a-card :bordered="false" class="form-card">
+        <WorkOrderTemplatePicker
+          v-model="selectedTemplateId"
+          @select="applyTemplate"
+        />
+
+        <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
+          <a-form-item field="title" label="工单标题">
+            <a-input v-model="form.title" :max-length="25" allow-clear show-word-limit placeholder="简要概括您的问题" />
+          </a-form-item>
+
+          <a-form-item field="content" label="问题描述">
+            <a-textarea
+              v-model="form.content"
+              placeholder="请详细说明您遇到的问题,信息越完整越便于我们快速处理"
+              :max-length="500"
+              :auto-size="{ minRows: 8, maxRows: 16 }"
+              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="通知邮箱">
+            <EmailAutoComplete
+              v-model="form.email"
+              :max-length="30"
+              allow-clear
+              placeholder="客服回复后将通过邮件通知您"
+            />
+          </a-form-item>
+
+          <a-alert v-if="!hasPermission('action.service.createOrder')" type="warning" class="perm-alert">
+            当前账号暂无发起工单权限
+          </a-alert>
+
+          <a-form-item>
+            <a-space>
+              <a-button
+                type="primary"
+                html-type="submit"
+                size="large"
+                class="submit-btn"
+                :loading="loading"
+                :disabled="!hasPermission('action.service.createOrder')"
+              >
+                提交工单
+              </a-button>
+              <a-button size="large" @click="clearForm">清空内容</a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+      </a-card>
     </div>
+  </div>
 </template>
 
 <script setup>
@@ -42,87 +80,100 @@ import { createOrder } from '@/api/workOrder'
 import { useRouter } from 'vue-router'
 import { Notification } from '@arco-design/web-vue'
 import { hasPermission } from '@/utils/permission'
+import WorkOrderTemplatePicker from '@/components/service/WorkOrderTemplatePicker.vue'
 
 const loading = ref(false)
 const router = useRouter()
+const selectedTemplateId = ref('')
 
 const rules = {
-    title: [
-        {
-            required: true,
-            message: '请输入工单标题',
-        },
-    ],
-    content: [
-        {
-            required: true,
-            message: '请详细说明您遇到的问题',
-        },
-    ],
-    email: [
-        {
-            type: 'email',
-            required: true,
-            message: '请填写正确的通知邮箱',
-        }
-    ]
+  title: [{ required: true, message: '请输入工单标题' }],
+  content: [{ required: true, message: '请详细说明您遇到的问题' }],
+  email: [{ type: 'email', required: true, message: '请填写正确的通知邮箱' }]
 }
 
 const form = reactive({
-    title: '',
-    content: '',
-    email: '',
-    files: [],
+  title: '',
+  content: '',
+  email: '',
+  files: []
 })
 
+const applyTemplate = (item) => {
+  form.title = item.title
+  form.content = item.content
+}
+
+const clearForm = () => {
+  selectedTemplateId.value = ''
+  form.title = ''
+  form.content = ''
+  form.files = []
+}
+
 const handleSubmit = async () => {
-    if (!hasPermission('action.service.createOrder')) {
-        Notification.warning({
-            title: '无权限',
-            content: '当前账号暂无发起工单权限'
-        })
-        return
-    }
-    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
+  if (!hasPermission('action.service.createOrder')) {
+    Notification.warning({ title: '无权限', content: '当前账号暂无发起工单权限' })
+    return
+  }
+  try {
+    loading.value = true
+    const res = await createOrder(form)
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '提交失败',
+        content: res?.msg ?? '请稍后再试'
+      })
     }
+    Notification.success({ title: '提交成功', content: '我们已收到您的工单' })
+    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
-        }
-    })
+  fileList.forEach((f) => {
+    if (f.status === 'done' && f.response?.downurl) {
+      f.url = f.response?.viewurl ?? f.response.downurl
+    }
+  })
+  form.files = fileList
+}
+</script>
+
+<style lang="less" scoped>
+@import '@/styles/store-theme.less';
 
-    form.files = fileList // 更新到表单数据中
+.page-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 20px;
 }
 
-</script>
+.form-card {
+  border-radius: @store-radius;
+  border: 1px solid @store-card-border;
+  box-shadow: @store-shadow;
+  padding: 8px 4px 4px;
+}
 
-<style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+.perm-alert {
+  margin-bottom: 16px;
+  border-radius: @store-radius-sm;
 }
 
-.table {
-    margin-top: 15px;
+.submit-btn {
+  border-radius: 999px;
+  background: @store-primary !important;
+  border-color: @store-primary !important;
 }
-</style>
+</style>

+ 280 - 254
src/pages/service/orderDetail.vue

@@ -1,89 +1,104 @@
 <template>
-    <div class="container">
-        <Breadcrumb />
-        <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 class="store-page service-page">
+    <div class="service-page__inner">
+      <Breadcrumb />
+
+      <a-spin :loading="loading" class="store-spin">
+        <template v-if="!loading && data.id">
+          <a-card :bordered="false" class="info-card">
+            <div class="info-card__head">
+              <div>
+                <h1 class="info-card__title">{{ data.title }}</h1>
+                <p class="info-card__sub">工单 #{{ data.id }} · {{ formatTimeFull(data.create_time) }}</p>
+              </div>
+              <WorkOrderStateTag :state="data.state" />
             </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>
-                        <IconUser v-if="!data.userInfo[msg.uuid]?.avatar" />
-                        <img alt="" :src="data.userInfo[msg.uuid]?.avatar" v-else />
-                    </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>
-                            <a-tag color="purple" v-else-if="msg.type === 'ai'">
-                                <template #icon>
-                                    <icon-robot />
-                                </template>
-                                AI回复
-                            </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>
+            <a-descriptions :column="{ xs: 1, sm: 2 }" size="medium" class="info-desc">
+              <a-descriptions-item label="通知邮箱">{{ data.email }}</a-descriptions-item>
+              <a-descriptions-item label="最后更新">{{ formatTimeFull(data.update_time) }}</a-descriptions-item>
+            </a-descriptions>
+            <div class="info-card__actions">
+              <a-button
+                type="primary"
+                :loading="buttonLoading"
+                :disabled="data.state === 2"
+                class="submit-btn"
+                @click="handleCloseOrder"
+              >
+                {{ data.state === 2 ? '已关闭' : '关闭工单' }}
+              </a-button>
+              <a-button @click="$router.push('/service/orderList')">返回列表</a-button>
+            </div>
+          </a-card>
+
+          <a-card :bordered="false" class="chat-card">
+            <template #title>
+              <span class="chat-card__title">沟通记录</span>
+            </template>
+            <div v-if="!data.msg?.length" class="chat-empty">暂无消息</div>
+            <div v-else class="chat-list">
+              <div
+                v-for="(msg, index) in data.msg"
+                :key="index"
+                class="chat-item"
+                :class="messageClass(msg.type)"
+              >
+                <a-avatar :size="36" class="chat-item__avatar">
+                  <icon-user v-if="!data.userInfo?.[msg.uuid]?.avatar" />
+                  <img v-else :src="data.userInfo[msg.uuid].avatar" alt="" />
+                </a-avatar>
+                <div class="chat-item__body">
+                  <div class="chat-item__meta">
+                    <span class="chat-item__name">{{ data.userInfo?.[msg.uuid]?.username || '用户' }}</span>
+                    <a-tag size="small" :color="tagColor(msg.type)">{{ tagLabel(msg.type) }}</a-tag>
+                    <span class="chat-item__time">{{ formatTimeFull(msg.time) }}</span>
+                  </div>
+                  <div class="chat-item__content">{{ msg.content }}</div>
+                  <div v-if="msg.files?.length" class="chat-item__files">
+                    <a-upload
+                      :default-file-list="msg.files"
+                      :show-upload-button="false"
+                      :show-remove-button="false"
+                    />
+                  </div>
                 </div>
+              </div>
             </div>
-        </a-card>
-
-        <a-card title="回复工单" style="margin-top: 15px;" v-if="data.state !== 2 && hasPermission('action.service.createOrder')">
-            <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-card>
+
+          <a-card
+            v-if="data.state !== 2 && hasPermission('action.service.createOrder')"
+            :bordered="false"
+            class="reply-card"
+          >
+            <template #title>
+              <span class="chat-card__title">继续回复</span>
+            </template>
+            <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
+              <a-form-item field="content" label="回复内容">
+                <a-textarea
+                  v-model="form.content"
+                  placeholder="补充说明或回复客服"
+                  :max-length="500"
+                  :auto-size="{ minRows: 4, maxRows: 12 }"
+                  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 type="primary" html-type="submit" :loading="formLoading" class="submit-btn">
+                  发送回复
+                </a-button>
+              </a-form-item>
             </a-form>
-        </a-card>
+          </a-card>
+        </template>
+      </a-spin>
     </div>
+  </div>
 </template>
 
 <script setup>
@@ -92,220 +107,231 @@ import { orderDetail, closeOrder, createOrder } from '@/api/workOrder'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import { hasPermission } from '@/utils/permission'
+import { formatStoreTimeFull } from '@/utils/storeFormat'
+import WorkOrderStateTag from '@/components/service/WorkOrderStateTag.vue'
 
 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 form = reactive({ content: '', files: [] })
 const rules = {
-    content: [
-        {
-            required: true,
-            message: '请详细说明您遇到的问题',
-        },
-    ],
+  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
-        }
-    })
+const formatTimeFull = (time) => formatStoreTimeFull(time)
+
+const tagLabel = (type) => {
+  const map = { system: '系统', user: '我', server: '客服', ai: 'AI' }
+  return map[type] ?? type
+}
+
+const tagColor = (type) => {
+  const map = { system: 'gray', user: 'orangered', server: 'arcoblue', ai: 'purple' }
+  return map[type] ?? 'gray'
+}
 
-    form.files = fileList // 更新到表单数据中
+const messageClass = (type) => {
+  if (type === 'user') return 'chat-item--mine'
+  if (type === 'server' || type === 'ai') return 'chat-item--staff'
+  return 'chat-item--system'
 }
 
-function getState(state) {
-    switch (state) {
-        case 0:
-            return '待处理'
-        case 1:
-            return '已回复'
-        case 2:
-            return '已关闭'
-        case 3:
-            return 'AI回复'
+const handleFileChange = (fileList) => {
+  fileList.forEach((f) => {
+    if (f.status === 'done' && f.response?.downurl) {
+      f.url = f.response?.viewurl ?? f.response.downurl
     }
-    return '未知'
+  })
+  form.files = fileList
 }
 
 const handleSubmit = async () => {
-    if (!hasPermission('action.service.createOrder')) {
-        Notification.warning({
-            title: '无权限',
-            content: '当前账号暂无回复工单权限'
-        })
-        return
-    }
-    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
+  if (!hasPermission('action.service.createOrder')) {
+    Notification.warning({ title: '无权限', content: '当前账号暂无回复权限' })
+    return
+  }
+  try {
+    formLoading.value = true
+    const res = await createOrder({ id: route.params.id, ...form })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '发送失败', content: res?.msg ?? '请稍后再试' })
     }
+    Notification.success({ title: '发送成功' })
+    form.content = ''
+    form.files = []
+    await fetchDetail()
+  } 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 handleCloseOrder = 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: '工单已关闭' })
+    await fetchDetail()
+  } 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 || '请稍后再试'
-        })
-    }
+const fetchDetail = async () => {
+  const res = await orderDetail({ id: route.params.id })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '获取详情失败', content: res?.msg ?? '请稍后再试' })
+    return
+  }
+  data.value = res.data ?? {}
 }
 
 let timer = null
-
-// 轮询
 const startPolling = () => {
-    if (!timer) {
-        timer = setInterval(async () => {
-            await getOrderDetail()
-        }, 2000)
-    }
+  if (!timer) {
+    timer = setInterval(() => {
+      if (data.value?.state !== 2) fetchDetail()
+    }, 3000)
+  }
 }
-
-// 停止轮询
 const stopPolling = () => {
-    if (timer) {
-        clearInterval(timer)
-        timer = null
-    }
+  if (timer) {
+    clearInterval(timer)
+    timer = null
+  }
 }
 
 onMounted(async () => {
-    loading.value = true
-    await getOrderDetail()
-    loading.value = false
-    startPolling()
+  loading.value = true
+  await fetchDetail()
+  loading.value = false
+  if (data.value?.state !== 2) startPolling()
 })
 
-// 组件销毁时停止轮询
-onUnmounted(() => {
-    stopPolling()
-})
+onUnmounted(stopPolling)
+</script>
 
-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' })
+<style lang="less" scoped>
+@import '@/styles/store-theme.less';
+
+.info-card,
+.chat-card,
+.reply-card {
+  border-radius: @store-radius;
+  border: 1px solid @store-card-border;
+  box-shadow: @store-shadow;
+  margin-bottom: 16px;
 }
-</script>
 
-<style scoped lang="less">
-.container {
-    padding: 0 20px 20px 20px;
+.info-card__head {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 16px;
+}
 
-    .buttonGroup {
-        display: flex;
-        justify-content: center;
-        gap: 20px;
-        margin: 15px;
-    }
+.info-card__title {
+  margin: 0 0 6px;
+  font-size: 1.25rem;
+  font-weight: 600;
+  color: @store-primary;
 }
 
-.message {
-    display: flex;
-    flex-direction: column;
-    margin-bottom: 5px;
-
-    .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;
-            }
-        }
-    }
+.info-card__sub {
+  margin: 0;
+  font-size: 0.85rem;
+  color: @store-text-muted;
+}
 
-    .content {
-        margin-top: 10px;
-    }
+.info-card__actions {
+  margin-top: 16px;
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+}
 
-    .filebox {
-        margin-top: -10px;
-        max-width: 500px;
-    }
+.chat-card__title {
+  font-weight: 600;
+  color: @store-primary;
+}
+
+.chat-empty {
+  text-align: center;
+  color: @store-text-muted;
+  padding: 24px;
+}
+
+.chat-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.chat-item {
+  display: flex;
+  gap: 12px;
+
+  &__meta {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 6px;
+  }
+
+  &__name {
+    font-weight: 600;
+    color: @store-primary;
+  }
+
+  &__time {
+    font-size: 0.8rem;
+    color: @store-text-muted;
+  }
+
+  &__content {
+    padding: 12px 14px;
+    border-radius: @store-radius-sm;
+    background: @store-bg;
+    line-height: 1.65;
+    white-space: pre-wrap;
+    color: @store-text-muted;
+  }
+
+  &__files {
+    margin-top: 8px;
+    max-width: 400px;
+  }
+
+  &--mine .chat-item__content {
+    background: fade(@store-accent, 12%);
+    border: 1px solid fade(@store-accent, 25%);
+  }
+
+  &--staff .chat-item__content {
+    background: #f0f6ff;
+    border: 1px solid #d6e4ff;
+  }
+
+  &--system .chat-item__content {
+    background: var(--color-fill-2);
+  }
+}
+
+.submit-btn {
+  border-radius: 999px;
+  background: @store-primary !important;
+  border-color: @store-primary !important;
 }
-</style>
+</style>

+ 186 - 139
src/pages/service/orderList.vue

@@ -1,64 +1,64 @@
 <template>
-    <div class="container">
-        <Breadcrumb />
-
-        <a-card title="工单列表">
-            <a-button type="primary" size="large" @click="$router.push('/service/createOrder')">
-                <template #icon>
-                    <icon-plus />
-                </template>
-                提交工单
-            </a-button>
-
-            <a-alert v-if="notice" style="margin-top: 15px;">{{ notice }}</a-alert>
-
-            <a-table :bordered="false" :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="purple" v-else-if="record.state === 3">
-                        <template #icon>
-                            <icon-robot />
-                        </template>
-                        AI回复
-                    </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 class="store-page service-page">
+    <div class="service-page__inner">
+      <Breadcrumb />
+
+      <header class="page-header">
+        <div>
+          <h1 class="store-section-title">我的工单</h1>
+          <p class="store-section-desc">查看售后处理进度与历史沟通记录</p>
+        </div>
+        <a-button type="primary" class="submit-btn" @click="$router.push('/service/createOrder')">
+          <template #icon><icon-plus /></template>
+          提交工单
+        </a-button>
+      </header>
+
+      <a-alert v-if="notice" type="info" closable class="notice">{{ notice }}</a-alert>
+
+      <a-spin :loading="loading" class="store-spin">
+        <a-empty v-if="!loading && data.length === 0" description="暂无工单">
+          <a-button type="primary" @click="$router.push('/service/createOrder')">去提交</a-button>
+        </a-empty>
+
+        <div v-else class="ticket-list">
+          <article
+            v-for="record in data"
+            :key="record.id"
+            class="ticket-card"
+            @click="$router.push(`/service/orderDetail/${record.id}`)"
+          >
+            <div class="ticket-card__head">
+              <span class="ticket-id">#{{ record.id }}</span>
+              <WorkOrderStateTag :state="record.state" />
+            </div>
+            <h3 class="ticket-card__title">{{ record.title }}</h3>
+            <div class="ticket-card__meta">
+              <span><icon-email /> {{ record.email }}</span>
+              <span><icon-clock-circle /> {{ formatTime(record.update_time) }}</span>
+            </div>
+            <div class="ticket-card__action">
+              <a-button type="text" size="small" @click.stop="$router.push(`/service/orderDetail/${record.id}`)">
+                查看详情
+              </a-button>
+            </div>
+          </article>
+        </div>
+
+        <div v-if="pagination.total > pagination.pagesize" class="pager">
+          <a-pagination
+            :total="pagination.total"
+            :current="pagination.current"
+            :page-size="pagination.pagesize"
+            show-total
+            show-page-size
+            @change="handlePageChange"
+            @page-size-change="handlePageSizeChange"
+          />
+        </div>
+      </a-spin>
     </div>
+  </div>
 </template>
 
 <script setup>
@@ -67,106 +67,153 @@ import { orderList } from '@/api/workOrder'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import { getNotice } from '@/utils/util'
+import { formatStoreTime } from '@/utils/storeFormat'
+import WorkOrderStateTag from '@/components/service/WorkOrderStateTag.vue'
 
 const notice = ref('')
-
-const GetNotice = async () => {
-  const { path } = useRoute()
-  const res = await getNotice(path)
-  notice.value = res
-}
-
-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 pagination = reactive({ total: 0, current: 1, pagesize: 15 })
+
+const formatTime = (time) => formatStoreTime(time)
+
 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
+  try {
+    loading.value = true
+    const res = await orderList({
+      pagesize: pagination.pagesize,
+      current: pagination.current
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取工单失败',
+        content: res?.msg ?? '请稍后再试'
+      })
     }
+    data.value = res.data ?? []
+    pagination.total = res.pagination?.total ?? 0
+  } catch (error) {
+    Notification.error({
+      title: '获取工单失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
 }
 
-// 分页 - 页码变化
 const handlePageChange = (page) => {
-    pagination.current = page
-    getOrderList()
+  pagination.current = page
+  getOrderList()
 }
 
-// 分页 - 每页条数变化
 const handlePageSizeChange = (size) => {
-    pagination.pagesize = size
-    pagination.current = 1 // 页大小变化后回到第一页
-    getOrderList()
+  pagination.pagesize = size
+  pagination.current = 1
+  getOrderList()
+}
+
+const GetNotice = async () => {
+  const { path } = useRoute()
+  notice.value = await getNotice(path)
 }
 
 onMounted(() => {
-    getOrderList()
-    GetNotice()
+  getOrderList()
+  GetNotice()
 })
+</script>
+
+<style lang="less" scoped>
+@import '@/styles/store-theme.less';
 
-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' })
+.page-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 20px;
 }
-</script>
 
-<style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+.submit-btn {
+  border-radius: 999px;
+  background: @store-primary !important;
+  border-color: @store-primary !important;
+}
+
+.notice {
+  margin-bottom: 16px;
+  border-radius: @store-radius-sm;
+}
+
+.ticket-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: 100%;
+}
+
+.ticket-card {
+  width: 100%;
+  box-sizing: border-box;
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  padding: 16px 20px;
+  cursor: pointer;
+  transition: box-shadow 0.2s, border-color 0.2s;
+
+  &:hover {
+    box-shadow: @store-shadow;
+    border-color: fade(@store-accent, 40%);
+  }
+
+  &__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 10px;
+  }
+
+  .ticket-id {
+    font-size: 0.8rem;
+    color: @store-text-muted;
+    font-family: ui-monospace, monospace;
+  }
+
+  &__title {
+    margin: 0 0 10px;
+    font-size: 1.05rem;
+    font-weight: 600;
+    color: @store-primary;
+  }
+
+  &__meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 16px;
+    font-size: 0.85rem;
+    color: @store-text-muted;
+
+    span {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+    }
+  }
+
+  &__action {
+    margin-top: 12px;
+    display: flex;
+    justify-content: flex-end;
+  }
 }
 
-.table {
-    margin-top: 15px;
+.pager {
+  margin-top: 20px;
+  display: flex;
+  justify-content: center;
 }
-</style>
+</style>

+ 104 - 0
src/utils/workOrderTemplates.js

@@ -0,0 +1,104 @@
+/** 售后服务 — 常见工单模板 */
+export const WORK_ORDER_TEMPLATES = [
+  {
+    id: 'count-not-received',
+    icon: '💳',
+    label: '次数未到账',
+    title: '乐跑次数购买后未到账',
+    content: `您好,我已完成支付但乐跑次数尚未到账,请协助核实。
+
+订单号:
+支付时间:
+购买商品:
+支付金额:`
+  },
+  {
+    id: 'account-bind',
+    icon: '🔗',
+    label: '账号绑定',
+    title: '乐跑账号绑定/换绑问题',
+    content: `您好,我在绑定或换绑乐跑账号时遇到问题。
+
+乐跑账号(学号):
+问题描述(无法绑定、提示错误等):
+期望操作(解绑):`
+  },
+  {
+    id: 'run-record',
+    icon: '🏃',
+    label: '跑步异常',
+    title: '跑步记录或成绩异常',
+    content: `您好,我的乐跑记录存在异常,请帮忙核查。
+
+乐跑账号:
+异常日期:
+问题类型(未记录/里程不对/状态失败等):
+补充说明:`
+  },
+  {
+    id: 'payment',
+    icon: '🧾',
+    label: '支付问题',
+    title: '订单支付相关问题',
+    content: `您好,我在支付过程中遇到问题。
+
+问题类型(支付失败/重复扣款/金额错误等):
+订单号(如有):
+支付时间与方式:
+截图说明(可上传附件):`
+  },
+  {
+    id: 'refund',
+    icon: '↩️',
+    label: '退款申请',
+    title: '申请退款',
+    content: `您好,我希望申请退款,相关信息如下。
+
+订单号:
+购买商品:
+退款原因:
+联系方式:`
+  },
+  {
+    id: 'how-to',
+    icon: '❓',
+    label: '使用咨询',
+    title: '功能使用咨询',
+    content: `您好,我想咨询平台功能的使用方法。
+
+想咨询的功能:
+当前遇到的问题:
+已尝试的操作:`
+  },
+  {
+    id: 'bug',
+    icon: '🐛',
+    label: '故障反馈',
+    title: '页面或功能故障反馈',
+    content: `您好,我遇到了系统故障,请协助排查。
+
+发生时间:
+使用设备/浏览器:
+操作步骤:
+错误现象(可附截图):`
+  },
+  {
+    id: 'other',
+    icon: '✏️',
+    label: '其他',
+    title: '其他问题反馈',
+    content: `您好,我遇到以下问题需要帮助:
+
+问题描述:`
+  }
+]
+
+export function getWorkOrderStateMeta(state) {
+  const map = {
+    0: { label: '待处理', color: 'orangered' },
+    1: { label: '已回复', color: 'arcoblue' },
+    2: { label: '已关闭', color: 'green' },
+    3: { label: 'AI 回复', color: 'purple' }
+  }
+  return map[state] ?? { label: '未知', color: 'gray' }
+}