Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

@@ -0,0 +1,919 @@
<!-- 客服端 - 工单详情页 -->
<template>
<view class="ticket-detail-page">
<!-- 工单状态 -->
<view class="ticket-status">
<view class="status-header">
<text class="ticket-id">工单 #{{ ticket.id }}</text>
<view class="status-badge" :class="getStatusClass()">{{ getStatusText() }}</view>
</view>
<text class="ticket-title">{{ ticket.title }}</text>
<view class="ticket-meta">
<text class="meta-item">{{ getCategoryText() }}</text>
<text class="meta-item">{{ getPriorityText() }}</text>
<text class="meta-item">{{ formatTime(ticket.created_at) }}</text>
</view>
</view>
<!-- 用户信息 -->
<view class="user-info">
<view class="section-title">用户信息</view>
<view class="user-card">
<image :src="user.avatar_url || '/static/default-avatar.png'" class="user-avatar" />
<view class="user-details">
<text class="user-name">{{ user.nickname || user.phone }}</text>
<text class="user-contact">{{ user.phone }}</text>
<text class="user-email">{{ user.email || '未设置邮箱' }}</text>
</view>
<view class="user-actions">
<button class="action-btn call" @click="callUser">📞</button>
<button class="action-btn message" @click="sendMessage">💬</button>
</view>
</view>
</view>
<!-- 工单内容 -->
<view class="ticket-content">
<view class="section-title">问题描述</view>
<text class="content-text">{{ ticket.description }}</text>
<view v-if="ticket.attachments && ticket.attachments.length > 0" class="attachments">
<text class="attachment-title">相关附件</text>
<view class="attachment-list">
<view v-for="(attachment, index) in ticket.attachments" :key="index"
class="attachment-item" @click="previewAttachment(attachment)">
<text class="attachment-icon">{{ getAttachmentIcon(attachment) }}</text>
<text class="attachment-name">{{ getAttachmentName(attachment) }}</text>
</view>
</view>
</view>
</view>
<!-- 处理记录 -->
<view class="ticket-logs">
<view class="section-title">处理记录</view>
<view v-if="ticketLogs.length === 0" class="empty-logs">
<text class="empty-text">暂无处理记录</text>
</view>
<view v-for="log in ticketLogs" :key="log.id" class="log-item">
<view class="log-header">
<text class="log-operator">{{ log.operator_name }}</text>
<text class="log-time">{{ formatTime(log.created_at) }}</text>
</view>
<text class="log-action">{{ getLogActionText(log.action) }}</text>
<text v-if="log.content" class="log-content">{{ log.content }}</text>
</view>
</view>
<!-- 相关订单 -->
<view v-if="relatedOrder" class="related-order">
<view class="section-title">相关订单</view>
<view class="order-card" @click="viewOrderDetail">
<view class="order-header">
<text class="order-no">{{ relatedOrder.order_no }}</text>
<text class="order-status">{{ getOrderStatusText(relatedOrder.status) }}</text>
</view>
<view class="order-info">
<text class="order-amount">¥{{ relatedOrder.actual_amount }}</text>
<text class="order-time">{{ formatTime(relatedOrder.created_at) }}</text>
</view>
</view>
</view>
<!-- 解决方案建议 -->
<view class="solution-suggestions">
<view class="section-title">解决方案建议</view>
<view v-for="suggestion in suggestions" :key="suggestion.id"
class="suggestion-item" @click="applySuggestion(suggestion)">
<text class="suggestion-title">{{ suggestion.title }}</text>
<text class="suggestion-desc">{{ suggestion.description }}</text>
</view>
</view>
<!-- 处理操作 -->
<view class="ticket-actions">
<view class="section-title">处理操作</view>
<view class="quick-replies">
<text class="quick-title">快速回复</text>
<view class="reply-tags">
<text v-for="reply in quickReplies" :key="reply"
class="reply-tag" @click="selectQuickReply(reply)">{{ reply }}</text>
</view>
</view>
<view class="response-form">
<textarea v-model="responseContent"
class="response-input"
placeholder="请输入处理内容或回复信息..."
:maxlength="500"></textarea>
<view class="form-actions">
<button class="attach-btn" @click="addAttachment">📎 添加附件</button>
<button class="template-btn" @click="useTemplate">📝 使用模板</button>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<button v-if="ticket.status === 'open'" class="action-btn primary" @click="processTicket">处理工单</button>
<button v-if="ticket.status === 'processing'" class="action-btn success" @click="resolveTicket">解决工单</button>
<button v-if="ticket.status === 'processing'" class="action-btn warning" @click="escalateTicket">升级工单</button>
<button class="action-btn secondary" @click="addNote">添加备注</button>
<button v-if="ticket.status !== 'closed'" class="action-btn danger" @click="closeTicket">关闭工单</button>
</view>
</view>
</template>
<script>
import { UserType, OrderType } from '@/types/mall-types.uts'
type TicketType = {
id: string
title: string
description: string
category: string
priority: string
status: string
user_id: string
assigned_to: string
attachments: Array<string>
created_at: string
}
type TicketLogType = {
id: string
ticket_id: string
action: string
content: string
operator_name: string
created_at: string
}
type SuggestionType = {
id: string
title: string
description: string
template: string
}
export default {
data() {
return {
ticket: {
id: '',
title: '',
description: '',
category: '',
priority: '',
status: '',
user_id: '',
assigned_to: '',
attachments: [],
created_at: ''
} as TicketType,
user: {
id: '',
phone: '',
email: '',
nickname: '',
avatar_url: '',
gender: 0,
user_type: 0,
status: 0,
created_at: ''
} as UserType,
ticketLogs: [] as Array<TicketLogType>,
relatedOrder: null as OrderType | null,
suggestions: [] as Array<SuggestionType>,
quickReplies: [] as Array<string>,
responseContent: ''
}
},
onLoad(options: any) {
const ticketId = options.ticketId as string
if (ticketId) {
this.loadTicketDetail(ticketId)
}
},
methods: {
loadTicketDetail(ticketId: string) {
// 模拟加载工单详情数据
this.ticket = {
id: ticketId,
title: '订单支付问题',
description: '我在支付订单时遇到了问题支付页面一直加载不出来尝试了多次都无法完成支付。订单号是ORD202401150001希望能尽快解决。',
category: 'payment',
priority: 'high',
status: 'processing',
user_id: 'user_001',
assigned_to: 'service_001',
attachments: ['/static/screenshot1.jpg', '/static/log.txt'],
created_at: '2024-01-15T10:30:00'
}
this.user = {
id: 'user_001',
phone: '13800138000',
email: 'user@example.com',
nickname: '张三',
avatar_url: '/static/avatar1.jpg',
gender: 1,
user_type: 1,
status: 1,
created_at: '2023-06-15T10:30:00'
}
this.ticketLogs = [
{
id: 'log_001',
ticket_id: ticketId,
action: 'created',
content: '工单已创建',
operator_name: '系统',
created_at: '2024-01-15T10:30:00'
},
{
id: 'log_002',
ticket_id: ticketId,
action: 'assigned',
content: '工单已分配给客服小王',
operator_name: '系统',
created_at: '2024-01-15T10:35:00'
},
{
id: 'log_003',
ticket_id: ticketId,
action: 'responded',
content: '已联系用户确认问题详情,正在排查支付系统。',
operator_name: '客服小王',
created_at: '2024-01-15T11:00:00'
}
]
if (this.ticket.category === 'payment') {
this.relatedOrder = {
id: 'order_001',
order_no: 'ORD202401150001',
user_id: 'user_001',
merchant_id: 'merchant_001',
status: 1,
total_amount: 299.98,
discount_amount: 30.00,
delivery_fee: 8.00,
actual_amount: 277.98,
payment_method: 1,
payment_status: 0,
delivery_address: {},
created_at: '2024-01-15T10:25:00'
}
}
this.suggestions = [
{
id: 'sug_001',
title: '检查支付方式',
description: '建议用户更换支付方式或检查账户余额',
template: '您好,建议您尝试更换支付方式,或检查您的账户余额是否充足。如果问题仍然存在,请联系我们的技术支持。'
},
{
id: 'sug_002',
title: '清除缓存',
description: '指导用户清除浏览器缓存后重试',
template: '您好,请尝试清除浏览器缓存后重新进行支付。具体操作:设置 > 清除浏览数据 > 清除缓存和Cookie。'
}
]
this.quickReplies = [
'感谢您的反馈,我们正在处理',
'问题已确认预计30分钟内解决',
'请提供更多详细信息',
'问题已解决,请您确认',
'如有其他问题,请随时联系我们'
]
},
getStatusClass(): string {
const classes: Record<string, string> = {
open: 'status-open',
processing: 'status-processing',
resolved: 'status-resolved',
closed: 'status-closed'
}
return classes[this.ticket.status] || 'status-open'
},
getStatusText(): string {
const statusTexts: Record<string, string> = {
open: '待处理',
processing: '处理中',
resolved: '已解决',
closed: '已关闭'
}
return statusTexts[this.ticket.status] || '未知'
},
getCategoryText(): string {
const categories: Record<string, string> = {
payment: '支付问题',
order: '订单问题',
delivery: '配送问题',
product: '商品问题',
account: '账户问题',
other: '其他问题'
}
return categories[this.ticket.category] || '其他'
},
getPriorityText(): string {
const priorities: Record<string, string> = {
low: '低优先级',
medium: '中优先级',
high: '高优先级',
urgent: '紧急'
}
return priorities[this.ticket.priority] || '普通'
},
getOrderStatusText(status: number): string {
const statusTexts = ['异常', '待支付', '待发货', '待收货', '已完成', '已取消']
return statusTexts[status] || '未知'
},
getLogActionText(action: string): string {
const actions: Record<string, string> = {
created: '创建工单',
assigned: '分配工单',
responded: '回复用户',
escalated: '升级工单',
resolved: '解决工单',
closed: '关闭工单'
}
return actions[action] || action
},
getAttachmentIcon(attachment: string): string {
const ext = attachment.split('.').pop()?.toLowerCase()
const icons: Record<string, string> = {
jpg: '🖼️',
jpeg: '🖼️',
png: '🖼️',
gif: '🖼️',
pdf: '📄',
doc: '📝',
docx: '📝',
txt: '📄',
zip: '📦'
}
return icons[ext || ''] || '📎'
},
getAttachmentName(attachment: string): string {
return attachment.split('/').pop() || '未知文件'
},
formatTime(timeStr: string): string {
return timeStr.replace('T', ' ').split('.')[0]
},
callUser() {
uni.makePhoneCall({
phoneNumber: this.user.phone
})
},
sendMessage() {
uni.navigateTo({
url: `/pages/mall/service/chat?userId=${this.user.id}&ticketId=${this.ticket.id}`
})
},
previewAttachment(attachment: string) {
uni.previewImage({
urls: [attachment],
current: attachment
})
},
viewOrderDetail() {
if (this.relatedOrder) {
uni.navigateTo({
url: `/pages/mall/service/order-detail?orderId=${this.relatedOrder.id}`
})
}
},
applySuggestion(suggestion: SuggestionType) {
this.responseContent = suggestion.template
},
selectQuickReply(reply: string) {
this.responseContent = reply
},
addAttachment() {
uni.chooseImage({
count: 1,
success: (res) => {
uni.showToast({
title: '附件添加成功',
icon: 'success'
})
}
})
},
useTemplate() {
uni.navigateTo({
url: '/pages/mall/service/response-templates'
})
},
processTicket() {
if (!this.responseContent.trim()) {
uni.showToast({
title: '请输入处理内容',
icon: 'none'
})
return
}
this.ticket.status = 'processing'
this.ticketLogs.push({
id: 'log_' + Date.now(),
ticket_id: this.ticket.id,
action: 'responded',
content: this.responseContent,
operator_name: '当前客服',
created_at: new Date().toISOString()
})
this.responseContent = ''
uni.showToast({
title: '处理成功',
icon: 'success'
})
},
resolveTicket() {
if (!this.responseContent.trim()) {
uni.showToast({
title: '请输入解决方案',
icon: 'none'
})
return
}
this.ticket.status = 'resolved'
this.ticketLogs.push({
id: 'log_' + Date.now(),
ticket_id: this.ticket.id,
action: 'resolved',
content: this.responseContent,
operator_name: '当前客服',
created_at: new Date().toISOString()
})
this.responseContent = ''
uni.showToast({
title: '工单已解决',
icon: 'success'
})
},
escalateTicket() {
uni.showModal({
title: '升级工单',
content: '确定要将此工单升级到上级处理吗?',
success: (res) => {
if (res.confirm) {
this.ticketLogs.push({
id: 'log_' + Date.now(),
ticket_id: this.ticket.id,
action: 'escalated',
content: '工单已升级到上级处理',
operator_name: '当前客服',
created_at: new Date().toISOString()
})
uni.showToast({
title: '工单已升级',
icon: 'success'
})
}
}
})
},
addNote() {
uni.navigateTo({
url: `/pages/mall/service/add-note?ticketId=${this.ticket.id}`
})
},
closeTicket() {
uni.showModal({
title: '关闭工单',
content: '确定要关闭此工单吗?关闭后将无法继续处理。',
success: (res) => {
if (res.confirm) {
this.ticket.status = 'closed'
this.ticketLogs.push({
id: 'log_' + Date.now(),
ticket_id: this.ticket.id,
action: 'closed',
content: '工单已关闭',
operator_name: '当前客服',
created_at: new Date().toISOString()
})
uni.showToast({
title: '工单已关闭',
icon: 'success'
})
}
}
})
}
}
}
</script>
<style>
.ticket-detail-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 200rpx;
}
.ticket-status, .user-info, .ticket-content, .ticket-logs, .related-order, .solution-suggestions, .ticket-actions {
background-color: #fff;
margin-bottom: 20rpx;
padding: 30rpx;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
}
.ticket-id {
font-size: 28rpx;
color: #666;
}
.status-badge {
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 22rpx;
color: #fff;
}
.status-open {
background-color: #ffa726;
}
.status-processing {
background-color: #2196f3;
}
.status-resolved {
background-color: #4caf50;
}
.status-closed {
background-color: #999;
}
.ticket-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 15rpx;
line-height: 1.4;
}
.ticket-meta {
display: flex;
gap: 20rpx;
}
.meta-item {
font-size: 24rpx;
color: #666;
background-color: #f0f0f0;
padding: 6rpx 12rpx;
border-radius: 8rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 25rpx;
}
.user-card {
display: flex;
align-items: center;
}
.user-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50rpx;
margin-right: 25rpx;
}
.user-details {
flex: 1;
}
.user-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.user-contact, .user-email {
font-size: 24rpx;
color: #666;
margin-bottom: 5rpx;
}
.user-actions {
display: flex;
gap: 15rpx;
}
.action-btn.call, .action-btn.message {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #f0f8ff;
border: none;
font-size: 24rpx;
}
.content-text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
margin-bottom: 20rpx;
}
.attachments {
margin-top: 25rpx;
}
.attachment-title {
font-size: 26rpx;
color: #666;
margin-bottom: 15rpx;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.attachment-item {
display: flex;
align-items: center;
padding: 15rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
min-width: 200rpx;
}
.attachment-icon {
font-size: 24rpx;
margin-right: 10rpx;
}
.attachment-name {
font-size: 24rpx;
color: #333;
}
.empty-logs {
text-align: center;
padding: 60rpx 0;
}
.empty-text {
font-size: 24rpx;
color: #999;
}
.log-item {
padding: 25rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.log-item:last-child {
border-bottom: none;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
}
.log-operator {
font-size: 26rpx;
color: #333;
font-weight: bold;
}
.log-time {
font-size: 22rpx;
color: #999;
}
.log-action {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.log-content {
font-size: 26rpx;
color: #333;
line-height: 1.4;
background-color: #f8f9fa;
padding: 15rpx;
border-radius: 8rpx;
}
.order-card {
padding: 25rpx;
background-color: #f8f9fa;
border-radius: 10rpx;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
}
.order-no {
font-size: 26rpx;
color: #333;
font-weight: bold;
}
.order-status {
font-size: 22rpx;
color: #666;
}
.order-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.order-amount {
font-size: 24rpx;
color: #ff4444;
font-weight: bold;
}
.order-time {
font-size: 22rpx;
color: #999;
}
.suggestion-item {
padding: 25rpx;
background-color: #f8f9fa;
border-radius: 10rpx;
margin-bottom: 15rpx;
}
.suggestion-title {
font-size: 26rpx;
color: #333;
font-weight: bold;
margin-bottom: 8rpx;
}
.suggestion-desc {
font-size: 24rpx;
color: #666;
line-height: 1.4;
}
.quick-replies {
margin-bottom: 25rpx;
}
.quick-title {
font-size: 26rpx;
color: #666;
margin-bottom: 15rpx;
}
.reply-tags {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.reply-tag {
padding: 12rpx 20rpx;
background-color: #e3f2fd;
color: #1976d2;
border-radius: 20rpx;
font-size: 24rpx;
}
.response-form {
margin-top: 25rpx;
}
.response-input {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
border: 1rpx solid #ddd;
border-radius: 10rpx;
font-size: 26rpx;
line-height: 1.4;
background-color: #fafafa;
resize: none;
}
.form-actions {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
}
.attach-btn, .template-btn {
padding: 15rpx 25rpx;
background-color: #f0f0f0;
border-radius: 8rpx;
font-size: 24rpx;
color: #666;
border: none;
}
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 25rpx 30rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
display: flex;
gap: 15rpx;
flex-wrap: wrap;
}
.bottom-actions .action-btn {
flex: 1;
min-width: 120rpx;
height: 60rpx;
border-radius: 30rpx;
font-size: 24rpx;
border: none;
}
.action-btn.primary {
background-color: #2196f3;
color: #fff;
}
.action-btn.success {
background-color: #4caf50;
color: #fff;
}
.action-btn.warning {
background-color: #ffa726;
color: #fff;
}
.action-btn.secondary {
background-color: #e0e0e0;
color: #666;
}
.action-btn.danger {
background-color: #ff4444;
color: #fff;
}
</style>