708 lines
18 KiB
Vue
708 lines
18 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="toolbar">
|
||
<text class="toolbar-total">共{{ total }}条</text>
|
||
<input
|
||
class="toolbar-input"
|
||
v-model="query.title"
|
||
placeholder="话术标题"
|
||
placeholder-style="color:#b0b0b0"
|
||
confirm-type="search"
|
||
@confirm="onSearch"
|
||
/>
|
||
<input
|
||
class="toolbar-input"
|
||
v-model="query.category"
|
||
placeholder="分类"
|
||
placeholder-style="color:#b0b0b0"
|
||
confirm-type="search"
|
||
@confirm="onSearch"
|
||
/>
|
||
<input
|
||
class="toolbar-input"
|
||
v-model="query.status"
|
||
placeholder="状态"
|
||
placeholder-style="color:#b0b0b0"
|
||
confirm-type="search"
|
||
@confirm="onSearch"
|
||
/>
|
||
<view class="toolbar-actions">
|
||
<view class="toolbar-btn toolbar-btn--add" @click="openCreateDialog">
|
||
<uni-icons type="plusempty" size="16" color="#16a34a" />
|
||
<text>新增</text>
|
||
</view>
|
||
<view class="toolbar-btn toolbar-btn--refresh" @click="onRefresh">
|
||
<uni-icons type="refresh" size="16" color="#2A68FF" />
|
||
<text>刷新</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view class="list" scroll-y enable-flex @scrolltolower="onReachBottom">
|
||
<view class="card" v-for="item in list" :key="item.id">
|
||
<view class="card-header">
|
||
<view class="title-wrap">
|
||
<text class="title">{{ item.title || '未命名话术' }}</text>
|
||
<text v-if="item.status" class="status">{{ item.status }}</text>
|
||
</view>
|
||
<view class="top-flag" v-if="item.isTop">置顶</view>
|
||
</view>
|
||
<view class="meta">
|
||
<text>分类:{{ item.category || '-' }}</text>
|
||
<text>行业:{{ item.industry || '-' }}</text>
|
||
<text>创建人:{{ item.creator || '-' }}</text>
|
||
</view>
|
||
<view class="content-line" v-if="item.question">问题:{{ item.question }}</view>
|
||
<view class="content-line" v-if="item.answerContent">内容:{{ item.answerContent }}</view>
|
||
<view class="time-line">
|
||
<text>创建:{{ formatDateTime(item.createTime) || '-' }}</text>
|
||
<text>更新:{{ formatDateTime(item.updateTime) || '-' }}</text>
|
||
</view>
|
||
<view class="actions">
|
||
<view class="action-btn action-btn--edit" @click="openEditDialog(item)">编辑</view>
|
||
<view class="action-btn action-btn--delete" @click="onDelete(item)">删除</view>
|
||
</view>
|
||
</view>
|
||
<view class="empty" v-if="!loading && !list.length">
|
||
<text>暂无话术数据</text>
|
||
</view>
|
||
<view class="loading-more" v-if="loading && list.length">
|
||
<text>正在加载...</text>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="dialog-mask" v-if="showDialog" @click="closeDialog">
|
||
<view class="dialog" @click.stop>
|
||
<view class="dialog-header">
|
||
<text class="dialog-title">{{ form.id ? '编辑话术' : '新增话术' }}</text>
|
||
<view class="dialog-close" @click="closeDialog">
|
||
<uni-icons type="close" size="18" color="#999" />
|
||
</view>
|
||
</view>
|
||
<scroll-view class="dialog-body" scroll-y>
|
||
<view class="form-item">
|
||
<text class="form-label">话术标题</text>
|
||
<input class="form-input" v-model="form.title" placeholder="请输入话术标题" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">话术分类</text>
|
||
<input class="form-input" v-model="form.category" placeholder="请输入话术分类" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">应用行业</text>
|
||
<input class="form-input" v-model="form.industry" placeholder="请输入应用行业" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">问题</text>
|
||
<textarea class="form-textarea" v-model="form.question" placeholder="请输入问题" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">话术内容</text>
|
||
<textarea class="form-textarea" v-model="form.answerContent" placeholder="请输入话术内容" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">详细分解</text>
|
||
<textarea class="form-textarea" v-model="form.detailedBreakdown" placeholder="请输入详细分解" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">使用场景</text>
|
||
<textarea class="form-textarea" v-model="form.usageScenario" placeholder="请输入使用场景" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">标签(逗号分隔)</text>
|
||
<input class="form-input" v-model="form.tags" placeholder="如:成交,开场,破冰" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">状态</text>
|
||
<input class="form-input" v-model="form.status" placeholder="如:已发布/草稿/已下架" />
|
||
</view>
|
||
<view class="form-item row-item">
|
||
<text class="form-label">是否置顶</text>
|
||
<switch :checked="!!form.isTop" @change="onTopSwitchChange" color="#2A68FF" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">备注</text>
|
||
<textarea class="form-textarea" v-model="form.remark" placeholder="请输入备注" />
|
||
</view>
|
||
</scroll-view>
|
||
<view class="dialog-footer">
|
||
<view class="dialog-btn dialog-btn--cancel" @click="closeDialog">取消</view>
|
||
<view class="dialog-btn dialog-btn--confirm" @click="onSubmit">保存</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { getApiUrl } from "@/common/config.js";
|
||
|
||
function createDefaultForm() {
|
||
return {
|
||
id: "",
|
||
title: "",
|
||
category: "",
|
||
industry: "",
|
||
question: "",
|
||
answerContent: "",
|
||
detailedBreakdown: "",
|
||
usageScenario: "",
|
||
status: "",
|
||
tags: "",
|
||
isTop: false,
|
||
remark: "",
|
||
};
|
||
}
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
loading: false,
|
||
total: 0,
|
||
page: {
|
||
current: 1,
|
||
size: 10,
|
||
},
|
||
query: {
|
||
title: "",
|
||
category: "",
|
||
industry: "",
|
||
creator: "",
|
||
status: "",
|
||
},
|
||
list: [],
|
||
showDialog: false,
|
||
form: createDefaultForm(),
|
||
};
|
||
},
|
||
mounted() {
|
||
this.fetchList({ force: true });
|
||
},
|
||
methods: {
|
||
getAuthHeaders() {
|
||
let tenantId = "";
|
||
let token = "";
|
||
try {
|
||
tenantId = uni.getStorageSync("backend-tenant-id") || "";
|
||
token = uni.getStorageSync("backend-token") || "";
|
||
} catch (e) {
|
||
console.error("读取认证信息失败:", e);
|
||
}
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
};
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
if (tenantId) {
|
||
headers["X-Tenant-Id"] = tenantId;
|
||
}
|
||
return headers;
|
||
},
|
||
buildQueryString() {
|
||
const payload = {
|
||
current: this.page.current,
|
||
size: this.page.size,
|
||
title: this.query.title.trim(),
|
||
category: this.query.category.trim(),
|
||
industry: this.query.industry.trim(),
|
||
creator: this.query.creator.trim(),
|
||
status: this.query.status.trim(),
|
||
};
|
||
return Object.keys(payload)
|
||
.filter((k) => payload[k] !== undefined && payload[k] !== null && payload[k] !== "")
|
||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(payload[k])}`)
|
||
.join("&");
|
||
},
|
||
onSearch() {
|
||
this.page.current = 1;
|
||
this.fetchList({ force: true });
|
||
},
|
||
onRefresh() {
|
||
this.page.current = 1;
|
||
this.fetchList({ force: true });
|
||
},
|
||
onReachBottom() {
|
||
if (this.loading) return;
|
||
if (this.list.length >= this.total) return;
|
||
this.page.current += 1;
|
||
this.fetchList();
|
||
},
|
||
async fetchList({ force = false } = {}) {
|
||
if (this.loading && !force) return;
|
||
this.loading = true;
|
||
try {
|
||
const qs = this.buildQueryString();
|
||
const baseUrl = getApiUrl("/api/scriptManagementMy/list");
|
||
const url = qs ? `${baseUrl}?${qs}` : baseUrl;
|
||
const res = await uni.request({
|
||
url,
|
||
method: "GET",
|
||
header: this.getAuthHeaders(),
|
||
timeout: 30000,
|
||
});
|
||
if (res.statusCode === 200 && res.data?.success) {
|
||
const rawData = res.data?.data;
|
||
const records = Array.isArray(rawData)
|
||
? rawData
|
||
: Array.isArray(rawData?.records)
|
||
? rawData.records
|
||
: [];
|
||
const total = Number(res.data?.total ?? rawData?.total ?? records.length) || 0;
|
||
this.total = total;
|
||
if (this.page.current === 1 || force) {
|
||
this.list = [...records];
|
||
} else {
|
||
this.list = [...this.list, ...records];
|
||
}
|
||
return;
|
||
}
|
||
this.handleRequestError(res.data?.message || "获取话术列表失败");
|
||
} catch (error) {
|
||
console.error("获取话术列表失败:", error);
|
||
this.handleRequestError("获取话术列表失败,请稍后重试");
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
openCreateDialog() {
|
||
this.form = createDefaultForm();
|
||
this.showDialog = true;
|
||
},
|
||
openEditDialog(item) {
|
||
this.form = {
|
||
id: item.id || "",
|
||
title: item.title || "",
|
||
category: item.category || "",
|
||
industry: item.industry || "",
|
||
question: item.question || "",
|
||
answerContent: item.answerContent || "",
|
||
detailedBreakdown: item.detailedBreakdown || "",
|
||
usageScenario: item.usageScenario || "",
|
||
status: item.status || "",
|
||
tags: item.tags || "",
|
||
isTop: !!item.isTop,
|
||
remark: item.remark || "",
|
||
};
|
||
this.showDialog = true;
|
||
},
|
||
closeDialog() {
|
||
this.showDialog = false;
|
||
},
|
||
onTopSwitchChange(e) {
|
||
this.form.isTop = !!e?.detail?.value;
|
||
},
|
||
async onSubmit() {
|
||
const title = this.form.title.trim();
|
||
if (!title) {
|
||
uni.showToast({ title: "请输入话术标题", icon: "none" });
|
||
return;
|
||
}
|
||
const payload = {
|
||
id: this.form.id || undefined,
|
||
title,
|
||
category: this.form.category.trim(),
|
||
industry: this.form.industry.trim(),
|
||
question: this.form.question.trim(),
|
||
answerContent: this.form.answerContent.trim(),
|
||
detailedBreakdown: this.form.detailedBreakdown.trim(),
|
||
usageScenario: this.form.usageScenario.trim(),
|
||
status: this.form.status.trim(),
|
||
tags: this.form.tags.trim(),
|
||
isTop: !!this.form.isTop,
|
||
remark: this.form.remark.trim(),
|
||
};
|
||
const isEdit = !!this.form.id;
|
||
const url = getApiUrl(isEdit ? "/api/scriptManagementMy/update" : "/api/scriptManagementMy/add");
|
||
const method = isEdit ? "PUT" : "POST";
|
||
try {
|
||
uni.showLoading({ title: "保存中..." });
|
||
const res = await uni.request({
|
||
url,
|
||
method,
|
||
data: payload,
|
||
header: this.getAuthHeaders(),
|
||
timeout: 30000,
|
||
});
|
||
uni.hideLoading();
|
||
if (res.statusCode === 200 && res.data?.success) {
|
||
uni.showToast({ title: isEdit ? "更新成功" : "新增成功", icon: "success" });
|
||
this.closeDialog();
|
||
this.page.current = 1;
|
||
this.fetchList({ force: true });
|
||
return;
|
||
}
|
||
this.handleRequestError(res.data?.message || "保存失败");
|
||
} catch (error) {
|
||
uni.hideLoading();
|
||
console.error("保存话术失败:", error);
|
||
this.handleRequestError("保存失败,请稍后重试");
|
||
}
|
||
},
|
||
onDelete(item) {
|
||
if (!item?.id) return;
|
||
uni.showModal({
|
||
title: "确认删除",
|
||
content: `确定删除「${item.title || "该话术"}」吗?`,
|
||
success: async (res) => {
|
||
if (!res.confirm) return;
|
||
try {
|
||
uni.showLoading({ title: "删除中..." });
|
||
const url = getApiUrl(`/api/scriptManagementMy/delete/${encodeURIComponent(item.id)}`);
|
||
const delRes = await uni.request({
|
||
url,
|
||
method: "DELETE",
|
||
header: this.getAuthHeaders(),
|
||
timeout: 30000,
|
||
});
|
||
uni.hideLoading();
|
||
if (delRes.statusCode === 200 && delRes.data?.success) {
|
||
uni.showToast({ title: "删除成功", icon: "success" });
|
||
this.page.current = 1;
|
||
this.fetchList({ force: true });
|
||
return;
|
||
}
|
||
this.handleRequestError(delRes.data?.message || "删除失败");
|
||
} catch (error) {
|
||
uni.hideLoading();
|
||
console.error("删除话术失败:", error);
|
||
this.handleRequestError("删除失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
},
|
||
handleRequestError(message) {
|
||
this.list = this.page.current === 1 ? [] : this.list;
|
||
this.total = this.page.current === 1 ? 0 : this.total;
|
||
uni.showToast({
|
||
title: message || "请求失败",
|
||
icon: "none",
|
||
});
|
||
},
|
||
formatDateTime(value) {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const hour = String(date.getHours()).padStart(2, "0");
|
||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||
const second = String(date.getSeconds()).padStart(2, "0");
|
||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
height: 100%;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: #f5f5f5;
|
||
padding: 0 16rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 12rpx 8rpx;
|
||
margin: 16rpx 0 24rpx;
|
||
background-color: #f8f8fa;
|
||
border-radius: 8rpx;
|
||
border: 1px solid #efeff2;
|
||
overflow-x: auto;
|
||
min-height: 64rpx;
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toolbar-total {
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
margin-right: 8rpx;
|
||
}
|
||
|
||
.toolbar-input {
|
||
flex: 1;
|
||
min-width: 120rpx;
|
||
max-width: 200rpx;
|
||
height: 56rpx;
|
||
line-height: 56rpx;
|
||
background-color: #f5f6fa;
|
||
border-radius: 8rpx;
|
||
padding: 0 12rpx;
|
||
font-size: 24rpx;
|
||
border: 1px solid transparent;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-left: auto;
|
||
gap: 8rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
height: 56rpx;
|
||
min-width: 56rpx;
|
||
padding: 0 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.toolbar-btn--add {
|
||
color: #16a34a;
|
||
}
|
||
|
||
.toolbar-btn--refresh {
|
||
color: #2a68ff;
|
||
}
|
||
|
||
.list {
|
||
flex: 1;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.card {
|
||
width: 100%;
|
||
background-color: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 28rpx;
|
||
margin-bottom: 20rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 14rpx;
|
||
}
|
||
|
||
.title-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
min-width: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: 30rpx;
|
||
color: #111827;
|
||
font-weight: 600;
|
||
max-width: 420rpx;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.status {
|
||
font-size: 22rpx;
|
||
color: #2563eb;
|
||
padding: 4rpx 10rpx;
|
||
border-radius: 999rpx;
|
||
background: #eaf2ff;
|
||
}
|
||
|
||
.top-flag {
|
||
font-size: 22rpx;
|
||
color: #b45309;
|
||
background-color: #fff7ed;
|
||
padding: 4rpx 10rpx;
|
||
border-radius: 999rpx;
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10rpx 16rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.meta text,
|
||
.content-line,
|
||
.time-line text {
|
||
font-size: 24rpx;
|
||
color: #4b5563;
|
||
}
|
||
|
||
.content-line {
|
||
margin-bottom: 10rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.time-line {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8rpx 16rpx;
|
||
padding-top: 12rpx;
|
||
border-top: 1px solid #f3f4f6;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12rpx;
|
||
margin-top: 16rpx;
|
||
}
|
||
|
||
.action-btn {
|
||
font-size: 24rpx;
|
||
padding: 8rpx 18rpx;
|
||
border-radius: 10rpx;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.action-btn--edit {
|
||
color: #2563eb;
|
||
border-color: rgba(37, 99, 235, 0.3);
|
||
background-color: #f0f6ff;
|
||
}
|
||
|
||
.action-btn--delete {
|
||
color: #dc2626;
|
||
border-color: rgba(220, 38, 38, 0.28);
|
||
background-color: #fff4f4;
|
||
}
|
||
|
||
.empty,
|
||
.loading-more {
|
||
text-align: center;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
padding: 40rpx 0;
|
||
}
|
||
|
||
.dialog-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.dialog {
|
||
width: 660rpx;
|
||
max-height: 84vh;
|
||
background-color: #fff;
|
||
border-radius: 24rpx;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.dialog-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 30rpx 30rpx 20rpx;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.dialog-title {
|
||
font-size: 32rpx;
|
||
color: #111827;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.dialog-close {
|
||
width: 44rpx;
|
||
height: 44rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.dialog-body {
|
||
flex: 1;
|
||
max-height: 900rpx;
|
||
padding: 24rpx 30rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-item {
|
||
margin-bottom: 22rpx;
|
||
}
|
||
|
||
.row-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: #374151;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.form-input,
|
||
.form-textarea {
|
||
width: 100%;
|
||
background-color: #f9fafb;
|
||
border-radius: 10rpx;
|
||
box-sizing: border-box;
|
||
font-size: 26rpx;
|
||
color: #111827;
|
||
}
|
||
|
||
.form-input {
|
||
height: 80rpx;
|
||
padding: 0 20rpx;
|
||
}
|
||
|
||
.form-textarea {
|
||
min-height: 140rpx;
|
||
padding: 16rpx 20rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
padding: 18rpx 30rpx 26rpx;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.dialog-btn {
|
||
flex: 1;
|
||
height: 82rpx;
|
||
border-radius: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 30rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.dialog-btn--cancel {
|
||
background-color: #f3f4f6;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.dialog-btn--confirm {
|
||
background-color: #4c8dff;
|
||
color: #fff;
|
||
}
|
||
</style>
|