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

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

View File

@@ -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 角色)。

View 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);

View 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 函数执行

View 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 '管理员删除客服账号';

View 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;
$$;

View 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;
$$;

View File

@@ -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 '管理员设置客服账号状态';

View 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 '管理员删除客服自动回复配置';

View 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 '管理员分页查询客服自动回复列表';

View 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 '管理员新增或更新自动回复配置';

View File

@@ -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 '管理员设置客服自动回复状态';

View 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 '管理员分页查询用户留言反馈列表';

View 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 '管理员处理并回复用户留言反馈';

View File

@@ -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 '管理员删除话术分类';

View File

@@ -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 '管理员获取话术分类列表';

View File

@@ -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 '管理员新增或更新话术分类';

View 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 '管理员删除快捷话术';

View 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 '管理员获取快捷话术列表';

View 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 '管理员新增或更新快捷话术';

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}