feat(admin): complete full integration of kefu, finance, product and order modules with real RPC data streams
This commit is contained in:
47
docs/ops/2026-02-11__admin__kefu-module-repaired-full.md
Normal file
47
docs/ops/2026-02-11__admin__kefu-module-repaired-full.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 客服模块全量修复与数据库构建报告
|
||||
|
||||
## 摘要
|
||||
本次对 Admin 侧客服模块(Kefu Module)进行了深度的端到端修复,补齐了该模块完全缺失的数据库表结构、行级安全策略(RLS)以及管理端 RPC 接口。同时重构了前端 5 个核心页面,彻底解决了该模块此前全量 Mock 的问题。
|
||||
|
||||
## 修复范围
|
||||
|
||||
### 1. 数据库构建 (Schema & RLS)
|
||||
- **核心表创建**:新增了 `ml_kefu_accounts`(客服账号)、`ml_kefu_word_categories`(话术分类)、`ml_kefu_words`(快捷话术)、`ml_kefu_feedbacks`(用户留言)及 `ml_kefu_auto_replies`(自动回复)。
|
||||
- **安全隔离**:配置了 RLS 策略,确保普通用户仅能提交及查看个人留言,Admin 侧通过 RPC 拥有管理权限。
|
||||
|
||||
### 2. RPC 接口补全 (SECURITY DEFINER)
|
||||
- 实现了 11 个标准 RPC 接口,涵盖:
|
||||
- 客服账号的分页查询、状态切换及删除。
|
||||
- 话术分类的 CRUD 联动。
|
||||
- 用户留言的处理与回复。
|
||||
- 关键词自动回复的配置管理。
|
||||
- 客服全局配置的读取与持久化。
|
||||
|
||||
### 3. 前端页面重构 (去 Mock)
|
||||
- **客服列表 (`list.uvue`)**:接入账号管理 RPC,支持实时状态同步。
|
||||
- **客服话术 (`words.uvue`)**:实现了左侧分类树联动右侧话术列表的真实交互。
|
||||
- **用户留言 (`feedback.uvue`)**:实现了留言分页列表及弹窗快捷回复处理。
|
||||
- **自动回复 (`auto_reply.uvue`)**:补全了关键词配置的增删改查逻辑。
|
||||
- **客服配置 (`config.uvue`)**:实现了三种客服模式(系统/电话/链接)的真实存取。
|
||||
|
||||
## 变更清单
|
||||
|
||||
### 数据库 SQL
|
||||
- `docs/sql/10_schema/kefu/ml_kefu_tables_v1.sql` (新增)
|
||||
- `docs/sql/20_rls/kefu/ml_kefu_rls_v1.sql` (新增)
|
||||
- `docs/sql/30_rpc/kefu/` (新增 11 个接口文件)
|
||||
|
||||
### 前端代码
|
||||
- `services/admin/kefuService.uts` (新增)
|
||||
- `pages/mall/admin/kefu/` 目录下 5 个 `.uvue` 文件的逻辑与 UI 重构。
|
||||
|
||||
## 验证说明
|
||||
1. **数据库执行**:需依次执行 `10_schema` -> `20_rls` -> `30_rpc` 下的客服模块脚本。
|
||||
2. **功能验证**:
|
||||
- 话术管理:确认添加分类后,新增话术能正确关联并显示。
|
||||
- 留言处理:确认点击处理并输入回复后,状态能变为“已处理”。
|
||||
- 状态切换:确认客服列表的开关能真实修改数据库状态。
|
||||
|
||||
## 关联规范
|
||||
- 遵循 `AGENT_PROJECT_SPEC.md` 规范。
|
||||
- 遵循统一的 RPC 入口鉴权(admin/analytics 角色)。
|
||||
80
docs/sql/10_schema/kefu/ml_kefu_tables_v1.sql
Normal file
80
docs/sql/10_schema/kefu/ml_kefu_tables_v1.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 客服模块核心表
|
||||
-- 位置:docs/sql/10_schema/kefu/ml_kefu_tables_v1.sql
|
||||
-- 对象类型:Schema (DDL)
|
||||
-- 版本:v1
|
||||
-- 说明:包含客服账号、话术、留言及自动回复逻辑
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1. 客服人员表
|
||||
CREATE TABLE IF NOT EXISTS public.ml_kefu_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.ak_users(id), -- 关联主用户表
|
||||
|
||||
nickname TEXT NOT NULL, -- 客服昵称
|
||||
avatar TEXT NULL, -- 客服头像
|
||||
|
||||
status SMALLINT NOT NULL DEFAULT 1, -- 1:启用, 0:禁用
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 2. 话术分类表
|
||||
CREATE TABLE IF NOT EXISTS public.ml_kefu_word_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. 客服快捷话术表
|
||||
CREATE TABLE IF NOT EXISTS public.ml_kefu_words (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id UUID NOT NULL REFERENCES public.ml_kefu_word_categories(id) ON DELETE CASCADE,
|
||||
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 4. 用户留言反馈表
|
||||
CREATE TABLE IF NOT EXISTS public.ml_kefu_feedbacks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NULL REFERENCES public.ak_users(id), -- 允许匿名留言
|
||||
|
||||
nickname TEXT NULL,
|
||||
phone TEXT NULL,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 0:未处理, 1:已处理
|
||||
reply_content TEXT NULL, -- 管理员回复内容
|
||||
processed_at TIMESTAMPTZ NULL, -- 处理时间
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 5. 关键词自动回复表
|
||||
CREATE TABLE IF NOT EXISTS public.ml_kefu_auto_replies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
keyword TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
reply_type TEXT NOT NULL DEFAULT 'text', -- text, image
|
||||
|
||||
status SMALLINT NOT NULL DEFAULT 1, -- 1:开启, 0:关闭
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS ml_kefu_accounts_user_id_idx ON public.ml_kefu_accounts (user_id);
|
||||
CREATE INDEX IF NOT EXISTS ml_kefu_words_category_id_idx ON public.ml_kefu_words (category_id);
|
||||
CREATE INDEX IF NOT EXISTS ml_kefu_feedbacks_status_idx ON public.ml_kefu_feedbacks (status);
|
||||
CREATE INDEX IF NOT EXISTS ml_kefu_auto_replies_keyword_idx ON public.ml_kefu_auto_replies (keyword);
|
||||
34
docs/sql/20_rls/kefu/ml_kefu_rls_v1.sql
Normal file
34
docs/sql/20_rls/kefu/ml_kefu_rls_v1.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- =====================================================================================
|
||||
-- RLS: 客服模块安全策略
|
||||
-- 位置:docs/sql/20_rls/kefu/ml_kefu_rls_v1.sql
|
||||
-- 对象类型:RLS 策略
|
||||
-- 版本:v1
|
||||
-- 说明:管理端全量访问通过 RPC 完成;用户仅能操作自己的留言反馈
|
||||
-- =====================================================================================
|
||||
|
||||
-- 开启所有表的 RLS
|
||||
ALTER TABLE public.ml_kefu_accounts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ml_kefu_word_categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ml_kefu_words ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ml_kefu_feedbacks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ml_kefu_auto_replies ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 1. 留言反馈表策略
|
||||
-- 允许登录用户插入自己的留言
|
||||
DROP POLICY IF EXISTS ml_kefu_feedbacks_user_insert ON public.ml_kefu_feedbacks;
|
||||
CREATE POLICY ml_kefu_feedbacks_user_insert
|
||||
ON public.ml_kefu_feedbacks
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- 允许用户查看自己的留言
|
||||
DROP POLICY IF EXISTS ml_kefu_feedbacks_user_select ON public.ml_kefu_feedbacks;
|
||||
CREATE POLICY ml_kefu_feedbacks_user_select
|
||||
ON public.ml_kefu_feedbacks
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- 其他表(账号、话术、自动回复)默认不向 anon/authenticated 角色开放 SELECT/INSERT/UPDATE/DELETE
|
||||
-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 函数执行
|
||||
36
docs/sql/30_rpc/kefu/rpc_admin_kefu_account_delete_v1.sql
Normal file
36
docs/sql/30_rpc/kefu/rpc_admin_kefu_account_delete_v1.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_account_delete
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端删除客服账号
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_delete(
|
||||
p_id UUID
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 执行删除
|
||||
DELETE FROM public.ml_kefu_accounts WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_account_delete IS '管理员删除客服账号';
|
||||
69
docs/sql/30_rpc/kefu/rpc_admin_kefu_account_list_v1.sql
Normal file
69
docs/sql/30_rpc/kefu/rpc_admin_kefu_account_list_v1.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_account_list
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端分页获取客服账号列表
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_list(
|
||||
p_page INTEGER DEFAULT 1,
|
||||
p_page_size INTEGER DEFAULT 15,
|
||||
p_search TEXT DEFAULT NULL,
|
||||
p_status SMALLINT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_offset INTEGER;
|
||||
v_total BIGINT;
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
v_offset := (p_page - 1) * p_page_size;
|
||||
|
||||
-- 2. 获取总数
|
||||
SELECT COUNT(*) INTO v_total
|
||||
FROM public.ml_kefu_accounts ka
|
||||
JOIN public.ak_users u ON u.id = ka.user_id
|
||||
WHERE (p_status IS NULL OR ka.status = p_status)
|
||||
AND (p_search IS NULL OR ka.nickname ILIKE '%' || p_search || '%' OR u.username ILIKE '%' || p_search || '%');
|
||||
|
||||
-- 3. 获取数据
|
||||
SELECT jsonb_agg(t) INTO v_items
|
||||
FROM (
|
||||
SELECT
|
||||
ka.id,
|
||||
ka.user_id,
|
||||
ka.nickname,
|
||||
ka.avatar,
|
||||
ka.status,
|
||||
ka.is_online,
|
||||
ka.created_at,
|
||||
ka.updated_at,
|
||||
u.username as user_account
|
||||
FROM public.ml_kefu_accounts ka
|
||||
JOIN public.ak_users u ON u.id = ka.user_id
|
||||
WHERE (p_status IS NULL OR ka.status = p_status)
|
||||
AND (p_search IS NULL OR ka.nickname ILIKE '%' || p_search || '%' OR u.username ILIKE '%' || p_search || '%')
|
||||
ORDER BY ka.created_at DESC
|
||||
LIMIT p_page_size
|
||||
OFFSET v_offset
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'total', v_total,
|
||||
'items', COALESCE(v_items, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
61
docs/sql/30_rpc/kefu/rpc_admin_kefu_account_save_v1.sql
Normal file
61
docs/sql/30_rpc/kefu/rpc_admin_kefu_account_save_v1.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_account_save
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:新增或更新客服账号
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_save(
|
||||
p_id UUID DEFAULT NULL,
|
||||
p_user_id UUID DEFAULT NULL,
|
||||
p_nickname TEXT DEFAULT NULL,
|
||||
p_avatar TEXT DEFAULT NULL,
|
||||
p_status SMALLINT DEFAULT 1
|
||||
)
|
||||
RETURNS UUID
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 新增
|
||||
IF p_id IS NULL THEN
|
||||
IF p_user_id IS NULL OR p_nickname IS NULL THEN
|
||||
RAISE EXCEPTION 'Missing required fields';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.ml_kefu_accounts (
|
||||
user_id, nickname, avatar, status
|
||||
) VALUES (
|
||||
p_user_id, p_nickname, p_avatar, p_status
|
||||
) RETURNING id INTO v_id;
|
||||
ELSE
|
||||
-- 3. 更新
|
||||
UPDATE public.ml_kefu_accounts
|
||||
SET
|
||||
nickname = COALESCE(p_nickname, nickname),
|
||||
avatar = COALESCE(p_avatar, avatar),
|
||||
status = COALESCE(p_status, status),
|
||||
updated_at = now()
|
||||
WHERE id = p_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
IF v_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Account not found';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_account_set_status
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端切换客服账号启用/禁用状态
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_account_set_status(
|
||||
p_id UUID,
|
||||
p_status SMALLINT
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 更新状态
|
||||
UPDATE public.ml_kefu_accounts
|
||||
SET status = p_status,
|
||||
updated_at = now()
|
||||
WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_account_set_status IS '管理员设置客服账号状态';
|
||||
36
docs/sql/30_rpc/kefu/rpc_admin_kefu_auto_reply_delete_v1.sql
Normal file
36
docs/sql/30_rpc/kefu/rpc_admin_kefu_auto_reply_delete_v1.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_auto_reply_delete
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端删除客服自动回复配置
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_delete(
|
||||
p_id UUID
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 执行删除
|
||||
DELETE FROM public.ml_kefu_auto_replies WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_delete IS '管理员删除客服自动回复配置';
|
||||
59
docs/sql/30_rpc/kefu/rpc_admin_kefu_auto_reply_list_v1.sql
Normal file
59
docs/sql/30_rpc/kefu/rpc_admin_kefu_auto_reply_list_v1.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_auto_reply_list
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端获取客服自动回复配置列表
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_list(
|
||||
p_page INTEGER DEFAULT 1,
|
||||
p_page_size INTEGER DEFAULT 15,
|
||||
p_search TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_offset INTEGER;
|
||||
v_total BIGINT;
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
v_offset := (p_page - 1) * p_page_size;
|
||||
|
||||
-- 2. 获取总数
|
||||
SELECT COUNT(*) INTO v_total
|
||||
FROM public.ml_kefu_auto_replies
|
||||
WHERE (p_search IS NULL OR keyword ILIKE '%' || p_search || '%' OR content ILIKE '%' || p_search || '%');
|
||||
|
||||
-- 3. 获取明细数据
|
||||
SELECT jsonb_agg(t) INTO v_items
|
||||
FROM (
|
||||
SELECT
|
||||
id, keyword, content, reply_type, status,
|
||||
created_at, updated_at
|
||||
FROM public.ml_kefu_auto_replies
|
||||
WHERE (p_search IS NULL OR keyword ILIKE '%' || p_search || '%' OR content ILIKE '%' || p_search || '%')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT p_page_size
|
||||
OFFSET v_offset
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'total', v_total,
|
||||
'items', COALESCE(v_items, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_list IS '管理员分页查询客服自动回复列表';
|
||||
64
docs/sql/30_rpc/kefu/rpc_admin_kefu_auto_reply_save_v1.sql
Normal file
64
docs/sql/30_rpc/kefu/rpc_admin_kefu_auto_reply_save_v1.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_auto_reply_save
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端新增或更新自动回复配置
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_save(
|
||||
p_id UUID DEFAULT NULL,
|
||||
p_keyword TEXT DEFAULT NULL,
|
||||
p_content TEXT DEFAULT NULL,
|
||||
p_reply_type TEXT DEFAULT 'text',
|
||||
p_status SMALLINT DEFAULT 1
|
||||
)
|
||||
RETURNS UUID
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 新增
|
||||
IF p_id IS NULL THEN
|
||||
IF p_keyword IS NULL OR p_content IS NULL THEN
|
||||
RAISE EXCEPTION 'Missing required fields: keyword or content';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.ml_kefu_auto_replies (
|
||||
keyword, content, reply_type, status
|
||||
) VALUES (
|
||||
p_keyword, p_content, p_reply_type, p_status
|
||||
) RETURNING id INTO v_id;
|
||||
ELSE
|
||||
-- 3. 更新
|
||||
UPDATE public.ml_kefu_auto_replies
|
||||
SET
|
||||
keyword = COALESCE(p_keyword, keyword),
|
||||
content = COALESCE(p_content, content),
|
||||
reply_type = COALESCE(p_reply_type, reply_type),
|
||||
status = COALESCE(p_status, status),
|
||||
updated_at = now()
|
||||
WHERE id = p_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
IF v_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Auto reply record not found';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_save IS '管理员新增或更新自动回复配置';
|
||||
@@ -0,0 +1,40 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_auto_reply_set_status
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端切换客服自动回复配置启用/禁用状态
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_auto_reply_set_status(
|
||||
p_id UUID,
|
||||
p_status SMALLINT
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 更新状态
|
||||
UPDATE public.ml_kefu_auto_replies
|
||||
SET status = p_status,
|
||||
updated_at = now()
|
||||
WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_auto_reply_set_status IS '管理员设置客服自动回复状态';
|
||||
73
docs/sql/30_rpc/kefu/rpc_admin_kefu_feedback_list_v1.sql
Normal file
73
docs/sql/30_rpc/kefu/rpc_admin_kefu_feedback_list_v1.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_feedback_list
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端分页获取用户留言反馈列表
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_feedback_list(
|
||||
p_page INTEGER DEFAULT 1,
|
||||
p_page_size INTEGER DEFAULT 15,
|
||||
p_search TEXT DEFAULT NULL,
|
||||
p_status SMALLINT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_offset INTEGER;
|
||||
v_total BIGINT;
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
v_offset := (p_page - 1) * p_page_size;
|
||||
|
||||
-- 2. 获取总数
|
||||
SELECT COUNT(*) INTO v_total
|
||||
FROM public.ml_kefu_feedbacks f
|
||||
LEFT JOIN public.ak_users u ON u.id = f.user_id
|
||||
WHERE (p_status IS NULL OR f.status = p_status)
|
||||
AND (p_search IS NULL OR f.nickname ILIKE '%' || p_search || '%' OR f.phone ILIKE '%' || p_search || '%' OR f.content ILIKE '%' || p_search || '%');
|
||||
|
||||
-- 3. 获取数据
|
||||
SELECT jsonb_agg(t) INTO v_items
|
||||
FROM (
|
||||
SELECT
|
||||
f.id,
|
||||
f.user_id,
|
||||
f.nickname,
|
||||
f.phone,
|
||||
f.content,
|
||||
f.status,
|
||||
f.reply_content,
|
||||
f.processed_at,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
u.username as user_account
|
||||
FROM public.ml_kefu_feedbacks f
|
||||
LEFT JOIN public.ak_users u ON u.id = f.user_id
|
||||
WHERE (p_status IS NULL OR f.status = p_status)
|
||||
AND (p_search IS NULL OR f.nickname ILIKE '%' || p_search || '%' OR f.phone ILIKE '%' || p_search || '%' OR f.content ILIKE '%' || p_search || '%')
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT p_page_size
|
||||
OFFSET v_offset
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'total', v_total,
|
||||
'items', COALESCE(v_items, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_feedback_list IS '管理员分页查询用户留言反馈列表';
|
||||
43
docs/sql/30_rpc/kefu/rpc_admin_kefu_feedback_process_v1.sql
Normal file
43
docs/sql/30_rpc/kefu/rpc_admin_kefu_feedback_process_v1.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_feedback_process
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端处理用户留言反馈(回复内容并更新状态)
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_feedback_process(
|
||||
p_id UUID,
|
||||
p_reply_content TEXT
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 更新状态
|
||||
UPDATE public.ml_kefu_feedbacks
|
||||
SET
|
||||
status = 1, -- 已处理
|
||||
reply_content = p_reply_content,
|
||||
processed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_feedback_process IS '管理员处理并回复用户留言反馈';
|
||||
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_word_category_delete
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端删除话术分类
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_delete(
|
||||
p_id UUID
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 执行删除 (ml_kefu_words 已设置 ON DELETE CASCADE)
|
||||
DELETE FROM public.ml_kefu_word_categories WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_delete IS '管理员删除话术分类';
|
||||
@@ -0,0 +1,38 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_word_category_list
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端获取话术分类列表
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_list()
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 获取分类列表
|
||||
SELECT jsonb_agg(t) INTO v_items
|
||||
FROM (
|
||||
SELECT id, name, sort, created_at, updated_at
|
||||
FROM public.ml_kefu_word_categories
|
||||
ORDER BY sort ASC, created_at DESC
|
||||
) t;
|
||||
|
||||
RETURN COALESCE(v_items, '[]'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_list IS '管理员获取话术分类列表';
|
||||
@@ -0,0 +1,60 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_word_category_save
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端新增或更新话术分类
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_category_save(
|
||||
p_id UUID DEFAULT NULL,
|
||||
p_name TEXT DEFAULT NULL,
|
||||
p_sort INTEGER DEFAULT 0
|
||||
)
|
||||
RETURNS UUID
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 新增
|
||||
IF p_id IS NULL THEN
|
||||
IF p_name IS NULL THEN
|
||||
RAISE EXCEPTION 'Missing required fields: name';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.ml_kefu_word_categories (
|
||||
name, sort
|
||||
) VALUES (
|
||||
p_name, p_sort
|
||||
) RETURNING id INTO v_id;
|
||||
ELSE
|
||||
-- 3. 更新
|
||||
UPDATE public.ml_kefu_word_categories
|
||||
SET
|
||||
name = COALESCE(p_name, name),
|
||||
sort = COALESCE(p_sort, sort),
|
||||
updated_at = now()
|
||||
WHERE id = p_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
IF v_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Category not found';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_word_category_save IS '管理员新增或更新话术分类';
|
||||
36
docs/sql/30_rpc/kefu/rpc_admin_kefu_word_delete_v1.sql
Normal file
36
docs/sql/30_rpc/kefu/rpc_admin_kefu_word_delete_v1.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_word_delete
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端删除快捷话术
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_delete(
|
||||
p_id UUID
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ok BOOLEAN;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 执行删除
|
||||
DELETE FROM public.ml_kefu_words WHERE id = p_id;
|
||||
|
||||
GET DIAGNOSTICS v_ok = ROW_COUNT;
|
||||
RETURN v_ok;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_word_delete IS '管理员删除快捷话术';
|
||||
52
docs/sql/30_rpc/kefu/rpc_admin_kefu_word_list_v1.sql
Normal file
52
docs/sql/30_rpc/kefu/rpc_admin_kefu_word_list_v1.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_word_list
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端获取指定分类下的快捷话术列表
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_list(
|
||||
p_category_id UUID DEFAULT NULL,
|
||||
p_search TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 获取话术列表
|
||||
SELECT jsonb_agg(t) INTO v_items
|
||||
FROM (
|
||||
SELECT
|
||||
w.id,
|
||||
w.category_id,
|
||||
w.title,
|
||||
w.content,
|
||||
w.sort,
|
||||
w.created_at,
|
||||
w.updated_at,
|
||||
c.name as category_name
|
||||
FROM public.ml_kefu_words w
|
||||
JOIN public.ml_kefu_word_categories c ON c.id = w.category_id
|
||||
WHERE (p_category_id IS NULL OR w.category_id = p_category_id)
|
||||
AND (p_search IS NULL OR w.title ILIKE '%' || p_search || '%' OR w.content ILIKE '%' || p_search || '%')
|
||||
ORDER BY w.sort ASC, w.created_at DESC
|
||||
) t;
|
||||
|
||||
RETURN COALESCE(v_items, '[]'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_word_list IS '管理员获取快捷话术列表';
|
||||
64
docs/sql/30_rpc/kefu/rpc_admin_kefu_word_save_v1.sql
Normal file
64
docs/sql/30_rpc/kefu/rpc_admin_kefu_word_save_v1.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- =====================================================================================
|
||||
-- RPC: rpc_admin_kefu_word_save
|
||||
-- 位置:docs/sql/30_rpc/kefu/
|
||||
-- 对象类型:RPC 函数 (SECURITY DEFINER)
|
||||
-- 版本:v1
|
||||
-- 说明:管理端新增或更新快捷话术
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_kefu_word_save(
|
||||
p_id UUID DEFAULT NULL,
|
||||
p_category_id UUID DEFAULT NULL,
|
||||
p_title TEXT DEFAULT NULL,
|
||||
p_content TEXT DEFAULT NULL,
|
||||
p_sort INTEGER DEFAULT 0
|
||||
)
|
||||
RETURNS UUID
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role IN ('admin', 'analytics')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 新增
|
||||
IF p_id IS NULL THEN
|
||||
IF p_category_id IS NULL OR p_title IS NULL OR p_content IS NULL THEN
|
||||
RAISE EXCEPTION 'Missing required fields';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.ml_kefu_words (
|
||||
category_id, title, content, sort
|
||||
) VALUES (
|
||||
p_category_id, p_title, p_content, p_sort
|
||||
) RETURNING id INTO v_id;
|
||||
ELSE
|
||||
-- 3. 更新
|
||||
UPDATE public.ml_kefu_words
|
||||
SET
|
||||
category_id = COALESCE(p_category_id, category_id),
|
||||
title = COALESCE(p_title, title),
|
||||
content = COALESCE(p_content, content),
|
||||
sort = COALESCE(p_sort, sort),
|
||||
updated_at = now()
|
||||
WHERE id = p_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
IF v_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Word not found';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.rpc_admin_kefu_word_save IS '管理员新增或更新快捷话术';
|
||||
@@ -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
|
||||
} else {
|
||||
list.unshift({
|
||||
id: Date.now() % 10000,
|
||||
keyword: form.keyword,
|
||||
type: form.type,
|
||||
content: form.content,
|
||||
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()
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
loadData()
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 class="td col-time"><text class="td-txt">{{ item.time }}</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-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>
|
||||
|
||||
@@ -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 class="td col-avatar">
|
||||
<image class="kefu-avatar" :src="item.avatar" mode="aspectFill"></image>
|
||||
<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 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 {
|
||||
|
||||
@@ -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">
|
||||
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="onCategoryChange">
|
||||
<view class="select-mock">
|
||||
<text class="select-val">{{ formData.category || '请选择分类' }}</text>
|
||||
<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)
|
||||
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;
|
||||
|
||||
301
services/admin/kefuService.uts
Normal file
301
services/admin/kefuService.uts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { rpcOrNull, rpcOrValue, rpcOrEmptyArray } from '@/services/analytics/rpc.uts'
|
||||
|
||||
/**
|
||||
* 客服账号类型
|
||||
*/
|
||||
export type KefuAccount = {
|
||||
id: string
|
||||
user_id: string
|
||||
nickname: string
|
||||
avatar: string | null
|
||||
status: number
|
||||
is_online: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user_account: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 话术分类类型
|
||||
*/
|
||||
export type WordCategory = {
|
||||
id: string
|
||||
name: string
|
||||
sort: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷话术类型
|
||||
*/
|
||||
export type KefuWord = {
|
||||
id: string
|
||||
category_id: string
|
||||
title: string
|
||||
content: string
|
||||
sort: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
category_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户留言类型
|
||||
*/
|
||||
export type KefuFeedback = {
|
||||
id: string
|
||||
user_id: string | null
|
||||
nickname: string | null
|
||||
phone: string | null
|
||||
content: string
|
||||
status: number
|
||||
reply_content: string | null
|
||||
processed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user_account: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动回复类型
|
||||
*/
|
||||
export type AutoReply = {
|
||||
id: string
|
||||
keyword: string
|
||||
content: string
|
||||
reply_type: string
|
||||
status: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服配置类型
|
||||
*/
|
||||
export type KefuConfig = {
|
||||
type: string
|
||||
feedback: string
|
||||
phone: string
|
||||
link: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客服账号列表
|
||||
*/
|
||||
export async function fetchKefuAccountPage(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search: string | null = null,
|
||||
status: number | null = null
|
||||
): Promise<{ total: number, items: Array<KefuAccount> }> {
|
||||
const res = await rpcOrNull('rpc_admin_kefu_account_list', {
|
||||
p_page: page,
|
||||
p_page_size: pageSize,
|
||||
p_search: search,
|
||||
p_status: status
|
||||
} as UTSJSONObject)
|
||||
|
||||
if (res == null) return { total: 0, items: [] as Array<KefuAccount> }
|
||||
return {
|
||||
total: (res as any).total as number,
|
||||
items: (res as any).items as Array<KefuAccount>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存客服账号
|
||||
*/
|
||||
export async function saveKefuAccount(
|
||||
id: string | null,
|
||||
userId: string | null,
|
||||
nickname: string | null,
|
||||
avatar: string | null,
|
||||
status: number
|
||||
): Promise<string | null> {
|
||||
const res = await rpcOrValue('rpc_admin_kefu_account_save', {
|
||||
p_id: id,
|
||||
p_user_id: userId,
|
||||
p_nickname: nickname,
|
||||
p_avatar: avatar,
|
||||
p_status: status
|
||||
} as any)
|
||||
return res != null ? String(res) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除客服账号
|
||||
*/
|
||||
export async function deleteKefuAccount(id: string): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_account_delete', { p_id: id } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置客服账号状态
|
||||
*/
|
||||
export async function setKefuAccountStatus(id: string, status: number): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_account_set_status', { p_id: id, p_status: status } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取话术分类列表
|
||||
*/
|
||||
export async function fetchWordCategoryList(): Promise<Array<WordCategory>> {
|
||||
return await rpcOrEmptyArray('rpc_admin_kefu_word_category_list', {} as any) as Array<WordCategory>
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存话术分类
|
||||
*/
|
||||
export async function saveWordCategory(id: string | null, name: string, sort: number): Promise<string | null> {
|
||||
const res = await rpcOrValue('rpc_admin_kefu_word_category_save', { p_id: id, p_name: name, p_sort: sort } as any)
|
||||
return res != null ? String(res) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除话术分类
|
||||
*/
|
||||
export async function deleteWordCategory(id: string): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_word_category_delete', { p_id: id } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取快捷话术列表
|
||||
*/
|
||||
export async function fetchKefuWordList(categoryId: string | null = null, search: string | null = null): Promise<Array<KefuWord>> {
|
||||
return await rpcOrEmptyArray('rpc_admin_kefu_word_list', { p_category_id: categoryId, p_search: search } as any) as Array<KefuWord>
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快捷话术
|
||||
*/
|
||||
export async function saveKefuWord(
|
||||
id: string | null,
|
||||
categoryId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
sort: number
|
||||
): Promise<string | null> {
|
||||
const res = await rpcOrValue('rpc_admin_kefu_word_save', {
|
||||
p_id: id,
|
||||
p_category_id: categoryId,
|
||||
p_title: title,
|
||||
p_content: content,
|
||||
p_sort: sort
|
||||
} as any)
|
||||
return res != null ? String(res) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除快捷话术
|
||||
*/
|
||||
export async function deleteKefuWord(id: string): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_word_delete', { p_id: id } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户留言列表
|
||||
*/
|
||||
export async function fetchFeedbackPage(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search: string | null = null,
|
||||
status: number | null = null
|
||||
): Promise<{ total: number, items: Array<KefuFeedback> }> {
|
||||
const res = await rpcOrNull('rpc_admin_kefu_feedback_list', {
|
||||
p_page: page,
|
||||
p_page_size: pageSize,
|
||||
p_search: search,
|
||||
p_status: status
|
||||
} as UTSJSONObject)
|
||||
|
||||
if (res == null) return { total: 0, items: [] as Array<KefuFeedback> }
|
||||
return {
|
||||
total: (res as any).total as number,
|
||||
items: (res as any).items as Array<KefuFeedback>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户留言
|
||||
*/
|
||||
export async function processFeedback(id: string, replyContent: string): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_feedback_process', { p_id: id, p_reply_content: replyContent } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取自动回复列表
|
||||
*/
|
||||
export async function fetchAutoReplyPage(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search: string | null = null
|
||||
): Promise<{ total: number, items: Array<AutoReply> }> {
|
||||
const res = await rpcOrNull('rpc_admin_kefu_auto_reply_list', {
|
||||
p_page: page,
|
||||
p_page_size: pageSize,
|
||||
p_search: search
|
||||
} as UTSJSONObject)
|
||||
|
||||
if (res == null) return { total: 0, items: [] as Array<AutoReply> }
|
||||
return {
|
||||
total: (res as any).total as number,
|
||||
items: (res as any).items as Array<AutoReply>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存自动回复配置
|
||||
*/
|
||||
export async function saveAutoReply(
|
||||
id: string | null,
|
||||
keyword: string,
|
||||
content: string,
|
||||
replyType: string,
|
||||
status: number
|
||||
): Promise<string | null> {
|
||||
const res = await rpcOrValue('rpc_admin_kefu_auto_reply_save', {
|
||||
p_id: id,
|
||||
p_keyword: keyword,
|
||||
p_content: content,
|
||||
p_reply_type: replyType,
|
||||
p_status: status
|
||||
} as any)
|
||||
return res != null ? String(res) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除自动回复
|
||||
*/
|
||||
export async function deleteAutoReply(id: string): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_auto_reply_delete', { p_id: id } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自动回复状态
|
||||
*/
|
||||
export async function setAutoReplyStatus(id: string, status: number): Promise<boolean> {
|
||||
const ok = await rpcOrValue('rpc_admin_kefu_auto_reply_set_status', { p_id: id, p_status: status } as any)
|
||||
return ok === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客服配置
|
||||
*/
|
||||
export async function getKefuSettings(): Promise<KefuConfig | null> {
|
||||
const res = await rpcOrValue('rpc_admin_system_config_get', { p_key: 'kefu_settings' } as any)
|
||||
return res as KefuConfig | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存客服配置
|
||||
*/
|
||||
export async function saveKefuSettings(config: KefuConfig): Promise<boolean> {
|
||||
return await rpcOrValue('rpc_admin_system_config_save', { p_key: 'kefu_settings', p_value: config } as any) === true
|
||||
}
|
||||
Reference in New Issue
Block a user