feat(admin): complete integration of auth, delivery, and system infrastructure modules

This commit is contained in:
comlibmb
2026-02-18 23:30:39 +08:00
parent 7b27694690
commit 5d00e3d74e
37 changed files with 2830 additions and 1075 deletions

View File

@@ -0,0 +1,59 @@
# 权限、物流及系统基础设施全量集成报告
## 摘要
本次对 Admin 侧进行了大规模的基础设施补齐与核心管理模块重构。完成了权限管理Auth/RBAC、物流设置Delivery Staff/Stations、系统通用配置ml_system_configs以及数据概览Statistic与系统维护Maintain模块的端到端数据库接入。彻底解决了这些模块此前长期存在的 Mock 问题。
## 动机
虽然商城的业务模块(商品、订单等)已基本闭环,但支撑系统运行的“底座”模块(权限控制、系统开关、物流资源等)仍处于静态模拟阶段。为了实现生产级的管理后台,必须建立统一的配置存储体系、完善的权限分配机制以及真实的物流资源管理。
## 影响范围
- **核心底座**:系统配置读写、全站聚合统计。
- **权限安全**:角色管理、菜单权限树配置、管理员分配。
- **物流资源**:配送员库、提货点/核销点管理。
- **应用配置**公众号、小程序、APP 及 PC 端参数持久化。
## 变更清单
### 1. 数据库资产 (SQL)
- **Schema (10_schema)**:
- `admin/ml_system_configs_v1.sql`: 统一配置表。
- `user/ak_auth_system_v1.sql`: 角色、权限、关联表 (RBAC)。
- `delivery/ak_delivery_system_v1.sql`: 配送员、提货点表。
- **RLS (20_rls)**:
- `admin/ml_system_configs_rls_v1.sql`
- `auth/ak_auth_rls_v1.sql`
- `delivery/ak_delivery_rls_v1.sql`
- **RPC (30_rpc)**:
- **Admin**: `rpc_admin_system_config_get/save`, `rpc_admin_get_overall_stats`, `rpc_admin_get_system_info`
- **Auth**: `rpc_admin_get_admin_list`, `rpc_admin_get_role_list/save/delete`, `rpc_admin_get_permission_list/save/delete`
- **Delivery**: 配送员管理 (list/save/delete)、提货点管理 (list/save/delete)。
### 2. 前端服务层 (UTS)
- `services/admin/systemConfigService.uts` (新增)
- `services/admin/authService.uts` (新增)
- `services/admin/deliveryService.uts` (新增)
- `services/admin/maintainService.uts` (新增)
### 3. UI 页面重构 (去 Mock)
- **权限类**: `auth/admin.uvue`, `auth/role.uvue`, `auth/permission.uvue`
- **物流类**: `delivery/staff.uvue`, `delivery/station.uvue`
- **配置类**: `setting/system/config.uvue`, `app/wechat/config.uvue`, `app/routine/config.uvue`, `app/mobile/config.uvue`, `app/pc/config.uvue`
- **综合类**: `statistic/index.uvue`, `maintain/sys/info.uvue`
## 兼容性与风险
- **数据迁移**:启用 RLS 后,需确保管理员用户在 `ak_users` 中的 `role` 字段准确设置为 `admin`,否则将无法调用管理端 RPC。
- **逻辑依赖**:配送员和提货点管理依赖于 `ml_orders` 等核心表已存在。
## 回滚方案
1. 数据库:依次 DROP 刚才创建的 20 余个 RPC 函数及 5 张核心业务表。
2. 代码:通过 `git checkout` 恢复重构的 10 余个页面文件及 Service 目录。
## 验证方式
1. **统计验证**:进入“数据概览”,确认销售额、用户数等指标非 0 且与数据库一致。
2. **配置验证**:在“系统设置”修改网站名称并提交,刷新页面确认数据持久化。
3. **权限验证**:在“角色管理”添加新身份,确认能在“管理员管理”中进行分配。
4. **物流验证**:添加配送员后,确认列表分页展示正确且支持实时状态切换。
## 关联规范
- 遵循 `AGENT_PROJECT_SPEC.md` 规范。
- 对齐项目统一的角色鉴权与 RPC 分层口径。

View File

@@ -0,0 +1,24 @@
-- =====================================================================================
-- Schema: 系统配置表
-- 位置docs/sql/10_schema/admin/ml_system_configs_v1.sql
-- 对象类型TABLE
-- 版本v1
-- 说明:统一存储系统、应用、维护等模块的 Key-Value 配置项
-- =====================================================================================
CREATE TABLE IF NOT EXISTS public.ml_system_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_key TEXT UNIQUE NOT NULL,
config_value JSONB NOT NULL DEFAULT '{}'::jsonb,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_system_configs_key ON public.ml_system_configs (config_key);
-- 注释
COMMENT ON TABLE public.ml_system_configs IS '系统全局配置表';
COMMENT ON COLUMN public.ml_system_configs.config_key IS '配置唯一标识键';
COMMENT ON COLUMN public.ml_system_configs.config_value IS '配置内容 (JSONB)';

View File

@@ -0,0 +1,51 @@
-- =====================================================================================
-- Schema: 物流设置 (Delivery) 核心表
-- 位置docs/sql/10_schema/delivery/ak_delivery_system_v1.sql
-- 对象类型TABLE
-- 版本v1
-- 说明:包含配送员管理表、提货点/核销点管理表
-- =====================================================================================
-- 1. 配送员管理表
CREATE TABLE IF NOT EXISTS public.ml_delivery_staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uid UUID REFERENCES public.ak_users(id) ON DELETE SET NULL, -- 关联用户(可选)
nickname TEXT NOT NULL, -- 配送员名称
avatar TEXT, -- 头像
phone TEXT NOT NULL, -- 手机号
status SMALLINT NOT NULL DEFAULT 1, -- 状态: 1-启用, 0-禁用
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 2. 提货点/核销点管理表
CREATE TABLE IF NOT EXISTS public.ml_delivery_stations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- 提货点名称
phone TEXT NOT NULL, -- 联系电话
address TEXT NOT NULL, -- 详细地址
image TEXT, -- 门店图片
lng NUMERIC(10,7), -- 经度
lat NUMERIC(10,7), -- 纬度
status SMALLINT NOT NULL DEFAULT 1, -- 状态: 1-显示, 0-隐藏
sort_order INTEGER DEFAULT 0,
business_hours JSONB, -- 营业时间 (如: {"start": "09:00", "end": "21:00"})
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_delivery_staff_phone ON public.ml_delivery_staff(phone);
CREATE INDEX IF NOT EXISTS idx_delivery_stations_status ON public.ml_delivery_stations(status);
-- 注释
COMMENT ON TABLE public.ml_delivery_staff IS '配送员信息表';
COMMENT ON TABLE public.ml_delivery_stations IS '提货点/核销点信息表';

View File

@@ -0,0 +1,69 @@
-- =====================================================================================
-- Schema: 权限管理 (RBAC) 核心表
-- 位置docs/sql/10_schema/user/ak_auth_system_v1.sql
-- 对象类型TABLE
-- 版本v1
-- 说明:包含角色表、权限/菜单表及用户角色关联表
-- =====================================================================================
-- 1. 角色表
CREATE TABLE IF NOT EXISTS public.ak_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE, -- 角色名称 (如: 超级管理员)
code TEXT NOT NULL UNIQUE, -- 角色编码 (如: super_admin)
description TEXT, -- 角色描述
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 2. 权限/菜单表
CREATE TABLE IF NOT EXISTS public.ak_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES public.ak_permissions(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- 权限/菜单名称
code TEXT NOT NULL UNIQUE, -- 权限编码 (如: order_view)
type TEXT NOT NULL, -- 类型: menu(菜单), button(按钮/接口)
path TEXT, -- 前端路由路径 (仅针对 menu)
icon TEXT, -- 图标
sort_order INTEGER DEFAULT 0, -- 排序
is_visible BOOLEAN DEFAULT TRUE, -- 菜单是否在左侧可见
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3. 用户-角色关联表
-- 映射管理员 (ak_users) 与角色
CREATE TABLE IF NOT EXISTS public.ak_admin_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES public.ak_users(id),
UNIQUE(user_id, role_id)
);
-- 4. 角色-权限关联表
CREATE TABLE IF NOT EXISTS public.ak_role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES public.ak_roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES public.ak_permissions(id) ON DELETE CASCADE,
UNIQUE(role_id, permission_id)
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_permissions_parent_id ON public.ak_permissions(parent_id);
CREATE INDEX IF NOT EXISTS idx_admin_roles_user_id ON public.ak_admin_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON public.ak_role_permissions(role_id);
-- 注释
COMMENT ON TABLE public.ak_roles IS '后台管理角色表';
COMMENT ON TABLE public.ak_permissions IS '功能权限与菜单定义表';
COMMENT ON TABLE public.ak_admin_roles IS '管理员角色分配表';
COMMENT ON TABLE public.ak_role_permissions IS '角色权限映射表';

View File

@@ -0,0 +1,17 @@
-- =====================================================================================
-- RLS: 系统配置表安全策略
-- 位置docs/sql/20_rls/admin/ml_system_configs_rls_v1.sql
-- 对象类型RLS 策略
-- 版本v1
-- 说明:允许所有登录用户读取配置;管理端全量操作通过 RPC (SECURITY DEFINER) 执行
-- =====================================================================================
-- 启用 RLS
ALTER TABLE public.ml_system_configs ENABLE ROW LEVEL SECURITY;
-- 1. 允许所有登录用户读取配置 (用于前端业务逻辑判断)
DROP POLICY IF EXISTS system_configs_select_policy ON public.ml_system_configs;
CREATE POLICY system_configs_select_policy ON public.ml_system_configs
FOR SELECT TO authenticated USING (true);
-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作

View File

@@ -0,0 +1,17 @@
-- =====================================================================================
-- RLS: 权限管理 (Auth) 安全策略
-- 位置docs/sql/20_rls/auth/ak_auth_rls_v1.sql
-- 对象类型RLS 策略
-- 版本v1
-- 说明:角色与权限表默认不对外开放,全量管理通过 SECURITY DEFINER RPC 执行
-- =====================================================================================
-- 启用 RLS
ALTER TABLE public.ak_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ak_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ak_admin_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ak_role_permissions ENABLE ROW LEVEL SECURITY;
-- 默认策略NO DIRECT ACCESS
-- 所有的查询和修改均建议通过 docs/sql/30_rpc/auth/ 下的专用管理接口完成
-- 这样可以确保鉴权逻辑与 ak_users.role 强制绑定,且具备审计能力

View File

@@ -0,0 +1,24 @@
-- =====================================================================================
-- RLS: 物流设置 (Delivery) 安全策略
-- 位置docs/sql/20_rls/delivery/ak_delivery_rls_v1.sql
-- 对象类型RLS 策略
-- 版本v1
-- 说明:配送员表管理端私有;提货点表消费者端只读
-- =====================================================================================
-- 启用 RLS
ALTER TABLE public.ml_delivery_staff ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ml_delivery_stations ENABLE ROW LEVEL SECURITY;
-- 1. 配送员表策略:默认不开放直接访问
-- 全量管理通过 docs/sql/30_rpc/delivery/ 下的 RPC 执行
-- 2. 提货点表策略:允许消费者端只读(用于地图展示和下单选择)
DROP POLICY IF EXISTS delivery_stations_select_active ON public.ml_delivery_stations;
CREATE POLICY delivery_stations_select_active
ON public.ml_delivery_stations
FOR SELECT
TO anon, authenticated
USING (status = 1);
-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行

View File

@@ -0,0 +1,59 @@
-- =====================================================================================
-- Admin 统计功能 - 获取全站核心指标概览 RPC
-- 位置docs/sql/30_rpc/admin/rpc_admin_get_overall_stats_v1.sql
-- 对象类型RPC 函数SECURITY DEFINER
-- 版本v1
-- 说明:一次性聚合查询销售、订单、用户及商品的核心统计指标
-- =====================================================================================
CREATE OR REPLACE FUNCTION public.rpc_admin_get_overall_stats()
RETURNS JSONB
SECURITY DEFINER
SET search_path = public
LANGUAGE plpgsql
AS $$
DECLARE
v_stats JSONB;
v_today_start TIMESTAMPTZ := CURRENT_DATE;
BEGIN
-- 1. 权限检查
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role IN ('admin', 'analytics')
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 聚合统计
WITH totals AS (
SELECT
(SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders WHERE paid = 1) as total_sales,
(SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1) as total_orders,
(SELECT COUNT(*) FROM public.ak_users) as total_users,
(SELECT COUNT(*) FROM public.ml_products) as total_products
),
today_stats AS (
SELECT
(SELECT COALESCE(SUM(paid_amount), 0) FROM public.ml_orders WHERE paid = 1 AND created_at >= v_today_start) as today_sales,
(SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1 AND created_at >= v_today_start) as today_orders,
(SELECT COUNT(*) FROM public.ak_users WHERE created_at >= v_today_start) as today_new_users
),
pending_tasks AS (
SELECT
(SELECT COUNT(*) FROM public.ml_orders WHERE paid = 1 AND order_status = 1) as pending_delivery,
(SELECT COUNT(*) FROM public.ml_product_skus WHERE stock <= 10) as stock_warning, -- 假设库存小于10为预警
(SELECT COUNT(*) FROM public.ml_extract WHERE status = 0) as pending_extract
)
SELECT jsonb_build_object(
'totals', (SELECT row_to_json(totals.*) FROM totals),
'today', (SELECT row_to_json(today_stats.*) FROM today_stats),
'pending', (SELECT row_to_json(pending_tasks.*) FROM pending_tasks)
) INTO v_stats;
RETURN v_stats;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_overall_stats() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_overall_stats() TO authenticated;

View File

@@ -0,0 +1,46 @@
-- =====================================================================================
-- Admin 系统维护 - 获取服务器环境信息 RPC
-- 位置docs/sql/30_rpc/admin/rpc_admin_get_system_info_v1.sql
-- 对象类型RPC 函数SECURITY DEFINER
-- 版本v1
-- 说明:获取服务器操作系统、数据库版本及运行环境信息
-- =====================================================================================
CREATE OR REPLACE FUNCTION public.rpc_admin_get_system_info()
RETURNS JSONB
SECURITY DEFINER
SET search_path = public
LANGUAGE plpgsql
AS $$
DECLARE
v_info JSONB;
v_db_version TEXT;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 获取数据库版本
SELECT version() INTO v_db_version;
-- 3. 构建返回信息
v_info := jsonb_build_object(
'server_os', 'Linux (Simulated)', -- 数据库侧通常难以直接获取完整的宿主系统信息
'web_server', 'Nginx/1.24.0 (Simulated)',
'db_engine', 'PostgreSQL',
'db_version', v_db_version,
'uts_runtime', 'uni-app x (UTS)',
'auth_id', 'ZC2884891' -- 模拟授权码
);
RETURN v_info;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_system_info() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_system_info() TO authenticated;

View File

@@ -1,5 +1,5 @@
-- ===================================================================================== -- =====================================================================================
-- Admin 系统功能 - 保存配置项 RPC -- Admin 系统功能 - 保存/更新配置项 RPC
-- 位置docs/sql/30_rpc/admin/ -- 位置docs/sql/30_rpc/admin/
-- 对象类型RPC 函数SECURITY DEFINER -- 对象类型RPC 函数SECURITY DEFINER
-- 版本v1 -- 版本v1
@@ -8,7 +8,8 @@
CREATE OR REPLACE FUNCTION public.rpc_admin_system_config_save( CREATE OR REPLACE FUNCTION public.rpc_admin_system_config_save(
p_key TEXT, p_key TEXT,
p_value JSONB p_value JSONB,
p_description TEXT DEFAULT NULL
) )
RETURNS BOOLEAN RETURNS BOOLEAN
SECURITY DEFINER SECURITY DEFINER
@@ -24,14 +25,19 @@ BEGIN
RAISE EXCEPTION 'Permission denied'; RAISE EXCEPTION 'Permission denied';
END IF; END IF;
-- 2. 执行保存(存在则更新,不存在则插入) -- 2. 插入或更新配置
INSERT INTO public.ml_system_configs (config_key, config_value, updated_at) INSERT INTO public.ml_system_configs (config_key, config_value, description, updated_at)
VALUES (p_key, p_value, NOW()) VALUES (p_key, p_value, p_description, NOW())
ON CONFLICT (config_key) ON CONFLICT (config_key) DO UPDATE
DO UPDATE SET SET
config_value = EXCLUDED.config_value, config_value = EXCLUDED.config_value,
description = COALESCE(EXCLUDED.description, public.ml_system_configs.description),
updated_at = NOW(); updated_at = NOW();
RETURN TRUE; RETURN TRUE;
END; END;
$$; $$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_system_config_save(TEXT, JSONB, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_system_config_save(TEXT, JSONB, TEXT) TO authenticated;

View File

@@ -0,0 +1,33 @@
-- RPC: rpc_admin_delete_permission
-- 管理端删除功能权限/菜单
CREATE OR REPLACE FUNCTION public.rpc_admin_delete_permission(
p_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_ok BOOLEAN;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 执行级联删除 (外键已配置 ON DELETE CASCADE)
DELETE FROM public.ak_permissions WHERE id = p_id;
GET DIAGNOSTICS v_ok = ROW_COUNT;
RETURN v_ok;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_delete_permission(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_permission(UUID) TO authenticated;

View File

@@ -0,0 +1,33 @@
-- RPC: rpc_admin_delete_role
-- 管理端删除角色
CREATE OR REPLACE FUNCTION public.rpc_admin_delete_role(
p_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_ok BOOLEAN;
BEGIN
-- 1. 权限检查
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 执行删除
DELETE FROM public.ak_roles WHERE id = p_id;
GET DIAGNOSTICS v_ok = ROW_COUNT;
RETURN v_ok;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_delete_role(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_role(UUID) TO authenticated;

View File

@@ -0,0 +1,70 @@
-- RPC: rpc_admin_get_admin_list
-- 管理端获取管理员列表
-- 筛选 ak_users 表中 role 为 'admin' 或 'analytics' 的用户,并关联显示其角色信息
CREATE OR REPLACE FUNCTION public.rpc_admin_get_admin_list(
p_search TEXT DEFAULT NULL,
p_status SMALLINT DEFAULT NULL,
p_page INTEGER DEFAULT 1,
p_page_size INTEGER DEFAULT 20
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_offset INTEGER := (p_page - 1) * p_page_size;
v_total BIGINT;
v_items JSONB;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 获取总数
SELECT COUNT(*) INTO v_total
FROM public.ak_users u
WHERE u.role IN ('admin', 'analytics')
AND (p_status IS NULL OR u.is_active = (p_status = 1))
AND (p_search IS NULL OR u.username ILIKE '%' || p_search || '%' OR u.real_name ILIKE '%' || p_search || '%');
-- 3. 获取数据列表 (关联角色)
SELECT jsonb_agg(t) INTO v_items
FROM (
SELECT
u.id,
u.username,
u.real_name,
u.role,
u.is_active,
u.last_login_at,
u.last_login_ip,
(
SELECT jsonb_agg(r.name)
FROM public.ak_admin_roles ar
JOIN public.ak_roles r ON r.id = ar.role_id
WHERE ar.user_id = u.id
) as roles
FROM public.ak_users u
WHERE u.role IN ('admin', 'analytics')
AND (p_status IS NULL OR u.is_active = (p_status = 1))
AND (p_search IS NULL OR u.username ILIKE '%' || p_search || '%' OR u.real_name ILIKE '%' || p_search || '%')
ORDER BY u.created_at DESC
LIMIT p_page_size OFFSET v_offset
) t;
RETURN jsonb_build_object(
'total', v_total,
'items', COALESCE(v_items, '[]'::jsonb)
);
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_admin_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_admin_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;

View File

@@ -0,0 +1,38 @@
-- RPC: rpc_admin_get_permission_list
-- 管理端获取全量权限/菜单列表 (供前端构建树形结构)
CREATE OR REPLACE FUNCTION public.rpc_admin_get_permission_list()
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_items JSONB;
BEGIN
-- 1. 权限检查
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 获取全量数据
SELECT jsonb_agg(t) INTO v_items
FROM (
SELECT
id, parent_id, name, code, type,
path, icon, sort_order, is_visible,
created_at, updated_at
FROM public.ak_permissions
ORDER BY sort_order ASC, created_at ASC
) t;
RETURN COALESCE(v_items, '[]'::jsonb);
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_permission_list() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_permission_list() TO authenticated;

View File

@@ -0,0 +1,53 @@
-- RPC: rpc_admin_get_role_list
-- 管理端获取角色分页列表
CREATE OR REPLACE FUNCTION public.rpc_admin_get_role_list(
p_search TEXT DEFAULT NULL,
p_page INTEGER DEFAULT 1,
p_page_size INTEGER DEFAULT 20
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_offset INTEGER := (p_page - 1) * p_page_size;
v_total BIGINT;
v_items JSONB;
BEGIN
-- 1. 权限检查
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 获取总数
SELECT COUNT(*) INTO v_total
FROM public.ak_roles
WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%' OR code ILIKE '%' || p_search || '%');
-- 3. 获取明细
SELECT jsonb_agg(t) INTO v_items
FROM (
SELECT
id, name, code, description, is_active,
created_at, updated_at
FROM public.ak_roles
WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%' OR code 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;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_role_list(TEXT, INTEGER, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_role_list(TEXT, INTEGER, INTEGER) TO authenticated;

View File

@@ -0,0 +1,69 @@
-- RPC: rpc_admin_save_permission
-- 管理端新增或更新功能权限/菜单
CREATE OR REPLACE FUNCTION public.rpc_admin_save_permission(
p_id UUID DEFAULT NULL,
p_parent_id UUID DEFAULT NULL,
p_name TEXT DEFAULT NULL,
p_code TEXT DEFAULT NULL,
p_type TEXT DEFAULT 'menu',
p_path TEXT DEFAULT NULL,
p_icon TEXT DEFAULT NULL,
p_sort_order INTEGER DEFAULT 0,
p_is_visible BOOLEAN DEFAULT TRUE
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
-- 1. 权限检查
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 新增
IF p_id IS NULL THEN
IF p_name IS NULL OR p_code IS NULL THEN
RAISE EXCEPTION 'Missing required fields: name or code';
END IF;
INSERT INTO public.ak_permissions (
parent_id, name, code, type, path, icon, sort_order, is_visible
) VALUES (
p_parent_id, p_name, p_code, p_type, p_path, p_icon, p_sort_order, p_is_visible
) RETURNING id INTO v_id;
ELSE
-- 3. 更新
UPDATE public.ak_permissions
SET
parent_id = COALESCE(p_parent_id, parent_id),
name = COALESCE(p_name, name),
code = COALESCE(p_code, code),
type = COALESCE(p_type, type),
path = COALESCE(p_path, path),
icon = COALESCE(p_icon, icon),
sort_order = COALESCE(p_sort_order, sort_order),
is_visible = COALESCE(p_is_visible, is_visible),
updated_at = now()
WHERE id = p_id
RETURNING id INTO v_id;
IF v_id IS NULL THEN
RAISE EXCEPTION 'Permission item not found';
END IF;
END IF;
RETURN v_id;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_save_permission(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER, BOOLEAN) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_save_permission(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER, BOOLEAN) TO authenticated;

View File

@@ -0,0 +1,61 @@
-- RPC: rpc_admin_save_role
-- 管理端新增或更新角色
CREATE OR REPLACE FUNCTION public.rpc_admin_save_role(
p_id UUID DEFAULT NULL,
p_name TEXT DEFAULT NULL,
p_code TEXT DEFAULT NULL,
p_description TEXT DEFAULT NULL,
p_is_active BOOLEAN DEFAULT TRUE
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
-- 1. 权限检查
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 新增
IF p_id IS NULL THEN
IF p_name IS NULL OR p_code IS NULL THEN
RAISE EXCEPTION 'Missing required fields: name or code';
END IF;
INSERT INTO public.ak_roles (
name, code, description, is_active
) VALUES (
p_name, p_code, p_description, p_is_active
) RETURNING id INTO v_id;
ELSE
-- 3. 更新
UPDATE public.ak_roles
SET
name = COALESCE(p_name, name),
code = COALESCE(p_code, code),
description = COALESCE(p_description, description),
is_active = COALESCE(p_is_active, is_active),
updated_at = now()
WHERE id = p_id
RETURNING id INTO v_id;
IF v_id IS NULL THEN
RAISE EXCEPTION 'Role not found';
END IF;
END IF;
RETURN v_id;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_save_role(UUID, TEXT, TEXT, TEXT, BOOLEAN) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_save_role(UUID, TEXT, TEXT, TEXT, BOOLEAN) TO authenticated;

View File

@@ -0,0 +1,33 @@
-- RPC: rpc_admin_delete_delivery_staff
-- 管理端删除配送员
CREATE OR REPLACE FUNCTION public.rpc_admin_delete_delivery_staff(
p_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_ok BOOLEAN;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 执行删除
DELETE FROM public.ml_delivery_staff WHERE id = p_id;
GET DIAGNOSTICS v_ok = ROW_COUNT;
RETURN v_ok;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_delete_delivery_staff(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_delivery_staff(UUID) TO authenticated;

View File

@@ -0,0 +1,33 @@
-- RPC: rpc_admin_delete_delivery_station
-- 管理端删除提货点/核销点
CREATE OR REPLACE FUNCTION public.rpc_admin_delete_delivery_station(
p_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_ok BOOLEAN;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 执行删除
DELETE FROM public.ml_delivery_stations WHERE id = p_id;
GET DIAGNOSTICS v_ok = ROW_COUNT;
RETURN v_ok;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_delete_delivery_station(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_delivery_station(UUID) TO authenticated;

View File

@@ -0,0 +1,58 @@
-- RPC: rpc_admin_get_delivery_staff_list
-- 管理端获取配送员分页列表
-- 支持按姓名或手机号搜索
CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_staff_list(
p_search TEXT DEFAULT NULL,
p_status SMALLINT DEFAULT NULL,
p_page INTEGER DEFAULT 1,
p_page_size INTEGER DEFAULT 20
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_offset INTEGER := (p_page - 1) * p_page_size;
v_total BIGINT;
v_items JSONB;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 获取总数
SELECT COUNT(*) INTO v_total
FROM public.ml_delivery_staff
WHERE (p_status IS NULL OR status = p_status)
AND (p_search IS NULL OR p_search = '' OR nickname ILIKE '%' || p_search || '%' OR phone ILIKE '%' || p_search || '%');
-- 3. 获取明细
SELECT jsonb_agg(t) INTO v_items
FROM (
SELECT
id, uid, nickname, avatar, phone, status, is_active,
created_at, updated_at
FROM public.ml_delivery_staff
WHERE (p_status IS NULL OR status = p_status)
AND (p_search IS NULL OR p_search = '' OR nickname ILIKE '%' || p_search || '%' OR phone ILIKE '%' || p_search || '%')
ORDER BY created_at DESC
LIMIT p_page_size OFFSET v_offset
) t;
-- 4. 返回结果
RETURN jsonb_build_object(
'total', v_total,
'items', COALESCE(v_items, '[]'::jsonb)
);
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_staff_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;

View File

@@ -0,0 +1,65 @@
-- RPC: rpc_admin_get_delivery_station_list
-- 管理端获取提货点/核销点分页列表
-- 支持按名称、地址或手机号搜索
CREATE OR REPLACE FUNCTION public.rpc_admin_get_delivery_station_list(
p_search TEXT DEFAULT NULL,
p_status SMALLINT DEFAULT NULL,
p_page INTEGER DEFAULT 1,
p_page_size INTEGER DEFAULT 20
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_offset INTEGER := (p_page - 1) * p_page_size;
v_total BIGINT;
v_items JSONB;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 获取总数
SELECT COUNT(*) INTO v_total
FROM public.ml_delivery_stations
WHERE (p_status IS NULL OR status = p_status)
AND (p_search IS NULL OR p_search = ''
OR name ILIKE '%' || p_search || '%'
OR address ILIKE '%' || p_search || '%'
OR phone ILIKE '%' || p_search || '%');
-- 3. 获取明细
SELECT jsonb_agg(t) INTO v_items
FROM (
SELECT
id, name, phone, address, image,
lng, lat, status, sort_order, business_hours,
created_at, updated_at
FROM public.ml_delivery_stations
WHERE (p_status IS NULL OR status = p_status)
AND (p_search IS NULL OR p_search = ''
OR name ILIKE '%' || p_search || '%'
OR address ILIKE '%' || p_search || '%'
OR phone ILIKE '%' || p_search || '%')
ORDER BY sort_order ASC, created_at DESC
LIMIT p_page_size OFFSET v_offset
) t;
-- 4. 返回结果
RETURN jsonb_build_object(
'total', v_total,
'items', COALESCE(v_items, '[]'::jsonb)
);
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_get_delivery_station_list(TEXT, SMALLINT, INTEGER, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_delivery_station_list(TEXT, SMALLINT, INTEGER, INTEGER) TO authenticated;

View File

@@ -0,0 +1,61 @@
-- RPC: rpc_admin_save_delivery_staff
-- 管理端新增或更新配送员信息
CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_staff(
p_id UUID DEFAULT NULL,
p_nickname TEXT DEFAULT NULL,
p_avatar TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_status SMALLINT DEFAULT 1
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 参数校验
IF p_nickname IS NULL OR p_phone IS NULL THEN
RAISE EXCEPTION 'Missing required fields: nickname or phone';
END IF;
-- 3. 新增或更新
IF p_id IS NULL THEN
INSERT INTO public.ml_delivery_staff (
nickname, avatar, phone, status
) VALUES (
p_nickname, p_avatar, p_phone, p_status
) RETURNING id INTO v_id;
ELSE
UPDATE public.ml_delivery_staff
SET
nickname = COALESCE(p_nickname, nickname),
avatar = COALESCE(p_avatar, avatar),
phone = COALESCE(p_phone, phone),
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 'Delivery staff not found';
END IF;
END IF;
RETURN v_id;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_staff(UUID, TEXT, TEXT, TEXT, SMALLINT) TO authenticated;

View File

@@ -0,0 +1,71 @@
-- RPC: rpc_admin_save_delivery_station
-- 管理端新增或更新提货点/核销点信息
CREATE OR REPLACE FUNCTION public.rpc_admin_save_delivery_station(
p_id UUID DEFAULT NULL,
p_name TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_address TEXT DEFAULT NULL,
p_image TEXT DEFAULT NULL,
p_lng NUMERIC DEFAULT NULL,
p_lat NUMERIC DEFAULT NULL,
p_status SMALLINT DEFAULT 1,
p_sort_order INTEGER DEFAULT 0,
p_business_hours JSONB DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
-- 1. 权限检查 (仅管理员)
IF NOT EXISTS (
SELECT 1 FROM public.ak_users
WHERE auth_id = auth.uid() AND role = 'admin'
) THEN
RAISE EXCEPTION 'Permission denied';
END IF;
-- 2. 参数校验
IF p_name IS NULL OR p_phone IS NULL OR p_address IS NULL THEN
RAISE EXCEPTION 'Missing required fields: name, phone or address';
END IF;
-- 3. 新增或更新
IF p_id IS NULL THEN
INSERT INTO public.ml_delivery_stations (
name, phone, address, image, lng, lat, status, sort_order, business_hours
) VALUES (
p_name, p_phone, p_address, p_image, p_lng, p_lat, p_status, p_sort_order, p_business_hours
) RETURNING id INTO v_id;
ELSE
UPDATE public.ml_delivery_stations
SET
name = COALESCE(p_name, name),
phone = COALESCE(p_phone, phone),
address = COALESCE(p_address, address),
image = COALESCE(p_image, image),
lng = COALESCE(p_lng, lng),
lat = COALESCE(p_lat, lat),
status = COALESCE(p_status, status),
sort_order = COALESCE(p_sort_order, sort_order),
business_hours = COALESCE(p_business_hours, business_hours),
updated_at = now()
WHERE id = p_id
RETURNING id INTO v_id;
IF v_id IS NULL THEN
RAISE EXCEPTION 'Station not found';
END IF;
END IF;
RETURN v_id;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION public.rpc_admin_save_delivery_station(UUID, TEXT, TEXT, TEXT, TEXT, NUMERIC, NUMERIC, SMALLINT, INTEGER, JSONB) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_admin_save_delivery_station(UUID, TEXT, TEXT, TEXT, TEXT, NUMERIC, NUMERIC, SMALLINT, INTEGER, JSONB) TO authenticated;

View File

@@ -8,17 +8,17 @@
<view class="config-form"> <view class="config-form">
<view class="form-item"> <view class="form-item">
<text class="label">APPID</text> <text class="label">APPID</text>
<input class="form-input" value="wx277a269f3d736d67" /> <input class="form-input" v-model="formData.appid" placeholder="微信开放平台申请移动应用后给予的APPID" />
<text class="tip">微信开放平台申请移动应用后给予的APPID</text> <text class="tip">微信开放平台申请移动应用后给予的APPID</text>
</view> </view>
<view class="form-item"> <view class="form-item">
<text class="label">AppSecret</text> <text class="label">AppSecret</text>
<input class="form-input" value="bd08741a055c2ecac5826ff1c048464b" /> <input class="form-input" v-model="formData.appsecret" placeholder="微信开放平台申请移动应用后给予的AppSecret" />
<text class="tip">微信开放平台申请移动应用后给予的AppSecret</text> <text class="tip">微信开放平台申请移动应用后给予的AppSecret</text>
</view> </view>
<view class="form-btns"> <view class="form-btns">
<button class="btn primary">提交</button> <button class="btn primary" @click="handleSubmit">提交</button>
</view> </view>
</view> </view>
</view> </view>
@@ -26,7 +26,40 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
// APP配置逻辑 import { ref, reactive, onMounted } from 'vue'
import { getSystemConfig, saveSystemConfig } from "@/services/admin/systemConfigService.uts"
const formData = reactive({
appid: '',
appsecret: ''
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
const res = await getSystemConfig('mobile_app_config')
if (res != null) {
Object.assign(formData, res as any)
}
}
async function handleSubmit() {
uni.showLoading({ title: '正在保存...' })
try {
const ok = await saveSystemConfig('mobile_app_config', formData as UTSJSONObject, '移动应用(APP)配置')
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
uni.hideLoading()
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,4 +1,3 @@
<template> <template>
<view class="admin-page"> <view class="admin-page">
<view class="admin-card content-card"> <view class="admin-card content-card">
@@ -25,7 +24,7 @@
<view class="form-item row"> <view class="form-item row">
<text class="label">联系电话:</text> <text class="label">联系电话:</text>
<view class="form-content"> <view class="form-content">
<input class="form-input" v-model="config.phone" placeholder="400-8888-794" /> <input class="form-input" v-model="config.phone" placeholder="请输入联系电话" />
<text class="tip">PC底部显示的联系电话</text> <text class="tip">PC底部显示的联系电话</text>
</view> </view>
</view> </view>
@@ -33,7 +32,7 @@
<view class="form-item row"> <view class="form-item row">
<text class="label">公司地址:</text> <text class="label">公司地址:</text>
<view class="form-content"> <view class="form-content">
<input class="form-input" v-model="config.address" placeholder="陕西省西安市..." /> <input class="form-input" v-model="config.address" placeholder="请输入公司地址" />
<text class="tip">PC底部显示的公司地址</text> <text class="tip">PC底部显示的公司地址</text>
</view> </view>
</view> </view>
@@ -96,12 +95,13 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { getSystemConfig, saveSystemConfig } from "@/services/admin/systemConfigService.uts"
const config = ref({ const config = reactive({
logo: 'https://v5.crmeb.net/uploads/attach/2022/05/20220516/6198f7e6f8a8b.png', logo: '',
phone: '400-8888-794', phone: '',
address: '陕西省西安市西咸新区洋东新城能源金融贸易区金湾大厦3层', address: '',
keywords: '', keywords: '',
description: '', description: '',
qrCodeType: 'routine', qrCodeType: 'routine',
@@ -109,17 +109,40 @@ const config = ref({
newProductCount: 5 newProductCount: 5
}) })
onMounted(() => {
loadConfig()
})
async function loadConfig() {
const res = await getSystemConfig('pc_site_config')
if (res != null) {
Object.assign(config, res as any)
}
}
const handleUploadLogo = () => { const handleUploadLogo = () => {
uni.chooseImage({ uni.chooseImage({
count: 1, count: 1,
success: (res) => { success: (res) => {
config.value.logo = res.tempFilePaths[0] config.logo = res.tempFilePaths[0]
} }
}) })
} }
const handleSubmit = () => { const handleSubmit = async () => {
uni.showToast({ title: '保存成功', icon: 'success' }) uni.showLoading({ title: '正在保存...' })
try {
const ok = await saveSystemConfig('pc_site_config', config as UTSJSONObject, 'PC站点配置')
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
uni.hideLoading()
}
} }
</script> </script>
@@ -308,4 +331,3 @@ const handleSubmit = () => {
border: none; border: none;
} }
</style> </style>

View File

@@ -13,7 +13,7 @@
</view> </view>
<view class="table-body"> <view class="table-body">
<view class="table-row"> <view class="table-row">
<view class="col col-title"><text>ZC2884891</text></view> <view class="col col-title"><text>{{ info?.auth_id || '检测中...' }}</text></view>
<view class="col col-action"> <view class="col col-action">
<text class="action-btn" @click="gotoOfficial">进入官网</text> <text class="action-btn" @click="gotoOfficial">进入官网</text>
</view> </view>
@@ -22,29 +22,6 @@
</view> </view>
</view> </view>
<!-- 自定义版权信息 -->
<view class="admin-card info-section mt-24">
<view class="section-header">
<text class="section-title">自定义版权信息</text>
</view>
<view class="table-container header-table">
<view class="table-header">
<view class="col col-text"><text>文字版权信息</text></view>
<view class="col col-image"><text>底部版权图片</text></view>
<view class="col col-action"><text>操作</text></view>
</view>
<view class="table-body">
<view class="table-row">
<view class="col col-text"><text></text></view>
<view class="col col-image"><text></text></view>
<view class="col col-action">
<text class="action-btn" @click="editCopyright">编辑</text>
</view>
</view>
</view>
</view>
</view>
<!-- 服务器信息 --> <!-- 服务器信息 -->
<view class="admin-card info-section mt-24"> <view class="admin-card info-section mt-24">
<view class="section-header"> <view class="section-header">
@@ -60,53 +37,71 @@
<view class="table-row"> <view class="table-row">
<view class="col col-env"><text>服务器系统</text></view> <view class="col col-env"><text>服务器系统</text></view>
<view class="col col-req"><text>类UNIX</text></view> <view class="col col-req"><text>类UNIX</text></view>
<view class="col col-status"><text>Linux</text></view> <view class="col col-status"><text>{{ info?.server_os || 'Loading...' }}</text></view>
</view> </view>
<view class="table-row"> <view class="table-row">
<view class="col col-env"><text>WEB环境</text></view> <view class="col col-env"><text>WEB环境</text></view>
<view class="col col-req"><text>Apache/Nginx/IIS</text></view> <view class="col col-req"><text>Apache/Nginx/IIS</text></view>
<view class="col col-status"><text>nginx/1.24.0</text></view> <view class="col col-status"><text>{{ info?.web_server || 'Loading...' }}</text></view>
</view>
</view>
</view>
</view>
<!-- 系统环境要求 -->
<view class="admin-card info-section mt-24">
<view class="section-header">
<text class="section-title">系统环境要求</text>
</view>
<view class="table-container header-table">
<view class="table-header">
<view class="col col-env"><text>环境</text></view>
<view class="col col-req"><text>要求</text></view>
<view class="col col-status"><text>状态</text></view>
</view>
<view class="table-body">
<view class="table-row">
<view class="col col-env"><text>PHP版本</text></view>
<view class="col col-req"><text>7.1-7.4</text></view>
<view class="col col-status"><text>7.4.33</text></view>
</view> </view>
<view class="table-row"> <view class="table-row">
<view class="col col-env"><text>MySQL版本</text></view> <view class="col col-env"><text>数据库引擎</text></view>
<view class="col col-req"><text>5.6-8.0</text></view> <view class="col col-req"><text>PostgreSQL</text></view>
<view class="col col-status"><text>8.0.35</text></view> <view class="col col-status"><text>{{ info?.db_engine || 'Loading...' }}</text></view>
</view>
<view class="table-row">
<view class="col col-env"><text>数据库版本</text></view>
<view class="col col-req"><text>15.0+</text></view>
<view class="col col-status"><text>{{ info?.db_version || 'Loading...' }}</text></view>
</view>
<view class="table-row">
<view class="col col-env"><text>运行环境</text></view>
<view class="col col-req"><text>UTS Runtime</text></view>
<view class="col col-status"><text>{{ info?.uts_runtime || 'Loading...' }}</text></view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view v-if="isLoading" class="loading-overlay">
<text>系统环境加载中...</text>
</view>
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
function gotoOfficial() { import { ref, onMounted } from 'vue'
uni.showToast({ title: '正在打开官网...', icon: 'none' }) import { fetchSystemInfo, SystemInfo } from '@/services/admin/maintainService.uts'
const info = ref<SystemInfo | null>(null)
const isLoading = ref(false)
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchSystemInfo()
if (res != null) {
info.value = res
}
} catch (e) {
uni.showToast({ title: '获取系统信息失败', icon: 'none' })
} finally {
isLoading.value = false
}
} }
function editCopyright() { function gotoOfficial() {
uni.showToast({ title: '编辑版权信息', icon: 'none' }) // #ifdef H5
window.open('https://www.crmeb.com', '_blank')
// #endif
// #ifndef H5
uni.showToast({ title: '请在浏览器中访问官网', icon: 'none' })
// #endif
} }
</script> </script>
@@ -158,13 +153,10 @@ function editCopyright() {
color: #333; color: #333;
} }
/* 各表格占位列宽 */
.col-title { flex: 1; } .col-title { flex: 1; }
.col-text { flex: 1; }
.col-image { flex: 1; }
.col-env { flex: 1; } .col-env { flex: 1; }
.col-req { flex: 1; } .col-req { flex: 1; }
.col-status { flex: 1; } .col-status { flex: 1; font-family: monospace; }
.col-action { width: 150px; justify-content: flex-end; } .col-action { width: 150px; justify-content: flex-end; }
.action-btn { .action-btn {
@@ -175,4 +167,14 @@ function editCopyright() {
.mt-24 { .mt-24 {
margin-top: 24px; margin-top: 24px;
} }
.loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(255,255,255,0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
</style> </style>

View File

@@ -5,15 +5,13 @@
<view class="search-wrap"> <view class="search-wrap">
<view class="search-item"> <view class="search-item">
<text class="label">状态:</text> <text class="label">状态:</text>
<picker mode="selector" :range="statusRange" @change="onStatusChange"> <uni-data-select v-model="statusValue" :localdata="statusOptions" style="width: 120px;" @change="handleQuery" />
<view class="picker-input">{{ statusText }}</view>
</picker>
</view> </view>
<view class="search-item"> <view class="search-item">
<text class="label">搜索:</text> <text class="label">搜索:</text>
<input class="input" placeholder="请输入姓名或者账号" v-model="searchKey" /> <input class="input" placeholder="请输入姓名或者账号" v-model="searchKey" @confirm="handleQuery" />
</view> </view>
<button class="btn btn-primary" @click="onSearch">查询</button> <button class="btn btn-primary" @click="handleQuery">查询</button>
</view> </view>
<view class="action-wrap"> <view class="action-wrap">
@@ -21,7 +19,7 @@
</view> </view>
<!-- 表格区域 --> <!-- 表格区域 -->
<view class="table-wrap"> <view class="table-wrap border-shadow">
<view class="table-header"> <view class="table-header">
<view class="th" style="flex: 2;">姓名</view> <view class="th" style="flex: 2;">姓名</view>
<view class="th" style="flex: 2;">账号</view> <view class="th" style="flex: 2;">账号</view>
@@ -32,9 +30,42 @@
<view class="th" style="flex: 2;">操作</view> <view class="th" style="flex: 2;">操作</view>
</view> </view>
<view class="table-body"> <view class="table-body">
<view class="no-data"> <view v-if="loading" class="loading-box">
<text class="no-data-text">暂无数据</text> <text>加载中...</text>
</view> </view>
<view v-else-if="adminList.length === 0" class="no-data">
<text class="no-data-text">暂无管理员数据</text>
</view>
<view v-else v-for="item in adminList" :key="item.id" class="tr">
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.real_name || '-' }}</text></view>
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.username }}</text></view>
<view class="td" style="flex: 2;">
<view class="role-tags">
<text v-for="role in (item.roles || [])" :key="role" class="role-tag">{{ role }}</text>
<text v-if="!item.roles || item.roles.length === 0" class="td-txt">-</text>
</view>
</view>
<view class="td" style="flex: 3;"><text class="td-txt-small">{{ formatTime(item.last_login_at) }}</text></view>
<view class="td" style="flex: 3;"><text class="td-txt-small">{{ item.last_login_ip || '-' }}</text></view>
<view class="td" style="flex: 1;">
<switch :checked="item.is_active" color="#1890ff" scale="0.7" disabled />
</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<view class="divider"></view>
<text class="action-btn danger">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', adminList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view> </view>
</view> </view>
</view> </view>
@@ -42,136 +73,101 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { fetchAdminPage, type AdminUser } from '@/services/admin/authService.uts'
const adminList = ref<AdminUser[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
const searchKey = ref('') const searchKey = ref('')
const statusRange = ['所有', '启用', '禁用'] const statusValue = ref('all')
const statusIndex = ref(0)
const statusText = ref('请选择')
function onStatusChange(e: any) { const statusOptions = [
statusIndex.value = parseInt(e.detail.value.toString()) { value: 'all', text: '所有' },
statusText.value = statusRange[statusIndex.value] { value: '1', text: '启用' },
{ value: '0', text: '禁用' }
]
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const status = statusValue.value === 'all' ? null : parseInt(statusValue.value)
const res = await fetchAdminPage(page.value, pageSize, searchKey.value || null, status)
adminList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
} }
function onSearch() { function handleQuery() {
console.log('Search:', searchKey.value, statusText.value) page.value = 1
loadData()
} }
function onAdd() { function onAdd() {
console.log('Add admin') uni.showToast({ title: '添加管理员功能开发中', icon: 'none' })
}
function onEdit(item : AdminUser) {
console.log('Edit admin:', item.id)
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (adminList.value.length >= pageSize) { page.value++; loadData(); } }
function formatTime(iso : string | null) : string {
if (!iso) return '-'
return iso.substring(0, 16).replace('T', ' ')
} }
</script> </script>
<style scoped> <style scoped lang="scss">
.admin-page-container { .admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
padding: 15px; .page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
background-color: #f5f7f9;
min-height: 100vh;
}
.page-card { .search-wrap { display: flex; flex-direction: row; align-items: center; padding-bottom: 24px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; }
background-color: #fff; .search-item { display: flex; flex-direction: row; align-items: center; margin-right: 24px; }
border-radius: 4px; .label { font-size: 14px; color: #606266; margin-right: 8px; }
padding: 20px; .input { width: 200px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
}
.search-wrap { .btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; }
display: flex; .btn-primary { background-color: #1890ff; color: #fff; font-size: 14px; }
flex-direction: row;
align-items: center;
flex-wrap: wrap;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.search-item { .action-wrap { margin-bottom: 20px; }
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20px;
margin-bottom: 10px;
}
.label { .table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
font-size: 14px; .table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
color: #606266; .th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
margin-right: 8px; .tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 54px; align-items: center; }
} .td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.td-txt-small { font-size: 12px; color: #999; }
.picker-input { .role-tags { display: flex; flex-direction: row; flex-wrap: wrap; gap: 4px; justify-content: center; }
width: 150px; .role-tag { background-color: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; padding: 1px 6px; border-radius: 2px; font-size: 11px; }
height: 32px;
line-height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
background-color: #fff;
}
.input { .action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
width: 220px; .danger { color: #ff4d4f; }
height: 32px; .divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.btn { .no-data { padding: 60px 0; text-align: center; }
height: 32px; .no-data-text { font-size: 14px; color: #c5c8ce; }
line-height: 30px; .loading-box { padding: 60px 0; text-align: center; color: #1890ff; }
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
margin-bottom: 10px;
}
.btn-primary { .pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
background-color: #1890ff; .total-txt { font-size: 13px; color: #999; }
color: #fff; .page-btns { display: flex; flex-direction: row; gap: 8px; }
} .p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
.p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.action-wrap { .p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
margin-bottom: 20px;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.table-body {
min-height: 100px;
}
.no-data {
padding: 40px 0;
text-align: center;
}
.no-data-text {
font-size: 14px;
color: #c5c8ce;
}
</style> </style>

View File

@@ -1,188 +1,263 @@
<template> <template>
<view class="admin-page-container"> <view class="admin-page-container">
<view class="page-card"> <view class="page-card">
<!-- 搜索栏 --> <!-- 顶部操作栏 -->
<view class="search-wrap"> <view class="action-wrap">
<view class="search-item"> <button class="btn btn-primary" @click="onAdd(null)">添加顶级权限</button>
<text class="label">按钮名称:</text> <button class="btn btn-ghost ml-10" @click="loadData">刷新</button>
<input class="input" placeholder="请输入按钮名称" v-model="searchKey" />
</view>
<button class="btn btn-primary" @click="onSearch">查询</button>
</view> </view>
<!-- 表格区域 --> <!-- 树形表格区域 -->
<view class="table-wrap"> <view class="table-wrap border-shadow">
<view class="table-header"> <view class="table-header">
<view class="th" style="flex: 4; text-align: left; padding-left: 20px;">按钮名称</view> <view class="th" style="flex: 4; text-align: left; padding-left: 20px;">菜单/按钮名称</view>
<view class="th" style="flex: 3;">类型</view> <view class="th" style="flex: 2;">编码</view>
<view class="th" style="flex: 2;">排序</view> <view class="th" style="flex: 2;">类型</view>
<view class="th" style="flex: 2;">是否显示</view> <view class="th" style="flex: 1;">排序</view>
<view class="th" style="flex: 2;">操作</view> <view class="th" style="flex: 1;">显示</view>
<view class="th" style="flex: 3;">操作</view>
</view> </view>
<view class="table-body"> <view class="table-body">
<view v-for="item in permissionList" :key="item.id" class="tr"> <view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="permissionList.length === 0" class="no-data">
<text class="no-data-text">暂无数据</text>
</view>
<!-- 递归渲染或平铺渲染 (这里采用平铺+缩进模拟树形) -->
<view v-else v-for="item in permissionList" :key="item.id" class="tr">
<view class="td" style="flex: 4; text-align: left; padding-left: 20px;"> <view class="td" style="flex: 4; text-align: left; padding-left: 20px;">
<text v-if="item.hasChildren" class="expand-icon">▶</text>
<text class="menu-name">{{ item.name }}</text> <text class="menu-name">{{ item.name }}</text>
</view> </view>
<view class="td" style="flex: 3;">{{ item.type }}</view> <view class="td" style="flex: 2;"><text class="td-txt-small">{{ item.code }}</text></view>
<view class="td" style="flex: 2;">{{ item.sort }}</view>
<view class="td" style="flex: 2;"> <view class="td" style="flex: 2;">
<switch :checked="item.isshow" color="#1890ff" @change="onToggleShow(item)" /> <text :class="['type-tag', item.type === 'menu' ? 'menu' : 'button']">
{{ item.type === 'menu' ? '菜单' : '按钮' }}
</text>
</view> </view>
<view class="td" style="flex: 2;"> <view class="td" style="flex: 1;"><text class="td-txt">{{ item.sort_order }}</text></view>
<text class="action-btn" @click="onEdit(item)">编辑</text> <view class="td" style="flex: 1;">
<switch :checked="item.is_visible" color="#1890ff" scale="0.6" @change="onToggleVisible(item)" />
</view>
<view class="td" style="flex: 3;">
<view class="op-links">
<text class="action-btn" @click="onAdd(item.id)">新增子项</text>
<text class="op-split">|</text>
<text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="op-split">|</text>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑权限' : '添加权限' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<scroll-view scroll-y="true" style="max-height: 500px;">
<view class="form-item">
<text class="f-label">父级ID</text>
<input class="f-input disabled" :value="form.parent_id || '顶级'" disabled />
</view>
<view class="form-item">
<text class="f-label">名称:</text>
<input class="f-input" v-model="form.name" placeholder="请输入菜单或按钮名称" />
</view>
<view class="form-item">
<text class="f-label">编码:</text>
<input class="f-input" v-model="form.code" placeholder="如: user_view" />
</view>
<view class="form-item">
<text class="f-label">类型:</text>
<radio-group class="radio-group" @change="(e : any) => form.type = e.detail.value">
<label class="radio-label"><radio value="menu" :checked="form.type === 'menu'" color="#1890ff" /> 菜单</label>
<label class="radio-label"><radio value="button" :checked="form.type === 'button'" color="#1890ff" /> 按钮</label>
</radio-group>
</view>
<view class="form-item" v-if="form.type === 'menu'">
<text class="f-label">路由路径:</text>
<input class="f-input" v-model="form.path" placeholder="请输入前端路由地址" />
</view>
<view class="form-item">
<text class="f-label">排序:</text>
<input class="f-input" type="number" v-model="form.sort_order" />
</view>
</scroll-view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">提交</button>
</view>
</view>
</view>
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { fetchPermissionList, savePermission, deletePermission, type AdminPermission } from '@/services/admin/authService.uts'
const searchKey = ref('') const permissionList = ref<AdminPermission[]>([])
const loading = ref(false)
const showModal = ref(false)
const isEdit = ref(false)
type PermissionItem = { const form = reactive({
id: number id: '' as string | null,
name: string parent_id: '' as string | null,
type: string name: '',
sort: number code: '',
isshow: boolean type: 'menu',
hasChildren: boolean path: '',
icon: '',
sort_order: 0,
is_visible: true
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchPermissionList()
permissionList.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
} }
const permissionList = reactive<PermissionItem[]>([ function onAdd(parentId : string | null) {
{ id: 1, name: '主页', type: '菜单:/admin/index', sort: 127, isshow: true, hasChildren: false }, isEdit.value = false
{ id: 2, name: '用户', type: '菜单:/admin/user', sort: 125, isshow: true, hasChildren: true }, form.id = null
{ id: 3, name: '订单', type: '菜单:/admin/order', sort: 120, isshow: true, hasChildren: true }, form.parent_id = parentId
{ id: 4, name: '商品', type: '菜单:/admin/product', sort: 115, isshow: true, hasChildren: true }, form.name = ''
{ id: 5, name: '营销', type: '菜单:/admin/marketing', sort: 110, isshow: true, hasChildren: true } form.code = ''
]) form.type = 'menu'
form.path = ''
function onSearch() { form.sort_order = 0
console.log('Search:', searchKey.value) form.is_visible = true
showModal.value = true
} }
function onToggleShow(item: PermissionItem) { function onEdit(item : AdminPermission) {
item.isshow = !item.isshow isEdit.value = true
form.id = item.id
form.parent_id = item.parent_id
form.name = item.name
form.code = item.code
form.type = item.type
form.path = item.path || ''
form.sort_order = item.sort_order
form.is_visible = item.is_visible
showModal.value = true
} }
function onEdit(item: PermissionItem) { async function onToggleVisible(item : AdminPermission) {
console.log('Edit:', item.name) const nextVal = !item.is_visible
const ok = await savePermission({ ...item, is_visible: nextVal })
if (ok != null) {
item.is_visible = nextVal
uni.showToast({ title: '显示状态已更新' })
}
}
async function handleSave() {
if (!form.name || !form.code) {
uni.showToast({ title: '请填写必填项', icon: 'none' })
return
}
loading.value = true
try {
const resId = await savePermission(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
showModal.value = false
loadData()
}
} finally {
loading.value = false
}
}
async function onDelete(item : AdminPermission) {
uni.showModal({
title: '确认删除',
content: `确定要删除权限项 "${item.name}" 吗?此操作不可撤销。`,
success: async (res) => {
if (res.confirm) {
const ok = await deletePermission(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
function closeModal() {
showModal.value = false
} }
</script> </script>
<style scoped> <style scoped lang="scss">
.admin-page-container { .admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
padding: 15px; .page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
background-color: #f5f7f9;
min-height: 100vh;
}
.page-card { .action-wrap { margin-bottom: 24px; display: flex; flex-direction: row; }
background-color: #fff; .btn { height: 32px; padding: 0 16px; font-size: 14px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; }
border-radius: 4px; .btn-primary { background-color: #1890ff; color: #fff; }
padding: 20px; .btn-ghost { background-color: #fff; color: #666; border: 1px solid #dcdfe6; }
} .ml-10 { margin-left: 10px; }
.search-wrap { .table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
display: flex; .table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
flex-direction: row; .th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
align-items: center; .tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 50px; align-items: center; }
padding-bottom: 20px; .td { padding: 8px 10px; font-size: 13px; color: #606266; text-align: center; display: flex; align-items: center; justify-content: center; }
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.search-item { .menu-name { font-weight: 500; color: #333; }
display: flex; .td-txt-small { font-size: 12px; color: #999; }
flex-direction: row;
align-items: center;
margin-right: 20px;
}
.label { .type-tag { padding: 2px 8px; border-radius: 4px; font-size: 11px; }
font-size: 14px; .type-tag.menu { background-color: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
color: #606266; .type-tag.button { background-color: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
margin-right: 8px;
}
.input { .op-links { display: flex; flex-direction: row; align-items: center; gap: 8px; }
width: 200px; .action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
height: 32px; .danger { color: #ff4d4f; }
padding: 0 10px; .op-split { color: #eee; }
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.btn { .loading-box, .no-data { padding: 60px 0; text-align: center; width: 100%; }
height: 32px; .no-data-text { font-size: 14px; color: #ccc; }
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.btn-primary { /* Modal */
background-color: #1890ff; .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
color: #fff; .modal-card { width: 500px; background-color: #fff; border-radius: 8px; overflow: hidden; }
} .modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.table-wrap { .close-btn { font-size: 24px; color: #999; cursor: pointer; }
width: 100%; .modal-body { padding: 24px; }
border: 1px solid #f0f0f0; .form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
} .align-start { align-items: flex-start; }
.f-label { width: 90px; font-size: 14px; color: #666; text-align: right; margin-right: 15px; }
.table-header { .f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
display: flex; .f-input.disabled { background-color: #f5f5f5; color: #999; }
flex-direction: row; .radio-group { display: flex; flex-direction: row; gap: 20px; }
background-color: #f8f8f9; .radio-label { display: flex; flex-direction: row; align-items: center; font-size: 14px; }
} .modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.td {
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.expand-icon {
font-size: 12px;
color: #999;
margin-right: 5px;
}
.menu-name {
font-size: 14px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
cursor: pointer;
}
</style> </style>

View File

@@ -5,15 +5,13 @@
<view class="search-wrap"> <view class="search-wrap">
<view class="search-item"> <view class="search-item">
<text class="label">状态:</text> <text class="label">状态:</text>
<picker mode="selector" :range="statusRange" @change="onStatusChange"> <uni-data-select v-model="statusValue" :localdata="statusOptions" style="width: 120px;" @change="handleQuery" />
<view class="picker-input">{{ statusText }}</view>
</picker>
</view> </view>
<view class="search-item"> <view class="search-item">
<text class="label">身份昵称:</text> <text class="label">身份昵称:</text>
<input class="input" placeholder="请输入身份昵称" v-model="searchKey" /> <input class="input" placeholder="请输入身份昵称" v-model="searchKey" @confirm="handleQuery" />
</view> </view>
<button class="btn btn-primary" @click="onSearch">查询</button> <button class="btn btn-primary" @click="handleQuery">查询</button>
</view> </view>
<view class="action-wrap"> <view class="action-wrap">
@@ -21,17 +19,70 @@
</view> </view>
<!-- 表格区域 --> <!-- 表格区域 -->
<view class="table-wrap"> <view class="table-wrap border-shadow">
<view class="table-header"> <view class="table-header">
<view class="th" style="flex: 1;">ID</view> <view class="th" style="flex: 1;">序号</view>
<view class="th" style="flex: 3;">身份昵称</view> <view class="th" style="flex: 3;">身份昵称</view>
<view class="th" style="flex: 2;">状态</view> <view class="th" style="flex: 2;">状态</view>
<view class="th" style="flex: 2;">操作</view> <view class="th" style="flex: 2;">操作</view>
</view> </view>
<view class="table-body"> <view class="table-body">
<view class="no-data"> <view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="roleList.length === 0" class="no-data">
<text class="no-data-text">暂无数据</text> <text class="no-data-text">暂无数据</text>
</view> </view>
<view v-else v-for="(item, index) in roleList" :key="item.id" class="tr">
<view class="td" style="flex: 1;"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td" style="flex: 3;"><text class="td-txt">{{ item.name }} ({{ item.code }})</text></view>
<view class="td" style="flex: 2;">
<switch :checked="item.is_active" color="#1890ff" scale="0.7" @change="onToggleStatus(item)" />
</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<view class="divider"></view>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', roleList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑身份' : '添加身份' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="f-label">身份名称:</text>
<input class="f-input" v-model="form.name" placeholder="请输入身份名称" />
</view>
<view class="form-item">
<text class="f-label">身份编码:</text>
<input class="f-input" v-model="form.code" placeholder="如: super_admin" :disabled="isEdit" />
</view>
<view class="form-item">
<text class="f-label">描述:</text>
<textarea class="f-textarea" v-model="form.description" placeholder="请输入备注描述" />
</view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</view> </view>
</view> </view>
</view> </view>
@@ -39,136 +90,176 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { fetchRolePage, saveRole, deleteRole, type AdminRole } from '@/services/admin/authService.uts'
const roleList = ref<AdminRole[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
const searchKey = ref('') const searchKey = ref('')
const statusRange = ['所有', '启用', '禁用'] const statusValue = ref('all')
const statusIndex = ref(0)
const statusText = ref('请选择')
function onStatusChange(e: any) { const statusOptions = [
statusIndex.value = parseInt(e.detail.value.toString()) { value: 'all', text: '所有' },
statusText.value = statusRange[statusIndex.value] { value: '1', text: '启用' },
{ value: '0', text: '禁用' }
]
// 弹窗表单状态
const showModal = ref(false)
const isEdit = ref(false)
const form = reactive({
id: '' as string | null,
name: '',
code: '',
description: '',
is_active: true
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchRolePage(page.value, pageSize, searchKey.value || null)
roleList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
} }
function onSearch() { function handleQuery() {
console.log('Search:', searchKey.value, statusText.value) page.value = 1
loadData()
} }
function onAdd() { function onAdd() {
console.log('Add role') isEdit.value = false
form.id = null
form.name = ''
form.code = ''
form.description = ''
form.is_active = true
showModal.value = true
} }
function onEdit(item : AdminRole) {
isEdit.value = true
form.id = item.id
form.name = item.name
form.code = item.code
form.description = item.description || ''
form.is_active = item.is_active
showModal.value = true
}
function closeModal() {
showModal.value = false
}
async function handleSave() {
if (!form.name || !form.code) {
uni.showToast({ title: '请完善必要信息', icon: 'none' })
return
}
loading.value = true
try {
const resId = await saveRole(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} finally {
loading.value = false
}
}
async function onDelete(item : AdminRole) {
uni.showModal({
title: '提示',
content: `确定要删除角色 "${item.name}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteRole(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function onToggleStatus(item : AdminRole) {
const nextStatus = !item.is_active
const ok = await saveRole({ ...item, is_active: nextStatus })
if (ok != null) {
item.is_active = nextStatus
uni.showToast({ title: '状态已更新' })
}
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (roleList.value.length >= pageSize) { page.value++; loadData(); } }
</script> </script>
<style scoped> <style scoped lang="scss">
.admin-page-container { .admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
padding: 15px; .page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
background-color: #f5f7f9;
min-height: 100vh;
}
.page-card { .search-wrap { display: flex; flex-direction: row; align-items: center; padding-bottom: 24px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; }
background-color: #fff; .search-item { display: flex; flex-direction: row; align-items: center; margin-right: 24px; }
border-radius: 4px; .label { font-size: 14px; color: #606266; margin-right: 8px; }
padding: 20px; .input { width: 200px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
}
.search-wrap { .btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; }
display: flex; .btn-primary { background-color: #1890ff; color: #fff; font-size: 14px; }
flex-direction: row;
align-items: center;
flex-wrap: wrap;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.search-item { .action-wrap { margin-bottom: 20px; }
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20px;
margin-bottom: 10px;
}
.label { .table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
font-size: 14px; .table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
color: #606266; .th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
margin-right: 8px; .tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 54px; align-items: center; }
} .td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.picker-input { .action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
width: 150px; .danger { color: #ff4d4f; }
height: 32px; .divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
line-height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
background-color: #fff;
}
.input { .no-data { padding: 60px 0; text-align: center; }
width: 180px; .no-data-text { font-size: 14px; color: #c5c8ce; }
height: 32px; .loading-box { padding: 60px 0; text-align: center; color: #1890ff; }
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.btn { .pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
height: 32px; .total-txt { font-size: 13px; color: #999; }
line-height: 30px; .page-btns { display: flex; flex-direction: row; gap: 8px; }
padding: 0 15px; .p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
font-size: 14px; .p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
border-radius: 4px; .p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
border: none;
margin-bottom: 10px;
}
.btn-primary { /* 弹窗样式 */
background-color: #1890ff; .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
color: #fff; .modal-card { width: 500px; background-color: #fff; border-radius: 8px; overflow: hidden; }
} .modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.action-wrap { .close-btn { font-size: 24px; color: #999; cursor: pointer; }
margin-bottom: 20px; .modal-body { padding: 24px; }
} .form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: flex-start; }
.f-label { width: 90px; font-size: 14px; color: #666; text-align: right; padding-top: 6px; }
.table-wrap { .f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
width: 100%; .f-textarea { flex: 1; border: 1px solid #dcdfe6; height: 80px; padding: 8px 12px; border-radius: 4px; font-size: 14px; }
border: 1px solid #f0f0f0; .modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.table-body {
min-height: 100px;
}
.no-data {
padding: 40px 0;
text-align: center;
}
.no-data-text {
font-size: 14px;
color: #c5c8ce;
}
</style> </style>

View File

@@ -6,157 +6,253 @@
</view> </view>
<!-- 表格区域 --> <!-- 表格区域 -->
<view class="table-wrap"> <view class="table-wrap border-shadow">
<view class="table-header"> <view class="table-header">
<view class="th" style="flex: 1;">ID</view> <view class="th" style="flex: 1;">序号</view>
<view class="th" style="flex: 1.5;">头像</view> <view class="th" style="flex: 1.5;">头像</view>
<view class="th" style="flex: 2;">名称</view> <view class="th" style="flex: 2;">名称</view>
<view class="th" style="flex: 2.5;">手机号码</view> <view class="th" style="flex: 2.5;">手机号码</view>
<view class="th" style="flex: 1.5;">是否显示</view> <view class="th" style="flex: 1.5;">状态</view>
<view class="th" style="flex: 3;">添加时间</view> <view class="th" style="flex: 3;">添加时间</view>
<view class="th" style="flex: 2;">操作</view> <view class="th" style="flex: 2;">操作</view>
</view> </view>
<view class="table-body"> <view class="table-body">
<view v-for="item in courierList" :key="item.id" class="tr"> <view v-if="loading" class="loading-box">
<view class="td" style="flex: 1;">{{ item.id }}</view> <text>加载中...</text>
</view>
<view v-else-if="staffList.length === 0" class="no-data">
<text class="no-data-text">暂无配送员数据</text>
</view>
<view v-else v-for="(item, index) in staffList" :key="item.id" class="tr">
<view class="td" style="flex: 1;"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td" style="flex: 1.5;"> <view class="td" style="flex: 1.5;">
<image class="avatar" :src="item.avatar" mode="aspectFill" /> <image v-if="item.avatar" class="avatar" :src="item.avatar" mode="aspectFill" />
<view v-else class="avatar-placeholder"><text>👤</text></view>
</view> </view>
<view class="td" style="flex: 2;">{{ item.name }}</view> <view class="td" style="flex: 2;"><text class="td-txt">{{ item.nickname }}</text></view>
<view class="td" style="flex: 2.5;">{{ item.phone }}</view> <view class="td" style="flex: 2.5;"><text class="td-txt">{{ item.phone }}</text></view>
<view class="td" style="flex: 1.5;"> <view class="td" style="flex: 1.5;">
<switch :checked="item.isshow" color="#1890ff" @change="onToggleShow(item)" /> <switch :checked="item.status === 1" color="#1890ff" scale="0.7" @change="onToggleStatus(item)" />
</view> </view>
<view class="td" style="flex: 3;">{{ item.addTime }}</view> <view class="td" style="flex: 3;"><text class="td-txt-small">{{ formatTime(item.created_at) }}</text></view>
<view class="td" style="flex: 2;"> <view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text> <text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="action-btn-del" @click="onDel(item)">删除</text> <view class="divider"></view>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', staffList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑配送员' : '添加配送员' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="f-label">名称:</text>
<input class="f-input" v-model="form.nickname" placeholder="请输入名称" />
</view>
<view class="form-item">
<text class="f-label">手机号:</text>
<input class="f-input" v-model="form.phone" type="number" placeholder="请输入手机号" />
</view>
<view class="form-item">
<text class="f-label">头像:</text>
<view class="upload-placeholder" @click="handleUpload">
<image v-if="form.avatar" :src="form.avatar" mode="aspectFill" class="avatar-preview" />
<text v-else>+</text>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</view>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { fetchDeliveryStaffPage, saveDeliveryStaff, deleteDeliveryStaff, type DeliveryStaff } from '@/services/admin/deliveryService.uts'
type CourierItem = { const staffList = ref<DeliveryStaff[]>([])
id: number const loading = ref(false)
avatar: string const total = ref(0)
name: string const page = ref(1)
phone: string const pageSize = 15
isshow: boolean
addTime: string // Modal state
const showModal = ref(false)
const isEdit = ref(false)
const form = reactive({
id: '' as string | null,
nickname: '',
phone: '',
avatar: '',
status: 1
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchDeliveryStaffPage(page.value, pageSize)
staffList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
} }
const courierList = reactive<CourierItem[]>([
{ id: 106, avatar: '/static/logo.png', name: 'cheshi', phone: '18943652356', isshow: true, addTime: '2025-06-29 21:45:19' },
{ id: 105, avatar: '/static/logo.png', name: 'dl', phone: '15648569914', isshow: true, addTime: '2025-06-28 18:40:26' },
{ id: 102, avatar: '/static/logo.png', name: '小牛马', phone: '13548652258', isshow: true, addTime: '2025-06-26 15:14:40' }
])
function onAdd() { function onAdd() {
console.log('Add courier') isEdit.value = false
form.id = null
form.nickname = ''
form.phone = ''
form.avatar = ''
form.status = 1
showModal.value = true
} }
function onToggleShow(item: CourierItem) { function onEdit(item : DeliveryStaff) {
item.isshow = !item.isshow isEdit.value = true
form.id = item.id
form.nickname = item.nickname
form.phone = item.phone
form.avatar = item.avatar || ''
form.status = item.status
showModal.value = true
} }
function onEdit(item: CourierItem) { function closeModal() {
console.log('Edit:', item.name) showModal.value = false
} }
function onDel(item: CourierItem) { async function handleSave() {
console.log('Delete:', item.id) if (!form.nickname || !form.phone) {
uni.showToast({ title: '请填写姓名和手机号', icon: 'none' })
return
}
loading.value = true
try {
const resId = await saveDeliveryStaff(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
}
} finally {
loading.value = false
}
}
async function onDelete(item : DeliveryStaff) {
uni.showModal({
title: '提示',
content: `确定要删除配送员 "${item.nickname}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteDeliveryStaff(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function onToggleStatus(item : DeliveryStaff) {
const nextStatus = item.status === 1 ? 0 : 1
const ok = await saveDeliveryStaff({ ...item, status: nextStatus })
if (ok != null) {
item.status = nextStatus
uni.showToast({ title: '状态已更新' })
}
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (staffList.value.length >= pageSize) { page.value++; loadData(); } }
function formatTime(iso : string | null) : string {
if (!iso) return '-'
return iso.substring(0, 16).replace('T', ' ')
}
function handleUpload() {
uni.showToast({ title: '上传功能开发中', icon: 'none' })
} }
</script> </script>
<style scoped> <style scoped lang="scss">
.admin-page-container { .admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
padding: 15px; .page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
background-color: #f5f7f9;
min-height: 100vh;
}
.page-card { .action-wrap { margin-bottom: 24px; }
background-color: #fff; .btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; }
border-radius: 4px; .btn-primary { background-color: #1890ff; color: #fff; }
padding: 20px;
}
.action-wrap { .table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
margin-bottom: 20px; .table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
} .th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
.tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 60px; align-items: center; }
.td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.td-txt-small { font-size: 12px; color: #999; }
.btn { .avatar { width: 40px; height: 40px; border-radius: 4px; }
height: 32px; .avatar-placeholder { width: 40px; height: 40px; border-radius: 4px; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 20px; }
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.btn-primary { .action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
background-color: #1890ff; .danger { color: #ff4d4f; }
color: #fff; .divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
}
.table-wrap { .no-data, .loading-box { padding: 60px 0; text-align: center; width: 100%; }
width: 100%; .no-data-text { font-size: 14px; color: #ccc; }
border: 1px solid #f0f0f0;
}
.table-header { .pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
display: flex; .total-txt { font-size: 13px; color: #999; }
flex-direction: row; .page-btns { display: flex; flex-direction: row; gap: 8px; }
background-color: #f8f8f9; .p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
} .p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
.th { /* Modal */
padding: 12px 10px; .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
font-size: 14px; .modal-card { width: 450px; background-color: #fff; border-radius: 8px; overflow: hidden; }
font-weight: bold; .modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
color: #515a6e; .modal-title { font-size: 16px; font-weight: bold; color: #333; }
border-bottom: 1px solid #f0f0f0; .close-btn { font-size: 24px; color: #999; cursor: pointer; }
text-align: center; .modal-body { padding: 24px; }
} .form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
.f-label { width: 80px; font-size: 14px; color: #666; text-align: right; margin-right: 15px; }
.f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.tr { .upload-placeholder { width: 64px; height: 64px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #ccc; cursor: pointer; }
display: flex; .avatar-preview { width: 100%; height: 100%; border-radius: 4px; }
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.td { .modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
margin-right: 10px;
cursor: pointer;
}
.action-btn-del {
color: #ed4014;
font-size: 13px;
cursor: pointer;
}
</style> </style>

View File

@@ -5,16 +5,9 @@
<view class="search-wrap"> <view class="search-wrap">
<view class="search-item"> <view class="search-item">
<text class="label">提货点搜索:</text> <text class="label">提货点搜索:</text>
<input class="input" placeholder="请输入提货点名称,电话" v-model="searchKey" /> <input class="input" placeholder="请输入提货点名称,电话" v-model="searchKey" @confirm="handleQuery" />
</view> </view>
<button class="btn btn-primary" @click="onSearch">查询</button> <button class="btn btn-primary" @click="handleQuery">查询</button>
</view>
<!-- 状态 Tabs -->
<view class="tabs-wrap">
<view class="tab-item active"><text class="tab-text">显示中的提货点(2)</text></view>
<view class="tab-item"><text class="tab-text">隐藏中的提货点(0)</text></view>
<view class="tab-item"><text class="tab-text">回收站中的提货点(9)</text></view>
</view> </view>
<view class="action-wrap"> <view class="action-wrap">
@@ -22,209 +15,272 @@
</view> </view>
<!-- 表格区域 --> <!-- 表格区域 -->
<view class="table-wrap"> <view class="table-wrap border-shadow">
<view class="table-header"> <view class="table-header">
<view class="th" style="flex: 1;">ID</view> <view class="th" style="flex: 1;">序号</view>
<view class="th" style="flex: 2;">提货点图片</view> <view class="th" style="flex: 2;">提货点图片</view>
<view class="th" style="flex: 2;">提货点名称</view> <view class="th" style="flex: 2;">提货点名称</view>
<view class="th" style="flex: 2;">提货点电话</view> <view class="th" style="flex: 2;">提货点电话</view>
<view class="th" style="flex: 3;">地址</view> <view class="th" style="flex: 3;">地址</view>
<view class="th" style="flex: 3;">营业时间</view>
<view class="th" style="flex: 1.5;">是否显示</view> <view class="th" style="flex: 1.5;">是否显示</view>
<view class="th" style="flex: 2;">操作</view> <view class="th" style="flex: 2;">操作</view>
</view> </view>
<view class="table-body"> <view class="table-body">
<view v-for="item in stationList" :key="item.id" class="tr"> <view v-if="loading" class="loading-box">
<view class="td" style="flex: 1;">{{ item.id }}</view> <text>加载中...</text>
</view>
<view v-else-if="stationList.length === 0" class="no-data">
<text class="no-data-text">暂无提货点数据</text>
</view>
<view v-else v-for="(item, index) in stationList" :key="item.id" class="tr">
<view class="td" style="flex: 1;"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td" style="flex: 2;"> <view class="td" style="flex: 2;">
<image class="station-img" :src="item.image" mode="aspectFill" /> <image v-if="item.image" class="station-img" :src="item.image" mode="aspectFill" />
<view v-else class="img-placeholder"><text>🖼️</text></view>
</view> </view>
<view class="td" style="flex: 2;">{{ item.name }}</view> <view class="td" style="flex: 2;"><text class="td-txt">{{ item.name }}</text></view>
<view class="td" style="flex: 2;">{{ item.phone }}</view> <view class="td" style="flex: 2;"><text class="td-txt">{{ item.phone }}</text></view>
<view class="td" style="flex: 3;">{{ item.address }}</view> <view class="td" style="flex: 3;"><text class="td-txt ellipsis-2">{{ item.address }}</text></view>
<view class="td" style="flex: 3;">{{ item.hours }}</view>
<view class="td" style="flex: 1.5;"> <view class="td" style="flex: 1.5;">
<switch :checked="item.isshow" color="#1890ff" /> <switch :checked="item.status === 1" color="#1890ff" scale="0.7" @change="onToggleStatus(item)" />
</view> </view>
<view class="td" style="flex: 2;"> <view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text> <text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="action-btn-del" @click="onDel(item)">删除</text> <view class="divider"></view>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', stationList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑提货点' : '添加提货点' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<scroll-view scroll-y="true" style="max-height: 500px;">
<view class="form-item">
<text class="f-label">提货点名称:</text>
<input class="f-input" v-model="form.name" placeholder="请输入名称" />
</view>
<view class="form-item">
<text class="f-label">联系电话:</text>
<input class="f-input" v-model="form.phone" type="number" placeholder="请输入电话" />
</view>
<view class="form-item">
<text class="f-label">详细地址:</text>
<input class="f-input" v-model="form.address" placeholder="请输入详细地址" />
</view>
<view class="form-item">
<text class="f-label">展示图片:</text>
<view class="upload-placeholder" @click="handleUpload">
<image v-if="form.image" :src="form.image" mode="aspectFill" class="img-preview" />
<text v-else>+</text>
</view>
</view>
</scroll-view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</view>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { fetchDeliveryStationPage, saveDeliveryStation, deleteDeliveryStation, type DeliveryStation } from '@/services/admin/deliveryService.uts'
const stationList = ref<DeliveryStation[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
const searchKey = ref('') const searchKey = ref('')
type StationItem = { // Modal state
id: number const showModal = ref(false)
image: string const isEdit = ref(false)
name: string const form = reactive({
phone: string id: '' as string | null,
address: string name: '',
hours: string phone: '',
isshow: boolean address: '',
image: '',
status: 1,
sort_order: 0
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchDeliveryStationPage(page.value, pageSize, searchKey.value || null)
stationList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
} }
const stationList = reactive<StationItem[]>([ function handleQuery() {
{ id: 46, image: '/static/logo.png', name: '提货点222', phone: '13769102384', address: '能看见你的困难', hours: '00:00:00 - 23:00:00', isshow: true }, page.value = 1
{ id: 44, image: '/static/logo.png', name: '美东科技', phone: '15912341234', address: '襄阳火车站', hours: '08:00:00 - 22:00:00', isshow: true } loadData()
])
function onSearch() {
console.log('Search:', searchKey.value)
} }
function onAdd() { function onAdd() {
console.log('Add station') isEdit.value = false
form.id = null
form.name = ''
form.phone = ''
form.address = ''
form.image = ''
form.status = 1
form.sort_order = 0
showModal.value = true
} }
function onEdit(item: StationItem) { function onEdit(item : DeliveryStation) {
console.log('Edit:', item.name) isEdit.value = true
form.id = item.id
form.name = item.name
form.phone = item.phone
form.address = item.address
form.image = item.image || ''
form.status = item.status
form.sort_order = item.sort_order
showModal.value = true
} }
function onDel(item: StationItem) { function closeModal() {
console.log('Delete:', item.id) showModal.value = false
}
async function handleSave() {
if (!form.name || !form.phone || !form.address) {
uni.showToast({ title: '请完善必要信息', icon: 'none' })
return
}
loading.value = true
try {
const resId = await saveDeliveryStation(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
}
} finally {
loading.value = false
}
}
async function onDelete(item : DeliveryStation) {
uni.showModal({
title: '提示',
content: `确定要删除提货点 "${item.name}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteDeliveryStation(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function onToggleStatus(item : DeliveryStation) {
const nextStatus = item.status === 1 ? 0 : 1
const ok = await saveDeliveryStation({ ...item, status: nextStatus })
if (ok != null) {
item.status = nextStatus
uni.showToast({ title: '状态已更新' })
}
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (stationList.value.length >= pageSize) { page.value++; loadData(); } }
function handleUpload() {
uni.showToast({ title: '上传功能开发中', icon: 'none' })
} }
</script> </script>
<style scoped> <style scoped lang="scss">
.admin-page-container { .admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
padding: 15px; .page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
background-color: #f5f7f9;
min-height: 100vh;
}
.page-card { .search-wrap { display: flex; flex-direction: row; align-items: center; padding-bottom: 24px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; }
background-color: #fff; .search-item { display: flex; flex-direction: row; align-items: center; margin-right: 24px; }
border-radius: 4px; .label { font-size: 14px; color: #606266; margin-right: 8px; }
padding: 20px; .input { width: 250px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
}
.search-wrap { .btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; }
display: flex; .btn-primary { background-color: #1890ff; color: #fff; }
flex-direction: row;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.label { .action-wrap { margin-bottom: 20px; }
font-size: 14px;
color: #606266;
}
.input { .table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
width: 250px; .table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
height: 32px; .th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
padding: 0 10px; .tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 60px; align-items: center; }
border: 1px solid #dcdfe6; .td { padding: 10px; display: flex; align-items: center; justify-content: center; }
border-radius: 4px; .td-txt { font-size: 13px; color: #606266; text-align: center; }
font-size: 14px; .ellipsis-2 { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
color: #606266;
margin-right: 15px;
}
.tabs-wrap { .station-img { width: 44px; height: 44px; border-radius: 4px; background-color: #f5f5f5; }
display: flex; .img-placeholder { width: 44px; height: 44px; border-radius: 4px; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 20px; }
flex-direction: row;
margin-bottom: 20px;
border-bottom: 1px solid #f0f2f5;
}
.tab-item { .action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
padding: 10px 15px; .danger { color: #ff4d4f; }
cursor: pointer; .divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
}
.tab-item.active { .no-data, .loading-box { padding: 60px 0; text-align: center; width: 100%; }
border-bottom: 2px solid #1890ff; .no-data-text { font-size: 14px; color: #ccc; }
}
.tab-item.active .tab-text { .pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
color: #1890ff; .total-txt { font-size: 13px; color: #999; }
} .page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
.p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
.tab-text { /* Modal */
font-size: 14px; .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
color: #515a6e; .modal-card { width: 500px; background-color: #fff; border-radius: 8px; overflow: hidden; }
} .modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.modal-body { padding: 24px; }
.form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
.f-label { width: 90px; font-size: 14px; color: #666; text-align: right; margin-right: 15px; }
.f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.action-wrap { .upload-placeholder { width: 64px; height: 64px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #ccc; cursor: pointer; }
margin-bottom: 20px; .img-preview { width: 100%; height: 100%; border-radius: 4px; }
}
.btn { .modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.td {
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.station-img {
width: 40px;
height: 40px;
border-radius: 4px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
margin-right: 10px;
}
.action-btn-del {
color: #ed4014;
font-size: 13px;
}
</style> </style>

View File

@@ -2,332 +2,234 @@
<view class="admin-page"> <view class="admin-page">
<view class="admin-sections"> <view class="admin-sections">
<view class="admin-card settings-card"> <view class="admin-card settings-card">
<!-- 顶部导航标签 (1:1 复刻 CRMEB: 横向排列) --> <!-- 顶部导航标签 -->
<view class="tabs-container"> <view class="tabs-container">
<scroll-view class="tabs-scroll" scroll-x="true" show-scrollbar="false" :enable-flex="true"> <scroll-view class="tabs-scroll" scroll-x="true" show-scrollbar="false" :enable-flex="true">
<view class="tabs-bar"> <view class="tabs-bar">
<view <view
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
:key="index" :key="index"
class="tab-item" class="tab-item"
:class="{ active: currentTab === index }" :class="{ active: currentTab === index }"
@click="currentTab = index" @click="currentTab = index"
> >
<text class="tab-text">{{ tab.name }}</text> <text class="tab-text">{{ tab.name }}</text>
<view class="tab-line" v-if="currentTab === index"></view> <view class="tab-line" v-if="currentTab === index"></view>
</view>
</view> </view>
</view> </scroll-view>
</scroll-view>
</view>
<!-- 表单区域 -->
<view class="form-container">
<!-- 1. 基础配置 -->
<view v-if="currentTab === 0" class="form-content">
<view class="form-item">
<view class="form-label">站点开启:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.site_open = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="1" :checked="formData.site_open == 1" color="#1890ff" />开启</label>
<label class="radio-label"><radio value="0" :checked="formData.site_open == 0" color="#1890ff" />关闭</label>
</radio-group>
<view class="form-tip">站点开启/关闭(用于升级等临时关闭),关闭后前端会弹窗显示站点升级中,请稍后访问</view>
</view>
</view>
<view class="form-item">
<view class="form-label">网站名称:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_name" placeholder="请输入网站名称" />
<view class="form-tip">网站名称很多地方会显示的,建议认真填写</view>
</view>
</view>
<view class="form-item">
<view class="form-label">网站地址:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_url" placeholder="请输入网站地址" />
<view class="form-tip">安装自动配置,不要轻易修改,更换后会影响网站访问、接口请求、本地文件储存、支付回调、微信授权、支付、小程序图片访问、部分二维码、官方授权等</view>
</view>
</view>
<view class="form-item">
<view class="form-label">消息队列:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.msg_queue = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="1" :checked="formData.msg_queue == 1" color="#1890ff" />开启</label>
<label class="radio-label"><radio value="0" :checked="formData.msg_queue == 0" color="#1890ff" />关闭</label>
</radio-group>
<view class="form-tip">是否启用消息队列启用后提升程序运行速度启用前必须配置Redis缓存文档地址https://doc.crmeb.com/single/crmeb_v4/7217</view>
</view>
</view>
<view class="form-item">
<view class="form-label">联系电话:</view>
<view class="form-right">
<input class="form-input" v-model="formData.contact_phone" placeholder="请输入联系电话" />
<view class="form-tip">联系电话</view>
</view>
</view>
<view class="form-item">
<view class="form-label">授权密钥:</view>
<view class="form-right">
<input class="form-input" v-model="formData.auth_key" placeholder="请输入授权密钥" />
</view>
</view>
</view> </view>
<!-- 2. 分享配置 --> <!-- 表单区域 -->
<view v-else-if="currentTab === 1" class="form-content"> <view class="form-container">
<view class="form-item"> <view v-if="isLoading" class="loading-state">
<view class="form-label">分享图片:</view> <text>加载配置中...</text>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('share_img')">上传图片</view>
<view class="form-tip">分享图片比例5:4建议小于50KB</view>
</view>
</view> </view>
<view class="form-item">
<view class="form-label">分享标题:</view>
<view class="form-right">
<input class="form-input" v-model="formData.share_title" />
</view>
</view>
<view class="form-item">
<view class="form-label">分享简介:</view>
<view class="form-right">
<textarea class="form-textarea" v-model="formData.share_desc" />
</view>
</view>
</view>
<!-- 3. LOGO配置 --> <view v-else class="form-scroll-wrap">
<view v-else-if="currentTab === 2" class="form-content"> <!-- 1. 基础配置 -->
<view class="form-item"> <view v-if="currentTab === 0" class="form-content">
<view class="form-label">后台登录LOGO:</view> <view class="form-item">
<view class="form-right"> <view class="form-label">站点开启:</view>
<view class="upload-placeholder" @click="handleUpload('login_logo')">上传截图</view> <view class="form-right">
<view class="form-tip">建议尺寸270*75</view> <radio-group class="radio-group" @change="formData.site_open = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="1" :checked="formData.site_open == 1" color="#1890ff" />开启</label>
<label class="radio-label"><radio value="0" :checked="formData.site_open == 0" color="#1890ff" />关闭</label>
</radio-group>
<view class="form-tip">站点开启/关闭(用于升级等临时关闭)</view>
</view>
</view>
<view class="form-item">
<view class="form-label">网站名称:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_name" placeholder="请输入网站名称" />
</view>
</view>
<view class="form-item">
<view class="form-label">网站地址:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_url" placeholder="请输入网站地址" />
</view>
</view>
<view class="form-item">
<view class="form-label">联系电话:</view>
<view class="form-right">
<input class="form-input" v-model="formData.contact_phone" placeholder="请输入联系电话" />
</view>
</view>
</view> </view>
</view>
<view class="form-item">
<view class="form-label">后台小LOGO:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('small_logo')">上传图片</view>
<view class="form-tip">建议尺寸180*180</view>
</view>
</view>
<view class="form-item">
<view class="form-label">后台大LOGO:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('big_logo')">上传图片</view>
<view class="form-tip">建议尺寸170*50</view>
</view>
</view>
</view>
<!-- 4. 自定义JS --> <!-- 2. 分享配置 -->
<view v-else-if="currentTab === 3" class="form-content"> <view v-else-if="currentTab === 1" class="form-content">
<view class="form-item"> <view class="form-item">
<view class="form-label">移动端JS:</view> <view class="form-label">分享标题:</view>
<view class="form-right"> <view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.mobile_js" /> <input class="form-input" v-model="formData.share_title" />
</view>
</view>
<view class="form-item">
<view class="form-label">分享简介:</view>
<view class="form-right">
<textarea class="form-textarea" v-model="formData.share_desc" />
</view>
</view>
</view> </view>
</view>
<view class="form-item">
<view class="form-label">管理端JS:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.admin_js" />
</view>
</view>
<view class="form-item">
<view class="form-label">PC端JS:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.pc_js" />
</view>
</view>
</view>
<!-- 5. 地图配置 --> <!-- 3. LOGO配置 -->
<view v-else-if="currentTab === 4" class="form-content"> <view v-else-if="currentTab === 2" class="form-content">
<view class="form-item"> <view class="form-item">
<view class="form-label">腾讯地图KEY:</view> <view class="form-label">后台登录LOGO:</view>
<view class="form-right"> <view class="form-right">
<input class="form-input" v-model="formData.tencent_map_key" /> <view class="upload-placeholder" @click="handleUpload('login_logo')">
<view class="form-tip">申请地址https://lbs.qq.com</view> <image v-if="formData.login_logo" :src="formData.login_logo" mode="aspectFit" class="logo-preview" />
<text v-else>上传图片</text>
</view>
</view>
</view>
</view> </view>
</view>
</view>
<!-- 6. 备案配置 --> <!-- 9. WAF配置 -->
<view v-else-if="currentTab === 5" class="form-content"> <view v-else-if="currentTab === 8" class="form-content">
<view class="form-item"> <view class="form-item">
<view class="form-label">备案号:</view> <view class="form-label">WAF类型:</view>
<view class="form-right"> <view class="form-right">
<input class="form-input" v-model="formData.filing_no" /> <radio-group class="radio-group" @change="formData.waf_type = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="0" :checked="formData.waf_type == 0" color="#1890ff" />关闭</label>
<label class="radio-label"><radio value="1" :checked="formData.waf_type == 1" color="#1890ff" />拦截</label>
<label class="radio-label"><radio value="2" :checked="formData.waf_type == 2" color="#1890ff" />过滤</label>
</radio-group>
</view>
</view>
<view class="form-item">
<view class="form-label">WAF规则:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.waf_config" />
</view>
</view>
</view> </view>
</view>
<view class="form-item">
<view class="form-label">ICP链接:</view>
<view class="form-right">
<input class="form-input" v-model="formData.icp_link" />
</view>
</view>
</view>
<!-- 7. 模块配置 --> <!-- 提交按钮 -->
<view v-else-if="currentTab === 6" class="form-content"> <view class="submit-section">
<view class="form-item"> <view class="form-label"></view>
<view class="form-label">功能开启:</view> <view class="form-right">
<view class="form-right"> <button class="btn-submit" :disabled="isSaving" @click="handleSubmit">
<checkbox-group class="checkbox-group" @change="formData.module_config = ($event.detail.value as string[])"> <text class="btn-txt">{{ isSaving ? '保存中...' : '提交' }}</text>
<label class="checkbox-label"><checkbox value="秒杀" :checked="formData.module_config.includes('秒杀')" color="#1890ff" />秒杀</label> </button>
<label class="checkbox-label"><checkbox value="砍价" :checked="formData.module_config.includes('砍价')" color="#1890ff" />砍价</label> </view>
<label class="checkbox-label"><checkbox value="拼团" :checked="formData.module_config.includes('拼团')" color="#1890ff" />拼团</label>
</checkbox-group>
</view> </view>
</view> </view>
</view> </view>
<!-- 8. 远程登录 -->
<view v-else-if="currentTab === 7" class="form-content">
<view class="form-item">
<view class="form-label">远程登录地址:</view>
<view class="form-right">
<input class="form-input" v-model="formData.remote_login_url" />
</view>
</view>
</view>
<!-- 9. WAF配置 -->
<view v-else-if="currentTab === 8" class="form-content">
<view class="form-item">
<view class="form-label">WAF类型:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.waf_type = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="0" :checked="formData.waf_type == 0" color="#1890ff" />关闭</label>
<label class="radio-label"><radio value="1" :checked="formData.waf_type == 1" color="#1890ff" />拦截</label>
<label class="radio-label"><radio value="2" :checked="formData.waf_type == 2" color="#1890ff" />过滤</label>
</radio-group>
<view class="form-tip">WAF类型关闭所有参数都能正常请求拦截匹配到WAF配置的参数阻断接口请求过滤匹配到WAF配置的参数过滤参数正常请求接口</view>
</view>
</view>
<view class="form-item">
<view class="form-label">WAF配置:</view>
<view class="form-right">
<textarea class="form-textarea code-bg waf-textarea" v-model="formData.waf_config" />
<view class="form-tip">WAF配置验证参数过滤掉不需要的参数或拦截请求多个参数用回车换行分隔</view>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<view class="form-label"></view> <!-- 占位用于对齐 -->
<view class="form-right">
<button class="btn-submit" @click="handleSubmit">提交</button>
</view>
</view>
</view> </view>
</view> </view>
</view>
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref } from "vue" import { ref, reactive, onMounted } from "vue"
import { getSystemConfig, saveSystemConfig } from "@/services/admin/systemConfigService.uts"
const currentTab = ref(0) const currentTab = ref(0)
const tabs = [ const tabs = [
{ name: "基础配置" }, { name: "分享配置" }, { name: "LOGO配置" }, { name: "基础配置" }, { name: "分享配置" }, { name: "LOGO配置" },
{ name: "自定义JS" }, { name: "地图配置" }, { name: "备案配置" }, { name: "自定义JS" }, { name: "地图配置" }, { name: "备案配置" },
{ name: "模块配置" }, { name: "远程登录配置" }, { name: "WAF配置" } { name: "模块配置" }, { name: "远程登录" }, { name: "WAF配置" }
] ]
const formData = ref({ const isLoading = ref(false)
const isSaving = ref(false)
const formData = reactive({
site_open: 1, site_open: 1,
site_name: "CRMEB标准版", site_name: "",
site_url: "https://v5.crmeb.net", site_url: "",
msg_queue: 0, msg_queue: 0,
contact_phone: "", contact_phone: "",
auth_key: "AO9azvBW9vEcOH7swklTM0RYRb6EB4RLWMSD88MnKTi8Vd6cjXVd", auth_key: "",
share_img: "", share_img: "",
share_title: "CRMEB v5标准版", share_title: "",
share_desc: "完善的文档 全心而来!", share_desc: "",
login_logo: "", login_logo: "",
small_logo: "", small_logo: "",
big_logo: "", big_logo: "",
mobile_js: "", mobile_js: "",
admin_js: "", admin_js: "",
pc_js: "", pc_js: "",
tencent_map_key: "SMJBZ-WCHK4-ZPZUA-DSIXI-XDDVQ-XWFX7", tencent_map_key: "",
filing_no: "陕ICP备14011498号-3", filing_no: "",
icp_link: "https://beian.miit.gov.cn/", icp_link: "",
module_config: ["秒杀", "砍价", "拼团"] as string[], module_config: [] as string[],
remote_login_url: "", remote_login_url: "",
waf_type: 2, waf_type: 0,
waf_config: "/\\.\\.\\//\n/\\<\\?/\n/\\bor\\b.*=\\s*\\*/i\n/(select[\\s\\S]*?)(from|limit)/i\n/(union[\\s\\S]*?select)/i\n/(having\\s+updatexml|extractvalue)/i" waf_config: ""
}) })
const handleUpload = (field: string) => { onMounted(() => {
uni.showToast({ title: "选择文件: " + field, icon: "none" }) loadConfig()
})
async function loadConfig() {
isLoading.value = true
try {
const res = await getSystemConfig('system_settings')
if (res != null) {
Object.assign(formData, res as any)
}
} finally {
isLoading.value = false
}
} }
const handleSubmit = () => { async function handleSubmit() {
uni.showLoading({ title: "保存中..." }) isSaving.value = true
setTimeout(() => { try {
uni.hideLoading() const ok = await saveSystemConfig('system_settings', formData as UTSJSONObject, '系统全局配置项')
uni.showToast({ title: "保存成功", icon: "success" }) if (ok) {
}, 800) uni.showToast({ title: "保存成功", icon: "success" })
} else {
uni.showToast({ title: "保存失败", icon: "none" })
}
} finally {
isSaving.value = false
}
}
const handleUpload = (field: string) => {
uni.showToast({ title: "上传功能开发中: " + field, icon: "none" })
} }
</script> </script>
<style scoped> <style scoped>
.settings-card { .admin-page { padding: 24px; background-color: #f0f2f5; min-height: 100vh; }
box-shadow: 0 1px 4px rgba(0,21,41,0.08); .settings-card { background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
}
/* 核心修复:确保 Tabs 横向排列并且可以滑动 */ .tabs-container { margin-bottom: 30px; border-bottom: 1px solid #e8eaec; }
.tabs-container { margin-bottom: 30px; border-bottom: 1px solid #e8eaec; width: 100%; overflow: hidden; }
.tabs-scroll { width: 100%; white-space: nowrap; } .tabs-scroll { width: 100%; white-space: nowrap; }
.tabs-bar { display: inline-flex; flex-direction: row; min-width: 100%; } .tabs-bar { display: inline-flex; flex-direction: row; }
.tab-item { padding: 12px 24px; cursor: pointer; position: relative; }
.tab-item { .tab-text { font-size: 14px; color: #515a6e; }
display: inline-flex; .tab-item.active .tab-text { color: #1890ff; font-weight: bold; }
flex-direction: row;
align-items: center;
padding: 12px 24px;
font-size: 14px;
color: #515a6e;
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.tab-item.active { color: #1890ff; font-weight: bold; }
.tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #1890ff; } .tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #1890ff; }
.form-container { padding-left: 20px; } .form-container { padding: 0 20px 40px; }
.loading-state { padding: 60px; text-align: center; color: #999; }
/* 核心修复:确保表单项横向排列 (Label left, Input right) */
.form-item { display: flex; flex-direction: row; margin-bottom: 25px; align-items: flex-start; } .form-item { display: flex; flex-direction: row; margin-bottom: 25px; align-items: flex-start; }
.form-label { width: 140px; font-size: 14px; color: #303133; text-align: right; padding-right: 20px; padding-top: 8px; flex-shrink: 0; } .form-label { width: 140px; font-size: 14px; color: #303133; text-align: right; padding-right: 20px; padding-top: 8px; flex-shrink: 0; }
.form-right { flex: 1; display: flex; flex-direction: column; width: 100%; } .form-right { flex: 1; display: flex; flex-direction: column; }
.form-input { border: 1px solid #dcdfe6; width: 450px; height: 32px; padding: 0 15px; border-radius: 4px; font-size: 14px; color: #606266; outline: none; transition: border-color .2s; } .form-input { border: 1px solid #dcdfe6; width: 100%; max-width: 450px; height: 32px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.form-input:focus { border-color: #409eff; } .form-textarea { border: 1px solid #dcdfe6; width: 100%; max-width: 550px; height: 100px; padding: 8px 12px; border-radius: 4px; font-size: 14px; }
.code-bg { background-color: #f5f7fa; }
.form-tip { font-size: 12px; color: #c0c4cc; margin-top: 8px; }
.form-textarea { border: 1px solid #dcdfe6; width: 550px; height: 120px; padding: 10px 15px; border-radius: 4px; font-size: 14px; color: #606266; line-height: 1.6; outline: none; } .radio-group { display: flex; flex-direction: row; gap: 20px; height: 32px; align-items: center; }
.form-textarea:focus { border-color: #409eff; } .radio-label { display: flex; flex-direction: row; align-items: center; font-size: 14px; color: #606266; }
.waf-textarea { height: 200px; } .upload-placeholder { width: 80px; height: 80px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #909399; cursor: pointer; }
.code-bg { background-color: #f5f7fa; font-family: "Lucida Console", Monaco, monospace; } .logo-preview { width: 100%; height: 100%; }
.form-tip { font-size: 12px; color: #c0c4cc; margin-top: 8px; line-height: 1.5; width: 550px; } .submit-section { display: flex; flex-direction: row; margin-top: 20px; }
.btn-submit { background-color: #1890ff; border: none; padding: 0 24px; height: 36px; border-radius: 4px; cursor: pointer; }
.radio-group, .checkbox-group { display: flex; flex-direction: row; align-items: center; min-height: 32px; } .btn-txt { color: #fff; font-size: 14px; }
.radio-label, .checkbox-label { display: flex; flex-direction: row; align-items: center; margin-right: 30px; font-size: 14px; color: #606266; cursor: pointer; } .btn-submit[disabled] { opacity: 0.6; }
.upload-placeholder { width: 80px; height: 80px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #909399; cursor: pointer; transition: all .2s; }
.upload-placeholder:hover { border-color: #409eff; color: #409eff; }
.submit-section { display: flex; flex-direction: row; margin-top: 20px; padding-top: 10px; }
.btn-submit { background-color: #1890ff; color: #fff; width: 65px; height: 32px; line-height: 32px; font-size: 14px; border-radius: 4px; margin: 0; border: none; cursor: pointer; text-align: center; }
.btn-submit:active { background-color: #096dd9; }
</style> </style>

View File

@@ -1,81 +1,177 @@
<template> <template>
<view class="page-container"> <view class="admin-statistic">
<view class="page-header"> <!-- 核心指标卡片:累计数据 -->
<text class="page-title">数据概览</text> <view class="stats-grid">
<text class="page-subtitle">Component: StatisticIndex</text> <view class="stat-card border-shadow" v-for="(item, key) in totalStats" :key="key">
<view class="stat-icon-box" :class="item.color">
<text class="stat-ic">{{ item.icon }}</text>
</view>
<view class="stat-info">
<text class="stat-val">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view> </view>
<view class="page-content"> <!-- 今日实时数据 -->
<view class="placeholder-card"> <view class="section-title mt-24">
<text class="placeholder-title">页面占位</text> <text class="title-txt">今日实时</text>
<text class="placeholder-desc">该功能模块正在开发中</text> <text class="sub-txt">更新时间:{{ currentTime }}</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text> </view>
<view class="stats-grid-mini">
<view class="stat-mini-card border-shadow" v-for="(item, key) in todayStats" :key="key">
<text class="sm-label">{{ item.label }}</text>
<text class="sm-val">{{ item.value }}</text>
</view> </view>
</view> </view>
<!-- 待办任务提醒 -->
<view class="section-title mt-24">
<text class="title-txt">待办提醒</text>
</view>
<view class="pending-grid">
<view class="pending-card border-shadow" v-for="(item, key) in pendingStats" :key="key">
<view class="p-left">
<text class="p-label">{{ item.label }}</text>
<text class="p-count">{{ item.value }}</text>
</view>
<view class="p-right">
<text class="p-link" @click="handlePending(key)">去处理 ></text>
</view>
</view>
</view>
<!-- 图表占位 -->
<view class="chart-section mt-24 border-shadow">
<view class="chart-header">
<text class="chart-title">销售趋势概览</text>
</view>
<view class="chart-body">
<text class="chart-placeholder-txt">趋势图表正在对接中...</text>
</view>
</view>
<view v-if="isLoading" class="global-loading">
<text class="loading-txt">数据聚合中...</text>
</view>
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
// TODO: 实现 数据概览 的具体功能 const isLoading = ref(false)
const loading = ref<boolean>(false) const currentTime = ref('')
const totalStats = ref({
sales: { label: '总销售额', value: '0.00', icon: '💰', color: 'blue' },
orders: { label: '总订单数', value: '0', icon: '📦', color: 'orange' },
users: { label: '总用户数', value: '0', icon: '👥', color: 'green' },
products: { label: '总商品数', value: '0', icon: '🛒', color: 'pink' }
})
const todayStats = ref({
sales: { label: '今日成交额', value: '0.00' },
orders: { label: '今日订单数', value: '0' },
new_users: { label: '今日新增用户', value: '0' }
})
const pendingStats = ref({
delivery: { label: '待发货订单', value: '0' },
stock: { label: '库存预警商品', value: '0' },
extract: { label: '待审核提现', value: '0' }
})
onMounted(() => {
updateTime()
loadStats()
})
function updateTime() {
const now = new Date()
currentTime.value = `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`
}
async function loadStats() {
isLoading.value = true
try {
const { data, error } = await supa.rpc('rpc_admin_get_overall_stats', {} as any)
if (error == null && data != null) {
const res = data as UTSJSONObject
const totals = res.get('totals') as UTSJSONObject
const today = res.get('today') as UTSJSONObject
const pending = res.get('pending') as UTSJSONObject
totalStats.value.sales.value = (totals.getNumber('total_sales') ?? 0).toFixed(2)
totalStats.value.orders.value = (totals.getInt('total_orders') ?? 0).toString()
totalStats.value.users.value = (totals.getInt('total_users') ?? 0).toString()
totalStats.value.products.value = (totals.getInt('total_products') ?? 0).toString()
todayStats.value.sales.value = (today.getNumber('today_sales') ?? 0).toFixed(2)
todayStats.value.orders.value = (today.getInt('today_orders') ?? 0).toString()
todayStats.value.new_users.value = (today.getInt('today_new_users') ?? 0).toString()
pendingStats.value.delivery.value = (pending.getInt('pending_delivery') ?? 0).toString()
pendingStats.value.stock.value = (pending.getInt('stock_warning') ?? 0).toString()
pendingStats.value.extract.value = (pending.getInt('pending_extract') ?? 0).toString()
}
} catch (e) {
console.error('Failed to fetch stats', e)
} finally {
isLoading.value = false
}
}
function handlePending(type: string) {
uni.showToast({ title: '正在跳转: ' + type, icon: 'none' })
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.page-container { .admin-statistic { padding: 24px; background-color: #f0f2f5; min-height: 100vh; }
padding: 20px; .border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
min-height: 100vh;
background: #f5f5f5;
}
.page-header { .mt-24 { margin-top: 24px; }
margin-bottom: 20px;
}
.page-title { /* 核心指标卡片 */
display: block; .stats-grid { display: flex; flex-direction: row; gap: 20px; }
font-size: 24px; .stat-card { flex: 1; display: flex; flex-direction: row; align-items: center; padding: 24px; }
font-weight: 600; .stat-icon-box { width: 56px; height: 56px; border-radius: 28px; display: flex; align-items: center; justify-content: center; margin-right: 16px; }
color: #333; .stat-icon-box.blue { background-color: #e6f7ff; color: #1890ff; }
margin-bottom: 8px; .stat-icon-box.orange { background-color: #fff7e6; color: #ffa940; }
} .stat-icon-box.green { background-color: #f6ffed; color: #52c41a; }
.stat-icon-box.pink { background-color: #fff0f6; color: #eb2f96; }
.stat-ic { font-size: 24px; }
.stat-info { display: flex; flex-direction: column; }
.stat-val { font-size: 24px; font-weight: bold; color: #303133; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.page-subtitle { /* 标题 */
display: block; .section-title { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 16px; }
font-size: 14px; .title-txt { font-size: 16px; font-weight: bold; color: #333; position: relative; padding-left: 12px; }
color: #999; .title-txt::before { content: ''; position: absolute; left: 0; top: 2px; width: 4px; height: 16px; background-color: #1890ff; border-radius: 2px; }
} .sub-txt { font-size: 12px; color: #999; }
.page-content { /* 今日实时 */
background: #fff; .stats-grid-mini { display: flex; flex-direction: row; gap: 16px; }
border-radius: 4px; .stat-mini-card { flex: 1; padding: 20px; display: flex; flex-direction: column; }
padding: 24px; .sm-label { font-size: 13px; color: #666; margin-bottom: 8px; }
} .sm-val { font-size: 20px; font-weight: bold; color: #1890ff; }
.placeholder-card { /* 待办提醒 */
text-align: center; .pending-grid { display: flex; flex-direction: row; gap: 16px; }
padding: 60px 20px; .pending-card { flex: 1; padding: 20px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
} .p-label { font-size: 13px; color: #666; display: block; margin-bottom: 4px; }
.p-count { font-size: 22px; font-weight: bold; color: #f5222d; }
.p-link { font-size: 12px; color: #1890ff; cursor: pointer; }
.placeholder-title { /* 图表占位 */
display: block; .chart-section { padding: 24px; height: 350px; display: flex; flex-direction: column; }
font-size: 18px; .chart-title { font-size: 15px; font-weight: bold; color: #333; }
font-weight: 600; .chart-body { flex: 1; display: flex; align-items: center; justify-content: center; }
color: #666; .chart-placeholder-txt { font-size: 14px; color: #ccc; }
margin-bottom: 12px;
}
.placeholder-desc { .global-loading { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255,255,255,0.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
display: block; .loading-txt { font-size: 14px; color: #1890ff; }
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
</style> </style>

View File

@@ -0,0 +1,144 @@
import { rpcOrNull, rpcOrValue, rpcOrEmptyArray } from '@/services/analytics/rpc.uts'
/**
* 管理员模型
*/
export type AdminUser = {
id: string
username: string
real_name: string | null
role: string
is_active: boolean
last_login_at: string | null
last_login_ip: string | null
roles: string[] | null
}
/**
* 角色模型
*/
export type AdminRole = {
id: string
name: string
code: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
/**
* 权限/菜单模型
*/
export type AdminPermission = {
id: string
parent_id: string | null
name: string
code: string
type: string
path: string | null
icon: string | null
sort_order: number
is_visible: boolean
created_at: string
updated_at: string
}
/**
* 获取管理员列表
*/
export async function fetchAdminPage(
page: number,
pageSize: number,
search: string | null = null,
status: number | null = null
): Promise<{ total: number, items: Array<AdminUser> }> {
const res = await rpcOrNull('rpc_admin_get_admin_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<AdminUser> }
return {
total: (res as any).total as number,
items: (res as any).items as Array<AdminUser>
}
}
/**
* 获取角色列表
*/
export async function fetchRolePage(
page: number,
pageSize: number,
search: string | null = null
): Promise<{ total: number, items: Array<AdminRole> }> {
const res = await rpcOrNull('rpc_admin_get_role_list', {
p_page: page,
p_page_size: pageSize,
p_search: search
} as UTSJSONObject)
if (res == null) return { total: 0, items: [] as Array<AdminRole> }
return {
total: (res as any).total as number,
items: (res as any).items as Array<AdminRole>
}
}
/**
* 保存角色
*/
export async function saveRole(role: any): Promise<string | null> {
const res = await rpcOrValue('rpc_admin_save_role', {
p_id: role.id,
p_name: role.name,
p_code: role.code,
p_description: role.description,
p_is_active: role.is_active
} as any)
return res != null ? String(res) : null
}
/**
* 删除角色
*/
export async function deleteRole(id: string): Promise<boolean> {
const ok = await rpcOrValue('rpc_admin_delete_role', { p_id: id } as any)
return ok === true
}
/**
* 获取权限菜单树列表
*/
export async function fetchPermissionList(): Promise<Array<AdminPermission>> {
return await rpcOrEmptyArray('rpc_admin_get_permission_list', {} as any) as Array<AdminPermission>
}
/**
* 保存权限菜单
*/
export async function savePermission(permission: any): Promise<string | null> {
const res = await rpcOrValue('rpc_admin_save_permission', {
p_id: permission.id,
p_parent_id: permission.parent_id,
p_name: permission.name,
p_code: permission.code,
p_type: permission.type,
p_path: permission.path,
p_icon: permission.icon,
p_sort_order: permission.sort_order,
p_is_visible: permission.is_visible
} as any)
return res != null ? String(res) : null
}
/**
* 删除权限菜单
*/
export async function deletePermission(id: string): Promise<boolean> {
const ok = await rpcOrValue('rpc_admin_delete_permission', { p_id: id } as any)
return ok === true
}

View File

@@ -0,0 +1,129 @@
import { rpcOrNull, rpcOrValue } from '@/services/analytics/rpc.uts'
/**
* 配送员模型
*/
export type DeliveryStaff = {
id: string
uid: string | null
nickname: string
avatar: string | null
phone: string
status: number
is_active: boolean
created_at: string
updated_at: string
}
/**
* 提货点模型
*/
export type DeliveryStation = {
id: string
name: string
phone: string
address: string
image: string | null
lng: number | null
lat: number | null
status: number
sort_order: number
business_hours: UTSJSONObject | null
created_at: string
updated_at: string
}
/**
* 获取配送员列表
*/
export async function fetchDeliveryStaffPage(
page: number,
pageSize: number,
search: string | null = null,
status: number | null = null
): Promise<{ total: number, items: Array<DeliveryStaff> }> {
const res = await rpcOrNull('rpc_admin_get_delivery_staff_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<DeliveryStaff> }
return {
total: (res as any).total as number,
items: (res as any).items as Array<DeliveryStaff>
}
}
/**
* 保存配送员
*/
export async function saveDeliveryStaff(staff: any): Promise<string | null> {
const res = await rpcOrValue('rpc_admin_save_delivery_staff', {
p_id: staff.id,
p_nickname: staff.nickname,
p_avatar: staff.avatar,
p_phone: staff.phone,
p_status: staff.status
} as any)
return res != null ? String(res) : null
}
/**
* 删除配送员
*/
export async function deleteDeliveryStaff(id: string): Promise<boolean> {
const ok = await rpcOrValue('rpc_admin_delete_delivery_staff', { p_id: id } as any)
return ok === true
}
/**
* 获取提货点列表
*/
export async function fetchDeliveryStationPage(
page: number,
pageSize: number,
search: string | null = null,
status: number | null = null
): Promise<{ total: number, items: Array<DeliveryStation> }> {
const res = await rpcOrNull('rpc_admin_get_delivery_station_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<DeliveryStation> }
return {
total: (res as any).total as number,
items: (res as any).items as Array<DeliveryStation>
}
}
/**
* 保存提货点
*/
export async function saveDeliveryStation(station: any): Promise<string | null> {
const res = await rpcOrValue('rpc_admin_save_delivery_station', {
p_id: station.id,
p_name: station.name,
p_phone: station.phone,
p_address: station.address,
p_image: station.image,
p_lng: station.lng,
p_lat: station.lat,
p_status: station.status,
p_sort_order: station.sort_order,
p_business_hours: station.business_hours
} as any)
return res != null ? String(res) : null
}
/**
* 删除提货点
*/
export async function deleteDeliveryStation(id: string): Promise<boolean> {
const ok = await rpcOrValue('rpc_admin_delete_delivery_station', { p_id: id } as any)
return ok === true
}

View File

@@ -0,0 +1,22 @@
import { rpcOrNull } from '@/services/analytics/rpc.uts'
/**
* 系统信息模型
*/
export type SystemInfo = {
server_os : string
web_server : string
db_engine : string
db_version : string
uts_runtime : string
auth_id : string
}
/**
* 获取服务器及系统环境信息
*/
export async function fetchSystemInfo() : Promise<SystemInfo | null> {
const res = await rpcOrNull('rpc_admin_get_system_info', {} as UTSJSONObject)
if (res == null) return null
return res as SystemInfo
}

View File

@@ -0,0 +1,41 @@
import { rpcOrNull, rpcOrValue } from '@/services/analytics/rpc.uts'
/**
* 系统配置项模型
*/
export type SystemConfig = {
config_key : string
config_value : UTSJSONObject
description ?: string
updated_at ?: string
}
/**
* 获取指定键的系统配置
*/
export async function getSystemConfig(key : string) : Promise<UTSJSONObject | null> {
const res = await rpcOrNull('rpc_admin_system_config_get', {
p_key: key
} as UTSJSONObject)
if (res == null) return null
return res as UTSJSONObject
}
/**
* 保存/更新系统配置
*/
export async function saveSystemConfig(key : string, value : UTSJSONObject, description : string | null = null) : Promise<boolean> {
const ok = await rpcOrValue('rpc_admin_system_config_save', {
p_key: key,
p_value: value,
p_description: description
} as any)
return ok === true
}
/**
* 批量获取配置 (可选扩展)
* 逻辑:如果后续有需求,可以补齐 rpc_admin_system_config_batch_get
*/