feat(admin): complete full integration of kefu, finance, product and order modules with real RPC data streams

This commit is contained in:
comlibmb
2026-02-11 18:45:46 +08:00
parent ee5c0c446b
commit 48320b410c
25 changed files with 2060 additions and 538 deletions

View File

@@ -3,29 +3,23 @@
<!-- 头部搜索 -->
<view class="search-card">
<view class="search-row">
<view class="search-item">
<text class="search-label">回复类型:</text>
<view class="mock-select">
<text class="select-val">请选择</text>
<text class="arrow-down-icon">▼</text>
</view>
</view>
<view class="search-item">
<text class="search-label">关键字:</text>
<input class="search-input" placeholder="请输入关键字" />
<input class="search-input" v-model="searchQuery" placeholder="请输入关键字" @confirm="handleQuery" />
</view>
<button class="btn-query">查询</button>
<button class="btn-query" @click="handleQuery">查询</button>
<button class="btn-reset" @click="handleReset">重置</button>
</view>
</view>
<!-- 数据表格区域 -->
<view class="table-card">
<view class="table-card border-shadow">
<view class="table-toolbar">
<button class="btn-primary-add" @click="openModal()">添加自动回复</button>
</view>
<view class="table-header-pane">
<view class="th flex-1">ID</view>
<view class="th flex-1">序号</view>
<view class="th flex-2">关键字</view>
<view class="th flex-2">回复类型</view>
<view class="th flex-4">回复内容</view>
@@ -34,33 +28,46 @@
</view>
<view class="table-body">
<view v-if="list.length === 0" class="empty-box">
<text class="empty-text">暂无数据</text>
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-for="(item, index) in list" :key="index" class="table-row-item">
<text class="td flex-1 color-9">{{ item.id }}</text>
<view v-else-if="list.length === 0" class="empty-box">
<text class="empty-text">暂无自动回复配置</text>
</view>
<view v-for="(item, index) in list" :key="item.id" class="table-row-item">
<text class="td flex-1 color-9">{{ (page - 1) * pageSize + index + 1 }}</text>
<text class="td flex-2">{{ item.keyword }}</text>
<text class="td flex-2">{{ item.type === 'text' ? '文字消息' : '图片消息' }}</text>
<text class="td flex-2">{{ item.reply_type === 'text' ? '文字消息' : '图片消息' }}</text>
<text class="td flex-4 color-6 truncate">{{ item.content }}</text>
<view class="td flex-2 row-center">
<view class="status-switch-mini" :class="item.status ? 'active' : ''" @click="toggleStatus(index)">
<view class="status-switch-mini" :class="item.status == 1 ? 'active' : ''" @click="toggleStatus(item)">
<view class="switch-dot-mini"></view>
</view>
</view>
<view class="td flex-2 row-center">
<text class="btn-action-blue" @click="openModal(item)">编辑</text>
<view class="v-divider-line"></view>
<text class="btn-action-red" @click="deleteItem(index)">删除</text>
<text class="btn-action-red" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-bar">
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-prev" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-next" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
<!-- 添加/编辑弹窗 (Centered Modal) -->
<view class="modal-overlay" v-if="showModal" @click="closeModal">
<view class="modal-main-pane" @click.stop>
<view class="modal-header-box">
<text class="modal-title-txt">客服自动回复</text>
<text class="modal-title-txt">{{ isEdit ? '编辑自动回复' : '添加自动回复' }}</text>
<text class="modal-close-icon" @click="closeModal">×</text>
</view>
<view class="modal-body-form">
@@ -73,15 +80,15 @@
<view class="form-item-box">
<view class="label-box"><text class="form-label">回复类型:</text></view>
<view class="val-box row-center-start">
<view class="radio-item" @click="form.type = 'text'">
<view class="radio-circle" :class="form.type === 'text' ? 'radio-checked' : ''">
<view v-if="form.type === 'text'" class="radio-dot-inner"></view>
<view class="radio-item" @click="form.reply_type = 'text'">
<view class="radio-circle" :class="form.reply_type === 'text' ? 'radio-checked' : ''">
<view v-if="form.reply_type === 'text'" class="radio-dot-inner"></view>
</view>
<text class="radio-txt">文字消息</text>
</view>
<view class="radio-item" @click="form.type = 'image'">
<view class="radio-circle" :class="form.type === 'image' ? 'radio-checked' : ''">
<view v-if="form.type === 'image'" class="radio-dot-inner"></view>
<view class="radio-item" @click="form.reply_type = 'image'">
<view class="radio-circle" :class="form.reply_type === 'image' ? 'radio-checked' : ''">
<view v-if="form.reply_type === 'image'" class="radio-dot-inner"></view>
</view>
<text class="radio-txt">图片消息</text>
</view>
@@ -90,21 +97,21 @@
<view class="form-item-box">
<view class="label-box"><text class="form-label font-star">回复内容:</text></view>
<view class="val-box">
<input class="input-ctrl" v-model="form.content" placeholder="请输入回复内容" />
<textarea class="textarea-ctrl" v-model="form.content" placeholder="请输入回复内容" />
</view>
</view>
<view class="form-item-box">
<view class="label-box"><text class="form-label">状态:</text></view>
<view class="val-box row-center-start">
<view class="radio-item" @click="form.status = true">
<view class="radio-circle" :class="form.status ? 'radio-checked' : ''">
<view v-if="form.status" class="radio-dot-inner"></view>
<view class="radio-item" @click="form.status = 1">
<view class="radio-circle" :class="form.status === 1 ? 'radio-checked' : ''">
<view v-if="form.status === 1" class="radio-dot-inner"></view>
</view>
<text class="radio-txt">开启</text>
</view>
<view class="radio-item" @click="form.status = false">
<view class="radio-circle" :class="!form.status ? 'radio-checked' : ''">
<view v-if="!form.status" class="radio-dot-inner"></view>
<view class="radio-item" @click="form.status = 0">
<view class="radio-circle" :class="form.status === 0 ? 'radio-checked' : ''">
<view v-if="form.status === 0" class="radio-dot-inner"></view>
</view>
<text class="radio-txt">关闭</text>
</view>
@@ -121,43 +128,122 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchAutoReplyPage,
saveAutoReply as saveAutoReplyService,
deleteAutoReply,
setAutoReplyStatus,
type AutoReply
} from '@/services/admin/kefuService.uts'
interface AutoReplyItem {
id: number;
keyword: string;
type: string; // 'text' | 'image'
content: string;
status: boolean;
}
const list = reactive<AutoReplyItem[]>([])
const list = ref<AutoReply[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const searchQuery = ref('')
const showModal = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const currentId = ref<string | null>(null)
const form = reactive({
keyword: '',
type: 'text',
reply_type: 'text',
content: '',
status: true
status: 1
})
function openModal(item: AutoReplyItem | null = null) {
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchAutoReplyPage(page.value, pageSize.value, searchQuery.value || null)
list.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleReset() {
searchQuery.value = ''
page.value = 1
loadData()
}
function prevPage() {
if (page.value > 1) {
page.value--
loadData()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadData()
}
}
async function toggleStatus(item: AutoReply) {
const targetStatus = item.status === 1 ? 0 : 1
const ok = await setAutoReplyStatus(item.id, targetStatus)
if (ok) {
item.status = targetStatus
uni.showToast({ title: '状态更新成功' })
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
async function handleDelete(item: AutoReply) {
uni.showModal({
title: '提示',
content: `确定删除关键字为 "${item.keyword}" 的自动回复吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteAutoReply(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
function openModal(item: AutoReply | null = null) {
if (item != null) {
isEdit.value = true
currentId.value = item.id
form.keyword = item.keyword
form.type = item.type
form.reply_type = item.reply_type
form.content = item.content
form.status = item.status
editIndex.value = list.indexOf(item)
} else {
isEdit.value = false
currentId.value = null
form.keyword = ''
form.type = 'text'
form.reply_type = 'text'
form.content = ''
form.status = true
form.status = 1
}
showModal.value = true
}
@@ -166,7 +252,7 @@ function closeModal() {
showModal.value = false
}
function saveReply() {
async function saveReply() {
if (!form.keyword) {
uni.showToast({ title: '请输入关键字', icon: 'none' })
return
@@ -176,40 +262,21 @@ function saveReply() {
return
}
if (isEdit.value) {
const item = list[editIndex.value]
item.keyword = form.keyword
item.type = form.type
item.content = form.content
item.status = form.status
const resId = await saveAutoReplyService(
currentId.value,
form.keyword,
form.content,
form.reply_type,
form.status
)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
} else {
list.unshift({
id: Date.now() % 10000,
keyword: form.keyword,
type: form.type,
content: form.content,
status: form.status
})
uni.showToast({ title: '保存失败', icon: 'none' })
}
closeModal()
uni.showToast({ title: '保存成功', icon: 'success' })
}
function toggleStatus(index: number) {
list[index].status = !list[index].status
}
function deleteItem(index: number) {
uni.showModal({
title: '提示',
content: '确定删除该自动回复吗?',
success: (res) => {
if (res.confirm) {
list.splice(index, 1)
uni.showToast({ title: '删除成功', icon: 'none' })
}
}
})
}
</script>
@@ -244,23 +311,10 @@ function deleteItem(index: number) {
margin-right: 10px;
white-space: nowrap;
}
.mock-select {
width: 200px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.select-val { font-size: 14px; color: #c0c4cc; }
.arrow-down-icon { font-size: 10px; color: #c0c4cc; }
.search-input {
width: 200px;
height: 32px;
width: 240px;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
@@ -269,14 +323,25 @@ function deleteItem(index: number) {
.btn-query {
background-color: #1890ff;
color: #fff;
height: 32px;
line-height: 32px;
height: 36px;
line-height: 36px;
padding: 0 20px;
border-radius: 4px;
font-size: 14px;
border: none;
margin-left: 10px;
}
.btn-reset {
background-color: #fff;
color: #666;
border: 1px solid #dcdfe6;
height: 36px;
line-height: 36px;
padding: 0 20px;
border-radius: 4px;
font-size: 14px;
margin-left: 10px;
}
/* 表格区域样式 */
.table-card {
@@ -290,8 +355,8 @@ function deleteItem(index: number) {
.btn-primary-add {
background-color: #1890ff;
color: #fff;
height: 32px;
line-height: 32px;
height: 34px;
line-height: 34px;
padding: 0 15px;
border-radius: 4px;
font-size: 14px;
@@ -307,9 +372,9 @@ function deleteItem(index: number) {
align-items: center;
}
.th {
font-size: 14px;
font-size: 13px;
font-weight: bold;
color: #333;
color: #606266;
padding: 0 10px;
}
.table-body {
@@ -318,7 +383,7 @@ function deleteItem(index: number) {
.table-row-item {
display: flex;
flex-direction: row;
height: 54px;
height: 64px;
align-items: center;
border-left: 1px solid #f0f0f0;
border-right: 1px solid #f0f0f0;
@@ -326,7 +391,7 @@ function deleteItem(index: number) {
}
.td {
padding: 0 10px;
font-size: 14px;
font-size: 13px;
color: #606266;
}
@@ -345,8 +410,8 @@ function deleteItem(index: number) {
.color-6 { color: #666; }
/* 操作按钮 */
.btn-action-blue { color: #1890ff; font-size: 14px; cursor: pointer; }
.btn-action-red { color: #ff4d4f; font-size: 14px; cursor: pointer; }
.btn-action-blue { color: #1890ff; font-size: 13px; cursor: pointer; }
.btn-action-red { color: #ff4d4f; font-size: 13px; cursor: pointer; }
.v-divider-line { width: 1px; height: 12px; background-color: #eee; margin: 0 10px; }
/* 状态开关 */
@@ -357,6 +422,7 @@ function deleteItem(index: number) {
border-radius: 11px;
position: relative;
transition: background-color 0.3s;
cursor: pointer;
}
.status-switch-mini.active {
background-color: #1890ff;
@@ -386,7 +452,7 @@ function deleteItem(index: number) {
align-items: center;
}
.modal-main-pane {
width: 600px;
width: 520px;
background-color: #fff;
border-radius: 4px;
overflow: hidden;
@@ -403,16 +469,16 @@ function deleteItem(index: number) {
.modal-close-icon { font-size: 22px; color: #999; cursor: pointer; }
.modal-body-form {
padding: 30px;
padding: 24px;
}
.form-item-box {
display: flex;
flex-direction: row;
margin-bottom: 24px;
margin-bottom: 20px;
align-items: center;
}
.label-box {
width: 100px;
width: 90px;
margin-right: 15px;
text-align: right;
}
@@ -428,12 +494,20 @@ function deleteItem(index: number) {
padding: 0 12px;
font-size: 14px;
}
.textarea-ctrl {
width: 100%;
height: 100px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
}
.radio-item {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 30px;
margin-right: 24px;
cursor: pointer;
}
.radio-circle {
@@ -463,7 +537,7 @@ function deleteItem(index: number) {
.btn-foot-cancel {
background-color: #fff; border: 1px solid #dcdfe6; color: #606266;
padding: 0 20px; height: 32px; line-height: 32px; border-radius: 4px; font-size: 14px;
margin-right: 15px;
margin-right: 12px;
}
.btn-foot-submit {
background-color: #1890ff; color: #fff; border: none;
@@ -477,4 +551,24 @@ function deleteItem(index: number) {
.row-center { display: flex; flex-direction: row; align-items: center; justify-content: center; }
.row-center-start { display: flex; flex-direction: row; align-items: center; justify-content: flex-start; }
.text-center { text-align: center; }
/* 分页 */
.pagination-bar {
padding: 15px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-nav { display: flex; flex-direction: row; align-items: center; }
.nav-prev, .nav-next, .nav-item {
width: 30px; height: 30px; border: 1px solid #dcdee2;
display: flex; align-items: center; justify-content: center;
border-radius: 4px; margin: 0 4px; cursor: pointer;
}
.nav-item.active { background-color: #1890ff; border-color: #1890ff; }
.nav-item.active .nav-num { color: #fff; }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { cursor: not-allowed; opacity: 0.5; }
</style>

View File

@@ -17,21 +17,21 @@
</view>
<view class="input-box flex-row">
<view class="radio-group">
<view class="radio-item" @click="configType = 'system'">
<view :class="['radio-circle', configType === 'system' ? 'checked' : '']">
<view v-if="configType === 'system'" class="radio-inner"></view>
<view class="radio-item" @click="form.type = 'system'">
<view :class="['radio-circle', form.type === 'system' ? 'checked' : '']">
<view v-if="form.type === 'system'" class="radio-inner"></view>
</view>
<text class="radio-label">系统客服</text>
</view>
<view class="radio-item" @click="configType = 'phone'">
<view :class="['radio-circle', configType === 'phone' ? 'checked' : '']">
<view v-if="configType === 'phone'" class="radio-inner"></view>
<view class="radio-item" @click="form.type = 'phone'">
<view :class="['radio-circle', form.type === 'phone' ? 'checked' : '']">
<view v-if="form.type === 'phone'" class="radio-inner"></view>
</view>
<text class="radio-label">拨打电话</text>
</view>
<view class="radio-item" @click="configType = 'link'">
<view :class="['radio-circle', configType === 'link' ? 'checked' : '']">
<view v-if="configType === 'link'" class="radio-inner"></view>
<view class="radio-item" @click="form.type = 'link'">
<view :class="['radio-circle', form.type === 'link' ? 'checked' : '']">
<view v-if="form.type === 'link'" class="radio-inner"></view>
</view>
<text class="radio-label">跳转链接</text>
</view>
@@ -47,7 +47,7 @@
</view>
<!-- 动态显示内容 -->
<template v-if="configType === 'system'">
<template v-if="form.type === 'system'">
<view class="form-item align-start">
<view class="label-box pt-10">
<text class="label-txt">客服反馈:</text>
@@ -55,34 +55,34 @@
<view class="input-box">
<textarea
class="textarea-base"
v-model="feedbackText"
v-model="form.feedback"
placeholder="请输入客服反馈内容"
/>
<text class="input-tip">暂无客服在线,联系客服跳转的客服反馈页面的显示文字</text>
<text class="input-tip">暂无客服在线,联系客服跳转的客服反馈页面的显示文字</text>
</view>
</view>
</template>
<template v-else-if="configType === 'phone'">
<template v-else-if="form.type === 'phone'">
<view class="form-item">
<view class="label-box">
<text class="label-txt">客服电话:</text>
</view>
<view class="input-box">
<input class="input-base" v-model="phoneNumber" placeholder="请输入客服电话" />
<text class="input-tip">客服类型选择不打电话是,用户点击联系客服的联系电话</text>
<input class="input-base" v-model="form.phone" placeholder="请输入客服电话" />
<text class="input-tip">用户点击联系客服时的拨打号码</text>
</view>
</view>
</template>
<template v-else-if="configType === 'link'">
<template v-else-if="form.type === 'link'">
<view class="form-item">
<view class="label-box">
<text class="label-txt">跳转链接:</text>
</view>
<view class="input-box">
<input class="input-base" v-model="linkUrl" placeholder="请输入外部链接地址" />
<text class="input-tip">客服类型选择跳转链接时,用户点击联系客服跳转的外部链接</text>
<input class="input-base" v-model="form.link" placeholder="请输入外部链接地址" />
<text class="input-tip">用户点击联系客服跳转的外部链接</text>
</view>
</view>
</template>
@@ -99,24 +99,44 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted, reactive } from 'vue'
import { getKefuSettings, saveKefuSettings, type KefuConfig } from '@/services/admin/kefuService.uts'
const configType = ref('system') // system, phone, link
const feedbackText = ref('尊敬的用户,客服当前不在线,有问题请留言,我们会第一时间进行处理!!!')
const phoneNumber = ref('4008888794')
const linkUrl = ref('')
const form = reactive({
type: 'system',
feedback: '',
phone: '',
link: ''
})
const handleSubmit = () => {
console.log('提交配置', {
type: configType.value,
feedback: feedbackText.value,
phone: phoneNumber.value,
link: linkUrl.value
})
uni.showToast({
title: '保存成功',
icon: 'success'
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
const res = await getKefuSettings()
if (res != null) {
form.type = res.type
form.feedback = res.feedback
form.phone = res.phone
form.link = res.link
}
}
const handleSubmit = async () => {
uni.showLoading({ title: '正在保存...' })
try {
const ok = await saveKefuSettings(form as KefuConfig)
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
uni.hideLoading()
}
}
</script>

View File

@@ -3,21 +3,14 @@
<view class="content-body">
<!-- 顶部搜索栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">留言时间:</text>
<view class="date-picker-mock">
<text class="placeholder">开始日期</text>
<text class="sep">-</text>
<text class="placeholder">结束日期</text>
<text class="calendar-icon">📅</text>
</view>
</view>
<view class="filter-item">
<text class="label-txt">留言信息:</text>
<input class="search-input" placeholder="请输入用户昵称/电话/留言内容搜索" v-model="searchQuery" />
<input class="search-input" placeholder="请输入用户昵称/电话/留言内容搜索" v-model="searchQuery" @confirm="handleQuery" />
</view>
<view class="filter-item">
<text class="label-txt">状态:</text>
<uni-data-select v-model="statusValue" :localdata="statusOptions" style="width: 120px;" @change="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
@@ -26,40 +19,54 @@
<!-- 数据表格 -->
<view class="table-card border-shadow">
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-nick"><text class="th-txt">昵称</text></view>
<view class="th col-phone"><text class="th-txt">电话</text></view>
<view class="th col-content"><text class="th-txt">内容</text></view>
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-nick"><text class="th-txt">用户信息</text></view>
<view class="th col-phone"><text class="th-txt">联系电话</text></view>
<view class="th col-content"><text class="th-txt">留言内容</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-time"><text class="th-txt">时间</text></view>
<view class="th col-time"><text class="th-txt">留言时间</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view class="table-row" v-for="item in feedbackList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view class="td col-nick"><text class="td-txt">{{ item.nickname }}</text></view>
<view class="td col-phone"><text class="td-txt">{{ item.phone }}</text></view>
<view class="td col-content"><text class="td-txt ellipsis">{{ item.content }}</text></view>
<view class="td col-status">
<text class="status-tag">{{ item.status }}</text>
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="feedbackList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无留言记录</text>
</view>
<view v-else class="table-row" v-for="(item, index) in feedbackList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-nick">
<view class="u-info">
<text class="td-txt">{{ item.nickname || '匿名' }}</text>
<text class="u-account" v-if="item.user_account">账号:{{ item.user_account }}</text>
</view>
</view>
<view class="td col-time"><text class="td-txt">{{ item.time }}</text></view>
<view class="td col-phone"><text class="td-txt">{{ item.phone || '-' }}</text></view>
<view class="td col-content">
<text class="td-txt ellipsis">{{ item.content }}</text>
</view>
<view class="td col-status">
<text class="status-tag" :class="item.status == 1 ? 'processed' : 'pending'">
{{ item.status == 1 ? '已处理' : '未处理' }}
</text>
</view>
<view class="td col-time"><text class="td-txt">{{ item.created_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-op">
<text class="btn-link" @click="handleProcess(item)">处理</text>
<view class="divider"></view>
<text class="btn-link danger" @click="handleDelete(item)">删除</text>
<text class="btn-link" v-if="item.status == 0" @click="handleProcess(item)">处理</text>
<text class="btn-link gray" v-else>已回复</text>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-bar">
<text class="page-total">共 {{ feedbackList.length }} 条</text>
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-btn"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">1</text></view>
<view class="nav-btn"><text class="nav-icon"> > </text></view>
<view class="nav-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
@@ -68,42 +75,80 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
interface FeedbackItem {
id: string
nickname: string
phone: string
content: string
status: string
time: string
}
import { ref, onMounted, computed } from 'vue'
import { fetchFeedbackPage, processFeedback, type KefuFeedback } from '@/services/admin/kefuService.uts'
const searchQuery = ref('')
const statusValue = ref('all')
const feedbackList = ref<KefuFeedback[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const loading = ref(false)
const feedbackList = ref<FeedbackItem[]>([
{ id: '37', nickname: 'hhh', phone: '14779580008', content: 'hhh', status: '未处理', time: '2025-12-20 03:22:10' },
{ id: '36', nickname: 'hhh', phone: '14779580008', content: 'hhh', status: '未处理', time: '2025-12-20 03:22:05' },
{ id: '35', nickname: '发给大哥他', phone: '13212324567', content: '回复挂号费', status: '未处理', time: '2025-12-17 22:11:35' },
{ id: '34', nickname: '郑立民', phone: '15604580931', content: '我提现为啥不到账', status: '未处理', time: '2025-11-24 12:29:02' },
{ id: '33', nickname: '11', phone: '15012760793', content: '22222', status: '未处理', time: '2025-10-16 17:55:31' },
{ id: '32', nickname: '玉兔', phone: '13133164548', content: '我去会吐', status: '未处理', time: '2025-09-09 15:31:45' },
{ id: '31', nickname: '哟', phone: '15151424728', content: '玩意', status: '未处理', time: '2025-09-04 15:40:28' },
{ id: '30', nickname: '哟', phone: '15151424728', content: '玩意', status: '未处理', time: '2025-09-04 15:40:27' },
{ id: '29', nickname: 'pww', phone: '15274289992', content: '我们的', status: '未处理', time: '2025-08-13 18:21:35' },
{ id: '28', nickname: '1', phone: '18888888888', content: '1', status: '未处理', time: '2024-12-12 11:56:48' }
])
const statusOptions = [
{ value: 'all', text: '全部状态' },
{ value: '0', text: '未处理' },
{ value: '1', text: '已处理' }
]
const handleQuery = () => {
console.log('查询:', searchQuery.value)
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
const loadData = async () => {
loading.value = true
try {
const status = statusValue.value == 'all' ? null : parseInt(statusValue.value)
const res = await fetchFeedbackPage(page.value, pageSize.value, searchQuery.value || null, status)
feedbackList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载留言失败', icon: 'none' })
} finally {
loading.value = false
}
}
const handleProcess = (item: FeedbackItem) => {
console.log('处理留言:', item.id)
onMounted(() => {
loadData()
})
function handleQuery() {
page.value = 1
loadData()
}
const handleDelete = (item: FeedbackItem) => {
console.log('删除留言:', item.id)
async function handleProcess(item: KefuFeedback) {
uni.showModal({
title: '留言处理',
editable: true,
placeholderText: '请输入回复内容',
success: async (res) => {
if (res.confirm && res.content) {
const ok = await processFeedback(item.id, res.content)
if (ok) {
uni.showToast({ title: '处理成功' })
loadData()
}
}
}
})
}
function prevPage() {
if (page.value > 1) {
page.value--
loadData()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadData()
}
}
</script>
@@ -120,7 +165,6 @@ const handleDelete = (item: FeedbackItem) => {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
/* 过滤栏 */
.filter-card {
padding: 20px;
margin-bottom: 20px;
@@ -128,7 +172,7 @@ const handleDelete = (item: FeedbackItem) => {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 30px;
gap: 20px;
}
.filter-item {
@@ -143,36 +187,8 @@ const handleDelete = (item: FeedbackItem) => {
margin-right: 12px;
}
.date-picker-mock {
width: 320px;
height: 38px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
}
.placeholder {
font-size: 13px;
color: #c0c4cc;
}
.sep {
margin: 0 8px;
color: #c0c4cc;
}
.calendar-icon {
margin-left: auto;
font-size: 14px;
color: #c0c4cc;
}
.search-input {
width: 320px;
height: 38px;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
@@ -222,6 +238,11 @@ const handleDelete = (item: FeedbackItem) => {
font-weight: bold;
}
.table-body {
display: flex;
flex-direction: column;
}
.table-row {
height: 60px;
display: flex;
@@ -245,6 +266,13 @@ const handleDelete = (item: FeedbackItem) => {
color: #606266;
}
.u-info {
display: flex;
flex-direction: column;
align-items: flex-start;
.u-account { font-size: 11px; color: #999; margin-top: 2px; }
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
@@ -252,23 +280,20 @@ const handleDelete = (item: FeedbackItem) => {
}
/* 列宽定义 */
.col-id { width: 80px; justify-content: center; }
.col-nick { width: 140px; }
.col-phone { width: 180px; }
.col-id { width: 60px; justify-content: center; }
.col-nick { width: 160px; }
.col-phone { width: 140px; }
.col-content { flex: 1; justify-content: flex-start; }
.col-status { width: 120px; justify-content: center; }
.col-time { width: 220px; justify-content: center; }
.col-op {
display: flex;
flex-direction: row;
align-items: center;
width: 150px;
justify-content: center;
}
.col-status { width: 100px; justify-content: center; }
.col-time { width: 160px; justify-content: center; }
.col-op { width: 100px; justify-content: center; }
.status-tag {
font-size: 12px;
color: #606266;
padding: 2px 8px;
border-radius: 2px;
&.processed { color: #52c41a; background: #f6ffed; border: 1px solid #b7eb8f; }
&.pending { color: #fa8c16; background: #fff7e6; border: 1px solid #ffd591; }
}
.btn-link {
@@ -276,17 +301,7 @@ const handleDelete = (item: FeedbackItem) => {
color: #2d8cf0;
cursor: pointer;
}
.btn-link.danger {
color: #2d8cf0; /* 根据截图,删除也是蓝色的链接感,但通常后台会区分,这里按截图感肉眼识别为蓝色 */
}
.divider {
width: 1px;
height: 12px;
background-color: #e8eaec;
margin: 0 10px;
}
.btn-link.gray { color: #999; cursor: default; }
/* 分页栏 */
.pagination-bar {
@@ -318,6 +333,7 @@ const handleDelete = (item: FeedbackItem) => {
justify-content: center;
border-radius: 4px;
margin: 0 4px;
cursor: pointer;
}
.nav-item {
@@ -344,4 +360,6 @@ const handleDelete = (item: FeedbackItem) => {
font-size: 13px;
color: #606266;
}
.disabled { cursor: not-allowed; opacity: 0.5; }
</style>

View File

@@ -10,7 +10,7 @@
<!-- 数据表格 -->
<view class="table-container border-shadow">
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-avatar"><text class="th-txt">客服头像</text></view>
<view class="th col-name"><text class="th-txt">客服名称</text></view>
<view class="th col-status"><text class="th-txt">客服状态</text></view>
@@ -19,25 +19,39 @@
</view>
<view class="table-body">
<view class="table-row" v-for="item in kefuList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="kefuList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无客服账号</text>
</view>
<view v-else class="table-row" v-for="(item, index) in kefuList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-avatar">
<image class="kefu-avatar" :src="item.avatar" mode="aspectFill"></image>
<image v-if="item.avatar" class="kefu-avatar" :src="item.avatar" mode="aspectFill"></image>
<view v-else class="kefu-avatar-placeholder">
<text class="p-txt">U</text>
</view>
</view>
<view class="td col-name">
<view class="u-info">
<text class="td-txt">{{ item.nickname }}</text>
<text class="u-account">账号: {{ item.user_account }}</text>
</view>
</view>
<view class="td col-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td col-status">
<view :class="['switch-btn', item.status ? 'switch-on' : 'switch-off']" @click="toggleStatus(item)">
<view :class="['switch-btn', item.status == 1 ? 'switch-on' : 'switch-off']" @click="toggleStatus(item)">
<view class="switch-inner">
<text class="switch-txt">{{ item.status ? '开启' : '关闭' }}</text>
<text class="switch-txt">{{ item.status == 1 ? '开启' : '关闭' }}</text>
</view>
<view class="switch-dot"></view>
</view>
</view>
<view class="td col-time"><text class="td-txt">{{ item.time }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.created_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-op">
<text class="btn-action">编辑</text>
<text class="btn-action" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="btn-action">删除</text>
<text class="btn-action danger" @click="handleDelete(item)">删除</text>
<view class="divider"></view>
<text class="btn-action">进入工作台</text>
</view>
@@ -46,19 +60,15 @@
<!-- 分页栏 -->
<view class="pagination-bar">
<text class="page-total">共 {{ kefuList.length }} 条</text>
<view class="page-size-select">
<text class="size-txt">15条/页</text>
<text class="arrow-down">▼</text>
</view>
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-prev"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">1</text></view>
<view class="nav-next"><text class="nav-icon"> > </text></view>
<view class="nav-prev" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-next" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" value="1" />
<input class="jump-input" v-model="jumpPage" type="number" @confirm="goToJumpPage" />
<text class="jump-txt">页</text>
</view>
</view>
@@ -67,32 +77,109 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { fetchKefuAccountPage, setKefuAccountStatus, deleteKefuAccount, type KefuAccount } from '@/services/admin/kefuService.uts'
interface KefuItem {
id: string
avatar: string
name: string
status: boolean
time: string
const kefuList = ref<KefuAccount[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const loading = ref(false)
const searchQuery = ref('')
const jumpPage = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
const loadData = async () => {
loading.value = true
try {
const res = await fetchKefuAccountPage(
page.value,
pageSize.value,
searchQuery.value || null
)
kefuList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载客服列表失败', icon: 'none' })
} finally {
loading.value = false
}
}
const kefuList = ref<KefuItem[]>([
{ id: '167', avatar: '/static/logo.png', name: '客服', status: true, time: '2025-12-31 11:24:35' },
{ id: '166', avatar: '/static/logo.png', name: '测试', status: true, time: '2025-11-18 17:33:26' },
{ id: '165', avatar: '/static/logo.png', name: 'zhicheng', status: true, time: '2025-09-10 10:13:00' },
{ id: '164', avatar: '/static/logo.png', name: 'kefu', status: true, time: '2025-07-29 11:50:20' },
{ id: '162', avatar: '/static/logo.png', name: '呐呐', status: true, time: '2024-12-09 10:51:11' },
{ id: '153', avatar: '/static/logo.png', name: '小天', status: true, time: '2024-09-19 16:04:59' },
{ id: '152', avatar: '/static/logo.png', name: 'kk', status: true, time: '2024-09-16 00:05:22' }
])
onMounted(() => {
loadData()
})
const toggleStatus = (item: KefuItem) => {
item.status = !item.status
const handleQuery = () => {
page.value = 1
loadData()
}
const toggleStatus = async (item: KefuAccount) => {
const targetStatus = item.status == 1 ? 0 : 1
const ok = await setKefuAccountStatus(item.id, targetStatus)
if (ok) {
item.status = targetStatus
uni.showToast({ title: '状态更新成功' })
} else {
uni.showToast({ title: '状态更新失败', icon: 'none' })
}
}
const handleAdd = () => {
console.log('Add Kefu')
uni.showToast({ title: '添加客服功能开发中', icon: 'none' })
}
const handleEdit = (item: KefuAccount) => {
console.log('Edit Kefu', item.id)
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
}
const handleDelete = (item: KefuAccount) => {
uni.showModal({
title: '提示',
content: `确定要删除客服 "${item.nickname}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteKefuAccount(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadData()
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
loadData()
}
}
const goToJumpPage = () => {
const target = parseInt(jumpPage.value)
if (!isNaN(target) && target >= 1 && target <= totalPages.value) {
page.value = target
loadData()
jumpPage.value = ''
} else {
uni.showToast({ title: '请输入有效的页码', icon: 'none' })
}
}
</script>
@@ -157,6 +244,11 @@ const handleAdd = () => {
font-weight: 500;
}
.table-body {
display: flex;
flex-direction: column;
}
.table-row {
height: 70px;
display: flex;
@@ -181,7 +273,7 @@ const handleAdd = () => {
color: #606266;
}
/* 列宽百分比对齐截图 */
/* 列宽百分比对齐 */
.col-id { width: 80px; }
.col-avatar { width: 120px; }
.col-name { flex: 1; justify-content: flex-start; }
@@ -202,6 +294,23 @@ const handleAdd = () => {
background-color: #f0f0f0;
}
.kefu-avatar-placeholder {
width: 36px;
height: 36px;
border-radius: 4px;
background-color: #eee;
display: flex;
align-items: center;
justify-content: center;
.p-txt { font-size: 14px; color: #999; }
}
.u-info {
display: flex;
flex-direction: column;
.u-account { font-size: 11px; color: #999; margin-top: 2px; }
}
/* 开关组件 1:1 复刻 */
.switch-btn {
width: 60px;
@@ -236,10 +345,11 @@ const handleAdd = () => {
border-radius: 9px;
position: absolute;
top: 3px;
transition: all 0.3s;
}
.switch-on .switch-dot { right: 3px; }
.switch-off .switch-dot { left: 3px; }
.switch-on .switch-dot { transform: translateX(33px); }
.switch-off .switch-dot { transform: translateX(0); }
/* 操作按钮 */
.btn-action {
@@ -248,6 +358,8 @@ const handleAdd = () => {
cursor: pointer;
}
.danger { color: #f5222d; }
.divider {
width: 1px;
height: 12px;
@@ -266,21 +378,8 @@ const handleAdd = () => {
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-size-select {
border: 1px solid #dcdee2;
padding: 4px 10px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
margin-right: 15px;
}
.size-txt { font-size: 13px; color: #606266; margin-right: 8px; }
.arrow-down { font-size: 10px; color: #808695; }
.page-nav { display: flex; flex-direction: row; align-items: center; margin-right: 15px; }
.nav-prev, .nav-next {
.nav-prev, .nav-next, .nav-item {
width: 32px;
height: 32px;
border: 1px solid #dcdee2;
@@ -289,20 +388,10 @@ const handleAdd = () => {
justify-content: center;
border-radius: 4px;
margin: 0 4px;
cursor: pointer;
}
.nav-item {
width: 32px;
height: 32px;
border: 1px solid #dcdee2;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
margin: 0 4px;
}
.active {
.nav-item.active {
background-color: #2d8cf0;
border-color: #2d8cf0;
}
@@ -310,6 +399,8 @@ const handleAdd = () => {
.active .nav-num { color: #fff; }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { cursor: not-allowed; opacity: 0.5; }
.page-jump { display: flex; flex-direction: row; align-items: center; }
.jump-txt { font-size: 13px; color: #606266; }
.jump-input {

View File

@@ -16,6 +16,10 @@
>
<view class="folder-icon">📂</view>
<text class="cat-name">{{ cat.name }}</text>
<view class="cat-ops" v-if="cat.id != 'all'">
<text class="cat-op-btn" @click.stop="handleEditCategory(cat)">✎</text>
<text class="cat-op-btn danger" @click.stop="handleDeleteCategory(cat)">×</text>
</view>
</view>
</scroll-view>
</view>
@@ -30,56 +34,41 @@
<view class="table-card border-shadow">
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-cat"><text class="th-txt">分类</text></view>
<view class="th col-title"><text class="th-txt">标题</text></view>
<view class="th col-detail"><text class="th-txt">详情</text></view>
<view class="th col-sort"><text class="th-txt">排序</text></view>
<view class="th col-time"><text class="th-txt">添加时间</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view class="table-row" v-for="item in wordList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view class="td col-cat"><text class="td-txt">{{ item.category }}</text></view>
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="wordList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>该分类下暂无话术</text>
</view>
<view v-else class="table-row" v-for="(item, index) in wordList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ index + 1 }}</text></view>
<view class="td col-cat"><text class="td-txt">{{ item.category_name }}</text></view>
<view class="td col-title"><text class="td-txt">{{ item.title }}</text></view>
<view class="td col-detail">
<text class="td-txt ellipsis-2">{{ item.content }}</text>
</view>
<view class="td col-sort"><text class="td-txt">{{ item.sort }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.time }}</text></view>
<view class="td col-op">
<text class="btn-action" @click="handleEdit(item)">编辑</text>
<text class="btn-action" @click="handleEditWord(item)">编辑</text>
<view class="divider"></view>
<text class="btn-action danger">删除</text>
<text class="btn-action danger" @click="handleDeleteWord(item)">删除</text>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-bar">
<text class="page-total">共 {{ wordList.length }} 条</text>
<view class="page-size-select">
<text class="size-txt">15条/页</text>
<text class="arrow-down">▼</text>
</view>
<view class="page-nav">
<view class="nav-prev"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">1</text></view>
<view class="nav-next"><text class="nav-icon"> > </text></view>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" value="1" />
<text class="jump-txt">页</text>
</view>
</view>
</view>
</view>
</view>
<!-- 侧边弹窗 (Drawer) -->
<!-- 话术编辑弹窗 (Drawer) -->
<view v-if="showDrawer" class="drawer-mask" @click="closeDrawer">
<view class="drawer-content" @click.stop>
<view class="drawer-header">
@@ -93,10 +82,12 @@
<text class="label-txt">话术分类:</text>
</view>
<view class="input-box">
<view class="select-mock">
<text class="select-val">{{ formData.category || '请选择分类' }}</text>
<text class="arrow-down">▼</text>
</view>
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="onCategoryChange">
<view class="select-mock">
<text class="select-val">{{ categoryOptions[categoryIndex]?.label || '请选择分类' }}</text>
<text class="arrow-down">▼</text>
</view>
</picker>
</view>
</view>
@@ -106,7 +97,7 @@
<text class="label-txt">话术标题:</text>
</view>
<view class="input-box">
<input class="input-base" v-model="formData.title" placeholder="请输入话术标题" />
<input class="input-base" v-model="wordForm.title" placeholder="请输入话术标题" />
</view>
</view>
@@ -116,7 +107,7 @@
<text class="label-txt">话术内容:</text>
</view>
<view class="input-box">
<textarea class="textarea-base" v-model="formData.content" placeholder="请输入话术内容" />
<textarea class="textarea-base" v-model="wordForm.content" placeholder="请输入话术内容" />
</view>
</view>
@@ -125,7 +116,7 @@
<text class="label-txt">排序:</text>
</view>
<view class="input-box">
<input class="input-base" type="number" v-model="formData.sort" />
<input class="input-base" type="number" v-model="wordForm.sort" />
</view>
</view>
</view>
@@ -134,7 +125,7 @@
<view class="btn-cancel" @click="closeDrawer">
<text class="cancel-txt">取消</text>
</view>
<view class="btn-confirm" @click="submitForm">
<view class="btn-confirm" @click="submitWordForm">
<text class="confirm-txt">确定</text>
</view>
</view>
@@ -144,85 +135,202 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchWordCategoryList,
saveWordCategory,
deleteWordCategory,
fetchKefuWordList,
saveKefuWord,
deleteKefuWord,
type WordCategory,
type KefuWord
} from '@/services/admin/kefuService.uts'
interface WordItem {
id: string
category: string
title: string
content: string
sort: number
time: string
}
const categories = ref<WordCategory[]>([])
const wordList = ref<KefuWord[]>([])
const loading = ref(false)
const activeCategoryId = ref<string>('all')
interface Category {
id: number
name: string
}
const categories = ref<Category[]>([
{ id: 1, name: '全部' },
{ id: 2, name: '系统话术' }
])
const activeCategoryId = ref(1)
const wordList = ref<WordItem[]>([
{
id: '67',
category: '系统默认',
title: '旗舰版介绍',
content: '【旗舰版】可以授权给公司或者个人,企业自用搭建,不限制授权域名,提供专属技术总监、产品总监等。',
sort: 0,
time: '2024-09-27 10:15:07'
}
])
// 表单逻辑
// 表单控制
const showDrawer = ref(false)
const isEdit = ref(false)
const formData = reactive({
const wordForm = reactive({
id: '',
category: '系统话术',
categoryId: '',
title: '',
content: '',
sort: 0
})
const selectCategory = (id: number) => {
const categoryOptions = computed(() : any[] => {
return categories.value.map(c => ({ label: c.name, value: c.id }))
})
const categoryIndex = ref(0)
onMounted(() => {
loadCategories()
})
async function loadCategories() {
const res = await fetchWordCategoryList()
categories.value = [{ id: 'all', name: '全部' } as WordCategory, ...res]
if (activeCategoryId.value == 'all') {
loadWords(null)
}
}
async function loadWords(catId : string | null) {
loading.value = true
try {
const res = await fetchKefuWordList(catId)
wordList.value = res
} catch (e) {
uni.showToast({ title: '加载话术失败', icon: 'none' })
} finally {
loading.value = false
}
}
function selectCategory(id : string) {
activeCategoryId.value = id
loadWords(id == 'all' ? null : id)
}
const handleAddCategory = () => {
console.log('添加分类')
// 分类操作
async function handleAddCategory() {
uni.showModal({
title: '添加分类',
editable: true,
placeholderText: '请输入分类名称',
success: async (res) => {
if (res.confirm && res.content) {
const id = await saveWordCategory(null, res.content, 0)
if (id != null) {
uni.showToast({ title: '添加成功' })
loadCategories()
}
}
}
})
}
const handleAddWord = () => {
async function handleEditCategory(cat : WordCategory) {
uni.showModal({
title: '编辑分类',
editable: true,
content: cat.name,
success: async (res) => {
if (res.confirm && res.content) {
const id = await saveWordCategory(cat.id, res.content, cat.sort)
if (id != null) {
uni.showToast({ title: '修改成功' })
loadCategories()
}
}
}
})
}
async function handleDeleteCategory(cat : WordCategory) {
uni.showModal({
title: '提示',
content: `确定删除分类 "${cat.name}" 及其下所有话术吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteWordCategory(cat.id)
if (ok) {
uni.showToast({ title: '删除成功' })
if (activeCategoryId.value == cat.id) {
activeCategoryId.value = 'all'
}
loadCategories()
}
}
}
})
}
// 话术操作
function handleAddWord() {
isEdit.value = false
formData.id = ''
formData.title = ''
formData.content = ''
formData.sort = 0
wordForm.id = ''
wordForm.categoryId = activeCategoryId.value == 'all' ? '' : activeCategoryId.value
wordForm.title = ''
wordForm.content = ''
wordForm.sort = 0
// 更新 picker 索引
const idx = categoryOptions.value.findIndex((opt : any) => opt.value == wordForm.categoryId)
categoryIndex.value = idx > -1 ? idx : 0
showDrawer.value = true
}
const handleEdit = (item: WordItem) => {
function handleEditWord(item : KefuWord) {
isEdit.value = true
formData.id = item.id
formData.category = item.category
formData.title = item.title
formData.content = item.content
formData.sort = item.sort
wordForm.id = item.id
wordForm.categoryId = item.category_id
wordForm.title = item.title
wordForm.content = item.content
wordForm.sort = item.sort
const idx = categoryOptions.value.findIndex((opt : any) => opt.value == item.category_id)
categoryIndex.value = idx > -1 ? idx : 0
showDrawer.value = true
}
const closeDrawer = () => {
async function handleDeleteWord(item : KefuWord) {
uni.showModal({
title: '提示',
content: '确定删除该话术吗?',
success: async (res) => {
if (res.confirm) {
const ok = await deleteKefuWord(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadWords(activeCategoryId.value == 'all' ? null : activeCategoryId.value)
}
}
}
})
}
function onCategoryChange(e : any) {
categoryIndex.value = e.detail.value as number
wordForm.categoryId = categoryOptions.value[categoryIndex.value].value as string
}
function closeDrawer() {
showDrawer.value = false
}
const submitForm = () => {
console.log('提交表单', formData)
showDrawer.value = false
async function submitWordForm() {
if (!wordForm.categoryId) {
uni.showToast({ title: '请选择分类', icon: 'none' })
return
}
if (!wordForm.title || !wordForm.content) {
uni.showToast({ title: '请完善话术内容', icon: 'none' })
return
}
const resId = await saveKefuWord(
isEdit.value ? wordForm.id : null,
wordForm.categoryId,
wordForm.title,
wordForm.content,
wordForm.sort
)
if (resId != null) {
uni.showToast({ title: '保存成功' })
showDrawer.value = false
loadWords(activeCategoryId.value == 'all' ? null : activeCategoryId.value)
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
</script>
@@ -248,9 +356,10 @@ const submitForm = () => {
/* 左侧分类 */
.category-sidebar {
width: 280px;
width: 240px;
display: flex;
flex-direction: column;
background-color: #fff;
}
.sidebar-header {
@@ -261,17 +370,19 @@ const submitForm = () => {
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
background-color: #fafafa;
}
.plus-icon {
font-size: 18px;
color: #c0c4cc;
color: #2d8cf0;
margin-right: 8px;
}
.header-txt {
font-size: 14px;
color: #606266;
color: #2d8cf0;
font-weight: 500;
}
.category-list {
@@ -284,8 +395,9 @@ const submitForm = () => {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 20px;
padding: 0 15px 0 20px;
cursor: pointer;
position: relative;
}
.category-item:hover {
@@ -294,7 +406,8 @@ const submitForm = () => {
.category-item.active {
background-color: #e6f7ff;
border-right: 2px solid #2d8cf0;
color: #2d8cf0;
border-right: 3px solid #2d8cf0;
}
.folder-icon {
@@ -304,9 +417,18 @@ const submitForm = () => {
.cat-name {
font-size: 14px;
color: #333;
flex: 1;
}
.cat-ops {
display: flex;
flex-direction: row;
gap: 8px;
opacity: 0.6;
}
.cat-op-btn { font-size: 16px; padding: 0 4px; }
.cat-op-btn.danger { color: #f5222d; }
/* 右侧内容 */
.content-main {
flex: 1;
@@ -339,6 +461,7 @@ const submitForm = () => {
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
}
.table-header {
@@ -365,10 +488,11 @@ const submitForm = () => {
.table-body {
flex: 1;
overflow-y: auto;
}
.table-row {
height: 70px;
min-height: 70px;
display: flex;
flex-direction: row;
align-items: center;
@@ -379,12 +503,13 @@ const submitForm = () => {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
padding: 10px;
}
.td-txt {
font-size: 13px;
color: #606266;
text-align: center;
}
.ellipsis-2 {
@@ -401,8 +526,7 @@ const submitForm = () => {
.col-title { width: 150px; }
.col-detail { flex: 1; justify-content: flex-start; }
.col-sort { width: 80px; }
.col-time { width: 180px; }
.col-op { width: 150px; }
.col-op { width: 120px; }
.btn-action {
font-size: 13px;
@@ -410,9 +534,7 @@ const submitForm = () => {
cursor: pointer;
}
.danger {
color: #ed4014;
}
.danger { color: #f5222d; }
.divider {
width: 1px;
@@ -421,90 +543,22 @@ const submitForm = () => {
margin: 0 10px;
}
/* 分页 */
.pagination-bar {
padding: 15px 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
border-top: 1px solid #f0f0f0;
}
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-size-select {
border: 1px solid #dcdee2;
padding: 4px 10px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
margin-right: 15px;
}
.size-txt { font-size: 13px; color: #606266; margin-right: 8px; }
.nav-icon, .arrow-down { font-size: 10px; color: #808695; }
.page-nav { display: flex; flex-direction: row; align-items: center; margin-right: 15px; }
.nav-prev, .nav-next, .nav-item {
width: 30px;
height: 30px;
border: 1px solid #dcdee2;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
margin: 0 4px;
}
.nav-item.active {
background-color: #2d8cf0;
border-color: #2d8cf0;
}
.active .nav-num { color: #fff; }
.nav-num { font-size: 13px; color: #606266; }
.page-jump { display: flex; flex-direction: row; align-items: center; }
.jump-txt { font-size: 13px; color: #606266; }
.jump-input {
width: 40px;
height: 30px;
border: 1px solid #dcdee2;
border-radius: 4px;
text-align: center;
margin: 0 8px;
font-size: 13px;
}
/* 抽屉 Drawer 1:1 复刻 - 修正位置到右侧 */
/* 抽屉 Drawer */
.drawer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 1000;
}
.drawer-content {
position: absolute;
top: 0;
right: 0; /* 强制靠右占据右边屏幕 */
width: 50%; /* 占屏幕一半宽度 */
height: 100%;
top: 0; right: 0;
width: 450px; height: 100%;
background-color: #fff;
display: flex;
flex-direction: column;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.drawer-header {
@@ -517,22 +571,10 @@ const submitForm = () => {
border-bottom: 1px solid #f0f0f0;
}
.drawer-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.drawer-title { font-size: 16px; font-weight: 600; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.close-btn {
font-size: 24px;
color: #999;
cursor: pointer;
}
.drawer-body {
flex: 1;
padding: 24px;
}
.drawer-body { flex: 1; padding: 24px; }
.form-item {
display: flex;
@@ -541,9 +583,7 @@ const submitForm = () => {
margin-bottom: 24px;
}
.align-start {
align-items: flex-start;
}
.align-start { align-items: flex-start; }
.label-box {
width: 100px;
@@ -553,19 +593,10 @@ const submitForm = () => {
margin-right: 15px;
}
.required {
color: #ed4014;
margin-right: 4px;
}
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.label-txt {
font-size: 14px;
color: #606266;
}
.input-box {
flex: 1;
}
.input-box { flex: 1; }
.select-mock {
height: 36px;
@@ -579,10 +610,10 @@ const submitForm = () => {
}
.select-val { font-size: 14px; color: #333; }
.arrow-down { font-size: 10px; color: #999; }
.input-base {
width: 100%;
height: 36px;
width: 100%; height: 36px;
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 0 12px;
@@ -590,8 +621,7 @@ const submitForm = () => {
}
.textarea-base {
width: 100%;
height: 120px;
width: 100%; height: 150px;
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 10px 12px;