Files
medical-mall/pages/mall/admin/kefu/words.uvue

657 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="admin-kefu-words">
<view class="words-container">
<!-- 左侧分类 -->
<view class="category-sidebar border-shadow">
<view class="sidebar-header" @click="handleAddCategory">
<text class="plus-icon">+</text>
<text class="header-txt">添加分类</text>
</view>
<scroll-view class="category-list" scroll-y="true">
<view
v-for="cat in categories"
:key="cat.id"
:class="['category-item', activeCategoryId == cat.id ? 'active' : '']"
@click="selectCategory(cat.id)"
>
<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>
<!-- 右侧话术列表 -->
<view class="content-main">
<view class="top-bar">
<view class="btn-primary" @click="handleAddWord">
<text class="btn-txt">添加话术</text>
</view>
</view>
<view class="table-card border-shadow">
<view class="table-header">
<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-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<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-op">
<text class="btn-action" @click="handleEditWord(item)">编辑</text>
<view class="divider"></view>
<text class="btn-action danger" @click="handleDeleteWord(item)">删除</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 话术编辑弹窗 (Drawer) -->
<view v-if="showDrawer" class="drawer-mask" @click="closeDrawer">
<view class="drawer-content" @click.stop>
<view class="drawer-header">
<text class="drawer-title">{{ isEdit ? '编辑话术' : '添加话术' }}</text>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<view class="drawer-body">
<view class="form-item">
<view class="label-box">
<text class="label-txt">话术分类:</text>
</view>
<view class="input-box">
<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>
<view class="form-item">
<view class="label-box">
<text class="required">*</text>
<text class="label-txt">话术标题:</text>
</view>
<view class="input-box">
<input class="input-base" v-model="wordForm.title" placeholder="请输入话术标题" />
</view>
</view>
<view class="form-item align-start">
<view class="label-box">
<text class="required">*</text>
<text class="label-txt">话术内容:</text>
</view>
<view class="input-box">
<textarea class="textarea-base" v-model="wordForm.content" placeholder="请输入话术内容" />
</view>
</view>
<view class="form-item">
<view class="label-box">
<text class="label-txt">排序:</text>
</view>
<view class="input-box">
<input class="input-base" type="number" v-model="wordForm.sort" />
</view>
</view>
</view>
<view class="drawer-footer">
<view class="btn-cancel" @click="closeDrawer">
<text class="cancel-txt">取消</text>
</view>
<view class="btn-confirm" @click="submitWordForm">
<text class="confirm-txt">确定</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchWordCategoryList,
saveWordCategory,
deleteWordCategory,
fetchKefuWordList,
saveKefuWord,
deleteKefuWord,
type WordCategory,
type KefuWord
} from '@/services/admin/kefuService.uts'
const categories = ref<WordCategory[]>([])
const wordList = ref<KefuWord[]>([])
const loading = ref(false)
const activeCategoryId = ref<string>('all')
// 表单控制
const showDrawer = ref(false)
const isEdit = ref(false)
const wordForm = reactive({
id: '',
categoryId: '',
title: '',
content: '',
sort: 0
})
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)
}
// 分类操作
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()
}
}
}
})
}
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
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
}
function handleEditWord(item : KefuWord) {
isEdit.value = true
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
}
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
}
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>
<style scoped lang="scss">
.admin-kefu-words {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.border-shadow {
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.words-container {
display: flex;
flex-direction: row;
height: calc(100vh - 40px);
gap: 20px;
}
/* 左侧分类 */
.category-sidebar {
width: 240px;
display: flex;
flex-direction: column;
background-color: #fff;
}
.sidebar-header {
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
background-color: #fafafa;
}
.plus-icon {
font-size: 18px;
color: #2d8cf0;
margin-right: 8px;
}
.header-txt {
font-size: 14px;
color: #2d8cf0;
font-weight: 500;
}
.category-list {
flex: 1;
padding: 10px 0;
}
.category-item {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 15px 0 20px;
cursor: pointer;
position: relative;
}
.category-item:hover {
background-color: #f5f7fa;
}
.category-item.active {
background-color: #e6f7ff;
color: #2d8cf0;
border-right: 3px solid #2d8cf0;
}
.folder-icon {
margin-right: 10px;
font-size: 14px;
}
.cat-name {
font-size: 14px;
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;
display: flex;
flex-direction: column;
}
.top-bar {
margin-bottom: 20px;
}
.btn-primary {
width: 90px;
height: 34px;
background-color: #2d8cf0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-txt {
color: #fff;
font-size: 13px;
}
.table-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
}
.table-header {
height: 50px;
background-color: #e6f0ff;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.th {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
}
.th-txt {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.table-body {
flex: 1;
overflow-y: auto;
}
.table-row {
min-height: 70px;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.td {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
}
.td-txt {
font-size: 13px;
color: #606266;
text-align: center;
}
.ellipsis-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-align: left;
}
/* 列宽分配 */
.col-id { width: 60px; }
.col-cat { width: 100px; }
.col-title { width: 150px; }
.col-detail { flex: 1; justify-content: flex-start; }
.col-sort { width: 80px; }
.col-op { width: 120px; }
.btn-action {
font-size: 13px;
color: #2d8cf0;
cursor: pointer;
}
.danger { color: #f5222d; }
.divider {
width: 1px;
height: 12px;
background-color: #e8eaec;
margin: 0 10px;
}
/* 抽屉 Drawer */
.drawer-mask {
position: fixed;
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: 450px; height: 100%;
background-color: #fff;
display: flex;
flex-direction: column;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
}
.drawer-header {
height: 55px;
padding: 0 20px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.drawer-title { font-size: 16px; font-weight: 600; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 24px; }
.form-item {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 24px;
}
.align-start { align-items: flex-start; }
.label-box {
width: 100px;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-right: 15px;
}
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; }
.select-mock {
height: 36px;
border: 1px solid #dcdee2;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 12px;
}
.select-val { font-size: 14px; color: #333; }
.arrow-down { font-size: 10px; color: #999; }
.input-base {
width: 100%; height: 36px;
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 0 12px;
font-size: 14px;
}
.textarea-base {
width: 100%; height: 150px;
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 10px 12px;
font-size: 14px;
}
.drawer-footer {
height: 60px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
padding: 0 24px;
}
.btn-cancel {
padding: 8px 18px;
border: 1px solid #dcdee2;
border-radius: 4px;
margin-right: 12px;
}
.btn-confirm {
padding: 8px 18px;
background-color: #2d8cf0;
border-radius: 4px;
}
.cancel-txt { font-size: 14px; color: #606266; }
.confirm-txt { font-size: 14px; color: #fff; }
</style>