chore(release): v1.1.0 unify DI(strategy), AI equivalence service, config domain refactor, docker alias; health checks and schedules
This commit is contained in:
56
.trae/documents/后端一致性问题清单(Java-vs-NestV1).md
Normal file
56
.trae/documents/后端一致性问题清单(Java-vs-NestV1).md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 后端一致性问题清单(Java vs Nest v1)
|
||||
|
||||
仅列出不一致项,供后端开发 AI 按 Java 源码核实与修复。每条均给出 Java 具体文件位置(含行号范围)。
|
||||
|
||||
## 1)缺失控制器:API 任务接口
|
||||
- 差异:Nest v1 缺少 `GET /api/task/growth` 与 `GET /api/task/point`
|
||||
- Java 基准:`niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/api/sys/TaskController.java:19-27`
|
||||
- 修复建议:在 v1 增加对应 `api/sys/task` 控制器与端点,实现从 `ITaskService` 获取成长任务与积分任务并返回 `Result.success(...)`
|
||||
|
||||
## 2)缺失端点:小程序消息推送
|
||||
- 差异:Nest v1 存在控制器但无方法;Java 端提供 `/api/weapp/serve/{site_id}`
|
||||
- Java 基准:`niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/api/weapp/ServeController.java:25-29`
|
||||
- 修复建议:在 v1 的 `/api/weapp` 控制器中添加 `serve/{site_id}` 端点,设置站点 `RequestUtils.setSiteId(siteId)`,调用 `IServeService.service(request, response)`
|
||||
|
||||
## 3)缺失端点:公众号消息推送
|
||||
- 差异:Nest v1 存在控制器但无方法;Java 端提供 `/api/wechat/serve/{site_id}`
|
||||
- Java 基准:`niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/api/wechat/ServeController.java:26-30`
|
||||
- 修复建议:在 v1 的 `/api/wechat` 控制器中添加 `serve/{site_id}` 端点,设置站点并调用 `IServeService.service(request, response)`
|
||||
|
||||
## 4)错误统一处理:`/error` 响应逻辑缺失
|
||||
- 差异:Nest v1 控制器存在但无方法;Java 端实现了状态码分支并返回 `Result.fail(...)`
|
||||
- Java 基准:`niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/HttpServerErrorController.java:16-33`
|
||||
- 修复建议:在 v1 的 `/error` 控制器实现 `handleError` 逻辑,按照 500 / 404 / 其他状态返回 `Result.fail(code, message)` 并附 `contextPath`
|
||||
|
||||
## 5)异步任务接口响应语义不一致
|
||||
- 差异:`GET /core/task/async`
|
||||
- Java:仅返回固定消息“异步任务开始”
|
||||
- Nest v1:返回异步执行结果对象
|
||||
- Java 基准:`niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/CoreAsyncTaskController.java:41-45`
|
||||
- 修复建议:将 v1 的 `/async` 响应改为 `Result.success("异步任务开始")`,与 Java 语义对齐;同步 `/sync` 保持返回执行结果
|
||||
|
||||
## 6)插件控制器多处行为与权限不一致
|
||||
- 差异:`/core/addon/*`
|
||||
- `/javaSetup`:Java 执行 `AddonInstallJavaTools.installExec("shop")` 后返回空成功;v1 返回检查结果对象
|
||||
- `/setup/{id}`:Java 执行 `installCheck` 后还执行 `install("shop", "local")`;v1 仅执行检查
|
||||
- `/exception`:Java 抛出运行时异常;v1 返回成功
|
||||
- `/auth`:Java 抛出 `AuthException`;v1 返回成功
|
||||
- `/saCheckLogin`:Java 需登录(`@SaCheckLogin`);v1 标记公开访问(`@Public`)
|
||||
- Java 基准:
|
||||
- `niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/CoreAddonController.java:31-35`(javaSetup)
|
||||
- `niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/CoreAddonController.java:42-47`(setup/{id})
|
||||
- `niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/CoreAddonController.java:54-60`(exception)
|
||||
- `niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/CoreAddonController.java:67-73`(auth)
|
||||
- `niucloud-java/niucloud-core/src/main/java/com/niu/core/controller/core/CoreAddonController.java:75-79`(saCheckLogin)
|
||||
- 修复建议:
|
||||
- `/javaSetup` 与 `/setup/{id}` 按 Java 流程执行对应安装工具与安装动作,返回空成功
|
||||
- `/exception` 与 `/auth` 改为抛出与 Java 对应的异常类型,交由全局异常处理器响应
|
||||
- `/saCheckLogin` 取消公开访问,启用登录守卫,语义对齐 Java 的登录校验
|
||||
|
||||
## 7)多余控制器(Nest v1)
|
||||
- 差异:`NiuExceptionHandlerController` 在 v1 存在但 Java 端无对应控制器
|
||||
- 说明:可保留作为框架级占位;若需对齐 Java,可删除或实现为全局异常处理而非控制器形式
|
||||
|
||||
---
|
||||
|
||||
注:以上为截至当前检索的全部不一致项。修复时需严格对齐 Java 业务逻辑与接口契约,完成后建议运行端到端契约测试以验证路由、参数与响应一致性。
|
||||
@@ -1,6 +1,8 @@
|
||||
VITE_APP_BASE_URL=http://localhost:3000
|
||||
VITE_APP_BASE_URL='/adminapi/'
|
||||
NODE_ENV=development
|
||||
VITE_APP_TIMEOUT=30000
|
||||
VITE_APP_MOCK=false
|
||||
VITE_REQUEST_HEADER_TOKEN_KEY='token'
|
||||
VITE_REQUEST_HEADER_SITEID_KEY='site-id'
|
||||
VITE_IMG_DOMAIN=''
|
||||
VITE_DETAULT_TITLE='WWJCloud Admin'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
VITE_APP_BASE_URL=http://localhost:3000
|
||||
VITE_APP_BASE_URL='/adminapi/'
|
||||
NODE_ENV=production
|
||||
VITE_APP_TIMEOUT=30000
|
||||
VITE_APP_MOCK=false
|
||||
VITE_REQUEST_HEADER_TOKEN_KEY='token'
|
||||
VITE_REQUEST_HEADER_SITEID_KEY='site-id'
|
||||
VITE_IMG_DOMAIN=''
|
||||
VITE_DETAULT_TITLE='WWJCloud Admin'
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
component: string;
|
||||
icon: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
id: number;
|
||||
siteName: string;
|
||||
siteLogo: string;
|
||||
siteDomain: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface LoginConfig {
|
||||
captchaEnabled: boolean;
|
||||
defaultUsername: string;
|
||||
defaultPassword: string;
|
||||
}
|
||||
|
||||
export interface SystemVersion {
|
||||
version: string;
|
||||
buildTime: string;
|
||||
nodeVersion: string;
|
||||
system: string;
|
||||
}
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
@@ -10,41 +60,41 @@ import { requestClient } from '#/api/request';
|
||||
export function loginApi(
|
||||
params: { username: string; password: string; captcha_code?: string },
|
||||
loginType: string,
|
||||
): Promise<AxiosResponse<any>> {
|
||||
): Promise<LoginResponse> {
|
||||
return requestClient.get(`login/${loginType}`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
export function logoutApi(): Promise<AxiosResponse<any>> {
|
||||
export function logoutApi(): Promise<void> {
|
||||
return requestClient.put('auth/logout', {}, { showErrorMessage: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户权限菜单
|
||||
*/
|
||||
export function getAuthMenusApi(params?: Record<string, any>): Promise<AxiosResponse<any>> {
|
||||
export function getAuthMenusApi(params?: Record<string, any>): Promise<MenuItem[]> {
|
||||
return requestClient.get('auth/authmenu', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站点信息
|
||||
*/
|
||||
export function getSiteInfoApi(): Promise<AxiosResponse<any>> {
|
||||
export function getSiteInfoApi(): Promise<SiteInfo> {
|
||||
return requestClient.get('auth/site');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录配置
|
||||
*/
|
||||
export function getLoginConfigApi(): Promise<AxiosResponse<any>> {
|
||||
export function getLoginConfigApi(): Promise<LoginConfig> {
|
||||
return requestClient.get('login/config');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统版本信息
|
||||
*/
|
||||
export function getVersionsApi(): Promise<AxiosResponse<any>> {
|
||||
export function getVersionsApi(): Promise<SystemVersion> {
|
||||
return requestClient.get('sys/info');
|
||||
}
|
||||
145
admin-vben/apps/web-antd/src/api/core/diy.ts
Normal file
145
admin-vben/apps/web-antd/src/api/core/diy.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { DiyPageForm, DiyShareForm } from './model/diyModel';
|
||||
|
||||
/**
|
||||
* DIY decoration management API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get DIY page list
|
||||
*/
|
||||
export const getDiyPageList = (params: any) => {
|
||||
return request.get('adminapi/diy/diy', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY page info
|
||||
*/
|
||||
export const getDiyPageInfo = (id: number) => {
|
||||
return request.get(`adminapi/diy/diy/${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add DIY page
|
||||
*/
|
||||
export const addDiyPage = (data: DiyPageForm) => {
|
||||
return request.post('adminapi/diy/diy', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit DIY page
|
||||
*/
|
||||
export const editDiyPage = (id: number, data: DiyPageForm) => {
|
||||
return request.put(`adminapi/diy/diy/${id}`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete DIY page
|
||||
*/
|
||||
export const deleteDiyPage = (id: number) => {
|
||||
return request.delete(`adminapi/diy/diy/${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set use DIY page
|
||||
*/
|
||||
export const setUseDiyPage = (id: number) => {
|
||||
return request.put(`adminapi/diy/diy/${id}/use`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy DIY page
|
||||
*/
|
||||
export const copyDiyPage = (id: number) => {
|
||||
return request.post(`adminapi/diy/diy/${id}/copy`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit DIY page share settings
|
||||
*/
|
||||
export const editDiyPageShare = (id: number, data: DiyShareForm) => {
|
||||
return request.put(`adminapi/diy/diy/${id}/share`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY template
|
||||
*/
|
||||
export const getDiyTemplate = (params: { addon: string }) => {
|
||||
return request.get('adminapi/diy/template', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY template pages
|
||||
*/
|
||||
export const getDiyTemplatePages = (params: { type: string; mode: string }) => {
|
||||
return request.get('adminapi/diy/template/pages', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize DIY page
|
||||
*/
|
||||
export const initDiyPage = (params: any) => {
|
||||
return request.post('adminapi/diy/init', params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get decorate page
|
||||
*/
|
||||
export const getDecoratePage = (params: any) => {
|
||||
return request.get('adminapi/diy/decorate', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Change template
|
||||
*/
|
||||
export const changeTemplate = (params: any) => {
|
||||
return request.post('adminapi/diy/template/change', params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY bottom list
|
||||
*/
|
||||
export const getDiyBottomList = (params: any) => {
|
||||
return request.get('adminapi/diy/bottom', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY bottom config
|
||||
*/
|
||||
export const getDiyBottomConfig = (key: string) => {
|
||||
return request.get(`adminapi/diy/bottom/${key}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set DIY bottom config
|
||||
*/
|
||||
export const setDiyBottomConfig = (key: string, data: any) => {
|
||||
return request.put(`adminapi/diy/bottom/${key}`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY route list
|
||||
*/
|
||||
export const getDiyRouteList = (params: any) => {
|
||||
return request.get('adminapi/diy/route', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DIY route apps
|
||||
*/
|
||||
export const getDiyRouteAppList = () => {
|
||||
return request.get('adminapi/diy/route/apps');
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit DIY route share
|
||||
*/
|
||||
export const editDiyRouteShare = (id: number, data: DiyShareForm) => {
|
||||
return request.put(`adminapi/diy/route/${id}/share`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get installed addon list
|
||||
*/
|
||||
export const getInstalledAddonList = () => {
|
||||
return request.get('adminapi/addon/installed');
|
||||
};
|
||||
@@ -1,10 +1,57 @@
|
||||
import type { RouteRecordStringComponent } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
import type { MenuListQuery, MenuForm } from './model/menuModel';
|
||||
|
||||
/**
|
||||
* 获取用户所有菜单
|
||||
* 获取菜单列表
|
||||
*/
|
||||
export async function getAllMenusApi() {
|
||||
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
|
||||
}
|
||||
export const getMenusApi = (appType: string) => {
|
||||
return request.get(`/adminapi/sys/menu/${appType}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取菜单详情
|
||||
*/
|
||||
export const getMenuInfoApi = (appType: string, menuKey: string) => {
|
||||
return request.get(`/adminapi/sys/menu/${appType}/${menuKey}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加菜单
|
||||
*/
|
||||
export const addMenuApi = (data: MenuForm) => {
|
||||
return request.post('/adminapi/sys/menu', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 编辑菜单
|
||||
*/
|
||||
export const editMenuApi = (data: MenuForm) => {
|
||||
return request.put('/adminapi/sys/menu', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除菜单
|
||||
*/
|
||||
export const deleteMenuApi = (appType: string, menuKey: string) => {
|
||||
return request.delete(`/adminapi/sys/menu/${appType}/${menuKey}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新菜单
|
||||
*/
|
||||
export const menuRefreshApi = (data: any) => {
|
||||
return request.post('/adminapi/sys/menu/refresh', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取系统菜单
|
||||
*/
|
||||
export const getSystemMenuApi = () => {
|
||||
return request.get('/adminapi/sys/menu/system');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件菜单
|
||||
*/
|
||||
export const getAddonMenuApi = (addon: string) => {
|
||||
return request.get(`/adminapi/sys/menu/addon/${addon}`);
|
||||
};
|
||||
90
admin-vben/apps/web-antd/src/api/core/model/diyModel.ts
Normal file
90
admin-vben/apps/web-antd/src/api/core/model/diyModel.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* DIY page form interface
|
||||
*/
|
||||
export interface DiyPageForm {
|
||||
title: string;
|
||||
type: string;
|
||||
addon_name?: string;
|
||||
value?: any;
|
||||
global?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY share form interface
|
||||
*/
|
||||
export interface DiyShareForm {
|
||||
wechat?: {
|
||||
share_title: string;
|
||||
share_desc: string;
|
||||
share_image?: string;
|
||||
};
|
||||
weapp?: {
|
||||
share_title: string;
|
||||
share_desc: string;
|
||||
share_image?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY page interface
|
||||
*/
|
||||
export interface DiyPage {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
type_name: string;
|
||||
addon_name: string;
|
||||
addon_title: string;
|
||||
is_use: number;
|
||||
share: any;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY template interface
|
||||
*/
|
||||
export interface DiyTemplate {
|
||||
key: string;
|
||||
title: string;
|
||||
type: string;
|
||||
type_name: string;
|
||||
icon: string;
|
||||
support_app: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY bottom config interface
|
||||
*/
|
||||
export interface DiyBottomConfig {
|
||||
list: DiyBottomItem[];
|
||||
style: {
|
||||
type: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
activeTextColor: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY bottom item interface
|
||||
*/
|
||||
export interface DiyBottomItem {
|
||||
text: string;
|
||||
link: string;
|
||||
iconPath: string;
|
||||
selectedIconPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY route interface
|
||||
*/
|
||||
export interface DiyRoute {
|
||||
id: number;
|
||||
title: string;
|
||||
addon_name: string;
|
||||
addon_title: string;
|
||||
wap_url: string;
|
||||
weapp_path: string;
|
||||
share: any;
|
||||
}
|
||||
68
admin-vben/apps/web-antd/src/api/core/model/menuModel.ts
Normal file
68
admin-vben/apps/web-antd/src/api/core/model/menuModel.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 菜单列表查询参数
|
||||
*/
|
||||
export interface MenuListQuery {
|
||||
app_type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单表单数据
|
||||
*/
|
||||
export interface MenuForm {
|
||||
id?: number;
|
||||
menu_name: string;
|
||||
menu_key?: string;
|
||||
menu_type: number;
|
||||
parent_key?: string;
|
||||
icon?: string;
|
||||
api_url?: string;
|
||||
router_path?: string;
|
||||
view_path?: string;
|
||||
methods?: string;
|
||||
sort?: number;
|
||||
status: number;
|
||||
is_show: number;
|
||||
app_type?: string;
|
||||
addon?: string;
|
||||
menu_short_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
export interface MenuItem {
|
||||
menu_key: string;
|
||||
menu_name: string;
|
||||
menu_type: number;
|
||||
parent_key: string;
|
||||
icon?: string;
|
||||
api_url?: string;
|
||||
router_path?: string;
|
||||
view_path?: string;
|
||||
methods?: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
is_show: number;
|
||||
menu_short_name?: string;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单树
|
||||
*/
|
||||
export interface MenuTree {
|
||||
menu_key: string;
|
||||
menu_name: string;
|
||||
menu_type: number;
|
||||
parent_key: string;
|
||||
icon?: string;
|
||||
api_url?: string;
|
||||
router_path?: string;
|
||||
view_path?: string;
|
||||
methods?: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
is_show: number;
|
||||
menu_short_name?: string;
|
||||
children?: MenuTree[];
|
||||
}
|
||||
74
admin-vben/apps/web-antd/src/api/core/model/siteModel.ts
Normal file
74
admin-vben/apps/web-antd/src/api/core/model/siteModel.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 站点列表查询参数
|
||||
*/
|
||||
export interface SiteListQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keywords?: string;
|
||||
site_domain?: string;
|
||||
app?: string;
|
||||
group_id?: string;
|
||||
status?: string;
|
||||
create_time?: string[];
|
||||
expire_time?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点表单数据
|
||||
*/
|
||||
export interface SiteForm {
|
||||
site_id?: number;
|
||||
site_name: string;
|
||||
site_domain: string;
|
||||
logo?: string;
|
||||
group_id: number;
|
||||
status: number;
|
||||
expire_time?: string;
|
||||
admin?: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点分组表单数据
|
||||
*/
|
||||
export interface SiteGroupForm {
|
||||
group_id?: number;
|
||||
group_name: string;
|
||||
remark?: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点项
|
||||
*/
|
||||
export interface SiteItem {
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
site_domain: string;
|
||||
logo: string;
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
status: number;
|
||||
status_name: string;
|
||||
create_time: string;
|
||||
expire_time: string;
|
||||
admin: {
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点分组项
|
||||
*/
|
||||
export interface SiteGroupItem {
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
remark: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
status_name: string;
|
||||
create_time: string;
|
||||
}
|
||||
63
admin-vben/apps/web-antd/src/api/core/role.ts
Normal file
63
admin-vben/apps/web-antd/src/api/core/role.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取角色列表
|
||||
*/
|
||||
export function getRoleListApi(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
role_name?: string;
|
||||
}): Promise<AxiosResponse<any>> {
|
||||
return requestClient.get('sys/role', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色详情
|
||||
*/
|
||||
export function getRoleInfoApi(roleId: number): Promise<AxiosResponse<any>> {
|
||||
return requestClient.get(`sys/role/${roleId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加角色
|
||||
*/
|
||||
export function addRoleApi(params: Record<string, any>): Promise<AxiosResponse<any>> {
|
||||
return requestClient.post('sys/role', params, { showSuccessMessage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑角色
|
||||
*/
|
||||
export function editRoleApi(params: Record<string, any>): Promise<AxiosResponse<any>> {
|
||||
return requestClient.put(`sys/role/${params.role_id}`, params, { showSuccessMessage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
*/
|
||||
export function deleteRoleApi(roleId: number): Promise<AxiosResponse<any>> {
|
||||
return requestClient.delete(`sys/role/${roleId}`, { showSuccessMessage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改角色状态
|
||||
*/
|
||||
export function modifyRoleStatusApi(params: { role_id: number; status: number }): Promise<AxiosResponse<any>> {
|
||||
return requestClient.put('sys/role/status', params, { showSuccessMessage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部角色
|
||||
*/
|
||||
export function getAllRoleApi(): Promise<AxiosResponse<any>> {
|
||||
return requestClient.get('sys/role/all');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站点菜单
|
||||
*/
|
||||
export function getSiteMenusApi(): Promise<AxiosResponse<any>> {
|
||||
return requestClient.get('site/site/menu');
|
||||
}
|
||||
125
admin-vben/apps/web-antd/src/api/core/site.ts
Normal file
125
admin-vben/apps/web-antd/src/api/core/site.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { SiteListQuery, SiteForm, SiteGroupForm } from './model/siteModel';
|
||||
import { requestClient as request } from '#/api/request';
|
||||
|
||||
/**
|
||||
* Site management API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get site list
|
||||
*/
|
||||
export const getSiteList = (params: SiteListQuery) => {
|
||||
return request.get('adminapi/site/site', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get site info
|
||||
*/
|
||||
export const getSiteInfo = (siteId: number) => {
|
||||
return request.get(`adminapi/site/site/${siteId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add site
|
||||
*/
|
||||
export const addSite = (data: SiteForm) => {
|
||||
return request.post('adminapi/site/site', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit site
|
||||
*/
|
||||
export const editSite = (siteId: number, data: SiteForm) => {
|
||||
return request.put(`adminapi/site/site/${siteId}`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete site
|
||||
*/
|
||||
export const deleteSite = (siteId: number, captchaCode: string) => {
|
||||
return request.delete(`adminapi/site/site/${siteId}?captcha_code=${captchaCode}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Modify site status
|
||||
*/
|
||||
export const modifySiteStatus = (siteId: number, status: number) => {
|
||||
return request.put(`adminapi/site/site/${siteId}/status`, { status });
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize site
|
||||
*/
|
||||
export const initSite = (siteId: number, captchaCode: string) => {
|
||||
return request.post('adminapi/site/init', { site_id: siteId, captcha_code: captchaCode });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get site captcha
|
||||
*/
|
||||
export const getSiteCaptcha = () => {
|
||||
return request.get('adminapi/site/captcha');
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch site
|
||||
*/
|
||||
export const switchSite = (siteId: number) => {
|
||||
return request.post('adminapi/site/switch', { site_id: siteId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get site group list
|
||||
*/
|
||||
export const getSiteGroupList = (params: SiteListQuery) => {
|
||||
return request.get('adminapi/site/group', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all site groups
|
||||
*/
|
||||
export const getSiteGroupAll = () => {
|
||||
return request.get('adminapi/site/group/all');
|
||||
};
|
||||
|
||||
/**
|
||||
* Add site group
|
||||
*/
|
||||
export const addSiteGroup = (data: SiteGroupForm) => {
|
||||
return request.post('adminapi/site/group', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit site group
|
||||
*/
|
||||
export const editSiteGroup = (groupId: number, data: SiteGroupForm) => {
|
||||
return request.put(`adminapi/site/group/${groupId}`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete site group
|
||||
*/
|
||||
export const deleteSiteGroup = (groupId: number) => {
|
||||
return request.delete(`adminapi/site/group/${groupId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get site users
|
||||
*/
|
||||
export const getSiteUsers = (siteId: number) => {
|
||||
return request.get(`adminapi/site/${siteId}/users`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add site user
|
||||
*/
|
||||
export const addSiteUser = (siteId: number, data: { user_id: number; role_id: number }) => {
|
||||
return request.post(`adminapi/site/${siteId}/user`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove site user
|
||||
*/
|
||||
export const removeSiteUser = (siteId: number, userId: number) => {
|
||||
return request.delete(`adminapi/site/${siteId}/user/${userId}`);
|
||||
};
|
||||
76
admin-vben/apps/web-antd/src/api/core/tools.ts
Normal file
76
admin-vben/apps/web-antd/src/api/core/tools.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 获取插件列表
|
||||
*/
|
||||
export const getAddonDevelopApi = (params: Record<string, any>) => {
|
||||
return request.get('adminapi/tools/addon_develop', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件类型配置
|
||||
*/
|
||||
export const getAddontypeApi = () => {
|
||||
return request.get('adminapi/tools/addontype');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件详情
|
||||
*/
|
||||
export const getAddonDevelopInfoApi = (key: string) => {
|
||||
return request.get(`adminapi/tools/addon_develop/${key}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件key是否存在
|
||||
*/
|
||||
export const getAddonDevelopCheckApi = (key: string) => {
|
||||
return request.get(`adminapi/tools/addon_develop/check/${key}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件key黑名单
|
||||
*/
|
||||
export const getAddonKeyBlackListApi = () => {
|
||||
return request.get('adminapi/tools/addon_develop/key/blacklist');
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加插件
|
||||
*/
|
||||
export const addAddonDevelopApi = (key: string, params: Record<string, any>) => {
|
||||
return request.post(`adminapi/tools/addon_develop/${key}`, params);
|
||||
};
|
||||
|
||||
/**
|
||||
* 编辑插件
|
||||
*/
|
||||
export const editAddonDevelopApi = (key: string, params: Record<string, any>) => {
|
||||
return request.put(`adminapi/tools/addon_develop/${key}`, params);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除插件
|
||||
*/
|
||||
export const deleteAddonDevelopApi = (key: string) => {
|
||||
return request.delete(`adminapi/tools/addon_develop/${key}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
*/
|
||||
export const installAddonDevelopApi = (key: string) => {
|
||||
return request.post(`adminapi/tools/addon_develop/${key}/install`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
export const uninstallAddonDevelopApi = (key: string) => {
|
||||
return request.post(`adminapi/tools/addon_develop/${key}/uninstall`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设计插件
|
||||
*/
|
||||
export const designAddonDevelopApi = (key: string) => {
|
||||
return request.post(`adminapi/tools/addon_develop/${key}/design`);
|
||||
};
|
||||
@@ -1,4 +1,37 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
export interface User {
|
||||
uid: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
user_type: string;
|
||||
status: number;
|
||||
create_time: number;
|
||||
last_login_time: number;
|
||||
site_id: number;
|
||||
role_ids: number[];
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
list: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UserForm {
|
||||
uid?: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
password?: string;
|
||||
user_type: string;
|
||||
status: number;
|
||||
role_ids: number[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
@@ -10,48 +43,62 @@ export function getUserListApi(params: {
|
||||
limit: number;
|
||||
username?: string;
|
||||
user_type?: string;
|
||||
}): Promise<AxiosResponse<any>> {
|
||||
}): Promise<UserListResponse> {
|
||||
return requestClient.get('site/user', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
export function getUserInfoApi(userId: number): Promise<AxiosResponse<any>> {
|
||||
export function getUserInfoApi(userId: number): Promise<User> {
|
||||
return requestClient.get(`site/user/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加用户
|
||||
*/
|
||||
export function addUserApi(params: Record<string, any>): Promise<AxiosResponse<any>> {
|
||||
export function addUserApi(params: UserForm): Promise<void> {
|
||||
return requestClient.post('site/user', params, { showSuccessMessage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑用户
|
||||
*/
|
||||
export function editUserApi(params: Record<string, any>): Promise<AxiosResponse<any>> {
|
||||
export function editUserApi(params: UserForm): Promise<void> {
|
||||
return requestClient.put(`site/user/${params.uid}`, params, { showSuccessMessage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定用户
|
||||
*/
|
||||
export function lockUserApi(userId: number): Promise<AxiosResponse<any>> {
|
||||
export function lockUserApi(userId: number): Promise<void> {
|
||||
return requestClient.put(`site/user/lock/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁用户
|
||||
*/
|
||||
export function unlockUserApi(userId: number): Promise<AxiosResponse<any>> {
|
||||
export function unlockUserApi(userId: number): Promise<void> {
|
||||
return requestClient.put(`site/user/unlock/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表选择器
|
||||
*/
|
||||
export function getUserListSelect(params: { keyword?: string }): Promise<User[]> {
|
||||
return requestClient.get('site/user_select', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
export function deleteUserApi(userId: number): Promise<AxiosResponse<any>> {
|
||||
export function deleteUserApi(userId: number): Promise<void> {
|
||||
return requestClient.delete(`site/user/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
export function getCurrentUserApi(): Promise<User> {
|
||||
return requestClient.get('auth/user');
|
||||
}
|
||||
156
admin-vben/apps/web-antd/src/api/core/weapp.ts
Normal file
156
admin-vben/apps/web-antd/src/api/core/weapp.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { requestClient as request } from '#/api/request';
|
||||
|
||||
enum Api {
|
||||
GetWeappConfig = '/weapp/config',
|
||||
SetWeappConfig = '/weapp/config',
|
||||
GetTemplateList = '/weapp/template',
|
||||
GetBatchAcquisition = '/weapp/template/batch',
|
||||
SetWeappVersion = '/weapp/version',
|
||||
GetWeappVersionList = '/weapp/version',
|
||||
GetWeappUploadLog = '/weapp/upload/log',
|
||||
GetWeappPreview = '/weapp/preview',
|
||||
UploadVersion = '/weapp/version/upload',
|
||||
SetWeappDomain = '/weapp/domain',
|
||||
GetWeappPrivacySetting = '/weapp/privacy',
|
||||
SetWeappPrivacySetting = '/weapp/privacy',
|
||||
GetIsTradeManaged = '/weapp/trade/managed',
|
||||
}
|
||||
|
||||
export interface WeappConfig {
|
||||
weapp_name: string;
|
||||
weapp_original: string;
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
qr_code: string;
|
||||
serve_url: string;
|
||||
token: string;
|
||||
encoding_aes_key: string;
|
||||
encryption_type: number;
|
||||
is_authorization: number;
|
||||
domain: string;
|
||||
request_domain: string;
|
||||
ws_request_domain: string;
|
||||
upload_domain: string;
|
||||
download_domain: string;
|
||||
udp_domain: string;
|
||||
tcp_domain: string;
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
addon: string;
|
||||
template_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
example: string;
|
||||
status: number;
|
||||
create_time: number;
|
||||
}
|
||||
|
||||
export interface VersionItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
version: string;
|
||||
version_desc: string;
|
||||
upload_time: number;
|
||||
audit_time: number;
|
||||
audit_result: string;
|
||||
audit_id: string;
|
||||
status: number;
|
||||
task_key: string;
|
||||
create_time: number;
|
||||
}
|
||||
|
||||
export interface UploadLog {
|
||||
task_key: string;
|
||||
status: number;
|
||||
percent: number;
|
||||
message: string;
|
||||
create_time: number;
|
||||
}
|
||||
|
||||
export interface PrivacySetting {
|
||||
owner_setting: {
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_qq: string;
|
||||
contact_weixin: string;
|
||||
store_expire_timestamp: string;
|
||||
};
|
||||
setting_list: Array<{
|
||||
privacy_key: string;
|
||||
privacy_text: string;
|
||||
}>;
|
||||
sdk_privacy_info_list: Array<{
|
||||
sdk_name: string;
|
||||
sdk_biz: string;
|
||||
privacy_key_list: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getWeappConfig = () => {
|
||||
return request.get<WeappConfig>(Api.GetWeappConfig);
|
||||
};
|
||||
|
||||
export const setWeappConfig = (data: Partial<WeappConfig>) => {
|
||||
return request.post(Api.SetWeappConfig, data);
|
||||
};
|
||||
|
||||
export const getTemplateList = (params: { addon?: string }) => {
|
||||
return request.get<TemplateItem[]>(Api.GetTemplateList, { params });
|
||||
};
|
||||
|
||||
export const getBatchAcquisition = (params: { addon?: string }) => {
|
||||
return request.post(Api.GetBatchAcquisition, params);
|
||||
};
|
||||
|
||||
export const setWeappVersion = (data: { version: string; version_desc: string; authorization_code?: string }) => {
|
||||
return request.post(Api.SetWeappVersion, data);
|
||||
};
|
||||
|
||||
export const getWeappVersionList = (params: { page?: number; limit?: number }) => {
|
||||
return request.get<{ list: VersionItem[]; total: number }>(Api.GetWeappVersionList, { params });
|
||||
};
|
||||
|
||||
export const getWeappUploadLog = (params: { task_key: string }) => {
|
||||
return request.get<UploadLog>(Api.GetWeappUploadLog, { params });
|
||||
};
|
||||
|
||||
export const getWeappPreview = () => {
|
||||
return request.get<{ preview_url: string }>(Api.GetWeappPreview);
|
||||
};
|
||||
|
||||
export const uploadVersion = (data: { file: File; version: string }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', data.file);
|
||||
formData.append('version', data.version);
|
||||
return request.post(Api.UploadVersion, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const setWeappDomain = (data: {
|
||||
request_domain: string;
|
||||
ws_request_domain: string;
|
||||
upload_domain: string;
|
||||
download_domain: string;
|
||||
udp_domain: string;
|
||||
tcp_domain: string;
|
||||
}) => {
|
||||
return request.post(Api.SetWeappDomain, data);
|
||||
};
|
||||
|
||||
export const getWeappPrivacySetting = () => {
|
||||
return request.get<PrivacySetting>(Api.GetWeappPrivacySetting);
|
||||
};
|
||||
|
||||
export const setWeappPrivacySetting = (data: PrivacySetting) => {
|
||||
return request.post(Api.SetWeappPrivacySetting, data);
|
||||
};
|
||||
|
||||
export const getIsTradeManaged = () => {
|
||||
return request.get<{ is_trade_managed: number }>(Api.GetIsTradeManaged);
|
||||
};
|
||||
275
admin-vben/apps/web-antd/src/api/core/wechat.ts
Normal file
275
admin-vben/apps/web-antd/src/api/core/wechat.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { requestClient as request } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 微信公众号配置接口
|
||||
*/
|
||||
|
||||
// 获取公众号配置
|
||||
export const getWechatConfigApi = () => {
|
||||
return request.get('/adminapi/wechat/config');
|
||||
};
|
||||
|
||||
// 保存公众号配置
|
||||
export const saveWechatConfigApi = (data: {
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
token: string;
|
||||
encoding_aes_key: string;
|
||||
encryption_type: number;
|
||||
qr_code: string;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/config', data);
|
||||
};
|
||||
|
||||
// 获取服务器配置
|
||||
export const getServerConfigApi = () => {
|
||||
return request.get('/adminapi/wechat/server');
|
||||
};
|
||||
|
||||
// 保存服务器配置
|
||||
export const saveServerConfigApi = (data: {
|
||||
serve_url: string;
|
||||
token: string;
|
||||
encoding_aes_key: string;
|
||||
encryption_type: number;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/server', data);
|
||||
};
|
||||
|
||||
// 获取域名配置
|
||||
export const getDomainConfigApi = () => {
|
||||
return request.get('/adminapi/wechat/domain');
|
||||
};
|
||||
|
||||
// 保存域名配置
|
||||
export const saveDomainConfigApi = (data: {
|
||||
request_domain: string[];
|
||||
ws_request_domain: string[];
|
||||
upload_domain: string[];
|
||||
download_domain: string[];
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/domain', data);
|
||||
};
|
||||
|
||||
// 获取隐私配置
|
||||
export const getPrivacyConfigApi = () => {
|
||||
return request.get('/adminapi/wechat/privacy');
|
||||
};
|
||||
|
||||
// 保存隐私配置
|
||||
export const savePrivacyConfigApi = (data: {
|
||||
owner_setting: {
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_qq: string;
|
||||
contact_weixin: string;
|
||||
store_expire_timestamp: string;
|
||||
};
|
||||
setting_list: Array<{
|
||||
privacy_key: string;
|
||||
privacy_text: string;
|
||||
}>;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/privacy', data);
|
||||
};
|
||||
|
||||
// 获取消息模板列表
|
||||
export const getTemplateListApi = (params: { page?: number; limit?: number }) => {
|
||||
return request.get('/adminapi/wechat/template', { params });
|
||||
};
|
||||
|
||||
// 同步消息模板
|
||||
export const syncTemplateApi = () => {
|
||||
return request.post('/adminapi/wechat/template/sync');
|
||||
};
|
||||
|
||||
// 启用/禁用消息模板
|
||||
export const modifyTemplateStatusApi = (id: number, status: number) => {
|
||||
return request.put(`/adminapi/wechat/template/status/${id}`, { status });
|
||||
};
|
||||
|
||||
// 获取自定义菜单
|
||||
export const getCustomMenuApi = () => {
|
||||
return request.get('/adminapi/wechat/menu');
|
||||
};
|
||||
|
||||
// 保存自定义菜单
|
||||
export const saveCustomMenuApi = (data: {
|
||||
button: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
key?: string;
|
||||
url?: string;
|
||||
media_id?: string;
|
||||
sub_button?: Array<any>;
|
||||
}>;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/menu', data);
|
||||
};
|
||||
|
||||
// 获取二维码列表
|
||||
export const getQrcodeListApi = (params: { page?: number; limit?: number }) => {
|
||||
return request.get('/adminapi/wechat/qrcode', { params });
|
||||
};
|
||||
|
||||
// 创建二维码
|
||||
export const createQrcodeApi = (data: {
|
||||
action_name: string;
|
||||
action_info: {
|
||||
scene: {
|
||||
scene_id?: number;
|
||||
scene_str?: string;
|
||||
};
|
||||
};
|
||||
expire_seconds?: number;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/qrcode', data);
|
||||
};
|
||||
|
||||
// 删除二维码
|
||||
export const deleteQrcodeApi = (id: number) => {
|
||||
return request.delete(`/adminapi/wechat/qrcode/${id}`);
|
||||
};
|
||||
|
||||
// 获取用户标签列表
|
||||
export const getUserTagListApi = (params: { page?: number; limit?: number }) => {
|
||||
return request.get('/adminapi/wechat/user/tag', { params });
|
||||
};
|
||||
|
||||
// 同步用户标签
|
||||
export const syncUserTagApi = () => {
|
||||
return request.post('/adminapi/wechat/user/tag/sync');
|
||||
};
|
||||
|
||||
// 获取用户列表
|
||||
export const getUserListApi = (params: { page?: number; limit?: number; nickname?: string; tag_id?: number }) => {
|
||||
return request.get('/adminapi/wechat/user', { params });
|
||||
};
|
||||
|
||||
// 同步用户
|
||||
export const syncUserApi = () => {
|
||||
return request.post('/adminapi/wechat/user/sync');
|
||||
};
|
||||
|
||||
// 获取用户详情
|
||||
export const getUserDetailApi = (openid: string) => {
|
||||
return request.get(`/adminapi/wechat/user/${openid}`);
|
||||
};
|
||||
|
||||
// 获取素材列表
|
||||
export const getMaterialListApi = (params: { page?: number; limit?: number; type: string }) => {
|
||||
return request.get('/adminapi/wechat/material', { params });
|
||||
};
|
||||
|
||||
// 同步素材
|
||||
export const syncMaterialApi = () => {
|
||||
return request.post('/adminapi/wechat/material/sync');
|
||||
};
|
||||
|
||||
// 上传素材
|
||||
export const uploadMaterialApi = (data: FormData) => {
|
||||
return request.post('/adminapi/wechat/material/upload', data);
|
||||
};
|
||||
|
||||
// 删除素材
|
||||
export const deleteMaterialApi = (id: number) => {
|
||||
return request.delete(`/adminapi/wechat/material/${id}`);
|
||||
};
|
||||
|
||||
// 获取图文消息列表
|
||||
export const getNewsListApi = (params: { page?: number; limit?: number }) => {
|
||||
return request.get('/adminapi/wechat/news', { params });
|
||||
};
|
||||
|
||||
// 创建图文消息
|
||||
export const createNewsApi = (data: {
|
||||
title: string;
|
||||
author: string;
|
||||
digest: string;
|
||||
show_cover_pic: number;
|
||||
content: string;
|
||||
content_source_url: string;
|
||||
thumb_media_id: string;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/news', data);
|
||||
};
|
||||
|
||||
// 编辑图文消息
|
||||
export const editNewsApi = (id: number, data: {
|
||||
title: string;
|
||||
author: string;
|
||||
digest: string;
|
||||
show_cover_pic: number;
|
||||
content: string;
|
||||
content_source_url: string;
|
||||
thumb_media_id: string;
|
||||
}) => {
|
||||
return request.put(`/adminapi/wechat/news/${id}`, data);
|
||||
};
|
||||
|
||||
// 删除图文消息
|
||||
export const deleteNewsApi = (id: number) => {
|
||||
return request.delete(`/adminapi/wechat/news/${id}`);
|
||||
};
|
||||
|
||||
// 获取自动回复规则
|
||||
export const getAutoReplyApi = () => {
|
||||
return request.get('/adminapi/wechat/reply');
|
||||
};
|
||||
|
||||
// 保存自动回复规则
|
||||
export const saveAutoReplyApi = (data: {
|
||||
is_open: number;
|
||||
reply_type: string;
|
||||
reply_content: string;
|
||||
media_id?: string;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/reply', data);
|
||||
};
|
||||
|
||||
// 获取关键词回复列表
|
||||
export const getKeywordReplyListApi = (params: { page?: number; limit?: number }) => {
|
||||
return request.get('/adminapi/wechat/keyword', { params });
|
||||
};
|
||||
|
||||
// 添加关键词回复
|
||||
export const addKeywordReplyApi = (data: {
|
||||
rule_name: string;
|
||||
keyword_list: Array<{
|
||||
keyword: string;
|
||||
match_type: string;
|
||||
}>;
|
||||
reply_list: Array<{
|
||||
reply_type: string;
|
||||
reply_content: string;
|
||||
media_id?: string;
|
||||
}>;
|
||||
}) => {
|
||||
return request.post('/adminapi/wechat/keyword', data);
|
||||
};
|
||||
|
||||
// 编辑关键词回复
|
||||
export const editKeywordReplyApi = (id: number, data: {
|
||||
rule_name: string;
|
||||
keyword_list: Array<{
|
||||
keyword: string;
|
||||
match_type: string;
|
||||
}>;
|
||||
reply_list: Array<{
|
||||
reply_type: string;
|
||||
reply_content: string;
|
||||
media_id?: string;
|
||||
}>;
|
||||
}) => {
|
||||
return request.put(`/adminapi/wechat/keyword/${id}`, data);
|
||||
};
|
||||
|
||||
// 删除关键词回复
|
||||
export const deleteKeywordReplyApi = (id: number) => {
|
||||
return request.delete(`/adminapi/wechat/keyword/${id}`);
|
||||
};
|
||||
|
||||
// 修改关键词回复状态
|
||||
export const modifyKeywordReplyStatusApi = (id: number, status: number) => {
|
||||
return request.put(`/adminapi/wechat/keyword/status/${id}`, { status });
|
||||
};
|
||||
38
admin-vben/apps/web-antd/src/api/core/wxoplatform.ts
Normal file
38
admin-vben/apps/web-antd/src/api/core/wxoplatform.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
enum Api {
|
||||
GetAuthorizationUrl = '/wxoplatform/authorization/url',
|
||||
SiteWeappCommit = '/wxoplatform/site/weapp/commit',
|
||||
UndoAudit = '/wxoplatform/site/weapp/undo/audit',
|
||||
}
|
||||
|
||||
export interface AuthorizationUrlParams {
|
||||
site_id?: number;
|
||||
}
|
||||
|
||||
export interface AuthorizationUrlResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SiteWeappCommitParams {
|
||||
site_id: number;
|
||||
version: string;
|
||||
version_desc: string;
|
||||
}
|
||||
|
||||
export interface UndoAuditParams {
|
||||
site_id: number;
|
||||
audit_id: string;
|
||||
}
|
||||
|
||||
export const getAuthorizationUrl = (params: AuthorizationUrlParams) => {
|
||||
return request.get<AuthorizationUrlResponse>(Api.GetAuthorizationUrl, { params });
|
||||
};
|
||||
|
||||
export const siteWeappCommit = (data: SiteWeappCommitParams) => {
|
||||
return request.post(Api.SiteWeappCommit, data);
|
||||
};
|
||||
|
||||
export const undoAudit = (data: UndoAuditParams) => {
|
||||
return request.post(Api.UndoAudit, data);
|
||||
};
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './core/auth';
|
||||
export * from './core/user';
|
||||
export * from './core/role';
|
||||
export * from './core/menu';
|
||||
export * from './core/site';
|
||||
export * from './core/diy';
|
||||
@@ -27,7 +27,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
...options,
|
||||
baseURL,
|
||||
transformResponse: (data: any, header: AxiosResponseHeaders) => {
|
||||
transformResponse: (data: string | object, header: AxiosResponseHeaders) => {
|
||||
// storeAsString指示将BigInt存储为字符串,设为false则会存储为内置的BigInt类型
|
||||
return header.getContentType()?.toString().includes('application/json')
|
||||
? cloneDeep(
|
||||
@@ -123,7 +123,7 @@ export const requestClient = createRequestClient(apiURL, {
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
|
||||
export interface PageFetchParams {
|
||||
[key: string]: any;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"title": "System Management",
|
||||
"role": {
|
||||
"title": "Role Management",
|
||||
"list": "Role List",
|
||||
"name": "Role",
|
||||
"roleName": "Role Name",
|
||||
"id": "Role ID",
|
||||
"status": "Status",
|
||||
"remark": "Remark",
|
||||
"createTime": "Creation Time",
|
||||
"operation": "Operation",
|
||||
"permissions": "Permissions",
|
||||
"setPermissions": "Permissions"
|
||||
},
|
||||
"dept": {
|
||||
"name": "Department",
|
||||
"title": "Department Management",
|
||||
@@ -12,22 +25,39 @@
|
||||
},
|
||||
"menu": {
|
||||
"title": "Menu Management",
|
||||
"addMenu": "Add Menu",
|
||||
"editMenu": "Edit Menu",
|
||||
"parent": "Parent Menu",
|
||||
"menuTitle": "Title",
|
||||
"menuName": "Menu Name",
|
||||
"menuNamePlaceholder": "Please enter menu name",
|
||||
"menuKey": "Menu Key",
|
||||
"menuKeyPlaceholder": "Please enter menu key",
|
||||
"menuKeyValidata": "Menu key format is incorrect",
|
||||
"name": "Menu",
|
||||
"type": "Type",
|
||||
"menuType": "Menu Type",
|
||||
"menuTypeDir": "Directory",
|
||||
"menuTypeMenu": "Menu",
|
||||
"menuTypeButton": "Button",
|
||||
"typeCatalog": "Catalog",
|
||||
"typeMenu": "Menu",
|
||||
"typeButton": "Button",
|
||||
"typeLink": "Link",
|
||||
"typeEmbedded": "Embedded",
|
||||
"icon": "Icon",
|
||||
"menuIcon": "Menu Icon",
|
||||
"activeIcon": "Active Icon",
|
||||
"activePath": "Active Path",
|
||||
"path": "Route Path",
|
||||
"routePath": "Route Path",
|
||||
"routePathPlaceholder": "Please enter route path",
|
||||
"viewPath": "View Path",
|
||||
"viewPathPlaceholder": "Please enter view path",
|
||||
"component": "Component",
|
||||
"status": "Status",
|
||||
"authId": "Auth ID",
|
||||
"authIdPlaceholder": "Please enter auth ID",
|
||||
"authCode": "Auth Code",
|
||||
"badge": "Badge",
|
||||
"operation": "Operation",
|
||||
@@ -47,19 +77,455 @@
|
||||
"normal": "Text",
|
||||
"none": "None"
|
||||
},
|
||||
"badgeVariants": "Badge Style"
|
||||
"badgeVariants": "Badge Style",
|
||||
"addon": "Addon",
|
||||
"parentMenu": "Parent Menu",
|
||||
"menuShortName": "Short Name",
|
||||
"menuShortNamePlaceholder": "Please enter menu short name",
|
||||
"isShow": "Show",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"sort": "Sort",
|
||||
"initializeMenu": "Initialize Menu",
|
||||
"initializeMenuTipsOne": "This operation will reset all menu data",
|
||||
"initializeMenuTipsTwo": "Are you sure you want to continue?",
|
||||
"initializeSuccess": "Menu initialized successfully",
|
||||
"initializeError": "Failed to initialize menu",
|
||||
"loadError": "Failed to load menu list",
|
||||
"loadMenuError": "Failed to load menu data",
|
||||
"loadAddonError": "Failed to load addon data",
|
||||
"loadMenuInfoError": "Failed to load menu information",
|
||||
"saveError": "Failed to save menu",
|
||||
"addSuccess": "Menu added successfully",
|
||||
"editSuccess": "Menu updated successfully",
|
||||
"deleteSuccess": "Menu deleted successfully",
|
||||
"deleteError": "Failed to delete menu",
|
||||
"deleteConfirm": "Are you sure you want to delete this menu?"
|
||||
},
|
||||
"role": {
|
||||
"title": "Role Management",
|
||||
"list": "Role List",
|
||||
"name": "Role",
|
||||
"roleName": "Role Name",
|
||||
"id": "Role ID",
|
||||
"status": "Status",
|
||||
"remark": "Remark",
|
||||
"group": {
|
||||
"title": "Site Group",
|
||||
"groupId": "Group ID",
|
||||
"groupName": "Group Name",
|
||||
"groupDesc": "Group Description",
|
||||
"mainApp": "Main Application",
|
||||
"containAddon": "Include Addons",
|
||||
"appName": "Application Name",
|
||||
"addonName": "Addon Name",
|
||||
"createTime": "Creation Time",
|
||||
"operation": "Operation",
|
||||
"permissions": "Permissions",
|
||||
"setPermissions": "Permissions"
|
||||
"addGroup": "Add Group",
|
||||
"editGroup": "Edit Group",
|
||||
"deleteConfirm": "Are you sure you want to delete this group?",
|
||||
"deleteSuccess": "Group deleted successfully",
|
||||
"deleteError": "Failed to delete group",
|
||||
"saveSuccess": "Group saved successfully",
|
||||
"saveError": "Failed to save group",
|
||||
"groupNamePlaceholder": "Please enter group name",
|
||||
"groupDescPlaceholder": "Please enter group description",
|
||||
"mainAppPlaceholder": "Please select main application",
|
||||
"containAddonPlaceholder": "Please select included addons",
|
||||
"appListEmpty": "No applications",
|
||||
"addonListEmpty": "No addons",
|
||||
"moreApps": "+{count} more applications",
|
||||
"moreAddons": "+{count} more addons",
|
||||
"selectAppFirst": "Please select the corresponding main application first",
|
||||
"addonRemovedDueToAppDependency": "Some addons have been automatically removed due to dependency relationships"
|
||||
},
|
||||
"diy": {
|
||||
"title": "DIY Decoration",
|
||||
"decorating": "Decorating",
|
||||
"list": {
|
||||
"title": "Custom Pages",
|
||||
"pageId": "Page ID",
|
||||
"pageTitle": "Page Title",
|
||||
"pageTitlePlaceholder": "Please enter page title",
|
||||
"addonName": "Belonging App",
|
||||
"pageType": "Page Type",
|
||||
"pageTypePlaceholder": "Please select page type",
|
||||
"status": "Status",
|
||||
"updateTime": "Update Time",
|
||||
"addPage": "Add Page",
|
||||
"preview": "Preview",
|
||||
"shareSetting": "Share Settings",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Page copied successfully",
|
||||
"copyError": "Failed to copy page",
|
||||
"deleteConfirm": "Are you sure you want to delete this page?",
|
||||
"deleteSuccess": "Page deleted successfully",
|
||||
"deleteError": "Failed to delete page",
|
||||
"setUseSuccess": "Set successfully",
|
||||
"setUseError": "Failed to set",
|
||||
"cannotSetUseDiyPage": "DIY_PAGE type pages cannot be set as in use",
|
||||
"shareTitle": "Share Title",
|
||||
"shareTitlePlaceholder": "Please enter share title",
|
||||
"shareDesc": "Share Description",
|
||||
"shareDescPlaceholder": "Please enter share description",
|
||||
"shareImage": "Share Image",
|
||||
"wechatShare": "WeChat Official Account Share",
|
||||
"weappShare": "WeChat Mini Program Share"
|
||||
},
|
||||
"design": {
|
||||
"templatePagePlaceholder": "Select Template Page",
|
||||
"templatePageEmpty": "Empty Template",
|
||||
"pageSet": "Page Settings",
|
||||
"moveUpComponent": "Move Up Component",
|
||||
"moveDownComponent": "Move Down Component",
|
||||
"copyComponent": "Copy Component",
|
||||
"delComponent": "Delete Component",
|
||||
"resetComponent": "Reset Component",
|
||||
"delComponentTips": "Are you sure you want to delete this component?",
|
||||
"changeTemplatePageTips": "Switching template page will clear current page content, are you sure you want to continue?",
|
||||
"developTitle": "Development Mode Configuration",
|
||||
"wapDomain": "WAP Domain",
|
||||
"wapDomainPlaceholder": "Please enter WAP domain, e.g.: https://www.example.com",
|
||||
"settingTips": "Configuration Instructions",
|
||||
"initPageError": "Failed to initialize page",
|
||||
"tabEditContent": "Content",
|
||||
"tabEditStyle": "Style",
|
||||
"pageSettings": "Page Settings",
|
||||
"pageName": "Page Name",
|
||||
"pageNamePlaceholder": "Please enter page name",
|
||||
"pageMode": "Page Mode",
|
||||
"style1": "Single Column Layout",
|
||||
"style2": "Left-Right Layout",
|
||||
"alignment": "Alignment",
|
||||
"alignLeft": "Left Align",
|
||||
"alignRight": "Right Align",
|
||||
"borderControl": "Border Control",
|
||||
"pageBackground": "Page Background",
|
||||
"bgColor": "Background Color",
|
||||
"bgColorTips": "Left is start color, right is end color",
|
||||
"gradientAngle": "Gradient Angle",
|
||||
"topToBottom": "Top to Bottom",
|
||||
"leftToRight": "Left to Right",
|
||||
"bgImage": "Background Image",
|
||||
"bgHeightScale": "Background Height Scale",
|
||||
"bgHeightScaleTips": "0 for auto height",
|
||||
"topStatusBar": "Top Status Bar",
|
||||
"showStatusBar": "Show Status Bar",
|
||||
"statusBarStyle": "Status Bar Style",
|
||||
"style1Text": "Text Only",
|
||||
"style2ImageText": "Image + Text",
|
||||
"style3ImageSearch": "Image + Search",
|
||||
"style4Location": "Location",
|
||||
"textAlign": "Text Alignment",
|
||||
"alignCenter": "Center",
|
||||
"logoImage": "Logo Image",
|
||||
"searchPlaceholder": "Search Placeholder",
|
||||
"searchPlaceholderTips": "Please enter search placeholder text",
|
||||
"link": "Jump Link",
|
||||
"linkPlaceholder": "Please select jump link",
|
||||
"popWindow": "Popup Settings",
|
||||
"showPopWindow": "Show Popup",
|
||||
"neverShow": "Never Show",
|
||||
"showOnce": "Show Once",
|
||||
"showAlways": "Show Always",
|
||||
"popImage": "Popup Image",
|
||||
"popLink": "Popup Link",
|
||||
"popLinkPlaceholder": "Please select popup jump link",
|
||||
"leavePageContentTips": "Page content has been modified, are you sure you want to leave?",
|
||||
"diyPageTitlePlaceholder": "Please enter page title",
|
||||
"componentCanOnlyAdd": "Can only add up to",
|
||||
"piece": "pieces",
|
||||
"componentNotMoved": "This component cannot be moved",
|
||||
"notCopy": "Cannot copy"
|
||||
},
|
||||
"bottomNav": {
|
||||
"title": "Bottom Navigation",
|
||||
"name": "Navigation Name",
|
||||
"namePlaceholder": "Please enter navigation name",
|
||||
"navigationItems": "Navigation Items",
|
||||
"item": "Item",
|
||||
"itemCount": "Item Count",
|
||||
"text": "Text",
|
||||
"textPlaceholder": "Please enter navigation text",
|
||||
"link": "Link",
|
||||
"linkPlaceholder": "Please enter navigation link",
|
||||
"selectedIcon": "Selected Icon",
|
||||
"unselectedIcon": "Unselected Icon",
|
||||
"addItem": "Add Navigation Item",
|
||||
"addBottomNav": "Add Bottom Navigation",
|
||||
"editBottomNav": "Edit Bottom Navigation",
|
||||
"deleteConfirm": "Are you sure you want to delete this bottom navigation?",
|
||||
"deleteSuccess": "Bottom navigation deleted successfully",
|
||||
"deleteError": "Failed to delete bottom navigation"
|
||||
},
|
||||
"route": {
|
||||
"title": "Route Management",
|
||||
"name": "Route Name",
|
||||
"namePlaceholder": "Please enter route name",
|
||||
"path": "Route Path",
|
||||
"pathPlaceholder": "Please enter route path",
|
||||
"component": "Component Path",
|
||||
"componentPlaceholder": "Please enter component path",
|
||||
"title": "Page Title",
|
||||
"titlePlaceholder": "Please enter page title",
|
||||
"icon": "Icon",
|
||||
"iconPlaceholder": "Please enter icon class name",
|
||||
"keepAlive": "Cache Page",
|
||||
"requireAuth": "Require Login",
|
||||
"addRoute": "Add Route",
|
||||
"editRoute": "Edit Route",
|
||||
"deleteConfirm": "Are you sure you want to delete this route?",
|
||||
"deleteSuccess": "Route deleted successfully",
|
||||
"deleteError": "Failed to delete route"
|
||||
}
|
||||
},
|
||||
"channel": {
|
||||
"weapp": {
|
||||
"access": {
|
||||
"title": "Mini Program Access",
|
||||
"tip": "Please follow the steps below to complete WeChat Mini Program access configuration",
|
||||
"qrCodeTip": "WeChat Mini Program QR Code",
|
||||
"bindAuth": "Bind Now",
|
||||
"refreshAuth": "Refresh Authorization",
|
||||
"viewTutorial": "View Tutorial"
|
||||
},
|
||||
"config": {
|
||||
"title": "Mini Program Configuration",
|
||||
"basicTab": "Basic Configuration",
|
||||
"serverTab": "Server Configuration",
|
||||
"domainTab": "Domain Configuration",
|
||||
"privacyTab": "Privacy Policy",
|
||||
"domainTip": "Please configure the business domain for the mini program, separate multiple domains with semicolons",
|
||||
"privacyTip": "Please configure the privacy policy information for the mini program",
|
||||
"modifyDomain": "Modify Domain",
|
||||
"modifyPrivacy": "Modify Privacy Policy"
|
||||
},
|
||||
"template": {
|
||||
"title": "Subscription Message Templates",
|
||||
"batchSync": "Batch Sync",
|
||||
"sync": "Sync"
|
||||
},
|
||||
"code": {
|
||||
"title": "Version Release",
|
||||
"cloudRelease": "Cloud Release",
|
||||
"cloudReleaseTip": "Release mini program version directly through the cloud",
|
||||
"localRelease": "Local Upload",
|
||||
"localReleaseTip": "Upload locally packaged mini program code",
|
||||
"preview": "Preview Code",
|
||||
"previewTip": "Scan code to preview mini program",
|
||||
"uploadLog": "Upload Log",
|
||||
"uploadProgress": "Upload Progress"
|
||||
},
|
||||
"course": {
|
||||
"title": "Mini Program Access Tutorial",
|
||||
"subtitle": "Follow the steps below to complete WeChat Mini Program access configuration",
|
||||
"start": "Start Access",
|
||||
"step1": {
|
||||
"title": "Bind WeChat Mini Program",
|
||||
"desc1": "First, you need to bind the WeChat Mini Program account to get the AppID and AppSecret of the mini program.",
|
||||
"desc2": "Log in to the WeChat public platform, enter the mini program management backend, and get the relevant configuration information.",
|
||||
"note": "Notes:",
|
||||
"note1": "Ensure the mini program has completed certification",
|
||||
"note2": "Get the correct AppID and AppSecret",
|
||||
"note3": "Configure the server domain for the mini program"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Configure Message Server",
|
||||
"desc1": "Configure the message server for the mini program to ensure normal reception of message pushes from the WeChat server.",
|
||||
"desc2": "Set parameters such as Token, EncodingAESKey, and choose the appropriate encryption method.",
|
||||
"config": "Configuration Items:",
|
||||
"config1": "Token: Custom verification token",
|
||||
"config2": "EncodingAESKey: Message encryption key",
|
||||
"config3": "Encryption Method: Plain text, Compatible, Secure mode"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Subscription Message Templates",
|
||||
"desc1": "Synchronize the subscription message templates of the mini program for sending notification messages to users.",
|
||||
"desc2": "You can sync existing templates from the WeChat public platform or create new templates.",
|
||||
"tip": "Subscription messages require user active subscription before sending"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Release Mini Program",
|
||||
"desc1": "After completing development and testing, submit the mini program version for review and release.",
|
||||
"desc2": "You can choose cloud release or local upload for version release.",
|
||||
"cloud": "Cloud Release Features",
|
||||
"cloud1": "Submit code directly online",
|
||||
"cloud2": "Automatically complete package upload",
|
||||
"cloud3": "Support version management",
|
||||
"local": "Local Upload Features",
|
||||
"local1": "Upload local packaged files",
|
||||
"local2": "Support custom packaging process",
|
||||
"local3": "Suitable for complex projects"
|
||||
}
|
||||
},
|
||||
"wechat": {
|
||||
"title": "WeChat Official Account",
|
||||
"access": {
|
||||
"title": "Access Guide",
|
||||
"config": "Configuration Management",
|
||||
"tip": "Please follow the steps below to complete WeChat Official Account integration",
|
||||
"step1": {
|
||||
"title": "Step 1: Register Official Account",
|
||||
"desc": "Visit WeChat Official Account Platform to register and verify your account"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Step 2: Get Configuration Info",
|
||||
"desc": "Get AppID and AppSecret from Official Account backend",
|
||||
"copyAppId": "Copy AppID",
|
||||
"copyAppSecret": "Copy AppSecret"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Step 3: Configure Server",
|
||||
"desc": "Download certificate and configure server information",
|
||||
"download": "Download Certificate"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Step 4: Test Connection",
|
||||
"desc": "Test if the connection between Official Account and system is working",
|
||||
"test": "Test Connection"
|
||||
},
|
||||
"next": "Next",
|
||||
"prev": "Previous",
|
||||
"complete": "Complete",
|
||||
"success": "Integration configuration completed",
|
||||
"copySuccess": "Copy successful"
|
||||
},
|
||||
"config": {
|
||||
"title": "Official Account Configuration",
|
||||
"basic": "Basic Configuration",
|
||||
"server": "Server Configuration",
|
||||
"domain": "Domain Configuration",
|
||||
"privacy": "Privacy Configuration",
|
||||
"appId": "AppID",
|
||||
"appIdPlaceholder": "Please enter Official Account AppID",
|
||||
"appIdRequired": "Please enter AppID",
|
||||
"appSecret": "AppSecret",
|
||||
"appSecretPlaceholder": "Please enter Official Account AppSecret",
|
||||
"appSecretRequired": "Please enter AppSecret",
|
||||
"token": "Token",
|
||||
"tokenPlaceholder": "Please enter Token",
|
||||
"tokenRequired": "Please enter Token",
|
||||
"aesKey": "AES Key",
|
||||
"aesKeyPlaceholder": "Please enter AES key",
|
||||
"originalId": "Original ID",
|
||||
"originalIdPlaceholder": "Please enter Official Account original ID",
|
||||
"qrcode": "QR Code",
|
||||
"upload": "Upload QR Code",
|
||||
"uploadSuccess": "Upload successful",
|
||||
"uploadError": "Upload failed",
|
||||
"imageOnly": "Only image files can be uploaded",
|
||||
"imageSize": "Image size cannot exceed 2MB",
|
||||
"save": "Save",
|
||||
"reset": "Reset",
|
||||
"saveSuccess": "Save successful",
|
||||
"saveError": "Save failed",
|
||||
"loadError": "Failed to load configuration",
|
||||
"serverUrl": "Server URL",
|
||||
"serverUrlPlaceholder": "Automatically generated by system",
|
||||
"encodingAesKey": "Message Encryption Key",
|
||||
"encodingAesKeyPlaceholder": "Please enter message encryption key",
|
||||
"encodingAesKeyTip": "43 characters, used for message encryption/decryption",
|
||||
"encryptType": "Encryption Type",
|
||||
"encryptType0": "Plain Text Mode",
|
||||
"encryptType1": "Compatible Mode",
|
||||
"encryptType2": "Secure Mode",
|
||||
"businessDomain": "Business Domain",
|
||||
"businessDomainPlaceholder": "One domain per line, maximum 3",
|
||||
"businessDomainRequired": "Please enter business domain",
|
||||
"businessDomainTip": "No security warning when users input on this domain",
|
||||
"jsDomain": "JS Interface Safe Domain",
|
||||
"jsDomainPlaceholder": "One domain per line, maximum 3",
|
||||
"jsDomainTip": "Domain for calling JS interfaces",
|
||||
"webDomain": "Web Authorization Domain",
|
||||
"webDomainPlaceholder": "One domain per line, maximum 3",
|
||||
"webDomainTip": "Domain for web authorization",
|
||||
"privacyTip": "Please configure privacy settings carefully to ensure compliance with relevant laws",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"privacyPolicy1": "Privacy protection enabled",
|
||||
"privacyPolicy0": "Privacy protection disabled",
|
||||
"privacyPolicyRequired": "Please select privacy policy",
|
||||
"userPrivacy": "User Privacy Description",
|
||||
"userPrivacyPlaceholder": "Please enter user privacy description",
|
||||
"userPrivacyTip": "Explain data collection and usage to users",
|
||||
"dataRetention": "Data Retention Period",
|
||||
"retention30": "30 days",
|
||||
"retention90": "90 days",
|
||||
"retention180": "180 days",
|
||||
"retention365": "365 days",
|
||||
"dataRetentionTip": "User data retention period, automatically deleted after expiration"
|
||||
},
|
||||
"template": {
|
||||
"title": "Message Templates",
|
||||
"sync": "Sync Templates",
|
||||
"edit": "Edit Template",
|
||||
"templateId": "Template ID",
|
||||
"primaryIndustry": "Primary Industry",
|
||||
"deputyIndustry": "Deputy Industry",
|
||||
"content": "Template Content",
|
||||
"example": "Example",
|
||||
"deleteConfirm": "Are you sure to delete template【{title}】?",
|
||||
"loadError": "Failed to load templates",
|
||||
"syncSuccess": "Sync successful",
|
||||
"syncError": "Sync failed"
|
||||
},
|
||||
"menu": {
|
||||
"title": "Custom Menu",
|
||||
"preview": "Preview",
|
||||
"publish": "Publish",
|
||||
"editor": "Menu Editor",
|
||||
"selectMenu": "Please select a menu to edit",
|
||||
"name": "Menu Name",
|
||||
"namePlaceholder": "Please enter menu name",
|
||||
"nameRequired": "Please enter menu name",
|
||||
"type": "Menu Type",
|
||||
"typeRequired": "Please select menu type",
|
||||
"typeClick": "Click Event",
|
||||
"typeView": "Redirect URL",
|
||||
"typeMiniprogram": "Mini Program",
|
||||
"typeScancode": "Scan Code Event",
|
||||
"typeLocation": "Send Location",
|
||||
"key": "Menu KEY",
|
||||
"keyPlaceholder": "Please enter menu KEY",
|
||||
"url": "Web Link",
|
||||
"urlPlaceholder": "Please enter web link",
|
||||
"appid": "Mini Program APPID",
|
||||
"appidPlaceholder": "Please enter mini program APPID",
|
||||
"pagepath": "Mini Program Path",
|
||||
"pagepathPlaceholder": "Please enter mini program path",
|
||||
"addSubMenu": "Add Sub Menu",
|
||||
"deleteConfirm": "Are you sure to delete this menu?",
|
||||
"loadError": "Failed to load menu",
|
||||
"saveSuccess": "Save successful",
|
||||
"saveError": "Save failed",
|
||||
"newMenu": "New Menu",
|
||||
"newSubMenu": "New Sub Menu"
|
||||
},
|
||||
"user": {
|
||||
"title": "User Management",
|
||||
"sync": "Sync Users",
|
||||
"export": "Export Users",
|
||||
"nickname": "Nickname",
|
||||
"nicknamePlaceholder": "Please enter nickname",
|
||||
"subscribe": "Subscription Status",
|
||||
"subscribed": "Subscribed",
|
||||
"unsubscribed": "Unsubscribed",
|
||||
"sex": "Gender",
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"unknown": "Unknown",
|
||||
"city": "City",
|
||||
"province": "Province",
|
||||
"country": "Country",
|
||||
"subscribeTime": "Subscription Time",
|
||||
"openid": "OpenID",
|
||||
"unionid": "UnionID",
|
||||
"groupid": "Group ID",
|
||||
"tagidList": "Tag List",
|
||||
"remark": "Remark",
|
||||
"language": "Language",
|
||||
"headimgurl": "Avatar",
|
||||
"detail": "User Details",
|
||||
"sendMessage": "Send Message",
|
||||
"setTag": "Set Tag",
|
||||
"sendMessageTip": "Send message function under development",
|
||||
"setTagTip": "Set tag function under development",
|
||||
"loadError": "Failed to load users",
|
||||
"syncSuccess": "Sync successful",
|
||||
"syncError": "Sync failed",
|
||||
"exporting": "Exporting user data..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,43 @@
|
||||
"layoutTitle": "布局设置",
|
||||
"appList": "应用列表",
|
||||
"chooseLayout": "选择布局"
|
||||
},
|
||||
"channel": {
|
||||
"weapp": {
|
||||
"title": "微信小程序",
|
||||
"access": "接入指引",
|
||||
"config": "配置管理",
|
||||
"template": "模板消息",
|
||||
"release": "版本发布",
|
||||
"tutorial": "使用教程"
|
||||
},
|
||||
"wechat": {
|
||||
"title": "微信公众号",
|
||||
"access": "接入指引",
|
||||
"config": "配置管理",
|
||||
"template": "模板消息",
|
||||
"menu": "自定义菜单",
|
||||
"user": "用户管理",
|
||||
"material": "素材管理",
|
||||
"tutorial": "使用教程"
|
||||
}
|
||||
},
|
||||
"setting": {
|
||||
"system": {
|
||||
"title": "系统设置",
|
||||
"config": "配置管理"
|
||||
},
|
||||
"payment": {
|
||||
"title": "支付设置",
|
||||
"list": "支付方式"
|
||||
},
|
||||
"sms": {
|
||||
"title": "短信设置",
|
||||
"list": "短信配置"
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储设置",
|
||||
"list": "存储配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,657 @@
|
||||
"adminDisabled": "系统管理员不可操作"
|
||||
},
|
||||
"role": {
|
||||
"title": "角色管理"
|
||||
"title": "角色管理",
|
||||
"roleName": "角色名称",
|
||||
"roleNamePlaceholder": "请输入角色名称",
|
||||
"addRole": "新增角色",
|
||||
"editRole": "编辑角色",
|
||||
"status": "状态",
|
||||
"createTime": "创建时间",
|
||||
"permission": "权限",
|
||||
"selectAll": "全选",
|
||||
"checkStrictly": "父子联动",
|
||||
"fold": "收起",
|
||||
"unfold": "展开",
|
||||
"deleteConfirm": "确定要删除该角色吗?",
|
||||
"loadError": "加载角色列表失败",
|
||||
"loadMenuError": "加载菜单数据失败",
|
||||
"loadRoleError": "加载角色信息失败",
|
||||
"saveError": "保存角色失败",
|
||||
"addSuccess": "角色添加成功",
|
||||
"editSuccess": "角色更新成功",
|
||||
"deleteSuccess": "角色删除成功",
|
||||
"deleteError": "删除角色失败",
|
||||
"statusChangeSuccess": "角色状态修改成功",
|
||||
"statusChangeError": "修改角色状态失败",
|
||||
"rulesPlaceholder": "请至少选择一个权限"
|
||||
},
|
||||
"menu": {
|
||||
"title": "菜单管理"
|
||||
"title": "菜单管理",
|
||||
"addMenu": "新增菜单",
|
||||
"editMenu": "编辑菜单",
|
||||
"menuName": "菜单名称",
|
||||
"menuNamePlaceholder": "请输入菜单名称",
|
||||
"menuKey": "菜单标识",
|
||||
"menuKeyPlaceholder": "请输入菜单标识",
|
||||
"menuKeyValidata": "菜单标识格式不正确",
|
||||
"menuType": "菜单类型",
|
||||
"menuTypeDir": "目录",
|
||||
"menuTypeMenu": "菜单",
|
||||
"menuTypeButton": "按钮",
|
||||
"menuIcon": "菜单图标",
|
||||
"routePath": "路由地址",
|
||||
"routePathPlaceholder": "请输入路由地址",
|
||||
"viewPath": "视图路径",
|
||||
"viewPathPlaceholder": "请输入视图路径",
|
||||
"authId": "权限标识",
|
||||
"authIdPlaceholder": "请输入权限标识",
|
||||
"addon": "应用",
|
||||
"parentMenu": "上级菜单",
|
||||
"menuShortName": "菜单简称",
|
||||
"menuShortNamePlaceholder": "请输入菜单简称",
|
||||
"isShow": "是否显示",
|
||||
"show": "显示",
|
||||
"hide": "隐藏",
|
||||
"sort": "排序",
|
||||
"status": "状态",
|
||||
"initializeMenu": "初始化菜单",
|
||||
"initializeMenuTipsOne": "此操作将重置所有菜单数据",
|
||||
"initializeMenuTipsTwo": "确定要继续吗?",
|
||||
"initializeSuccess": "菜单初始化成功",
|
||||
"initializeError": "菜单初始化失败",
|
||||
"loadError": "加载菜单列表失败",
|
||||
"loadMenuError": "加载菜单数据失败",
|
||||
"loadAddonError": "加载应用数据失败",
|
||||
"loadMenuInfoError": "加载菜单信息失败",
|
||||
"saveError": "保存菜单失败",
|
||||
"addSuccess": "菜单添加成功",
|
||||
"editSuccess": "菜单更新成功",
|
||||
"deleteSuccess": "菜单删除成功",
|
||||
"deleteError": "删除菜单失败",
|
||||
"deleteConfirm": "确定要删除此菜单吗?"
|
||||
},
|
||||
"dept": {
|
||||
"title": "部门管理"
|
||||
},
|
||||
"group": {
|
||||
"title": "站点套餐",
|
||||
"groupId": "套餐ID",
|
||||
"groupName": "套餐名称",
|
||||
"groupDesc": "套餐说明",
|
||||
"mainApp": "主应用",
|
||||
"containAddon": "包含插件",
|
||||
"appName": "应用名称",
|
||||
"addonName": "插件名称",
|
||||
"createTime": "创建时间",
|
||||
"addGroup": "添加套餐",
|
||||
"editGroup": "编辑套餐",
|
||||
"deleteConfirm": "确定要删除此套餐吗?",
|
||||
"deleteSuccess": "套餐删除成功",
|
||||
"deleteError": "套餐删除失败",
|
||||
"saveSuccess": "套餐保存成功",
|
||||
"saveError": "套餐保存失败",
|
||||
"groupNamePlaceholder": "请输入套餐名称",
|
||||
"groupDescPlaceholder": "请输入套餐说明",
|
||||
"mainAppPlaceholder": "请选择主应用",
|
||||
"containAddonPlaceholder": "请选择包含插件",
|
||||
"appListEmpty": "暂无应用",
|
||||
"addonListEmpty": "暂无插件",
|
||||
"moreApps": "还有 {count} 个应用",
|
||||
"moreAddons": "还有 {count} 个插件",
|
||||
"selectAppFirst": "请先选择对应的主应用",
|
||||
"addonRemovedDueToAppDependency": "部分插件因依赖关系已自动移除"
|
||||
},
|
||||
"diy": {
|
||||
"title": "DIY装修",
|
||||
"decorating": "装修",
|
||||
"list": {
|
||||
"title": "自定义页面",
|
||||
"pageId": "页面ID",
|
||||
"pageTitle": "页面标题",
|
||||
"pageTitlePlaceholder": "请输入页面标题",
|
||||
"addonName": "所属应用",
|
||||
"pageType": "页面类型",
|
||||
"pageTypePlaceholder": "请选择页面类型",
|
||||
"status": "状态",
|
||||
"updateTime": "更新时间",
|
||||
"addPage": "添加页面",
|
||||
"preview": "预览",
|
||||
"shareSetting": "分享设置",
|
||||
"copy": "复制",
|
||||
"copySuccess": "页面复制成功",
|
||||
"copyError": "页面复制失败",
|
||||
"deleteConfirm": "确定要删除此页面吗?",
|
||||
"deleteSuccess": "页面删除成功",
|
||||
"deleteError": "页面删除失败",
|
||||
"setUseSuccess": "设置成功",
|
||||
"setUseError": "设置失败",
|
||||
"cannotSetUseDiyPage": "DIY_PAGE类型页面不能设为使用",
|
||||
"shareTitle": "分享标题",
|
||||
"shareTitlePlaceholder": "请输入分享标题",
|
||||
"shareDesc": "分享描述",
|
||||
"shareDescPlaceholder": "请输入分享描述",
|
||||
"shareImage": "分享图片",
|
||||
"wechatShare": "微信公众号分享",
|
||||
"weappShare": "微信小程序分享"
|
||||
},
|
||||
"design": {
|
||||
"templatePagePlaceholder": "选择模板页面",
|
||||
"templatePageEmpty": "空模板",
|
||||
"pageSet": "页面设置",
|
||||
"moveUpComponent": "上移组件",
|
||||
"moveDownComponent": "下移组件",
|
||||
"copyComponent": "复制组件",
|
||||
"delComponent": "删除组件",
|
||||
"resetComponent": "重置组件",
|
||||
"delComponentTips": "确定要删除该组件吗?",
|
||||
"changeTemplatePageTips": "切换模板页面将清空当前页面内容,确定要继续吗?",
|
||||
"developTitle": "开发模式配置",
|
||||
"wapDomain": "WAP域名",
|
||||
"wapDomainPlaceholder": "请输入WAP域名,如:https://www.example.com",
|
||||
"settingTips": "配置说明",
|
||||
"initPageError": "页面初始化失败",
|
||||
"tabEditContent": "内容",
|
||||
"tabEditStyle": "样式",
|
||||
"pageSettings": "页面设置",
|
||||
"pageName": "页面名称",
|
||||
"pageNamePlaceholder": "请输入页面名称",
|
||||
"pageMode": "页面模式",
|
||||
"style1": "单列平铺",
|
||||
"style2": "左右排列",
|
||||
"alignment": "对齐方式",
|
||||
"alignLeft": "左对齐",
|
||||
"alignRight": "右对齐",
|
||||
"borderControl": "边框控制",
|
||||
"pageBackground": "页面背景",
|
||||
"bgColor": "背景颜色",
|
||||
"bgColorTips": "左侧为开始颜色,右侧为结束颜色",
|
||||
"gradientAngle": "渐变角度",
|
||||
"topToBottom": "从上到下",
|
||||
"leftToRight": "从左到右",
|
||||
"bgImage": "背景图片",
|
||||
"bgHeightScale": "背景高度比例",
|
||||
"bgHeightScaleTips": "0为高度自适应",
|
||||
"topStatusBar": "顶部状态栏",
|
||||
"showStatusBar": "显示状态栏",
|
||||
"statusBarStyle": "状态栏样式",
|
||||
"style1Text": "纯文字",
|
||||
"style2ImageText": "图片+文字",
|
||||
"style3ImageSearch": "图片+搜索",
|
||||
"style4Location": "定位",
|
||||
"textAlign": "文字对齐",
|
||||
"alignCenter": "居中",
|
||||
"logoImage": "Logo图片",
|
||||
"searchPlaceholder": "搜索占位符",
|
||||
"searchPlaceholderTips": "请输入搜索占位符文字",
|
||||
"link": "跳转链接",
|
||||
"linkPlaceholder": "请选择跳转链接",
|
||||
"popWindow": "弹窗设置",
|
||||
"showPopWindow": "显示弹窗",
|
||||
"neverShow": "从不显示",
|
||||
"showOnce": "仅显示一次",
|
||||
"showAlways": "每次都显示",
|
||||
"popImage": "弹窗图片",
|
||||
"popLink": "弹窗链接",
|
||||
"popLinkPlaceholder": "请选择弹窗跳转链接",
|
||||
"leavePageContentTips": "页面内容已修改,确定要离开吗?",
|
||||
"diyPageTitlePlaceholder": "请输入页面标题",
|
||||
"componentCanOnlyAdd": "最多只能添加",
|
||||
"piece": "个",
|
||||
"componentNotMoved": "该组件不能移动",
|
||||
"notCopy": "不能复制"
|
||||
},
|
||||
"bottomNav": {
|
||||
"title": "底部导航",
|
||||
"name": "导航名称",
|
||||
"namePlaceholder": "请输入导航名称",
|
||||
"navigationItems": "导航项",
|
||||
"item": "项",
|
||||
"itemCount": "项数",
|
||||
"text": "文字",
|
||||
"icon": "图标",
|
||||
"iconPlaceholder": "请选择图标",
|
||||
"link": "跳转链接",
|
||||
"linkPlaceholder": "请选择跳转链接",
|
||||
"addItem": "添加导航项",
|
||||
"deleteItem": "删除导航项"
|
||||
},
|
||||
"wechat": {
|
||||
"title": "微信公众号",
|
||||
"access": {
|
||||
"title": "接入指南",
|
||||
"tips": "按照以下步骤完成微信公众号的接入配置",
|
||||
"step1": {
|
||||
"title": "注册公众号",
|
||||
"desc": "首先需要注册一个微信公众号",
|
||||
"requirements": "注册要求",
|
||||
"req1": "企业或个体工商户营业执照",
|
||||
"req2": "运营者身份证信息",
|
||||
"req3": "邮箱和手机号",
|
||||
"goRegister": "前往注册"
|
||||
},
|
||||
"step2": {
|
||||
"title": "获取开发者信息",
|
||||
"desc": "在公众号后台获取开发者凭据",
|
||||
"instructions": "获取步骤",
|
||||
"step1": "登录微信公众号后台",
|
||||
"step2": "进入开发-基本配置页面",
|
||||
"step3": "获取AppID和AppSecret",
|
||||
"appId": "AppID",
|
||||
"appIdPlaceholder": "请输入AppID",
|
||||
"appSecret": "AppSecret",
|
||||
"appSecretPlaceholder": "请输入AppSecret"
|
||||
},
|
||||
"step3": {
|
||||
"title": "配置服务器",
|
||||
"desc": "配置服务器地址和Token",
|
||||
"serverInfo": "服务器配置信息",
|
||||
"serverUrl": "服务器地址",
|
||||
"token": "Token",
|
||||
"instructions": "配置步骤",
|
||||
"step1": "在基本配置页面填写服务器配置",
|
||||
"step2": "填写服务器地址和Token",
|
||||
"step3": "选择消息加解密方式",
|
||||
"step4": "提交配置并启用"
|
||||
},
|
||||
"step4": {
|
||||
"title": "完成配置",
|
||||
"desc": "恭喜!您已完成微信公众号的接入配置",
|
||||
"success": "配置成功",
|
||||
"successDesc": "您的微信公众号已成功接入系统",
|
||||
"nextSteps": "下一步操作",
|
||||
"next1": "配置消息模板",
|
||||
"next2": "设置自定义菜单",
|
||||
"next3": "管理用户标签",
|
||||
"goConfig": "前往配置",
|
||||
"goTutorial": "查看教程"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"title": "公众号配置",
|
||||
"basic": {
|
||||
"tab": "基本配置",
|
||||
"appId": "AppID",
|
||||
"appIdRequired": "请输入AppID",
|
||||
"appIdPlaceholder": "请输入微信公众号AppID",
|
||||
"appSecret": "AppSecret",
|
||||
"appSecretRequired": "请输入AppSecret",
|
||||
"appSecretPlaceholder": "请输入微信公众号AppSecret",
|
||||
"token": "Token",
|
||||
"tokenRequired": "请输入Token",
|
||||
"tokenPlaceholder": "请输入Token",
|
||||
"encodingAesKey": "EncodingAESKey",
|
||||
"encodingAesKeyPlaceholder": "请输入消息加密密钥",
|
||||
"encryptionType": "消息加解密方式",
|
||||
"encryptionType0": "明文模式",
|
||||
"encryptionType1": "兼容模式",
|
||||
"encryptionType2": "安全模式",
|
||||
"qrCode": "二维码"
|
||||
},
|
||||
"server": {
|
||||
"tab": "服务器配置",
|
||||
"serveUrl": "服务器地址",
|
||||
"serveUrlRequired": "请输入服务器地址",
|
||||
"serveUrlPlaceholder": "请输入服务器地址URL",
|
||||
"token": "Token",
|
||||
"tokenRequired": "请输入Token",
|
||||
"tokenPlaceholder": "请输入Token",
|
||||
"encodingAesKey": "EncodingAESKey",
|
||||
"encodingAesKeyPlaceholder": "请输入消息加密密钥",
|
||||
"encryptionType": "消息加解密方式",
|
||||
"encryptionType0": "明文模式",
|
||||
"encryptionType1": "兼容模式",
|
||||
"encryptionType2": "安全模式"
|
||||
},
|
||||
"domain": {
|
||||
"tab": "域名配置",
|
||||
"requestDomain": "请求域名",
|
||||
"requestDomainRequired": "请输入请求域名",
|
||||
"requestDomainPlaceholder": "请输入请求域名,多个用回车分隔",
|
||||
"wsRequestDomain": "WebSocket域名",
|
||||
"wsRequestDomainRequired": "请输入WebSocket域名",
|
||||
"wsRequestDomainPlaceholder": "请输入WebSocket域名,多个用回车分隔",
|
||||
"uploadDomain": "上传域名",
|
||||
"uploadDomainRequired": "请输入上传域名",
|
||||
"uploadDomainPlaceholder": "请输入上传域名,多个用回车分隔",
|
||||
"downloadDomain": "下载域名",
|
||||
"downloadDomainRequired": "请输入下载域名",
|
||||
"downloadDomainPlaceholder": "请输入下载域名,多个用回车分隔"
|
||||
},
|
||||
"privacy": {
|
||||
"tab": "隐私配置",
|
||||
"contactEmail": "联系邮箱",
|
||||
"contactEmailRequired": "请输入联系邮箱",
|
||||
"contactEmailPlaceholder": "请输入联系邮箱",
|
||||
"contactPhone": "联系电话",
|
||||
"contactPhoneRequired": "请输入联系电话",
|
||||
"contactPhonePlaceholder": "请输入联系电话",
|
||||
"contactQQ": "联系QQ",
|
||||
"contactQQPlaceholder": "请输入联系QQ",
|
||||
"contactWeixin": "联系微信",
|
||||
"contactWeixinPlaceholder": "请输入联系微信",
|
||||
"storeExpireTimestamp": "存储到期时间",
|
||||
"storeExpireTimestampPlaceholder": "请选择存储到期时间",
|
||||
"settingList": "隐私设置列表"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"title": "消息模板",
|
||||
"searchPlaceholder": "请输入模板标题搜索",
|
||||
"sync": "同步模板"
|
||||
},
|
||||
"tutorial": {
|
||||
"title": "使用教程",
|
||||
"basic": {
|
||||
"title": "基础介绍"
|
||||
},
|
||||
"config": {
|
||||
"title": "配置指南"
|
||||
},
|
||||
"message": {
|
||||
"title": "消息管理"
|
||||
},
|
||||
"user": {
|
||||
"title": "用户管理"
|
||||
},
|
||||
"material": {
|
||||
"title": "素材管理"
|
||||
},
|
||||
"faq": {
|
||||
"title": "常见问题"
|
||||
}
|
||||
}
|
||||
}
|
||||
"textPlaceholder": "请输入导航文字",
|
||||
"link": "链接",
|
||||
"linkPlaceholder": "请输入导航链接",
|
||||
"selectedIcon": "选中图标",
|
||||
"unselectedIcon": "未选中图标",
|
||||
"addItem": "添加导航项",
|
||||
"addBottomNav": "添加底部导航",
|
||||
"editBottomNav": "编辑底部导航",
|
||||
"deleteConfirm": "确定要删除此底部导航吗?",
|
||||
"deleteSuccess": "底部导航删除成功",
|
||||
"deleteError": "底部导航删除失败"
|
||||
},
|
||||
"route": {
|
||||
"title": "路由管理",
|
||||
"name": "路由名称",
|
||||
"namePlaceholder": "请输入路由名称",
|
||||
"path": "路由路径",
|
||||
"pathPlaceholder": "请输入路由路径",
|
||||
"component": "组件路径",
|
||||
"componentPlaceholder": "请输入组件路径",
|
||||
"title": "页面标题",
|
||||
"titlePlaceholder": "请输入页面标题",
|
||||
"icon": "图标",
|
||||
"iconPlaceholder": "请输入图标类名",
|
||||
"keepAlive": "缓存页面",
|
||||
"requireAuth": "需要登录",
|
||||
"addRoute": "添加路由",
|
||||
"editRoute": "编辑路由",
|
||||
"deleteConfirm": "确定要删除此路由吗?",
|
||||
"deleteSuccess": "路由删除成功",
|
||||
"deleteError": "路由删除失败"
|
||||
}
|
||||
},
|
||||
"channel": {
|
||||
"weapp": {
|
||||
"access": {
|
||||
"title": "小程序接入",
|
||||
"tip": "请按照以下步骤完成微信小程序的接入配置",
|
||||
"qrCodeTip": "微信小程序二维码",
|
||||
"bindAuth": "立即绑定",
|
||||
"refreshAuth": "刷新授权",
|
||||
"viewTutorial": "查看教程"
|
||||
},
|
||||
"config": {
|
||||
"title": "小程序配置",
|
||||
"basicTab": "基础配置",
|
||||
"serverTab": "服务器配置",
|
||||
"domainTab": "域名配置",
|
||||
"privacyTab": "隐私协议",
|
||||
"domainTip": "请配置小程序的业务域名,多个域名用分号分隔",
|
||||
"privacyTip": "请配置小程序的隐私协议信息",
|
||||
"modifyDomain": "修改域名",
|
||||
"modifyPrivacy": "修改隐私协议"
|
||||
},
|
||||
"template": {
|
||||
"title": "订阅消息模板",
|
||||
"batchSync": "批量同步",
|
||||
"sync": "同步"
|
||||
},
|
||||
"code": {
|
||||
"title": "版本发布",
|
||||
"cloudRelease": "云端发布",
|
||||
"cloudReleaseTip": "通过云端直接发布小程序版本",
|
||||
"localRelease": "本地上传",
|
||||
"localReleaseTip": "上传本地打包的小程序代码",
|
||||
"preview": "预览码",
|
||||
"previewTip": "扫码预览小程序",
|
||||
"uploadLog": "上传日志",
|
||||
"uploadProgress": "上传进度"
|
||||
},
|
||||
"course": {
|
||||
"title": "小程序接入教程",
|
||||
"subtitle": "按照以下步骤完成微信小程序的接入配置",
|
||||
"start": "开始接入",
|
||||
"step1": {
|
||||
"title": "绑定微信小程序",
|
||||
"desc1": "首先需要绑定微信小程序账号,获取小程序的AppID和AppSecret。",
|
||||
"desc2": "登录微信公众平台,进入小程序管理后台,获取相关配置信息。",
|
||||
"note": "注意事项:",
|
||||
"note1": "确保小程序已完成认证",
|
||||
"note2": "获取正确的AppID和AppSecret",
|
||||
"note3": "配置小程序的服务器域名"
|
||||
},
|
||||
"step2": {
|
||||
"title": "配置消息服务器",
|
||||
"desc1": "配置小程序的消息服务器,确保能够正常接收微信服务器的消息推送。",
|
||||
"desc2": "设置Token、EncodingAESKey等参数,选择合适的加密方式。",
|
||||
"config": "配置项说明:",
|
||||
"config1": "Token:自定义的验证令牌",
|
||||
"config2": "EncodingAESKey:消息加密密钥",
|
||||
"config3": "加密方式:明文、兼容、安全模式"
|
||||
},
|
||||
"step3": {
|
||||
"title": "订阅消息模板",
|
||||
"desc1": "同步小程序的订阅消息模板,用于向用户发送通知消息。",
|
||||
"desc2": "可以从微信公众平台同步已有的模板,也可以创建新的模板。",
|
||||
"tip": "订阅消息需要用户主动订阅后才能发送"
|
||||
},
|
||||
"step4": {
|
||||
"title": "发布小程序",
|
||||
"desc1": "完成开发和测试后,提交小程序版本进行审核发布。",
|
||||
"desc2": "可以选择云端发布或本地上传的方式进行版本发布。",
|
||||
"cloud": "云端发布特点",
|
||||
"cloud1": "直接在线提交代码",
|
||||
"cloud2": "自动完成打包上传",
|
||||
"cloud3": "支持版本管理",
|
||||
"local": "本地上传特点",
|
||||
"local1": "上传本地打包文件",
|
||||
"local2": "支持自定义打包流程",
|
||||
"local3": "适合复杂项目"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wechat": {
|
||||
"title": "微信公众号",
|
||||
"access": {
|
||||
"title": "接入指南",
|
||||
"config": "配置管理",
|
||||
"tip": "请按照以下步骤完成微信公众号的接入配置",
|
||||
"step1": {
|
||||
"title": "第一步:注册公众号",
|
||||
"desc": "访问微信公众平台注册并认证您的公众号"
|
||||
},
|
||||
"step2": {
|
||||
"title": "第二步:获取配置信息",
|
||||
"desc": "在公众号后台获取AppID和AppSecret",
|
||||
"copyAppId": "复制AppID",
|
||||
"copyAppSecret": "复制AppSecret"
|
||||
},
|
||||
"step3": {
|
||||
"title": "第三步:配置服务器",
|
||||
"desc": "下载证书并配置服务器信息",
|
||||
"download": "下载证书"
|
||||
},
|
||||
"step4": {
|
||||
"title": "第四步:测试连接",
|
||||
"desc": "测试公众号与系统的连接是否正常",
|
||||
"test": "测试连接"
|
||||
},
|
||||
"next": "下一步",
|
||||
"prev": "上一步",
|
||||
"complete": "完成",
|
||||
"success": "接入配置完成",
|
||||
"copySuccess": "复制成功"
|
||||
},
|
||||
"config": {
|
||||
"title": "公众号配置",
|
||||
"basic": "基本配置",
|
||||
"server": "服务器配置",
|
||||
"domain": "域名配置",
|
||||
"privacy": "隐私配置",
|
||||
"appId": "AppID",
|
||||
"appIdPlaceholder": "请输入公众号AppID",
|
||||
"appIdRequired": "请输入AppID",
|
||||
"appSecret": "AppSecret",
|
||||
"appSecretPlaceholder": "请输入公众号AppSecret",
|
||||
"appSecretRequired": "请输入AppSecret",
|
||||
"token": "Token",
|
||||
"tokenPlaceholder": "请输入Token",
|
||||
"tokenRequired": "请输入Token",
|
||||
"aesKey": "AES密钥",
|
||||
"aesKeyPlaceholder": "请输入AES密钥",
|
||||
"originalId": "原始ID",
|
||||
"originalIdPlaceholder": "请输入公众号原始ID",
|
||||
"qrcode": "二维码",
|
||||
"upload": "上传二维码",
|
||||
"uploadSuccess": "上传成功",
|
||||
"uploadError": "上传失败",
|
||||
"imageOnly": "只能上传图片文件",
|
||||
"imageSize": "图片大小不能超过2MB",
|
||||
"save": "保存",
|
||||
"reset": "重置",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveError": "保存失败",
|
||||
"loadError": "加载配置失败",
|
||||
"serverUrl": "服务器URL",
|
||||
"serverUrlPlaceholder": "系统自动生成",
|
||||
"encodingAesKey": "消息加解密密钥",
|
||||
"encodingAesKeyPlaceholder": "请输入消息加解密密钥",
|
||||
"encodingAesKeyTip": "43位字符,用于消息加解密",
|
||||
"encryptType": "加密方式",
|
||||
"encryptType0": "明文模式",
|
||||
"encryptType1": "兼容模式",
|
||||
"encryptType2": "安全模式",
|
||||
"businessDomain": "业务域名",
|
||||
"businessDomainPlaceholder": "每行一个域名,最多3个",
|
||||
"businessDomainRequired": "请输入业务域名",
|
||||
"businessDomainTip": "用户在该域名上进行输入时,不出现安全提示",
|
||||
"jsDomain": "JS接口安全域名",
|
||||
"jsDomainPlaceholder": "每行一个域名,最多3个",
|
||||
"jsDomainTip": "用于调用JS接口的域名",
|
||||
"webDomain": "网页授权域名",
|
||||
"webDomainPlaceholder": "每行一个域名,最多3个",
|
||||
"webDomainTip": "用于网页授权的域名",
|
||||
"privacyTip": "请谨慎配置隐私设置,确保符合相关法律法规",
|
||||
"privacyPolicy": "隐私政策",
|
||||
"privacyPolicy1": "已启用隐私保护",
|
||||
"privacyPolicy0": "未启用隐私保护",
|
||||
"privacyPolicyRequired": "请选择隐私政策",
|
||||
"userPrivacy": "用户隐私说明",
|
||||
"userPrivacyPlaceholder": "请输入用户隐私说明",
|
||||
"userPrivacyTip": "向用户说明数据收集和使用情况",
|
||||
"dataRetention": "数据保留期限",
|
||||
"retention30": "30天",
|
||||
"retention90": "90天",
|
||||
"retention180": "180天",
|
||||
"retention365": "365天",
|
||||
"dataRetentionTip": "用户数据保留期限,到期后自动删除"
|
||||
},
|
||||
"template": {
|
||||
"title": "消息模板",
|
||||
"sync": "同步模板",
|
||||
"edit": "编辑模板",
|
||||
"templateId": "模板ID",
|
||||
"primaryIndustry": "主行业",
|
||||
"deputyIndustry": "副行业",
|
||||
"content": "模板内容",
|
||||
"example": "示例",
|
||||
"deleteConfirm": "确定删除模板【{title}】吗?",
|
||||
"loadError": "加载模板失败",
|
||||
"syncSuccess": "同步成功",
|
||||
"syncError": "同步失败"
|
||||
},
|
||||
"menu": {
|
||||
"title": "自定义菜单",
|
||||
"preview": "预览",
|
||||
"publish": "发布",
|
||||
"editor": "菜单编辑器",
|
||||
"selectMenu": "请选择要编辑的菜单",
|
||||
"name": "菜单名称",
|
||||
"namePlaceholder": "请输入菜单名称",
|
||||
"nameRequired": "请输入菜单名称",
|
||||
"type": "菜单类型",
|
||||
"typeRequired": "请选择菜单类型",
|
||||
"typeClick": "点击推事件",
|
||||
"typeView": "跳转URL",
|
||||
"typeMiniprogram": "小程序",
|
||||
"typeScancode": "扫码推事件",
|
||||
"typeLocation": "发送位置",
|
||||
"key": "菜单KEY值",
|
||||
"keyPlaceholder": "请输入菜单KEY值",
|
||||
"url": "网页链接",
|
||||
"urlPlaceholder": "请输入网页链接",
|
||||
"appid": "小程序APPID",
|
||||
"appidPlaceholder": "请输入小程序APPID",
|
||||
"pagepath": "小程序路径",
|
||||
"pagepathPlaceholder": "请输入小程序路径",
|
||||
"addSubMenu": "添加子菜单",
|
||||
"deleteConfirm": "确定删除该菜单吗?",
|
||||
"loadError": "加载菜单失败",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveError": "保存失败",
|
||||
"newMenu": "新菜单",
|
||||
"newSubMenu": "新子菜单"
|
||||
},
|
||||
"user": {
|
||||
"title": "用户管理",
|
||||
"sync": "同步用户",
|
||||
"export": "导出用户",
|
||||
"nickname": "昵称",
|
||||
"nicknamePlaceholder": "请输入昵称",
|
||||
"subscribe": "关注状态",
|
||||
"subscribed": "已关注",
|
||||
"unsubscribed": "未关注",
|
||||
"sex": "性别",
|
||||
"male": "男",
|
||||
"female": "女",
|
||||
"unknown": "未知",
|
||||
"city": "城市",
|
||||
"province": "省份",
|
||||
"country": "国家",
|
||||
"subscribeTime": "关注时间",
|
||||
"openid": "OpenID",
|
||||
"unionid": "UnionID",
|
||||
"groupid": "分组ID",
|
||||
"tagidList": "标签列表",
|
||||
"remark": "备注",
|
||||
"language": "语言",
|
||||
"headimgurl": "头像",
|
||||
"detail": "用户详情",
|
||||
"sendMessage": "发送消息",
|
||||
"setTag": "设置标签",
|
||||
"sendMessageTip": "发送消息功能开发中",
|
||||
"setTagTip": "设置标签功能开发中",
|
||||
"loadError": "加载用户失败",
|
||||
"syncSuccess": "同步成功",
|
||||
"syncError": "同步失败",
|
||||
"exporting": "正在导出用户数据..."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -64,6 +708,8 @@
|
||||
"total": "共 {total} 条",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"inUse": "使用中",
|
||||
"notInUse": "未使用",
|
||||
"warning": "提示"
|
||||
},
|
||||
"authentication": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
@@ -35,13 +36,13 @@ const resetRoutes = () => resetStaticRoutes(router, routes);
|
||||
function getAppType(): 'admin' | 'site' | 'home' {
|
||||
const path = location.pathname.replace(/^\/+/, '');
|
||||
const first = path.split('/')[0];
|
||||
if (first === 'site' || first === 'home' || first === 'admin') return first as any;
|
||||
if (first === 'site' || first === 'home' || first === 'admin') return first as 'admin' | 'site' | 'home';
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
// 重写 push,自动补齐 app 前缀
|
||||
const originPush = router.push.bind(router);
|
||||
router.push = (to: any) => {
|
||||
router.push = (to: RouteLocationRaw) => {
|
||||
const route = typeof to === 'string' ? { path: to } : { ...to };
|
||||
if (route.path) {
|
||||
const parts = route.path.split('/').filter(Boolean);
|
||||
@@ -54,7 +55,7 @@ router.push = (to: any) => {
|
||||
|
||||
// 重写 resolve,保证解析时也有 app 前缀
|
||||
const originResolve = router.resolve.bind(router);
|
||||
router.resolve = (to: any, currentLocation?: any) => {
|
||||
router.resolve = (to: RouteLocationRaw, currentLocation?: RouteLocationRaw) => {
|
||||
const route = typeof to === 'string' ? { path: to } : { ...to };
|
||||
if (route.path) {
|
||||
const parts = route.path.split('/').filter(Boolean);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { notification } from 'ant-design-vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||
import { getAccessCodesApi, getCurrentUserApi, loginApi, logoutApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -27,7 +27,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* @param onSuccess 成功之后的回调函数
|
||||
*/
|
||||
async function authLogin(
|
||||
params: Recordable<any>,
|
||||
params: { username: string; password: string; captcha_code?: string },
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
@@ -101,7 +101,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function fetchUserInfo() {
|
||||
let userInfo: null | UserInfo = null;
|
||||
userInfo = await getUserInfoApi();
|
||||
userInfo = await getCurrentUserApi();
|
||||
userStore.setUserInfo(userInfo);
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
501
admin-vben/apps/web-antd/src/stores/diy.ts
Normal file
501
admin-vben/apps/web-antd/src/stores/diy.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import type { GlobalConfig } from '@/views/diy/design/data';
|
||||
|
||||
export interface ComponentItem {
|
||||
id: string;
|
||||
componentName: string;
|
||||
componentTitle: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
path: string;
|
||||
uses: number;
|
||||
position?: string;
|
||||
ignore?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const useDiyStore = defineStore('diy', () => {
|
||||
// Basic page info
|
||||
const id = ref(0);
|
||||
const name = ref('');
|
||||
const pageTitle = ref('');
|
||||
const type = ref('');
|
||||
const typeName = ref('');
|
||||
const templateName = ref('');
|
||||
const isDefault = ref(0);
|
||||
const pageMode = ref('diy');
|
||||
|
||||
// Loading state
|
||||
const load = ref(false);
|
||||
|
||||
// Current editing index
|
||||
const currentIndex = ref(-99); // -99 for page settings
|
||||
|
||||
// Edit tab
|
||||
const editTab = ref<'content' | 'style'>('content');
|
||||
|
||||
// Global configuration
|
||||
const global = ref<GlobalConfig>({
|
||||
title: '页面',
|
||||
completeLayout: 'style-1',
|
||||
completeAlign: 'left',
|
||||
borderControl: true,
|
||||
pageStartBgColor: '',
|
||||
pageEndBgColor: '',
|
||||
pageGradientAngle: 'to bottom',
|
||||
bgUrl: '',
|
||||
bgHeightScale: 0,
|
||||
imgWidth: '',
|
||||
imgHeight: '',
|
||||
topStatusBar: {
|
||||
control: true,
|
||||
isShow: true,
|
||||
bgColor: '#ffffff',
|
||||
rollBgColor: '#ffffff',
|
||||
style: 'style-1',
|
||||
styleName: '风格1',
|
||||
textColor: '#333333',
|
||||
rollTextColor: '#333333',
|
||||
textAlign: 'center',
|
||||
inputPlaceholder: '请输入搜索关键词',
|
||||
imgUrl: '',
|
||||
link: { name: '' },
|
||||
},
|
||||
bottomTabBar: {
|
||||
control: true,
|
||||
isShow: true,
|
||||
},
|
||||
popWindow: {
|
||||
imgUrl: '',
|
||||
imgWidth: '',
|
||||
imgHeight: '',
|
||||
count: 'once',
|
||||
show: 0,
|
||||
link: { name: '' },
|
||||
},
|
||||
template: {
|
||||
textColor: '#303133',
|
||||
pageStartBgColor: '',
|
||||
pageEndBgColor: '',
|
||||
pageGradientAngle: 'to bottom',
|
||||
componentBgUrl: '',
|
||||
componentBgAlpha: 2,
|
||||
componentStartBgColor: '',
|
||||
componentEndBgColor: '',
|
||||
componentGradientAngle: 'to bottom',
|
||||
topRounded: 0,
|
||||
bottomRounded: 0,
|
||||
elementBgColor: '',
|
||||
topElementRounded: 0,
|
||||
bottomElementRounded: 0,
|
||||
margin: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
both: 0,
|
||||
},
|
||||
isHidden: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Component values
|
||||
const value = ref<any[]>([]);
|
||||
|
||||
// Available components
|
||||
const components = ref<ComponentItem[]>([]);
|
||||
|
||||
// Position types
|
||||
const positionTypes = ['top_fixed', 'right_fixed', 'bottom_fixed', 'left_fixed', 'fixed'];
|
||||
|
||||
// Current component
|
||||
const currentComponent = computed(() => {
|
||||
if (currentIndex.value === -99) {
|
||||
return 'page-settings';
|
||||
}
|
||||
return value.value[currentIndex.value]?.path || '';
|
||||
});
|
||||
|
||||
// Edit component
|
||||
const editComponent = computed(() => {
|
||||
if (currentIndex.value === -99) {
|
||||
return global.value;
|
||||
}
|
||||
return value.value[currentIndex.value];
|
||||
});
|
||||
|
||||
// Initialize store
|
||||
const init = () => {
|
||||
id.value = 0;
|
||||
name.value = '';
|
||||
pageTitle.value = '';
|
||||
type.value = '';
|
||||
typeName.value = '';
|
||||
templateName.value = '';
|
||||
isDefault.value = 0;
|
||||
pageMode.value = 'diy';
|
||||
load.value = false;
|
||||
currentIndex.value = -99;
|
||||
editTab.value = 'content';
|
||||
|
||||
// Reset global config
|
||||
global.value = {
|
||||
title: '页面',
|
||||
completeLayout: 'style-1',
|
||||
completeAlign: 'left',
|
||||
borderControl: true,
|
||||
pageStartBgColor: '',
|
||||
pageEndBgColor: '',
|
||||
pageGradientAngle: 'to bottom',
|
||||
bgUrl: '',
|
||||
bgHeightScale: 100,
|
||||
imgWidth: '',
|
||||
imgHeight: '',
|
||||
topStatusBar: {
|
||||
control: true,
|
||||
isShow: true,
|
||||
bgColor: '#ffffff',
|
||||
rollBgColor: '#ffffff',
|
||||
style: 'style-1',
|
||||
styleName: '风格1',
|
||||
textColor: '#333333',
|
||||
rollTextColor: '#333333',
|
||||
textAlign: 'center',
|
||||
inputPlaceholder: '请输入搜索关键词',
|
||||
imgUrl: '',
|
||||
link: { name: '' },
|
||||
},
|
||||
bottomTabBar: {
|
||||
control: true,
|
||||
isShow: true,
|
||||
},
|
||||
popWindow: {
|
||||
imgUrl: '',
|
||||
imgWidth: '',
|
||||
imgHeight: '',
|
||||
count: 'once',
|
||||
show: 0,
|
||||
link: { name: '' },
|
||||
},
|
||||
template: {
|
||||
textColor: '#303133',
|
||||
pageStartBgColor: '',
|
||||
pageEndBgColor: '',
|
||||
pageGradientAngle: 'to bottom',
|
||||
componentBgUrl: '',
|
||||
componentBgAlpha: 2,
|
||||
componentStartBgColor: '',
|
||||
componentEndBgColor: '',
|
||||
componentGradientAngle: 'to bottom',
|
||||
topRounded: 0,
|
||||
bottomRounded: 0,
|
||||
elementBgColor: '',
|
||||
topElementRounded: 0,
|
||||
bottomElementRounded: 0,
|
||||
margin: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
both: 0,
|
||||
},
|
||||
isHidden: false,
|
||||
},
|
||||
};
|
||||
|
||||
value.value = [];
|
||||
components.value = [];
|
||||
};
|
||||
|
||||
// Generate random ID
|
||||
const generateRandom = (len: number = 5) => {
|
||||
return Number(Math.random().toString().substr(3, len) + Date.now()).toString(36);
|
||||
};
|
||||
|
||||
// Add component
|
||||
const addComponent = (key: string, data: any) => {
|
||||
if (!load.value) return;
|
||||
|
||||
let component = cloneDeep(data);
|
||||
component.id = generateRandom();
|
||||
component.componentName = key;
|
||||
component.componentTitle = component.title;
|
||||
component.ignore = component.ignore || [];
|
||||
|
||||
// Apply template properties
|
||||
let template = cloneDeep(global.value.template);
|
||||
Object.assign(component, template);
|
||||
|
||||
if (component.template) {
|
||||
Object.assign(component, component.template);
|
||||
delete component.template;
|
||||
}
|
||||
|
||||
// Check if component can be added
|
||||
if (!checkComponentIsAdd(component)) {
|
||||
message.warning(`${component.componentTitle}最多只能添加${component.uses}个`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle position-based components
|
||||
if (component.position && positionTypes.includes(component.position)) {
|
||||
if (component.position === 'top_fixed') {
|
||||
value.value.splice(0, 0, component);
|
||||
currentIndex.value = 0;
|
||||
} else if (component.position === 'bottom_fixed') {
|
||||
value.value.splice(value.value.length, 0, component);
|
||||
currentIndex.value = value.value.length - 1;
|
||||
} else {
|
||||
value.value.splice(0, 0, component);
|
||||
currentIndex.value = 0;
|
||||
}
|
||||
} else if (currentIndex.value === -99) {
|
||||
// Add to end
|
||||
let index = value.value.length;
|
||||
for (let i = value.value.length - 1; i >= 0; i--) {
|
||||
if (value.value[i].position === 'bottom_fixed') {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index === value.value.length) {
|
||||
value.value.push(component);
|
||||
currentIndex.value = value.value.length - 1;
|
||||
} else {
|
||||
value.value.splice(index, 0, component);
|
||||
currentIndex.value = index;
|
||||
}
|
||||
} else {
|
||||
// Insert after current
|
||||
let index = currentIndex.value + 1;
|
||||
for (let i = value.value.length - 1; i >= 0; i--) {
|
||||
if (value.value[i].position === 'bottom_fixed') {
|
||||
if (i === currentIndex.value || (i - currentIndex.value) === 1) {
|
||||
index = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
value.value.splice(index, 0, component);
|
||||
currentIndex.value = index;
|
||||
}
|
||||
|
||||
currentComponent.value = component.path;
|
||||
};
|
||||
|
||||
// Check if component can be added
|
||||
const checkComponentIsAdd = (component: ComponentItem) => {
|
||||
if (component.uses === 0) return true;
|
||||
|
||||
let count = 0;
|
||||
for (let i in value.value) {
|
||||
if (value.value[i].componentName === component.componentName) count++;
|
||||
}
|
||||
|
||||
return count < component.uses;
|
||||
};
|
||||
|
||||
// Delete component
|
||||
const delComponent = () => {
|
||||
if (currentIndex.value === -99) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: '删除组件',
|
||||
content: '确定要删除该组件吗?',
|
||||
onOk: () => {
|
||||
value.value.splice(currentIndex.value, 1);
|
||||
|
||||
if (value.value.length === 0) {
|
||||
currentIndex.value = -99;
|
||||
} else if (currentIndex.value === value.value.length) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
|
||||
let component = cloneDeep(value.value[currentIndex.value]);
|
||||
changeCurrentIndex(currentIndex.value, component);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Move component up
|
||||
const moveUpComponent = () => {
|
||||
if (currentIndex.value <= 0) return;
|
||||
|
||||
const temp = cloneDeep(value.value[currentIndex.value]);
|
||||
let prevIndex = currentIndex.value - 1;
|
||||
const temp2 = cloneDeep(value.value[prevIndex]);
|
||||
|
||||
if (prevIndex < 0 || (temp2.position && positionTypes.includes(temp2.position))) return;
|
||||
|
||||
if (temp.position && positionTypes.includes(temp.position)) {
|
||||
message.warning('该组件不能移动');
|
||||
return;
|
||||
}
|
||||
|
||||
temp.id = generateRandom();
|
||||
temp2.id = generateRandom();
|
||||
|
||||
value.value[currentIndex.value] = temp2;
|
||||
value.value[prevIndex] = temp;
|
||||
|
||||
changeCurrentIndex(prevIndex, temp);
|
||||
};
|
||||
|
||||
// Move component down
|
||||
const moveDownComponent = () => {
|
||||
if (currentIndex.value >= value.value.length - 1) return;
|
||||
|
||||
const nextIndex = currentIndex.value + 1;
|
||||
const temp = cloneDeep(value.value[currentIndex.value]);
|
||||
temp.id = generateRandom();
|
||||
|
||||
const temp2 = cloneDeep(value.value[nextIndex]);
|
||||
temp2.id = generateRandom();
|
||||
|
||||
if (temp2.position && positionTypes.includes(temp2.position)) return;
|
||||
|
||||
if (temp.position && positionTypes.includes(temp.position)) {
|
||||
message.warning('该组件不能移动');
|
||||
return;
|
||||
}
|
||||
|
||||
value.value[currentIndex.value] = temp2;
|
||||
value.value[nextIndex] = temp;
|
||||
|
||||
changeCurrentIndex(nextIndex, temp);
|
||||
};
|
||||
|
||||
// Copy component
|
||||
const copyComponent = () => {
|
||||
if (currentIndex.value < 0) return;
|
||||
|
||||
let component = cloneDeep(value.value[currentIndex.value]);
|
||||
component.id = generateRandom();
|
||||
|
||||
if (!checkComponentIsAdd(component)) {
|
||||
message.warning(`不能复制,${component.componentTitle}最多只能添加${component.uses}个`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (component.position && positionTypes.includes(component.position)) {
|
||||
message.warning(`不能复制,${component.componentTitle}只能添加1个`);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = currentIndex.value + 1;
|
||||
value.value.splice(index, 0, component);
|
||||
changeCurrentIndex(index, component);
|
||||
};
|
||||
|
||||
// Reset component
|
||||
const resetComponent = () => {
|
||||
if (currentIndex.value < 0) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: '重置组件',
|
||||
content: '确定要重置该组件吗?',
|
||||
onOk: () => {
|
||||
for (let i = 0; i < components.value.length; i++) {
|
||||
if (components.value[i].componentName === editComponent.value.componentName) {
|
||||
Object.assign(editComponent.value, components.value[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Change current index
|
||||
const changeCurrentIndex = (index: number, component?: any) => {
|
||||
currentIndex.value = index;
|
||||
if (index === -99) {
|
||||
currentComponent.value = 'page-settings';
|
||||
} else if (component) {
|
||||
currentComponent.value = component.path;
|
||||
}
|
||||
};
|
||||
|
||||
// Post message to iframe
|
||||
const postMessage = () => {
|
||||
const diyData = {
|
||||
pageMode: pageMode.value,
|
||||
currentIndex: currentIndex.value,
|
||||
global: global.value,
|
||||
value: value.value,
|
||||
};
|
||||
|
||||
const iframe = document.getElementById('previewIframe') as HTMLIFrameElement;
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify(diyData), '*');
|
||||
}
|
||||
};
|
||||
|
||||
// Validate
|
||||
const verify = () => {
|
||||
if (pageTitle.value === '') {
|
||||
message.warning('请输入页面名称');
|
||||
changeCurrentIndex(-99);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (global.value.popWindow.show && !global.value.popWindow.imgUrl) {
|
||||
message.warning('请上传弹窗图片');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.value.length; i++) {
|
||||
try {
|
||||
if (value.value[i].verify) {
|
||||
const res = value.value[i].verify(i);
|
||||
if (!res.code) {
|
||||
changeCurrentIndex(i, value.value[i]);
|
||||
message.warning(res.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('verify Error:', e, i, value.value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
id,
|
||||
name,
|
||||
pageTitle,
|
||||
type,
|
||||
typeName,
|
||||
templateName,
|
||||
isDefault,
|
||||
pageMode,
|
||||
load,
|
||||
currentIndex,
|
||||
editTab,
|
||||
global,
|
||||
value,
|
||||
components,
|
||||
|
||||
// Computed
|
||||
currentComponent,
|
||||
editComponent,
|
||||
|
||||
// Actions
|
||||
init,
|
||||
generateRandom,
|
||||
addComponent,
|
||||
checkComponentIsAdd,
|
||||
delComponent,
|
||||
moveUpComponent,
|
||||
moveDownComponent,
|
||||
copyComponent,
|
||||
resetComponent,
|
||||
changeCurrentIndex,
|
||||
postMessage,
|
||||
verify,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface WeappAccessApi {
|
||||
getWeappConfig: () => Promise<any>;
|
||||
getAuthorizationUrl: (params: { site_id?: number }) => Promise<any>;
|
||||
getWxoplatform: () => Promise<any>;
|
||||
}
|
||||
|
||||
export interface WeappAccessItem {
|
||||
step: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'completed' | 'current' | 'pending';
|
||||
action: string;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export const accessSteps: WeappAccessItem[] = [
|
||||
{
|
||||
step: 1,
|
||||
title: '绑定微信小程序',
|
||||
description: '绑定微信小程序账号,获取小程序相关信息',
|
||||
status: 'current',
|
||||
action: '立即绑定',
|
||||
route: '/channel/weapp/config',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: '配置消息服务器',
|
||||
description: '配置小程序消息服务器,确保消息正常接收',
|
||||
status: 'pending',
|
||||
action: '去配置',
|
||||
route: '/channel/weapp/config',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: '订阅消息模板',
|
||||
description: '同步订阅消息模板,开启消息通知功能',
|
||||
status: 'pending',
|
||||
action: '去同步',
|
||||
route: '/channel/weapp/template',
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
title: '发布小程序',
|
||||
description: '发布小程序版本,提交审核并上线',
|
||||
status: 'pending',
|
||||
action: '去发布',
|
||||
route: '/channel/weapp/code',
|
||||
},
|
||||
];
|
||||
180
admin-vben/apps/web-antd/src/views/channel/weapp/access/list.vue
Normal file
180
admin-vben/apps/web-antd/src/views/channel/weapp/access/list.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card :title="$t('channel.weapp.access.title')">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="handleRefresh">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="mb-8">
|
||||
<Alert
|
||||
:message="$t('channel.weapp.access.tip')"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="w-full max-w-4xl">
|
||||
<Steps :current="currentStep" size="small">
|
||||
<Step
|
||||
v-for="step in accessSteps"
|
||||
:key="step.step"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
/>
|
||||
</Steps>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<Qrcode
|
||||
v-if="qrCode"
|
||||
:value="qrCode"
|
||||
:size="200"
|
||||
error-level="H"
|
||||
/>
|
||||
<div v-else class="w-48 h-48 bg-gray-100 flex items-center justify-center rounded">
|
||||
<Icon icon="ant-design:qrcode-outlined" class="text-4xl text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600">{{ $t('channel.weapp.access.qrCodeTip') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center space-x-4">
|
||||
<Button
|
||||
v-if="hasOpenPlatformConfig"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleAuthorization"
|
||||
>
|
||||
{{ isAuthorized ? $t('channel.weapp.access.refreshAuth') : $t('channel.weapp.access.bindAuth') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleViewTutorial"
|
||||
>
|
||||
{{ $t('channel.weapp.access.viewTutorial') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card
|
||||
v-for="step in accessSteps"
|
||||
:key="step.step"
|
||||
hoverable
|
||||
class="cursor-pointer"
|
||||
@click="handleStepClick(step)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center mx-auto"
|
||||
:class="{
|
||||
'bg-blue-500 text-white': step.status === 'current',
|
||||
'bg-green-500 text-white': step.status === 'completed',
|
||||
'bg-gray-200 text-gray-500': step.status === 'pending',
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-bold">{{ step.step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">{{ step.title }}</h3>
|
||||
<p class="text-gray-600 mb-4">{{ step.description }}</p>
|
||||
<Button type="link">{{ step.action }}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Card, Button, Steps, Step, Alert, Divider, Qrcode } from 'ant-design-vue';
|
||||
import { getWeappConfig } from '#/api/core/weapp';
|
||||
import { getAuthorizationUrl } from '#/api/core/wxoplatform';
|
||||
import { getWxoplatform } from '#/api/core/sys';
|
||||
|
||||
import { accessSteps } from './data';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const config = ref<any>({});
|
||||
const wxoplatformConfig = ref<any>({});
|
||||
|
||||
const qrCode = computed(() => config.value?.qr_code || '');
|
||||
const isAuthorized = computed(() => config.value?.is_authorization === 1);
|
||||
const hasOpenPlatformConfig = computed(() => {
|
||||
return wxoplatformConfig.value?.app_id && wxoplatformConfig.value?.app_secret;
|
||||
});
|
||||
|
||||
const currentStep = computed(() => {
|
||||
if (!config.value) return 0;
|
||||
if (!config.value.app_id) return 0;
|
||||
if (!config.value.serve_url) return 1;
|
||||
// Check if templates exist (simplified logic)
|
||||
return 2;
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const [configRes, wxoplatformRes] = await Promise.all([
|
||||
getWeappConfig(),
|
||||
getWxoplatform(),
|
||||
]);
|
||||
config.value = configRes;
|
||||
wxoplatformConfig.value = wxoplatformRes;
|
||||
} catch (error) {
|
||||
message.error('加载数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleAuthorization = async () => {
|
||||
try {
|
||||
const { url } = await getAuthorizationUrl({});
|
||||
window.open(url, '_blank');
|
||||
} catch (error) {
|
||||
message.error('获取授权链接失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewTutorial = () => {
|
||||
router.push('/channel/weapp/course');
|
||||
};
|
||||
|
||||
const handleStepClick = (step: any) => {
|
||||
if (step.route) {
|
||||
router.push(step.route);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
120
admin-vben/apps/web-antd/src/views/channel/weapp/code/data.ts
Normal file
120
admin-vben/apps/web-antd/src/views/channel/weapp/code/data.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface WeappCodeApi {
|
||||
setWeappVersion: (data: any) => Promise<any>;
|
||||
getWeappVersionList: (params: any) => Promise<any>;
|
||||
getWeappUploadLog: (params: { task_key: string }) => Promise<any>;
|
||||
getWeappPreview: () => Promise<any>;
|
||||
uploadVersion: (data: any) => Promise<any>;
|
||||
siteWeappCommit: (data: any) => Promise<any>;
|
||||
undoAudit: (data: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface VersionItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
version: string;
|
||||
version_desc: string;
|
||||
upload_time: number;
|
||||
audit_time: number;
|
||||
audit_result: string;
|
||||
audit_id: string;
|
||||
status: number;
|
||||
task_key: string;
|
||||
create_time: number;
|
||||
}
|
||||
|
||||
export const gridSchema: VxeGridProps = {
|
||||
stripe: true,
|
||||
showHeaderOverflow: true,
|
||||
showOverflow: true,
|
||||
height: 'auto',
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
isCurrent: true,
|
||||
},
|
||||
columnConfig: {
|
||||
resizable: true,
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
title: '序号',
|
||||
},
|
||||
{
|
||||
field: 'version',
|
||||
title: '版本号',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'version_desc',
|
||||
title: '版本描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: 'status',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'upload_time',
|
||||
title: '上传时间',
|
||||
width: 180,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue ? new Date(cellValue * 1000).toLocaleString() : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'audit_time',
|
||||
title: '审核时间',
|
||||
width: 180,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue ? new Date(cellValue * 1000).toLocaleString() : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'audit_result',
|
||||
title: '审核结果',
|
||||
minWidth: 200,
|
||||
slots: {
|
||||
default: 'auditResult',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
},
|
||||
],
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
refresh: true,
|
||||
zoom: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
// This will be implemented in the component
|
||||
return { result: [], total: 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const statusMap = {
|
||||
0: { text: '草稿', color: 'default' },
|
||||
1: { text: '上传中', color: 'processing' },
|
||||
2: { text: '上传成功', color: 'success' },
|
||||
3: { text: '审核中', color: 'processing' },
|
||||
4: { text: '审核成功', color: 'success' },
|
||||
5: { text: '审核失败', color: 'error' },
|
||||
6: { text: '发布成功', color: 'success' },
|
||||
7: { text: '发布失败', color: 'error' },
|
||||
};
|
||||
392
admin-vben/apps/web-antd/src/views/channel/weapp/code/list.vue
Normal file
392
admin-vben/apps/web-antd/src/views/channel/weapp/code/list.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card :title="$t('channel.weapp.code.title')">
|
||||
<div class="mb-6">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Card size="small" :title="$t('channel.weapp.code.cloudRelease')">
|
||||
<div class="mb-4">
|
||||
<Alert
|
||||
:message="$t('channel.weapp.code.cloudReleaseTip')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<BasicForm
|
||||
:schema="cloudFormSchema"
|
||||
:model="cloudFormModel"
|
||||
ref="cloudFormRef"
|
||||
>
|
||||
<template #form-submit="{ loading: submitLoading }">
|
||||
<Button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
:loading="submitLoading"
|
||||
@click="handleCloudRelease"
|
||||
>
|
||||
{{ $t('channel.weapp.code.cloudRelease') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Card size="small" :title="$t('channel.weapp.code.localRelease')">
|
||||
<div class="mb-4">
|
||||
<Alert
|
||||
:message="$t('channel.weapp.code.localReleaseTip')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<BasicForm
|
||||
:schema="localFormSchema"
|
||||
:model="localFormModel"
|
||||
ref="localFormRef"
|
||||
>
|
||||
<template #form-submit="{ loading: submitLoading }">
|
||||
<Button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
:loading="submitLoading"
|
||||
@click="handleLocalRelease"
|
||||
>
|
||||
{{ $t('channel.weapp.code.localRelease') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div v-if="previewUrl" class="mb-6">
|
||||
<Card size="small" :title="$t('channel.weapp.code.preview')">
|
||||
<div class="text-center">
|
||||
<Qrcode :value="previewUrl" :size="200" error-level="H" />
|
||||
<p class="text-gray-600 mt-2">{{ $t('channel.weapp.code.previewTip') }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="mb-6" v-if="uploadLog">
|
||||
<Card size="small" :title="$t('channel.weapp.code.uploadLog')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p>{{ uploadLog.message }}</p>
|
||||
<p class="text-sm text-gray-600">{{ $t('channel.weapp.code.uploadProgress') }}: {{ uploadLog.percent }}%</p>
|
||||
</div>
|
||||
<Progress :percent="uploadLog.percent" :status="uploadLog.status === 2 ? 'success' : 'active'" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:grid-options="gridSchema"
|
||||
@register="registerGrid"
|
||||
>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="statusMap[row.status]?.color">
|
||||
{{ statusMap[row.status]?.text }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #auditResult="{ row }">
|
||||
<div v-if="row.audit_result">
|
||||
<Button
|
||||
v-if="row.status === 5"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="showAuditResult(row)"
|
||||
>
|
||||
查看原因
|
||||
</Button>
|
||||
<span v-else>{{ row.audit_result }}</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<Button
|
||||
v-if="row.status === 3"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleUndoAudit(row)"
|
||||
>
|
||||
撤回审核
|
||||
</Button>
|
||||
<Button
|
||||
v-if="row.status === 5"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleRecommit(row)"
|
||||
>
|
||||
重新提交
|
||||
</Button>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { Card, Row, Col, Button, Alert, Qrcode, Progress, Tag } from 'ant-design-vue';
|
||||
import { VbenVxeGrid, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
import { BasicForm } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
setWeappVersion,
|
||||
getWeappVersionList,
|
||||
getWeappUploadLog,
|
||||
getWeappPreview,
|
||||
uploadVersion,
|
||||
} from '#/api/core/weapp';
|
||||
import { siteWeappCommit, undoAudit } from '#/api/core/wxoplatform';
|
||||
|
||||
import { gridSchema, statusMap } from './data';
|
||||
|
||||
const gridRef = ref();
|
||||
const cloudFormRef = ref();
|
||||
const localFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const previewUrl = ref('');
|
||||
const uploadLog = ref<any>(null);
|
||||
const logTimer = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
const cloudFormModel = reactive({
|
||||
version: '',
|
||||
version_desc: '',
|
||||
authorization_code: '',
|
||||
});
|
||||
|
||||
const localFormModel = reactive({
|
||||
file: null,
|
||||
version: '',
|
||||
});
|
||||
|
||||
const cloudFormSchema = [
|
||||
{
|
||||
fieldName: 'version',
|
||||
label: '版本号',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '例如:1.0.0',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'version_desc',
|
||||
label: '版本描述',
|
||||
component: 'Textarea',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '请输入版本描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'authorization_code',
|
||||
label: '授权码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入授权码(可选)',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const localFormSchema = [
|
||||
{
|
||||
fieldName: 'file',
|
||||
label: '上传文件',
|
||||
component: 'Upload',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
accept: '.zip',
|
||||
maxCount: 1,
|
||||
beforeUpload: (file: File) => {
|
||||
localFormModel.file = file;
|
||||
return false; // Prevent auto upload
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'version',
|
||||
label: '版本号',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '例如:1.0.0',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [registerGrid, { reload }] = useVbenVxeGrid({
|
||||
gridOptions: gridSchema,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
try {
|
||||
const response = await getWeappVersionList({
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
});
|
||||
return {
|
||||
result: response.list,
|
||||
total: response.total,
|
||||
};
|
||||
} catch (error) {
|
||||
message.error('获取版本列表失败');
|
||||
return { result: [], total: 0 };
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loadPreview = async () => {
|
||||
try {
|
||||
const { preview_url } = await getWeappPreview();
|
||||
previewUrl.value = preview_url;
|
||||
} catch (error) {
|
||||
// Silent fail
|
||||
}
|
||||
};
|
||||
|
||||
const startLogPolling = (taskKey: string) => {
|
||||
logTimer.value = setInterval(async () => {
|
||||
try {
|
||||
const log = await getWeappUploadLog({ task_key: taskKey });
|
||||
uploadLog.value = log;
|
||||
|
||||
if (log.status === 2 || log.status === 5) {
|
||||
stopLogPolling();
|
||||
if (log.status === 2) {
|
||||
message.success('上传成功');
|
||||
loadPreview();
|
||||
reload();
|
||||
} else {
|
||||
message.error('上传失败: ' + log.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
stopLogPolling();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const stopLogPolling = () => {
|
||||
if (logTimer.value) {
|
||||
clearInterval(logTimer.value);
|
||||
logTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloudRelease = async () => {
|
||||
try {
|
||||
const form = await cloudFormRef.value?.validate();
|
||||
loading.value = true;
|
||||
|
||||
const result = await setWeappVersion(form);
|
||||
|
||||
if (result.task_key) {
|
||||
startLogPolling(result.task_key);
|
||||
}
|
||||
|
||||
message.success('云端发布已启动');
|
||||
} catch (error) {
|
||||
if (error !== false) {
|
||||
message.error('云端发布失败');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalRelease = async () => {
|
||||
try {
|
||||
const form = await localFormRef.value?.validate();
|
||||
|
||||
if (!form.file) {
|
||||
message.error('请选择上传文件');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const result = await uploadVersion({
|
||||
file: form.file,
|
||||
version: form.version,
|
||||
});
|
||||
|
||||
if (result.task_key) {
|
||||
startLogPolling(result.task_key);
|
||||
}
|
||||
|
||||
message.success('本地上传已启动');
|
||||
} catch (error) {
|
||||
if (error !== false) {
|
||||
message.error('本地上传失败');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showAuditResult = (row: any) => {
|
||||
Modal.info({
|
||||
title: '审核失败原因',
|
||||
content: row.audit_result,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUndoAudit = (row: any) => {
|
||||
Modal.confirm({
|
||||
title: '撤回审核确认',
|
||||
content: '确定要撤回审核吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await undoAudit({
|
||||
site_id: row.site_id,
|
||||
audit_id: row.audit_id,
|
||||
});
|
||||
message.success('撤回审核成功');
|
||||
reload();
|
||||
} catch (error) {
|
||||
message.error('撤回审核失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRecommit = (row: any) => {
|
||||
Modal.confirm({
|
||||
title: '重新提交确认',
|
||||
content: '确定要重新提交审核吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await siteWeappCommit({
|
||||
site_id: row.site_id,
|
||||
version: row.version,
|
||||
version_desc: row.version_desc,
|
||||
});
|
||||
message.success('重新提交成功');
|
||||
reload();
|
||||
} catch (error) {
|
||||
message.error('重新提交失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPreview();
|
||||
reload();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLogPolling();
|
||||
});
|
||||
</script>
|
||||
155
admin-vben/apps/web-antd/src/views/channel/weapp/config/data.ts
Normal file
155
admin-vben/apps/web-antd/src/views/channel/weapp/config/data.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface WeappConfigApi {
|
||||
getWeappConfig: () => Promise<any>;
|
||||
setWeappConfig: (data: any) => Promise<any>;
|
||||
setWeappDomain: (data: any) => Promise<any>;
|
||||
getWeappPrivacySetting: () => Promise<any>;
|
||||
setWeappPrivacySetting: (data: any) => Promise<any>;
|
||||
getIsTradeManaged: () => Promise<any>;
|
||||
}
|
||||
|
||||
export interface DomainForm {
|
||||
request_domain: string;
|
||||
ws_request_domain: string;
|
||||
upload_domain: string;
|
||||
download_domain: string;
|
||||
udp_domain: string;
|
||||
tcp_domain: string;
|
||||
}
|
||||
|
||||
export const basicFormSchema: VbenFormSchema[] = [
|
||||
{
|
||||
fieldName: 'weapp_name',
|
||||
label: '小程序名称',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'weapp_original',
|
||||
label: '小程序原始ID',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'app_id',
|
||||
label: 'AppID',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'app_secret',
|
||||
label: 'AppSecret',
|
||||
component: 'InputPassword',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'qr_code',
|
||||
label: '小程序码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入小程序码图片地址',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const serverFormSchema: VbenFormSchema[] = [
|
||||
{
|
||||
fieldName: 'serve_url',
|
||||
label: '服务器地址',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '请以http://或https://开头',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'token',
|
||||
label: '令牌(Token)',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '必须为3-32字符',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'encoding_aes_key',
|
||||
label: '消息加密密钥',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '43位字符',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'encryption_type',
|
||||
label: '加密方式',
|
||||
component: 'RadioGroup',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '明文模式', value: 1 },
|
||||
{ label: '兼容模式', value: 2 },
|
||||
{ label: '安全模式(推荐)', value: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const domainFormSchema: VbenFormSchema[] = [
|
||||
{
|
||||
fieldName: 'request_domain',
|
||||
label: 'request合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:https://api.example.com;https://api2.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'ws_request_domain',
|
||||
label: 'socket合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:wss://ws.example.com;wss://ws2.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'upload_domain',
|
||||
label: 'uploadFile合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:https://upload.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'download_domain',
|
||||
label: 'downloadFile合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:https://download.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'udp_domain',
|
||||
label: 'udp合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:udp://udp.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'tcp_domain',
|
||||
label: 'tcp合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:tcp://tcp.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
271
admin-vben/apps/web-antd/src/views/channel/weapp/config/list.vue
Normal file
271
admin-vben/apps/web-antd/src/views/channel/weapp/config/list.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card :title="$t('channel.weapp.config.title')">
|
||||
<Tabs v-model:activeKey="activeKey">
|
||||
<TabPane key="basic" :tab="$t('channel.weapp.config.basicTab')">
|
||||
<BasicForm
|
||||
:schema="basicFormSchema"
|
||||
:model="basicFormModel"
|
||||
:loading="loading"
|
||||
@submit="handleBasicSubmit"
|
||||
>
|
||||
<template #form-submit="{ loading: submitLoading }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="server" :tab="$t('channel.weapp.config.serverTab')">
|
||||
<BasicForm
|
||||
:schema="serverFormSchema"
|
||||
:model="serverFormModel"
|
||||
:loading="loading"
|
||||
@submit="handleServerSubmit"
|
||||
>
|
||||
<template #form-submit="{ loading: submitLoading }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="domain" :tab="$t('channel.weapp.config.domainTab')">
|
||||
<div class="mb-4">
|
||||
<Alert
|
||||
:message="$t('channel.weapp.config.domainTip')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<BasicForm
|
||||
:schema="domainFormSchema"
|
||||
:model="domainFormModel"
|
||||
:loading="loading"
|
||||
@submit="handleDomainSubmit"
|
||||
>
|
||||
<template #form-submit="{ loading: submitLoading }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BasicForm>
|
||||
<div class="mt-4" v-if="isAuthorized">
|
||||
<Button @click="handleModifyDomain">
|
||||
{{ $t('channel.weapp.config.modifyDomain') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="privacy" :tab="$t('channel.weapp.config.privacyTab')" v-if="isAuthorized">
|
||||
<div class="mb-4">
|
||||
<Alert
|
||||
:message="$t('channel.weapp.config.privacyTip')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<PrivacySettingForm
|
||||
:model="privacyFormModel"
|
||||
:loading="loading"
|
||||
@submit="handlePrivacySubmit"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<Button @click="handleModifyPrivacy">
|
||||
{{ $t('channel.weapp.config.modifyPrivacy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<ModifyDomainModal
|
||||
v-model:visible="domainModalVisible"
|
||||
:current-domains="currentDomains"
|
||||
@success="handleDomainSuccess"
|
||||
/>
|
||||
|
||||
<ModifyPrivacyModal
|
||||
v-model:visible="privacyModalVisible"
|
||||
:current-privacy="currentPrivacy"
|
||||
@success="handlePrivacySuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { Card, Tabs, TabPane, Button, Alert } from 'ant-design-vue';
|
||||
import { BasicForm } from '@vben/common-ui';
|
||||
|
||||
import { getWeappConfig, setWeappConfig, setWeappDomain } from '#/api/core/weapp';
|
||||
import { getWeappPrivacySetting, setWeappPrivacySetting } from '#/api/core/weapp';
|
||||
import { getIsTradeManaged } from '#/api/core/weapp';
|
||||
|
||||
import { basicFormSchema, serverFormSchema, domainFormSchema } from './data';
|
||||
import ModifyDomainModal from './modules/modify-domain.vue';
|
||||
import ModifyPrivacyModal from './modules/modify-privacy.vue';
|
||||
import PrivacySettingForm from './modules/privacy-setting-form.vue';
|
||||
|
||||
const activeKey = ref('basic');
|
||||
const loading = ref(false);
|
||||
const isAuthorized = ref(false);
|
||||
const isTradeManaged = ref(false);
|
||||
|
||||
const basicFormModel = reactive({
|
||||
weapp_name: '',
|
||||
weapp_original: '',
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
qr_code: '',
|
||||
});
|
||||
|
||||
const serverFormModel = reactive({
|
||||
serve_url: '',
|
||||
token: '',
|
||||
encoding_aes_key: '',
|
||||
encryption_type: 1,
|
||||
});
|
||||
|
||||
const domainFormModel = reactive({
|
||||
request_domain: '',
|
||||
ws_request_domain: '',
|
||||
upload_domain: '',
|
||||
download_domain: '',
|
||||
udp_domain: '',
|
||||
tcp_domain: '',
|
||||
});
|
||||
|
||||
const privacyFormModel = reactive({
|
||||
owner_setting: {
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
contact_qq: '',
|
||||
contact_weixin: '',
|
||||
store_expire_timestamp: '',
|
||||
},
|
||||
setting_list: [],
|
||||
sdk_privacy_info_list: [],
|
||||
});
|
||||
|
||||
const domainModalVisible = ref(false);
|
||||
const privacyModalVisible = ref(false);
|
||||
const currentDomains = ref({});
|
||||
const currentPrivacy = ref({});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const [configRes, privacyRes, tradeRes] = await Promise.all([
|
||||
getWeappConfig(),
|
||||
getWeappPrivacySetting(),
|
||||
getIsTradeManaged(),
|
||||
]);
|
||||
|
||||
// Basic config
|
||||
Object.assign(basicFormModel, {
|
||||
weapp_name: configRes.weapp_name || '',
|
||||
weapp_original: configRes.weapp_original || '',
|
||||
app_id: configRes.app_id || '',
|
||||
app_secret: configRes.app_secret || '',
|
||||
qr_code: configRes.qr_code || '',
|
||||
});
|
||||
|
||||
// Server config
|
||||
Object.assign(serverFormModel, {
|
||||
serve_url: configRes.serve_url || '',
|
||||
token: configRes.token || '',
|
||||
encoding_aes_key: configRes.encoding_aes_key || '',
|
||||
encryption_type: configRes.encryption_type || 1,
|
||||
});
|
||||
|
||||
// Domain config
|
||||
Object.assign(domainFormModel, {
|
||||
request_domain: configRes.request_domain || '',
|
||||
ws_request_domain: configRes.ws_request_domain || '',
|
||||
upload_domain: configRes.upload_domain || '',
|
||||
download_domain: configRes.download_domain || '',
|
||||
udp_domain: configRes.udp_domain || '',
|
||||
tcp_domain: configRes.tcp_domain || '',
|
||||
});
|
||||
|
||||
// Privacy config
|
||||
if (privacyRes) {
|
||||
Object.assign(privacyFormModel, privacyRes);
|
||||
}
|
||||
|
||||
isAuthorized.value = configRes.is_authorization === 1;
|
||||
isTradeManaged.value = tradeRes.is_trade_managed === 1;
|
||||
} catch (error) {
|
||||
message.error('加载配置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBasicSubmit = async (values: any) => {
|
||||
try {
|
||||
await setWeappConfig(values);
|
||||
message.success('保存成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerSubmit = async (values: any) => {
|
||||
try {
|
||||
await setWeappConfig(values);
|
||||
message.success('保存成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainSubmit = async (values: any) => {
|
||||
try {
|
||||
await setWeappDomain(values);
|
||||
message.success('保存成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacySubmit = async (values: any) => {
|
||||
try {
|
||||
await setWeappPrivacySetting(values);
|
||||
message.success('保存成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModifyDomain = () => {
|
||||
currentDomains.value = { ...domainFormModel };
|
||||
domainModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleModifyPrivacy = () => {
|
||||
currentPrivacy.value = { ...privacyFormModel };
|
||||
privacyModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDomainSuccess = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handlePrivacySuccess = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model:show="show"
|
||||
:title="$t('channel.weapp.config.modifyDomain')"
|
||||
:loading="loading"
|
||||
@confirm="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<Description
|
||||
:column="1"
|
||||
:data="domainDescriptions"
|
||||
:schema="domainSchema"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<BasicForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
ref="formRef"
|
||||
/>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { VbenDrawer, Description } from '@vben/common-ui';
|
||||
import { BasicForm } from '@vben/common-ui';
|
||||
|
||||
import { setWeappDomain } from '#/api/core/weapp';
|
||||
|
||||
interface Props {
|
||||
currentDomains: any;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'success'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const show = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const loading = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
const formModel = reactive({
|
||||
request_domain: '',
|
||||
ws_request_domain: '',
|
||||
upload_domain: '',
|
||||
download_domain: '',
|
||||
udp_domain: '',
|
||||
tcp_domain: '',
|
||||
});
|
||||
|
||||
const domainDescriptions = computed(() => ({
|
||||
current_request: props.currentDomains?.request_domain || '未设置',
|
||||
current_ws: props.currentDomains?.ws_request_domain || '未设置',
|
||||
current_upload: props.currentDomains?.upload_domain || '未设置',
|
||||
current_download: props.currentDomains?.download_domain || '未设置',
|
||||
current_udp: props.currentDomains?.udp_domain || '未设置',
|
||||
current_tcp: props.currentDomains?.tcp_domain || '未设置',
|
||||
}));
|
||||
|
||||
const domainSchema = [
|
||||
{ field: 'current_request', label: '当前request域名' },
|
||||
{ field: 'current_ws', label: '当前socket域名' },
|
||||
{ field: 'current_upload', label: '当前upload域名' },
|
||||
{ field: 'current_download', label: '当前download域名' },
|
||||
{ field: 'current_udp', label: '当前udp域名' },
|
||||
{ field: 'current_tcp', label: '当前tcp域名' },
|
||||
];
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'request_domain',
|
||||
label: 'request合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:https://api.example.com;https://api2.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const domains = value.split(';').filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
if (!domain.startsWith('https://')) {
|
||||
return Promise.reject(new Error('域名必须以https://开头'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'ws_request_domain',
|
||||
label: 'socket合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:wss://ws.example.com;wss://ws2.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const domains = value.split(';').filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
if (!domain.startsWith('wss://')) {
|
||||
return Promise.reject(new Error('域名必须以wss://开头'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'upload_domain',
|
||||
label: 'uploadFile合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:https://upload.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const domains = value.split(';').filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
if (!domain.startsWith('https://')) {
|
||||
return Promise.reject(new Error('域名必须以https://开头'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'download_domain',
|
||||
label: 'downloadFile合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:https://download.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const domains = value.split(';').filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
if (!domain.startsWith('https://')) {
|
||||
return Promise.reject(new Error('域名必须以https://开头'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'udp_domain',
|
||||
label: 'udp合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:udp://udp.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const domains = value.split(';').filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
if (!domain.startsWith('udp://')) {
|
||||
return Promise.reject(new Error('域名必须以udp://开头'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'tcp_domain',
|
||||
label: 'tcp合法域名',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '多个域名以;分隔,如:tcp://tcp.example.com',
|
||||
rows: 3,
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const domains = value.split(';').filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
if (!domain.startsWith('tcp://')) {
|
||||
return Promise.reject(new Error('域名必须以tcp://开头'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const form = await formRef.value?.validate();
|
||||
loading.value = true;
|
||||
await setWeappDomain(form);
|
||||
message.success('域名设置成功');
|
||||
show.value = false;
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
if (error !== false) {
|
||||
message.error('域名设置失败');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
show.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.currentDomains,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
Object.assign(formModel, {
|
||||
request_domain: newVal.request_domain || '',
|
||||
ws_request_domain: newVal.ws_request_domain || '',
|
||||
upload_domain: newVal.upload_domain || '',
|
||||
download_domain: newVal.download_domain || '',
|
||||
udp_domain: newVal.udp_domain || '',
|
||||
tcp_domain: newVal.tcp_domain || '',
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model:show="show"
|
||||
:title="$t('channel.weapp.config.modifyPrivacy')"
|
||||
:loading="loading"
|
||||
@confirm="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<Tabs v-model:activeKey="activeTab">
|
||||
<TabPane key="info" tab="信息收集项">
|
||||
<div class="mb-4">
|
||||
<Button type="primary" @click="addInfoItem" class="mb-2">
|
||||
添加信息收集项
|
||||
</Button>
|
||||
<Table
|
||||
:columns="infoColumns"
|
||||
:data-source="infoList"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'privacy_key'">
|
||||
<Select
|
||||
v-model:value="record.privacy_key"
|
||||
:options="privacyKeyOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'privacy_text'">
|
||||
<Input v-model:value="record.privacy_text" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" danger @click="removeInfoItem(index)">
|
||||
删除
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="sdk" tab="SDK信息">
|
||||
<div class="mb-4">
|
||||
<Button type="primary" @click="addSdkItem" class="mb-2">
|
||||
添加SDK
|
||||
</Button>
|
||||
<Table
|
||||
:columns="sdkColumns"
|
||||
:data-source="sdkList"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'sdk_name'">
|
||||
<Input v-model:value="record.sdk_name" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'sdk_biz'">
|
||||
<Input v-model:value="record.sdk_biz" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'privacy_key_list'">
|
||||
<Select
|
||||
v-model:value="record.privacy_key_list"
|
||||
mode="tags"
|
||||
placeholder="请选择信息收集项"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" danger @click="removeSdkItem(index)">
|
||||
删除
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="storage" tab="存储规则">
|
||||
<BasicForm
|
||||
:schema="storageFormSchema"
|
||||
:model="storageFormModel"
|
||||
ref="storageFormRef"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { VbenDrawer } from '@vben/common-ui';
|
||||
import { BasicForm } from '@vben/common-ui';
|
||||
|
||||
import { Tabs, TabPane, Button, Table, Select, Input, RadioGroup, Radio } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
currentPrivacy: any;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'success'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const show = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('info');
|
||||
|
||||
const infoList = ref<any[]>([]);
|
||||
const sdkList = ref<any[]>([]);
|
||||
|
||||
const storageFormModel = reactive({
|
||||
storage_type: '1',
|
||||
storage_duration: '',
|
||||
notification_method: '',
|
||||
});
|
||||
|
||||
const storageFormRef = ref();
|
||||
|
||||
const privacyKeyOptions = [
|
||||
{ label: 'UserInfo', value: 'UserInfo' },
|
||||
{ label: 'Location', value: 'Location' },
|
||||
{ label: 'PhoneNumber', value: 'PhoneNumber' },
|
||||
];
|
||||
|
||||
const infoColumns = [
|
||||
{ title: '信息收集项', dataIndex: 'privacy_key', key: 'privacy_key' },
|
||||
{ title: '描述', dataIndex: 'privacy_text', key: 'privacy_text' },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
];
|
||||
|
||||
const sdkColumns = [
|
||||
{ title: 'SDK名称', dataIndex: 'sdk_name', key: 'sdk_name' },
|
||||
{ title: '提供方', dataIndex: 'sdk_biz', key: 'sdk_biz' },
|
||||
{ title: '收集信息', dataIndex: 'privacy_key_list', key: 'privacy_key_list' },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
];
|
||||
|
||||
const storageFormSchema = [
|
||||
{
|
||||
fieldName: 'storage_type',
|
||||
label: '存储类型',
|
||||
component: 'RadioGroup',
|
||||
defaultValue: '1',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '固定时长', value: '1' },
|
||||
{ label: '最短期限', value: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_duration',
|
||||
label: '存储期限',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入存储期限描述',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'notification_method',
|
||||
label: '通知方式',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入通知方式',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const addInfoItem = () => {
|
||||
infoList.value.push({
|
||||
privacy_key: '',
|
||||
privacy_text: '',
|
||||
});
|
||||
};
|
||||
|
||||
const removeInfoItem = (index: number) => {
|
||||
infoList.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const addSdkItem = () => {
|
||||
sdkList.value.push({
|
||||
sdk_name: '',
|
||||
sdk_biz: '',
|
||||
privacy_key_list: [],
|
||||
});
|
||||
};
|
||||
|
||||
const removeSdkItem = (index: number) => {
|
||||
sdkList.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
let storageData = {};
|
||||
if (activeTab.value === 'storage') {
|
||||
storageData = await storageFormRef.value?.validate();
|
||||
}
|
||||
|
||||
const result = {
|
||||
setting_list: infoList.value,
|
||||
sdk_privacy_info_list: sdkList.value,
|
||||
...storageData,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
// Call API to save privacy settings
|
||||
message.success('隐私设置保存成功');
|
||||
show.value = false;
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
if (error !== false) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
show.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.currentPrivacy,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
infoList.value = newVal.setting_list || [];
|
||||
sdkList.value = newVal.sdk_privacy_info_list || [];
|
||||
if (newVal.storage_type) {
|
||||
Object.assign(storageFormModel, {
|
||||
storage_type: newVal.storage_type,
|
||||
storage_duration: newVal.storage_duration || '',
|
||||
notification_method: newVal.notification_method || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<BasicForm
|
||||
:schema="formSchema"
|
||||
:model="model"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-submit="{ loading: submitLoading }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { BasicForm } from '@vben/common-ui';
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { setWeappPrivacySetting } from '#/api/core/weapp';
|
||||
|
||||
interface Props {
|
||||
model: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', data: any): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'owner_setting.contact_email',
|
||||
label: '联系邮箱',
|
||||
component: 'Input',
|
||||
rules: 'required|email',
|
||||
},
|
||||
{
|
||||
fieldName: 'owner_setting.contact_phone',
|
||||
label: '联系电话',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'owner_setting.contact_qq',
|
||||
label: '联系QQ',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'owner_setting.contact_weixin',
|
||||
label: '联系微信',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'owner_setting.store_expire_timestamp',
|
||||
label: '存储期限',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入存储期限描述',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
await setWeappPrivacySetting(values);
|
||||
message.success('保存成功');
|
||||
emit('submit', values);
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
114
admin-vben/apps/web-antd/src/views/channel/weapp/course/list.vue
Normal file
114
admin-vben/apps/web-antd/src/views/channel/weapp/course/list.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card :title="$t('channel.weapp.course.title')">
|
||||
<div class="space-y-6">
|
||||
<div class="text-lg font-semibold mb-4">{{ $t('channel.weapp.course.subtitle') }}</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold mr-4">1</div>
|
||||
<h3 class="text-xl font-semibold">{{ $t('channel.weapp.course.step1.title') }}</h3>
|
||||
</div>
|
||||
<div class="ml-12 space-y-4">
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step1.desc1') }}</p>
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step1.desc2') }}</p>
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<p class="font-medium mb-2">{{ $t('channel.weapp.course.step1.note') }}</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li>{{ $t('channel.weapp.course.step1.note1') }}</li>
|
||||
<li>{{ $t('channel.weapp.course.step1.note2') }}</li>
|
||||
<li>{{ $t('channel.weapp.course.step1.note3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold mr-4">2</div>
|
||||
<h3 class="text-xl font-semibold">{{ $t('channel.weapp.course.step2.title') }}</h3>
|
||||
</div>
|
||||
<div class="ml-12 space-y-4">
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step2.desc1') }}</p>
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step2.desc2') }}</p>
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<p class="font-medium mb-2">{{ $t('channel.weapp.course.step2.config') }}</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm text-gray-600">
|
||||
<li>{{ $t('channel.weapp.course.step2.config1') }}</li>
|
||||
<li>{{ $t('channel.weapp.course.step2.config2') }}</li>
|
||||
<li>{{ $t('channel.weapp.course.step2.config3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold mr-4">3</div>
|
||||
<h3 class="text-xl font-semibold">{{ $t('channel.weapp.course.step3.title') }}</h3>
|
||||
</div>
|
||||
<div class="ml-12 space-y-4">
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step3.desc1') }}</p>
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step3.desc2') }}</p>
|
||||
<div class="bg-blue-50 p-4 rounded border-l-4 border-blue-400">
|
||||
<p class="text-blue-800 font-medium">{{ $t('channel.weapp.course.step3.tip') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold mr-4">4</div>
|
||||
<h3 class="text-xl font-semibold">{{ $t('channel.weapp.course.step4.title') }}</h3>
|
||||
</div>
|
||||
<div class="ml-12 space-y-4">
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step4.desc1') }}</p>
|
||||
<p class="text-gray-700">{{ $t('channel.weapp.course.step4.desc2') }}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="bg-green-50 p-4 rounded border">
|
||||
<h4 class="font-semibold text-green-800 mb-2">{{ $t('channel.weapp.course.step4.cloud') }}</h4>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>• {{ $t('channel.weapp.course.step4.cloud1') }}</li>
|
||||
<li>• {{ $t('channel.weapp.course.step4.cloud2') }}</li>
|
||||
<li>• {{ $t('channel.weapp.course.step4.cloud3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-orange-50 p-4 rounded border">
|
||||
<h4 class="font-semibold text-orange-800 mb-2">{{ $t('channel.weapp.course.step4.local') }}</h4>
|
||||
<ul class="text-sm text-orange-700 space-y-1">
|
||||
<li>• {{ $t('channel.weapp.course.step4.local1') }}</li>
|
||||
<li>• {{ $t('channel.weapp.course.step4.local2') }}</li>
|
||||
<li>• {{ $t('channel.weapp.course.step4.local3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<Button type="primary" size="large" @click="handleStart">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:rocket-outlined" />
|
||||
</template>
|
||||
{{ $t('channel.weapp.course.start') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
import { Card, Button } from 'ant-design-vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleStart = () => {
|
||||
router.push('/channel/weapp/access');
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface WeappTemplateApi {
|
||||
getTemplateList: (params: { addon?: string }) => Promise<any>;
|
||||
getBatchAcquisition: (params: { addon?: string }) => Promise<any>;
|
||||
editNoticeStatus: (params: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
addon: string;
|
||||
template_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
example: string;
|
||||
status: number;
|
||||
create_time: number;
|
||||
}
|
||||
|
||||
export const gridSchema: VxeGridProps = {
|
||||
stripe: true,
|
||||
showHeaderOverflow: true,
|
||||
showOverflow: true,
|
||||
height: 'auto',
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
isCurrent: true,
|
||||
},
|
||||
columnConfig: {
|
||||
resizable: true,
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
title: '序号',
|
||||
},
|
||||
{
|
||||
field: 'addon',
|
||||
title: '应用',
|
||||
minWidth: 120,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue || '系统';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
title: '模板标题',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'template_id',
|
||||
title: '模板ID',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
title: '模板内容',
|
||||
minWidth: 300,
|
||||
},
|
||||
{
|
||||
field: 'example',
|
||||
title: '示例',
|
||||
minWidth: 250,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: 'status',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue ? new Date(cellValue * 1000).toLocaleString() : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
},
|
||||
],
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
refresh: true,
|
||||
zoom: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
// This will be implemented in the component
|
||||
return { result: [], total: 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card :title="$t('channel.weapp.template.title')">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="handleBatchSync">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:sync-outlined" />
|
||||
</template>
|
||||
{{ $t('channel.weapp.template.batchSync') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:grid-options="gridSchema"
|
||||
:query-form-schema="queryFormSchema"
|
||||
@register="registerGrid"
|
||||
>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 1"
|
||||
@change="(checked) => handleStatusChange(row, checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<Button type="link" size="small" @click="handleSyncSingle(row)">
|
||||
{{ $t('channel.weapp.template.sync') }}
|
||||
</Button>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
import { Card, Button, Switch } from 'ant-design-vue';
|
||||
import { VbenVxeGrid, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
|
||||
import { getTemplateList, getBatchAcquisition } from '#/api/core/weapp';
|
||||
import { editNoticeStatus } from '#/api/core/notice';
|
||||
|
||||
import { gridSchema } from './data';
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const queryFormSchema = [
|
||||
{
|
||||
fieldName: 'addon',
|
||||
label: '应用',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入应用标识',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [registerGrid, { reload }] = useVbenVxeGrid({
|
||||
gridOptions: gridSchema,
|
||||
queryFormOptions: {
|
||||
schema: queryFormSchema,
|
||||
showCollapseButton: false,
|
||||
collapsed: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
try {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
const response = await getTemplateList(params);
|
||||
return {
|
||||
result: response,
|
||||
total: response.length,
|
||||
};
|
||||
} catch (error) {
|
||||
message.error('获取模板列表失败');
|
||||
return { result: [], total: 0 };
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatusChange = async (row: any, checked: boolean) => {
|
||||
try {
|
||||
await editNoticeStatus({
|
||||
id: row.id,
|
||||
status: checked ? 1 : 0,
|
||||
type: 'weapp',
|
||||
});
|
||||
message.success('状态更新成功');
|
||||
reload();
|
||||
} catch (error) {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncSingle = (row: any) => {
|
||||
Modal.confirm({
|
||||
title: '同步确认',
|
||||
content: `确定要同步模板 "${row.title}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await getBatchAcquisition({ addon: row.addon });
|
||||
message.success('同步成功');
|
||||
reload();
|
||||
} catch (error) {
|
||||
message.error('同步失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchSync = () => {
|
||||
Modal.confirm({
|
||||
title: '批量同步确认',
|
||||
content: '确定要批量同步所有模板吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await getBatchAcquisition({});
|
||||
message.success('批量同步成功');
|
||||
reload();
|
||||
} catch (error) {
|
||||
message.error('批量同步失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
reload();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Card :bordered="false">
|
||||
<template #title>
|
||||
<span class="text-lg font-medium">{{ $t('channel.wechat.access.title') }}</span>
|
||||
</template>
|
||||
<div class="p-4">
|
||||
<Alert
|
||||
:message="$t('channel.wechat.access.tips')"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<Steps :current="currentStep" class="mb-8">
|
||||
<Step :title="$t('channel.wechat.access.step1.title')" />
|
||||
<Step :title="$t('channel.wechat.access.step2.title')" />
|
||||
<Step :title="$t('channel.wechat.access.step3.title')" />
|
||||
<Step :title="$t('channel.wechat.access.step4.title')" />
|
||||
</Steps>
|
||||
|
||||
<div class="step-content">
|
||||
<!-- Step 1: 注册公众号 -->
|
||||
<div v-if="currentStep === 0" class="step-panel">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-6xl mb-4">📱</div>
|
||||
<h3 class="text-xl font-medium mb-2">{{ $t('channel.wechat.access.step1.title') }}</h3>
|
||||
<p class="text-gray-600 mb-6">{{ $t('channel.wechat.access.step1.desc') }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 class="font-medium mb-3">{{ $t('channel.wechat.access.step1.requirements') }}</h4>
|
||||
<ul class="list-disc pl-5 space-y-2 text-sm">
|
||||
<li>{{ $t('channel.wechat.access.step1.req1') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step1.req2') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step1.req3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<Button type="primary" size="large" @click="openOfficialWebsite">
|
||||
{{ $t('channel.wechat.access.step1.goRegister') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 获取开发者信息 -->
|
||||
<div v-if="currentStep === 1" class="step-panel">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-6xl mb-4">🔧</div>
|
||||
<h3 class="text-xl font-medium mb-2">{{ $t('channel.wechat.access.step2.title') }}</h3>
|
||||
<p class="text-gray-600 mb-6">{{ $t('channel.wechat.access.step2.desc') }}</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 p-4 rounded-lg mb-6">
|
||||
<h4 class="font-medium mb-3">{{ $t('channel.wechat.access.step2.instructions') }}</h4>
|
||||
<ol class="list-decimal pl-5 space-y-2 text-sm">
|
||||
<li>{{ $t('channel.wechat.access.step2.step1') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step2.step2') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step2.step3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h5 class="font-medium mb-2">{{ $t('channel.wechat.access.step2.appId') }}</h5>
|
||||
<Input :placeholder="$t('channel.wechat.access.step2.appIdPlaceholder')" v-model:value="config.appId" />
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h5 class="font-medium mb-2">{{ $t('channel.wechat.access.step2.appSecret') }}</h5>
|
||||
<Input.Password :placeholder="$t('channel.wechat.access.step2.appSecretPlaceholder')" v-model:value="config.appSecret" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<Button type="primary" size="large" @click="nextStep" :disabled="!config.appId || !config.appSecret">
|
||||
{{ $t('common.nextStep') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 配置服务器 -->
|
||||
<div v-if="currentStep === 2" class="step-panel">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-6xl mb-4">⚙️</div>
|
||||
<h3 class="text-xl font-medium mb-2">{{ $t('channel.wechat.access.step3.title') }}</h3>
|
||||
<p class="text-gray-600 mb-6">{{ $t('channel.wechat.access.step3.desc') }}</p>
|
||||
</div>
|
||||
<div class="bg-yellow-50 p-4 rounded-lg mb-6">
|
||||
<h4 class="font-medium mb-3">{{ $t('channel.wechat.access.step3.serverInfo') }}</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('channel.wechat.access.step3.serverUrl') }}:</span>
|
||||
<span class="font-mono text-blue-600">{{ serverConfig.serverUrl }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('channel.wechat.access.step3.token') }}:</span>
|
||||
<span class="font-mono text-blue-600">{{ serverConfig.token }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 class="font-medium mb-3">{{ $t('channel.wechat.access.step3.instructions') }}</h4>
|
||||
<ol class="list-decimal pl-5 space-y-2 text-sm">
|
||||
<li>{{ $t('channel.wechat.access.step3.step1') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step3.step2') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step3.step3') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step3.step4') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<Button type="primary" size="large" @click="nextStep">
|
||||
{{ $t('common.nextStep') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: 完成配置 -->
|
||||
<div v-if="currentStep === 3" class="step-panel">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-6xl mb-4">🎉</div>
|
||||
<h3 class="text-xl font-medium mb-2">{{ $t('channel.wechat.access.step4.title') }}</h3>
|
||||
<p class="text-gray-600 mb-6">{{ $t('channel.wechat.access.step4.desc') }}</p>
|
||||
</div>
|
||||
<div class="bg-green-50 p-4 rounded-lg mb-6">
|
||||
<h4 class="font-medium mb-3 text-green-800">{{ $t('channel.wechat.access.step4.success') }}</h4>
|
||||
<p class="text-green-700 text-sm">{{ $t('channel.wechat.access.step4.successDesc') }}</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 p-4 rounded-lg mb-6">
|
||||
<h4 class="font-medium mb-3">{{ $t('channel.wechat.access.step4.nextSteps') }}</h4>
|
||||
<ul class="list-disc pl-5 space-y-1 text-sm">
|
||||
<li>{{ $t('channel.wechat.access.step4.next1') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step4.next2') }}</li>
|
||||
<li>{{ $t('channel.wechat.access.step4.next3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="text-center space-x-4">
|
||||
<Button type="primary" size="large" @click="goToConfig">
|
||||
{{ $t('channel.wechat.access.step4.goConfig') }}
|
||||
</Button>
|
||||
<Button size="large" @click="goToTutorial">
|
||||
{{ $t('channel.wechat.access.step4.goTutorial') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-actions mt-8 flex justify-between">
|
||||
<Button v-if="currentStep > 0" @click="prevStep">
|
||||
{{ $t('common.prevStep') }}
|
||||
</Button>
|
||||
<div v-else></div>
|
||||
|
||||
<Button v-if="currentStep < 3" type="primary" @click="nextStep" :disabled="stepDisabled">
|
||||
{{ $t('common.nextStep') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Button, Card, Alert, Steps, Input } from 'ant-design-vue';
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const currentStep = ref(0);
|
||||
|
||||
const config = reactive({
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
});
|
||||
|
||||
const serverConfig = reactive({
|
||||
serverUrl: 'https://your-domain.com/api/wechat',
|
||||
token: 'your_token_here',
|
||||
});
|
||||
|
||||
const stepDisabled = computed(() => {
|
||||
if (currentStep.value === 1) {
|
||||
return !config.appId || !config.appSecret;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const openOfficialWebsite = () => {
|
||||
window.open('https://mp.weixin.qq.com/', '_blank');
|
||||
};
|
||||
|
||||
const goToConfig = () => {
|
||||
router.push('/channel/wechat/config');
|
||||
};
|
||||
|
||||
const goToTutorial = () => {
|
||||
router.push('/channel/wechat/tutorial');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-panel {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
margin: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Card :bordered="false">
|
||||
<template #title>
|
||||
<span class="text-lg font-medium">{{ $t('channel.wechat.config.title') }}</span>
|
||||
</template>
|
||||
<div class="p-4">
|
||||
<Tabs v-model:activeKey="activeKey">
|
||||
<TabPane key="basic" :tab="$t('channel.wechat.config.basic.tab')">
|
||||
<BasicConfig />
|
||||
</TabPane>
|
||||
<TabPane key="server" :tab="$t('channel.wechat.config.server.tab')">
|
||||
<ServerConfig />
|
||||
</TabPane>
|
||||
<TabPane key="domain" :tab="$t('channel.wechat.config.domain.tab')">
|
||||
<DomainConfig />
|
||||
</TabPane>
|
||||
<TabPane key="privacy" :tab="$t('channel.wechat.config.privacy.tab')">
|
||||
<PrivacyConfig />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Card, Tabs } from 'ant-design-vue';
|
||||
|
||||
import BasicConfig from './modules/basic-config.vue';
|
||||
import DomainConfig from './modules/domain-config.vue';
|
||||
import PrivacyConfig from './modules/privacy-config.vue';
|
||||
import ServerConfig from './modules/server-config.vue';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
const activeKey = ref('basic');
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 14 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.basic.appId')"
|
||||
name="app_id"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.basic.appIdRequired') }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.app_id"
|
||||
:placeholder="$t('channel.wechat.config.basic.appIdPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.basic.appSecret')"
|
||||
name="app_secret"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.basic.appSecretRequired') }]"
|
||||
>
|
||||
<Input.Password
|
||||
v-model:value="formData.app_secret"
|
||||
:placeholder="$t('channel.wechat.config.basic.appSecretPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.basic.token')"
|
||||
name="token"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.basic.tokenRequired') }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.token"
|
||||
:placeholder="$t('channel.wechat.config.basic.tokenPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.basic.encodingAesKey')"
|
||||
name="encoding_aes_key"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.encoding_aes_key"
|
||||
:placeholder="$t('channel.wechat.config.basic.encodingAesKeyPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.basic.encryptionType')"
|
||||
name="encryption_type"
|
||||
>
|
||||
<RadioGroup v-model:value="formData.encryption_type">
|
||||
<Radio :value="0">{{ $t('channel.wechat.config.basic.encryptionType0') }}</Radio>
|
||||
<Radio :value="1">{{ $t('channel.wechat.config.basic.encryptionType1') }}</Radio>
|
||||
<Radio :value="2">{{ $t('channel.wechat.config.basic.encryptionType2') }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.basic.qrCode')"
|
||||
name="qr_code"
|
||||
>
|
||||
<Upload
|
||||
v-model:file-list="fileList"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:before-upload="beforeUpload"
|
||||
@change="handleUploadChange"
|
||||
list-type="picture-card"
|
||||
:max-count="1"
|
||||
>
|
||||
<div v-if="fileList.length === 0">
|
||||
<PlusOutlined />
|
||||
<div class="mt-2">{{ $t('common.upload') }}</div>
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ offset: 4, span: 14 }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
<Button class="ml-4" @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { Button, Form, FormItem, Input, Radio, RadioGroup, Upload, message } from 'ant-design-vue';
|
||||
|
||||
import { getWechatConfigApi, saveWechatConfigApi } from '#/api/core/wechat';
|
||||
|
||||
const { Item: FormItem } = Form;
|
||||
|
||||
interface FormData {
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
token: string;
|
||||
encoding_aes_key: string;
|
||||
encryption_type: number;
|
||||
qr_code: string;
|
||||
}
|
||||
|
||||
const formRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
const fileList = ref<any[]>([]);
|
||||
|
||||
const formData = reactive<FormData>({
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
token: '',
|
||||
encoding_aes_key: '',
|
||||
encryption_type: 0,
|
||||
qr_code: '',
|
||||
});
|
||||
|
||||
const uploadAction = computed(() => {
|
||||
return `${import.meta.env.VITE_APP_BASE_URL}/upload/image`;
|
||||
});
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
return {
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
||||
};
|
||||
});
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUploadChange = (info: any) => {
|
||||
if (info.file.status === 'done') {
|
||||
const response = info.file.response;
|
||||
if (response.code === 0) {
|
||||
formData.qr_code = response.data.url;
|
||||
message.success('上传成功');
|
||||
} else {
|
||||
message.error(response.message || '上传失败');
|
||||
}
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error('上传失败');
|
||||
}
|
||||
fileList.value = info.fileList;
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getWechatConfigApi();
|
||||
Object.assign(formData, config);
|
||||
if (config.qr_code) {
|
||||
fileList.value = [
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'qr_code.png',
|
||||
status: 'done',
|
||||
url: config.qr_code,
|
||||
},
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
await saveWechatConfigApi(formData);
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value?.resetFields();
|
||||
fileList.value = [];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="config-form">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 12 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<FormItem :label="$t('system.wechat.config.appId')" name="appId">
|
||||
<Input
|
||||
v-model:value="formData.appId"
|
||||
:placeholder="$t('system.wechat.config.appIdPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.appSecret')" name="appSecret">
|
||||
<Input.Password
|
||||
v-model:value="formData.appSecret"
|
||||
:placeholder="$t('system.wechat.config.appSecretPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.token')" name="token">
|
||||
<Input
|
||||
v-model:value="formData.token"
|
||||
:placeholder="$t('system.wechat.config.tokenPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.aesKey')" name="aesKey">
|
||||
<Input
|
||||
v-model:value="formData.aesKey"
|
||||
:placeholder="$t('system.wechat.config.aesKeyPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.originalId')" name="originalId">
|
||||
<Input
|
||||
v-model:value="formData.originalId"
|
||||
:placeholder="$t('system.wechat.config.originalIdPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.qrcode')" name="qrcode">
|
||||
<Upload
|
||||
v-model:file-list="fileList"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
@change="handleUploadChange"
|
||||
>
|
||||
<div v-if="fileList.length < 1">
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">{{ $t('system.wechat.config.upload') }}</div>
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ span: 12, offset: 6 }">
|
||||
<Button type="primary" html-type="submit" :loading="loading">
|
||||
{{ $t('system.wechat.config.save') }}
|
||||
</Button>
|
||||
<Button @click="handleReset" class="ml-2">
|
||||
{{ $t('system.wechat.config.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { Form, FormItem, Input, Button, Upload, message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { useI18n } from '@vben/locales';
|
||||
import { getWechatConfigApi, saveWechatConfigApi } from '@api/core/wechat';
|
||||
|
||||
const { t } = useI18n();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const fileList = ref<any[]>([]);
|
||||
|
||||
const formData = reactive({
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
token: '',
|
||||
aesKey: '',
|
||||
originalId: '',
|
||||
qrcode: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
appId: [{ required: true, message: t('system.wechat.config.appIdRequired') }],
|
||||
appSecret: [{ required: true, message: t('system.wechat.config.appSecretRequired') }],
|
||||
token: [{ required: true, message: t('system.wechat.config.tokenRequired') }],
|
||||
};
|
||||
|
||||
const uploadAction = '/adminapi/upload/image';
|
||||
const uploadHeaders = {
|
||||
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
|
||||
};
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error(t('system.wechat.config.imageOnly'));
|
||||
return false;
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error(t('system.wechat.config.imageSize'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUploadChange = (info: any) => {
|
||||
if (info.file.status === 'done') {
|
||||
formData.qrcode = info.file.response?.data?.url || '';
|
||||
message.success(t('system.wechat.config.uploadSuccess'));
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(t('system.wechat.config.uploadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await getWechatConfigApi();
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(formData, res.data);
|
||||
if (res.data.qrcode) {
|
||||
fileList.value = [{
|
||||
uid: '-1',
|
||||
name: 'qrcode.png',
|
||||
status: 'done',
|
||||
url: res.data.qrcode,
|
||||
}];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.loadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await formRef.value.validate();
|
||||
await saveWechatConfigApi(formData);
|
||||
message.success(t('system.wechat.config.saveSuccess'));
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.saveError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value.resetFields();
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 14 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.domain.requestDomain')"
|
||||
name="request_domain"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.domain.requestDomainRequired') }]"
|
||||
>
|
||||
<Select
|
||||
v-model:value="formData.request_domain"
|
||||
mode="tags"
|
||||
:placeholder="$t('channel.wechat.config.domain.requestDomainPlaceholder')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.domain.wsRequestDomain')"
|
||||
name="ws_request_domain"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.domain.wsRequestDomainRequired') }]"
|
||||
>
|
||||
<Select
|
||||
v-model:value="formData.ws_request_domain"
|
||||
mode="tags"
|
||||
:placeholder="$t('channel.wechat.config.domain.wsRequestDomainPlaceholder')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.domain.uploadDomain')"
|
||||
name="upload_domain"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.domain.uploadDomainRequired') }]"
|
||||
>
|
||||
<Select
|
||||
v-model:value="formData.upload_domain"
|
||||
mode="tags"
|
||||
:placeholder="$t('channel.wechat.config.domain.uploadDomainPlaceholder')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.domain.downloadDomain')"
|
||||
name="download_domain"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.domain.downloadDomainRequired') }]"
|
||||
>
|
||||
<Select
|
||||
v-model:value="formData.download_domain"
|
||||
mode="tags"
|
||||
:placeholder="$t('channel.wechat.config.domain.downloadDomainPlaceholder')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ offset: 4, span: 14 }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
<Button class="ml-4" @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Button, Form, FormItem, message, Select } from 'ant-design-vue';
|
||||
|
||||
import { getDomainConfigApi, saveDomainConfigApi } from '#/api/core/wechat';
|
||||
|
||||
const { Item: FormItem } = Form;
|
||||
|
||||
interface FormData {
|
||||
request_domain: string[];
|
||||
ws_request_domain: string[];
|
||||
upload_domain: string[];
|
||||
download_domain: string[];
|
||||
}
|
||||
|
||||
const formRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
const formData = reactive<FormData>({
|
||||
request_domain: [],
|
||||
ws_request_domain: [],
|
||||
upload_domain: [],
|
||||
download_domain: [],
|
||||
});
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getDomainConfigApi();
|
||||
Object.assign(formData, config);
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
await saveDomainConfigApi(formData);
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="config-form">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 12 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<Alert
|
||||
:message="$t('system.wechat.config.domainTip')"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.businessDomain')" name="businessDomain">
|
||||
<Textarea
|
||||
v-model:value="formData.businessDomain"
|
||||
:placeholder="$t('system.wechat.config.businessDomainPlaceholder')"
|
||||
:rows="3"
|
||||
/>
|
||||
<div class="text-gray-500 text-sm mt-1">
|
||||
{{ $t('system.wechat.config.businessDomainTip') }}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.jsDomain')" name="jsDomain">
|
||||
<Textarea
|
||||
v-model:value="formData.jsDomain"
|
||||
:placeholder="$t('system.wechat.config.jsDomainPlaceholder')"
|
||||
:rows="3"
|
||||
/>
|
||||
<div class="text-gray-500 text-sm mt-1">
|
||||
{{ $t('system.wechat.config.jsDomainTip') }}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.webDomain')" name="webDomain">
|
||||
<Textarea
|
||||
v-model:value="formData.webDomain"
|
||||
:placeholder="$t('system.wechat.config.webDomainPlaceholder')"
|
||||
:rows="3"
|
||||
/>
|
||||
<div class="text-gray-500 text-sm mt-1">
|
||||
{{ $t('system.wechat.config.webDomainTip') }}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ span: 12, offset: 6 }">
|
||||
<Button type="primary" html-type="submit" :loading="loading">
|
||||
{{ $t('system.wechat.config.save') }}
|
||||
</Button>
|
||||
<Button @click="handleReset" class="ml-2">
|
||||
{{ $t('system.wechat.config.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { Form, FormItem, Input, Button, Textarea, Alert, message } from 'ant-design-vue';
|
||||
import { useI18n } from '@vben/locales';
|
||||
import { getDomainConfigApi, saveDomainConfigApi } from '@api/core/wechat';
|
||||
|
||||
const { t } = useI18n();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
businessDomain: '',
|
||||
jsDomain: '',
|
||||
webDomain: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
businessDomain: [{ required: true, message: t('system.wechat.config.businessDomainRequired') }],
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await getDomainConfigApi();
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(formData, res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.loadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await formRef.value.validate();
|
||||
await saveDomainConfigApi(formData);
|
||||
message.success(t('system.wechat.config.saveSuccess'));
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.saveError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value.resetFields();
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 14 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.privacy.contactEmail')"
|
||||
name="owner_setting.contact_email"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.privacy.contactEmailRequired') }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.owner_setting.contact_email"
|
||||
:placeholder="$t('channel.wechat.config.privacy.contactEmailPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.privacy.contactPhone')"
|
||||
name="owner_setting.contact_phone"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.privacy.contactPhoneRequired') }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.owner_setting.contact_phone"
|
||||
:placeholder="$t('channel.wechat.config.privacy.contactPhonePlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.privacy.contactQQ')"
|
||||
name="owner_setting.contact_qq"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.owner_setting.contact_qq"
|
||||
:placeholder="$t('channel.wechat.config.privacy.contactQQPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.privacy.contactWeixin')"
|
||||
name="owner_setting.contact_weixin"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.owner_setting.contact_weixin"
|
||||
:placeholder="$t('channel.wechat.config.privacy.contactWeixinPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.privacy.storeExpireTimestamp')"
|
||||
name="owner_setting.store_expire_timestamp"
|
||||
>
|
||||
<DatePicker
|
||||
v-model:value="formData.owner_setting.store_expire_timestamp"
|
||||
style="width: 100%"
|
||||
:placeholder="$t('channel.wechat.config.privacy.storeExpireTimestampPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.privacy.settingList')"
|
||||
name="setting_list"
|
||||
>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="formData.setting_list"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Button type="link" danger size="small" @click="removeSetting(index)">
|
||||
{{ $t('common.delete') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<Button type="dashed" style="width: 100%; margin-top: 8px" @click="addSetting">
|
||||
<PlusOutlined />
|
||||
{{ $t('common.add') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ offset: 4, span: 14 }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
<Button class="ml-4" @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { Button, DatePicker, Form, FormItem, Input, Table, message } from 'ant-design-vue';
|
||||
|
||||
import { getPrivacyConfigApi, savePrivacyConfigApi } from '#/api/core/wechat';
|
||||
|
||||
const { Item: FormItem } = Form;
|
||||
|
||||
interface FormData {
|
||||
owner_setting: {
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_qq: string;
|
||||
contact_weixin: string;
|
||||
store_expire_timestamp: string;
|
||||
};
|
||||
setting_list: Array<{
|
||||
privacy_key: string;
|
||||
privacy_text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const formRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
const formData = reactive<FormData>({
|
||||
owner_setting: {
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
contact_qq: '',
|
||||
contact_weixin: '',
|
||||
store_expire_timestamp: '',
|
||||
},
|
||||
setting_list: [],
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: '隐私键',
|
||||
dataIndex: 'privacy_key',
|
||||
key: 'privacy_key',
|
||||
},
|
||||
{
|
||||
title: '隐私文本',
|
||||
dataIndex: 'privacy_text',
|
||||
key: 'privacy_text',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
const addSetting = () => {
|
||||
formData.setting_list.push({
|
||||
privacy_key: '',
|
||||
privacy_text: '',
|
||||
});
|
||||
};
|
||||
|
||||
const removeSetting = (index: number) => {
|
||||
formData.setting_list.splice(index, 1);
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getPrivacyConfigApi();
|
||||
Object.assign(formData, config);
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
await savePrivacyConfigApi(formData);
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value?.resetFields();
|
||||
formData.setting_list = [];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="config-form">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 12 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<Alert
|
||||
:message="$t('system.wechat.config.privacyTip')"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.privacyPolicy')" name="privacyPolicy">
|
||||
<RadioGroup v-model:value="formData.privacyPolicy">
|
||||
<Radio :value="1">{{ $t('system.wechat.config.privacyPolicy1') }}</Radio>
|
||||
<Radio :value="0">{{ $t('system.wechat.config.privacyPolicy0') }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.userPrivacy')" name="userPrivacy">
|
||||
<Textarea
|
||||
v-model:value="formData.userPrivacy"
|
||||
:placeholder="$t('system.wechat.config.userPrivacyPlaceholder')"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="text-gray-500 text-sm mt-1">
|
||||
{{ $t('system.wechat.config.userPrivacyTip') }}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.dataRetention')" name="dataRetention">
|
||||
<Select v-model:value="formData.dataRetention" style="width: 200px">
|
||||
<SelectOption :value="30">{{ $t('system.wechat.config.retention30') }}</SelectOption>
|
||||
<SelectOption :value="90">{{ $t('system.wechat.config.retention90') }}</SelectOption>
|
||||
<SelectOption :value="180">{{ $t('system.wechat.config.retention180') }}</SelectOption>
|
||||
<SelectOption :value="365">{{ $t('system.wechat.config.retention365') }}</SelectOption>
|
||||
</Select>
|
||||
<div class="text-gray-500 text-sm mt-1">
|
||||
{{ $t('system.wechat.config.dataRetentionTip') }}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ span: 12, offset: 6 }">
|
||||
<Button type="primary" html-type="submit" :loading="loading">
|
||||
{{ $t('system.wechat.config.save') }}
|
||||
</Button>
|
||||
<Button @click="handleReset" class="ml-2">
|
||||
{{ $t('system.wechat.config.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { Form, FormItem, Input, Button, Textarea, Radio, RadioGroup, Select, SelectOption, Alert, message } from 'ant-design-vue';
|
||||
import { useI18n } from '@vben/locales';
|
||||
import { getPrivacyConfigApi, savePrivacyConfigApi } from '@api/core/wechat';
|
||||
|
||||
const { t } = useI18n();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
privacyPolicy: 1,
|
||||
userPrivacy: '',
|
||||
dataRetention: 90,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
privacyPolicy: [{ required: true, message: t('system.wechat.config.privacyPolicyRequired') }],
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await getPrivacyConfigApi();
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(formData, res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.loadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await formRef.value.validate();
|
||||
await savePrivacyConfigApi(formData);
|
||||
message.success(t('system.wechat.config.saveSuccess'));
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.saveError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value.resetFields();
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 14 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.server.serveUrl')"
|
||||
name="serve_url"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.server.serveUrlRequired') }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.serve_url"
|
||||
:placeholder="$t('channel.wechat.config.server.serveUrlPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.server.token')"
|
||||
name="token"
|
||||
:rules="[{ required: true, message: $t('channel.wechat.config.server.tokenRequired') }]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.token"
|
||||
:placeholder="$t('channel.wechat.config.server.tokenPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.server.encodingAesKey')"
|
||||
name="encoding_aes_key"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formData.encoding_aes_key"
|
||||
:placeholder="$t('channel.wechat.config.server.encodingAesKeyPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
:label="$t('channel.wechat.config.server.encryptionType')"
|
||||
name="encryption_type"
|
||||
>
|
||||
<RadioGroup v-model:value="formData.encryption_type">
|
||||
<Radio :value="0">{{ $t('channel.wechat.config.server.encryptionType0') }}</Radio>
|
||||
<Radio :value="1">{{ $t('channel.wechat.config.server.encryptionType1') }}</Radio>
|
||||
<Radio :value="2">{{ $t('channel.wechat.config.server.encryptionType2') }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ offset: 4, span: 14 }">
|
||||
<Button type="primary" html-type="submit" :loading="submitLoading">
|
||||
{{ $t('common.save') }}
|
||||
</Button>
|
||||
<Button class="ml-4" @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Button, Form, FormItem, Input, Radio, RadioGroup, message } from 'ant-design-vue';
|
||||
|
||||
import { getServerConfigApi, saveServerConfigApi } from '#/api/core/wechat';
|
||||
|
||||
const { Item: FormItem } = Form;
|
||||
|
||||
interface FormData {
|
||||
serve_url: string;
|
||||
token: string;
|
||||
encoding_aes_key: string;
|
||||
encryption_type: number;
|
||||
}
|
||||
|
||||
const formRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
|
||||
const formData = reactive<FormData>({
|
||||
serve_url: '',
|
||||
token: '',
|
||||
encoding_aes_key: '',
|
||||
encryption_type: 0,
|
||||
});
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfigApi();
|
||||
Object.assign(formData, config);
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
await saveServerConfigApi(formData);
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="config-form">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 12 }"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<FormItem :label="$t('system.wechat.config.serverUrl')" name="serverUrl">
|
||||
<Input
|
||||
v-model:value="formData.serverUrl"
|
||||
:placeholder="$t('system.wechat.config.serverUrlPlaceholder')"
|
||||
disabled
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.token')" name="token">
|
||||
<Input
|
||||
v-model:value="formData.token"
|
||||
:placeholder="$t('system.wechat.config.tokenPlaceholder')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.encodingAesKey')" name="encodingAesKey">
|
||||
<Input
|
||||
v-model:value="formData.encodingAesKey"
|
||||
:placeholder="$t('system.wechat.config.encodingAesKeyPlaceholder')"
|
||||
/>
|
||||
<div class="text-gray-500 text-sm mt-1">
|
||||
{{ $t('system.wechat.config.encodingAesKeyTip') }}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('system.wechat.config.encryptType')" name="encryptType">
|
||||
<RadioGroup v-model:value="formData.encryptType">
|
||||
<Radio :value="0">{{ $t('system.wechat.config.encryptType0') }}</Radio>
|
||||
<Radio :value="1">{{ $t('system.wechat.config.encryptType1') }}</Radio>
|
||||
<Radio :value="2">{{ $t('system.wechat.config.encryptType2') }}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :wrapper-col="{ span: 12, offset: 6 }">
|
||||
<Button type="primary" html-type="submit" :loading="loading">
|
||||
{{ $t('system.wechat.config.save') }}
|
||||
</Button>
|
||||
<Button @click="handleReset" class="ml-2">
|
||||
{{ $t('system.wechat.config.reset') }}
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { Form, FormItem, Input, Button, Radio, RadioGroup, message } from 'ant-design-vue';
|
||||
import { useI18n } from '@vben/locales';
|
||||
import { getServerConfigApi, saveServerConfigApi } from '@api/core/wechat';
|
||||
|
||||
const { t } = useI18n();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
serverUrl: '',
|
||||
token: '',
|
||||
encodingAesKey: '',
|
||||
encryptType: 0,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
token: [{ required: true, message: t('system.wechat.config.tokenRequired') }],
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await getServerConfigApi();
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(formData, res.data);
|
||||
// 设置默认服务器URL
|
||||
if (!formData.serverUrl) {
|
||||
formData.serverUrl = `${window.location.origin}/api/wechat`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.loadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await formRef.value.validate();
|
||||
await saveServerConfigApi(formData);
|
||||
message.success(t('system.wechat.config.saveSuccess'));
|
||||
} catch (error) {
|
||||
message.error(t('system.wechat.config.saveError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.value.resetFields();
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface MaterialItem {
|
||||
id: number;
|
||||
media_id: string;
|
||||
type: 'image' | 'voice' | 'video' | 'news';
|
||||
title?: string;
|
||||
introduction?: string;
|
||||
url: string;
|
||||
thumb_url?: string;
|
||||
local_url?: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
news_item?: NewsItem[];
|
||||
status: 0 | 1;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
title: string;
|
||||
author: string;
|
||||
digest: string;
|
||||
show_cover_pic: 0 | 1;
|
||||
content: string;
|
||||
content_source_url: string;
|
||||
thumb_media_id: string;
|
||||
thumb_url: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MaterialForm {
|
||||
id?: number;
|
||||
type: 'image' | 'voice' | 'video' | 'news';
|
||||
title?: string;
|
||||
introduction?: string;
|
||||
file?: File;
|
||||
news_item?: NewsItem[];
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
export const materialTypeOptions = [
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '语音', value: 'voice' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '图文', value: 'news' },
|
||||
];
|
||||
|
||||
export const materialTypeMap = {
|
||||
image: '图片',
|
||||
voice: '语音',
|
||||
video: '视频',
|
||||
news: '图文',
|
||||
};
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '正常', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
export const statusMap = {
|
||||
1: '正常',
|
||||
0: '禁用',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '素材类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: materialTypeOptions,
|
||||
placeholder: '请选择素材类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'create_time',
|
||||
label: '创建时间',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'thumb_url',
|
||||
title: '缩略图',
|
||||
width: 100,
|
||||
slots: { default: 'thumb' },
|
||||
align: 'center',
|
||||
},
|
||||
{ field: 'title', title: '标题', minWidth: 150 },
|
||||
{ field: 'type', title: '类型', width: 100, slots: { default: 'type' } },
|
||||
{ field: 'filename', title: '文件名', minWidth: 200 },
|
||||
{ field: 'size', title: '大小', width: 120, slots: { default: 'size' } },
|
||||
{ field: 'width', title: '宽度', width: 100 },
|
||||
{ field: 'height', title: '高度', width: 100 },
|
||||
{ field: 'duration', title: '时长', width: 100, slots: { default: 'duration' } },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'create_time', title: '创建时间', width: 180 },
|
||||
{ field: 'update_time', title: '更新时间', width: 180 },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleAdd">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
上传素材
|
||||
</VbenButton>
|
||||
<VbenButton type="success" @click="handleSync">
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
同步素材
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #thumb="{ row }">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
v-if="row.thumb_url"
|
||||
:src="row.thumb_url"
|
||||
:alt="row.title"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-gray-400"
|
||||
>
|
||||
<FileText class="w-8 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #type="{ row }">
|
||||
<VbenTag :type="getTypeColor(row.type)">
|
||||
{{ materialTypeMap[row.type] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #size="{ row }">
|
||||
{{ formatFileSize(row.size) }}
|
||||
</template>
|
||||
|
||||
<template #duration="{ row }">
|
||||
{{ row.duration ? formatDuration(row.duration) : '-' }}
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[row.status] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleView(row)"
|
||||
>
|
||||
查看
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenPopconfirm
|
||||
title="确定删除该素材吗?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<VbenButton type="text" size="small" danger>
|
||||
删除
|
||||
</VbenButton>
|
||||
</VbenPopconfirm>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<MaterialUploadModal
|
||||
v-model="uploadModalVisible"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<MaterialEditModal
|
||||
v-model="editModalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<MaterialViewModal
|
||||
v-model="viewModalVisible"
|
||||
:data="currentData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Plus, RefreshCw, FileText } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getWechatMaterialList, deleteWechatMaterial, syncWechatMaterial } from '#/api/core/wechat';
|
||||
import MaterialUploadModal from './modules/upload-modal.vue';
|
||||
import MaterialEditModal from './modules/edit-modal.vue';
|
||||
import MaterialViewModal from './modules/view-modal.vue';
|
||||
|
||||
import type { MaterialItem } from './data';
|
||||
import { columns, querySchema, materialTypeMap, statusMap } from './data';
|
||||
|
||||
const uploadModalVisible = ref(false);
|
||||
const editModalVisible = ref(false);
|
||||
const viewModalVisible = ref(false);
|
||||
const currentData = ref<MaterialItem | null>(null);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: true,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
return await getWechatMaterialList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
function getTypeColor(type: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
image: 'blue',
|
||||
voice: 'green',
|
||||
video: 'orange',
|
||||
news: 'purple',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const target = event.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.nextElementSibling?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
uploadModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleView(row: MaterialItem) {
|
||||
currentData.value = row;
|
||||
viewModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: MaterialItem) {
|
||||
currentData.value = row;
|
||||
editModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: MaterialItem) {
|
||||
try {
|
||||
await deleteWechatMaterial(row.id);
|
||||
VbenMessage.success('删除成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
try {
|
||||
await syncWechatMaterial();
|
||||
VbenMessage.success('素材同步成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('素材同步失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
title="编辑素材"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { updateWechatMaterial } from '#/api/core/wechat';
|
||||
|
||||
import type { MaterialItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: MaterialItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
id: 0,
|
||||
title: '',
|
||||
introduction: '',
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formSchema = computed(() => [
|
||||
{
|
||||
fieldName: 'media_id',
|
||||
label: '媒体ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '媒体ID',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '素材类型',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '素材类型',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
maxlength: 64,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
label: '描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入描述',
|
||||
maxlength: 200,
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '正常', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const formRules = {
|
||||
title: 'max:64',
|
||||
introduction: 'max:200',
|
||||
status: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
id: val.id,
|
||||
title: val.title || '',
|
||||
introduction: val.introduction || '',
|
||||
status: val.status,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await updateWechatMaterial(formModel.value.id, {
|
||||
title: formModel.value.title,
|
||||
introduction: formModel.value.introduction,
|
||||
status: formModel.value.status,
|
||||
});
|
||||
VbenMessage.success('更新成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<VbenModal
|
||||
v-model="visible"
|
||||
title="上传素材"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">上传</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenForm, VbenMessage, VbenModal, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { uploadWechatMaterial } from '#/api/core/wechat';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
type: 'image' as 'image' | 'voice' | 'video' | 'news',
|
||||
title: '',
|
||||
introduction: '',
|
||||
file: null as File | null,
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '素材类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '语音', value: 'voice' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '图文', value: 'news' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'file',
|
||||
label: '选择文件',
|
||||
component: 'Upload',
|
||||
componentProps: {
|
||||
accept: getAcceptTypes(),
|
||||
maxCount: 1,
|
||||
beforeUpload: handleBeforeUpload,
|
||||
onChange: handleFileChange,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => ['image', 'voice', 'video'].includes(formModel.value.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
maxlength: 64,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => ['video', 'news'].includes(formModel.value.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
label: '描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入描述',
|
||||
maxlength: 200,
|
||||
rows: 3,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => ['video', 'news'].includes(formModel.value.type),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
type: 'required',
|
||||
file: 'required',
|
||||
title: 'required|max:64',
|
||||
introduction: 'max:200',
|
||||
};
|
||||
|
||||
function getAcceptTypes() {
|
||||
const typeMap = {
|
||||
image: 'image/*',
|
||||
voice: 'audio/*',
|
||||
video: 'video/*',
|
||||
news: '',
|
||||
};
|
||||
return typeMap[formModel.value.type];
|
||||
}
|
||||
|
||||
function handleBeforeUpload(file: File) {
|
||||
const type = formModel.value.type;
|
||||
const maxSize = getMaxFileSize(type);
|
||||
|
||||
if (file.size > maxSize) {
|
||||
VbenMessage.error(`文件大小不能超过 ${formatFileSize(maxSize)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFileChange(info: any) {
|
||||
if (info.file.status === 'done') {
|
||||
formModel.value.file = info.file.originFileObj;
|
||||
} else if (info.file.status === 'removed') {
|
||||
formModel.value.file = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxFileSize(type: string): number {
|
||||
const sizeMap = {
|
||||
image: 2 * 1024 * 1024, // 2MB
|
||||
voice: 2 * 1024 * 1024, // 2MB
|
||||
video: 10 * 1024 * 1024, // 10MB
|
||||
news: 0,
|
||||
};
|
||||
return sizeMap[type as keyof typeof sizeMap] || 0;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => formModel.value.type,
|
||||
() => {
|
||||
formModel.value.file = null;
|
||||
formModel.value.title = '';
|
||||
formModel.value.introduction = '';
|
||||
}
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('type', formModel.value.type);
|
||||
|
||||
if (formModel.value.file) {
|
||||
formData.append('file', formModel.value.file);
|
||||
}
|
||||
|
||||
if (formModel.value.title) {
|
||||
formData.append('title', formModel.value.title);
|
||||
}
|
||||
|
||||
if (formModel.value.introduction) {
|
||||
formData.append('introduction', formModel.value.introduction);
|
||||
}
|
||||
|
||||
await uploadWechatMaterial(formData);
|
||||
VbenMessage.success('上传成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<VbenModal
|
||||
v-model="visible"
|
||||
title="查看素材"
|
||||
:width="800"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">媒体ID:</span>
|
||||
<span>{{ data?.media_id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">类型:</span>
|
||||
<VbenTag :type="getTypeColor(data?.type || '')">
|
||||
{{ materialTypeMap[data?.type || ''] }}
|
||||
</VbenTag>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">标题:</span>
|
||||
<span>{{ data?.title || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">描述:</span>
|
||||
<span>{{ data?.introduction || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">文件名:</span>
|
||||
<span>{{ data?.filename }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">文件大小:</span>
|
||||
<span>{{ formatFileSize(data?.size || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data?.width && data?.height" class="flex items-center space-x-4">
|
||||
<span class="font-medium">尺寸:</span>
|
||||
<span>{{ data.width }} x {{ data.height }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data?.duration" class="flex items-center space-x-4">
|
||||
<span class="font-medium">时长:</span>
|
||||
<span>{{ formatDuration(data.duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="font-medium">状态:</span>
|
||||
<VbenTag :type="data?.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[data?.status || 0] }}
|
||||
</VbenTag>
|
||||
</div>
|
||||
|
||||
<div v-if="data?.url" class="space-y-2">
|
||||
<div class="font-medium">预览:</div>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
v-if="data.type === 'image'"
|
||||
:src="data.url"
|
||||
:alt="data.title"
|
||||
class="max-w-md max-h-64 object-contain rounded border"
|
||||
/>
|
||||
<video
|
||||
v-else-if="data.type === 'video'"
|
||||
:src="data.url"
|
||||
controls
|
||||
class="max-w-md max-h-64 rounded border"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="data.type === 'voice'"
|
||||
:src="data.url"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data?.news_item && data.news_item.length > 0" class="space-y-4">
|
||||
<div class="font-medium">图文内容:</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(item, index) in data.news_item"
|
||||
:key="index"
|
||||
class="border rounded p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-start space-x-4">
|
||||
<img
|
||||
v-if="item.thumb_url"
|
||||
:src="item.thumb_url"
|
||||
:alt="item.title"
|
||||
class="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="font-medium">{{ item.title }}</div>
|
||||
<div class="text-sm text-gray-600">作者: {{ item.author }}</div>
|
||||
<div class="text-sm text-gray-600">{{ item.digest }}</div>
|
||||
<div v-if="item.content" class="text-sm text-gray-700">
|
||||
<div v-html="item.content" class="prose prose-sm max-w-none"></div>
|
||||
</div>
|
||||
<div v-if="item.content_source_url" class="text-sm">
|
||||
<a :href="item.content_source_url" target="_blank" class="text-blue-600 hover:underline">
|
||||
阅读原文
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VbenButton @click="handleCancel">关闭</VbenButton>
|
||||
</template>
|
||||
</VbenModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { VbenButton, VbenModal, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import type { MaterialItem } from '../data';
|
||||
import { materialTypeMap, statusMap } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: MaterialItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
function getTypeColor(type: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
image: 'blue',
|
||||
voice: 'green',
|
||||
video: 'orange',
|
||||
news: 'purple',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
111
admin-vben/apps/web-antd/src/views/channel/wechat/menu/data.ts
Normal file
111
admin-vben/apps/web-antd/src/views/channel/wechat/menu/data.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface MenuItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'click' | 'view' | 'miniprogram' | 'scancode_push' | 'scancode_waitmsg' | 'pic_sysphoto' | 'pic_photo_or_album' | 'pic_weixin' | 'location_select';
|
||||
key?: string;
|
||||
url?: string;
|
||||
appid?: string;
|
||||
pagepath?: string;
|
||||
media_id?: string;
|
||||
parent_id: number;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface MenuForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
type: 'click' | 'view' | 'miniprogram' | 'scancode_push' | 'scancode_waitmsg' | 'pic_sysphoto' | 'pic_photo_or_album' | 'pic_weixin' | 'location_select';
|
||||
key?: string;
|
||||
url?: string;
|
||||
appid?: string;
|
||||
pagepath?: string;
|
||||
media_id?: string;
|
||||
parent_id: number;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
export const menuTypeOptions = [
|
||||
{ label: '点击推事件', value: 'click' },
|
||||
{ label: '跳转URL', value: 'view' },
|
||||
{ label: '扫码推事件', value: 'scancode_push' },
|
||||
{ label: '扫码推事件且弹出提示', value: 'scancode_waitmsg' },
|
||||
{ label: '弹出系统拍照发图', value: 'pic_sysphoto' },
|
||||
{ label: '弹出拍照或者相册发图', value: 'pic_photo_or_album' },
|
||||
{ label: '弹出微信相册发图器', value: 'pic_weixin' },
|
||||
{ label: '弹出地理位置选择器', value: 'location_select' },
|
||||
{ label: '跳转小程序', value: 'miniprogram' },
|
||||
];
|
||||
|
||||
export const menuTypeMap = {
|
||||
click: '点击推事件',
|
||||
view: '跳转URL',
|
||||
scancode_push: '扫码推事件',
|
||||
scancode_waitmsg: '扫码推事件且弹出提示',
|
||||
pic_sysphoto: '弹出系统拍照发图',
|
||||
pic_photo_or_album: '弹出拍照或者相册发图',
|
||||
pic_weixin: '弹出微信相册发图器',
|
||||
location_select: '弹出地理位置选择器',
|
||||
miniprogram: '跳转小程序',
|
||||
};
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
export const statusMap = {
|
||||
1: '启用',
|
||||
0: '禁用',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '菜单名称',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '菜单类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: menuTypeOptions,
|
||||
placeholder: '请选择菜单类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'name', title: '菜单名称', minWidth: 150 },
|
||||
{ field: 'type', title: '菜单类型', width: 150, slots: { default: 'menuType' } },
|
||||
{ field: 'key', title: '菜单KEY', width: 150 },
|
||||
{ field: 'url', title: '跳转URL', minWidth: 200, showOverflow: true },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'create_time', title: '创建时间', width: 180 },
|
||||
{ field: 'update_time', title: '更新时间', width: 180 },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
176
admin-vben/apps/web-antd/src/views/channel/wechat/menu/list.vue
Normal file
176
admin-vben/apps/web-antd/src/views/channel/wechat/menu/list.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleAdd">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新增菜单
|
||||
</VbenButton>
|
||||
<VbenButton type="success" @click="handlePublish">
|
||||
<Upload class="mr-2 h-4 w-4" />
|
||||
发布菜单
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #menuType="{ row }">
|
||||
<VbenTag :type="getMenuTypeColor(row.type)">
|
||||
{{ menuTypeMap[row.type] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[row.status] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenPopconfirm
|
||||
title="确定删除该菜单吗?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<VbenButton type="text" size="small" danger>
|
||||
删除
|
||||
</VbenButton>
|
||||
</VbenPopconfirm>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<MenuEditModal
|
||||
v-model="modalVisible"
|
||||
:data="currentData"
|
||||
:parent-menus="parentMenus"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Plus, Upload } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getWechatMenuList, deleteWechatMenu, publishWechatMenu } from '#/api/core/wechat';
|
||||
import MenuEditModal from './modules/menu-edit.vue';
|
||||
|
||||
import type { MenuItem } from './data';
|
||||
import { columns, querySchema, menuTypeMap, statusMap } from './data';
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const currentData = ref<MenuItem | null>(null);
|
||||
const menuList = ref<MenuItem[]>([]);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: false,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-3 lg:grid-cols-4',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
const response = await getWechatMenuList(params);
|
||||
menuList.value = response.data;
|
||||
return response;
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
const parentMenus = computed(() => {
|
||||
return menuList.value.filter(item => item.parent_id === 0);
|
||||
});
|
||||
|
||||
function getMenuTypeColor(type: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
click: 'blue',
|
||||
view: 'green',
|
||||
miniprogram: 'orange',
|
||||
scancode_push: 'purple',
|
||||
scancode_waitmsg: 'purple',
|
||||
pic_sysphoto: 'pink',
|
||||
pic_photo_or_album: 'pink',
|
||||
pic_weixin: 'pink',
|
||||
location_select: 'cyan',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentData.value = null;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: MenuItem) {
|
||||
currentData.value = row;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: MenuItem) {
|
||||
try {
|
||||
await deleteWechatMenu(row.id);
|
||||
VbenMessage.success('删除成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
try {
|
||||
await publishWechatMenu();
|
||||
VbenMessage.success('菜单发布成功');
|
||||
} catch (error) {
|
||||
VbenMessage.error('菜单发布失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { createWechatMenu, updateWechatMenu } from '#/api/core/wechat';
|
||||
|
||||
import type { MenuItem, MenuForm } from '../data';
|
||||
import { menuTypeOptions, statusOptions } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: MenuItem | null;
|
||||
parentMenus?: MenuItem[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
parentMenus: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => (props.data ? '编辑菜单' : '新增菜单'));
|
||||
|
||||
const formModel = ref<MenuForm>({
|
||||
name: '',
|
||||
type: 'click',
|
||||
parent_id: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formSchema = computed(() => [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '菜单名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入菜单名称',
|
||||
maxlength: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'parent_id',
|
||||
label: '上级菜单',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择上级菜单',
|
||||
options: [
|
||||
{ label: '作为一级菜单', value: 0 },
|
||||
...props.parentMenus.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '菜单类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择菜单类型',
|
||||
options: menuTypeOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'key',
|
||||
label: '菜单KEY',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入菜单KEY',
|
||||
maxlength: 128,
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => ['click', 'scancode_push', 'scancode_waitmsg', 'pic_sysphoto', 'pic_photo_or_album', 'pic_weixin', 'location_select'].includes(formModel.value.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'url',
|
||||
label: '跳转URL',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入跳转URL',
|
||||
maxlength: 1024,
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'view',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'appid',
|
||||
label: '小程序APPID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入小程序APPID',
|
||||
maxlength: 64,
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'miniprogram',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'pagepath',
|
||||
label: '小程序页面路径',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入小程序页面路径',
|
||||
maxlength: 128,
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'miniprogram',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'media_id',
|
||||
label: '媒体ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入媒体ID',
|
||||
maxlength: 128,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => ['pic_sysphoto', 'pic_photo_or_album', 'pic_weixin'].includes(formModel.value.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
min: 0,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const formRules = {
|
||||
name: 'required',
|
||||
type: 'required',
|
||||
sort: 'required',
|
||||
status: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
id: val.id,
|
||||
name: val.name,
|
||||
type: val.type,
|
||||
key: val.key || '',
|
||||
url: val.url || '',
|
||||
appid: val.appid || '',
|
||||
pagepath: val.pagepath || '',
|
||||
media_id: val.media_id || '',
|
||||
parent_id: val.parent_id,
|
||||
sort: val.sort,
|
||||
status: val.status,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {
|
||||
name: '',
|
||||
type: 'click',
|
||||
parent_id: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (formModel.value.id) {
|
||||
await updateWechatMenu(formModel.value.id, formModel.value);
|
||||
VbenMessage.success('更新成功');
|
||||
} else {
|
||||
await createWechatMenu(formModel.value);
|
||||
VbenMessage.success('创建成功');
|
||||
}
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Card :bordered="false">
|
||||
<template #title>
|
||||
<span class="text-lg font-medium">{{ $t('channel.wechat.template.title') }}</span>
|
||||
</template>
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Input
|
||||
v-model:value="searchParams.keyword"
|
||||
:placeholder="$t('channel.wechat.template.searchPlaceholder')"
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
{{ $t('common.search') }}
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button type="primary" @click="handleSync">
|
||||
{{ $t('channel.wechat.template.sync') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? $t('common.enable') : $t('common.disable') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleToggleStatus(record)"
|
||||
>
|
||||
{{ record.status === 1 ? $t('common.disable') : $t('common.enable') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, message, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getTemplateListApi,
|
||||
modifyTemplateStatusApi,
|
||||
syncTemplateApi,
|
||||
} from '#/api/core/wechat';
|
||||
|
||||
interface TemplateItem {
|
||||
id: number;
|
||||
template_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
example: string;
|
||||
status: number;
|
||||
create_time: number;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<TemplateItem[]>([]);
|
||||
|
||||
const searchParams = reactive({
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '模板ID',
|
||||
dataIndex: 'template_id',
|
||||
key: 'template_id',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '示例',
|
||||
dataIndex: 'example',
|
||||
key: 'example',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'create_time',
|
||||
key: 'create_time',
|
||||
width: 160,
|
||||
customRender: ({ text }: { text: number }) => {
|
||||
return new Date(text * 1000).toLocaleString();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
},
|
||||
]);
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
keyword: searchParams.keyword,
|
||||
};
|
||||
const response = await getTemplateListApi(params);
|
||||
dataSource.value = response.list;
|
||||
pagination.total = response.total;
|
||||
} catch (error) {
|
||||
console.error('加载模板列表失败:', error);
|
||||
message.error('加载模板列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchParams.keyword = '';
|
||||
pagination.current = 1;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (record: TemplateItem) => {
|
||||
try {
|
||||
const newStatus = record.status === 1 ? 0 : 1;
|
||||
await modifyTemplateStatusApi(record.id, newStatus);
|
||||
message.success('状态更新成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('状态更新失败:', error);
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await syncTemplateApi();
|
||||
message.success('同步成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('同步失败:', error);
|
||||
message.error('同步失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Card :bordered="false">
|
||||
<template #title>
|
||||
<span class="text-lg font-medium">{{ $t('channel.wechat.tutorial.title') }}</span>
|
||||
</template>
|
||||
<div class="p-4">
|
||||
<Collapse v-model:activeKey="activeKey">
|
||||
<CollapsePanel key="1" :header="$t('channel.wechat.tutorial.basic.title')">
|
||||
<div class="tutorial-content">
|
||||
<h3 class="text-lg font-medium mb-4">{{ $t('channel.wechat.tutorial.basic.title') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.basic.what.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.basic.what.content') }}</p>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.basic.features.title') }}</h4>
|
||||
<ul class="list-disc pl-5 text-gray-600 space-y-1">
|
||||
<li>{{ $t('channel.wechat.tutorial.basic.features.item1') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.basic.features.item2') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.basic.features.item3') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.basic.features.item4') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
|
||||
<CollapsePanel key="2" :header="$t('channel.wechat.tutorial.config.title')">
|
||||
<div class="tutorial-content">
|
||||
<h3 class="text-lg font-medium mb-4">{{ $t('channel.wechat.tutorial.config.title') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.config.basic.title') }}</h4>
|
||||
<ol class="list-decimal pl-5 text-gray-600 space-y-1">
|
||||
<li>{{ $t('channel.wechat.tutorial.config.basic.step1') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.config.basic.step2') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.config.basic.step3') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.config.basic.step4') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.config.server.title') }}</h4>
|
||||
<ol class="list-decimal pl-5 text-gray-600 space-y-1">
|
||||
<li>{{ $t('channel.wechat.tutorial.config.server.step1') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.config.server.step2') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.config.server.step3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
|
||||
<CollapsePanel key="3" :header="$t('channel.wechat.tutorial.message.title')">
|
||||
<div class="tutorial-content">
|
||||
<h3 class="text-lg font-medium mb-4">{{ $t('channel.wechat.tutorial.message.title') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.message.template.title') }}</h4>
|
||||
<p class="text-gray-600 mb-2">{{ $t('channel.wechat.tutorial.message.template.desc') }}</p>
|
||||
<ul class="list-disc pl-5 text-gray-600 space-y-1">
|
||||
<li>{{ $t('channel.wechat.tutorial.message.template.item1') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.message.template.item2') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.message.template.item3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.message.custom.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.message.custom.desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
|
||||
<CollapsePanel key="4" :header="$t('channel.wechat.tutorial.user.title')">
|
||||
<div class="tutorial-content">
|
||||
<h3 class="text-lg font-medium mb-4">{{ $t('channel.wechat.tutorial.user.title') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.user.tag.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.user.tag.desc') }}</p>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.user.group.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.user.group.desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
|
||||
<CollapsePanel key="5" :header="$t('channel.wechat.tutorial.material.title')">
|
||||
<div class="tutorial-content">
|
||||
<h3 class="text-lg font-medium mb-4">{{ $t('channel.wechat.tutorial.material.title') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.material.type.title') }}</h4>
|
||||
<ul class="list-disc pl-5 text-gray-600 space-y-1">
|
||||
<li>{{ $t('channel.wechat.tutorial.material.type.item1') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.material.type.item2') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.material.type.item3') }}</li>
|
||||
<li>{{ $t('channel.wechat.tutorial.material.type.item4') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.material.limit.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.material.limit.desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
|
||||
<CollapsePanel key="6" :header="$t('channel.wechat.tutorial.faq.title')">
|
||||
<div class="tutorial-content">
|
||||
<h3 class="text-lg font-medium mb-4">{{ $t('channel.wechat.tutorial.faq.title') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.faq.q1.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.faq.q1.answer') }}</p>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.faq.q2.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.faq.q2.answer') }}</p>
|
||||
</div>
|
||||
<div class="tutorial-item">
|
||||
<h4 class="font-medium mb-2">{{ $t('channel.wechat.tutorial.faq.q3.title') }}</h4>
|
||||
<p class="text-gray-600">{{ $t('channel.wechat.tutorial.faq.q3.answer') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Card, Collapse } from 'ant-design-vue';
|
||||
|
||||
const { Panel: CollapsePanel } = Collapse;
|
||||
|
||||
const activeKey = ref(['1']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.tutorial-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tutorial-item h4 {
|
||||
color: #1890ff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
145
admin-vben/apps/web-antd/src/views/channel/wechat/user/data.ts
Normal file
145
admin-vben/apps/web-antd/src/views/channel/wechat/user/data.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
openid: string;
|
||||
nickname: string;
|
||||
headimgurl: string;
|
||||
sex: 0 | 1 | 2; // 0:未知, 1:男, 2:女
|
||||
city: string;
|
||||
province: string;
|
||||
country: string;
|
||||
subscribe: 0 | 1; // 0:未关注, 1:已关注
|
||||
subscribe_time: string;
|
||||
unsubscribe_time?: string;
|
||||
unionid?: string;
|
||||
groupid: number;
|
||||
tagid_list: string;
|
||||
subscribe_scene: string;
|
||||
qr_scene?: string;
|
||||
qr_scene_str?: string;
|
||||
remark: string;
|
||||
language: string;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface UserForm {
|
||||
id?: number;
|
||||
openid: string;
|
||||
remark?: string;
|
||||
groupid?: number;
|
||||
}
|
||||
|
||||
export const sexOptions = [
|
||||
{ label: '未知', value: 0 },
|
||||
{ label: '男', value: 1 },
|
||||
{ label: '女', value: 2 },
|
||||
];
|
||||
|
||||
export const sexMap = {
|
||||
0: '未知',
|
||||
1: '男',
|
||||
2: '女',
|
||||
};
|
||||
|
||||
export const subscribeOptions = [
|
||||
{ label: '已关注', value: 1 },
|
||||
{ label: '未关注', value: 0 },
|
||||
];
|
||||
|
||||
export const subscribeMap = {
|
||||
1: '已关注',
|
||||
0: '未关注',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'openid',
|
||||
label: 'OpenID',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'sex',
|
||||
label: '性别',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: sexOptions,
|
||||
placeholder: '请选择性别',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'subscribe',
|
||||
label: '关注状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: subscribeOptions,
|
||||
placeholder: '请选择关注状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'city',
|
||||
label: '城市',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'province',
|
||||
label: '省份',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'country',
|
||||
label: '国家',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'subscribe_time',
|
||||
label: '关注时间',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'headimgurl',
|
||||
title: '头像',
|
||||
width: 80,
|
||||
slots: { default: 'avatar' },
|
||||
align: 'center',
|
||||
},
|
||||
{ field: 'nickname', title: '昵称', minWidth: 150 },
|
||||
{ field: 'sex', title: '性别', width: 80, slots: { default: 'sex' } },
|
||||
{ field: 'city', title: '城市', width: 120 },
|
||||
{ field: 'province', title: '省份', width: 120 },
|
||||
{ field: 'country', title: '国家', width: 120 },
|
||||
{ field: 'subscribe', title: '关注状态', width: 100, slots: { default: 'subscribe' } },
|
||||
{ field: 'subscribe_time', title: '关注时间', width: 180 },
|
||||
{ field: 'unsubscribe_time', title: '取消关注时间', width: 180 },
|
||||
{ field: 'remark', title: '备注', minWidth: 150, showOverflow: true },
|
||||
{ field: 'groupid', title: '分组ID', width: 100 },
|
||||
{ field: 'tagid_list', title: '标签ID', minWidth: 150, showOverflow: true },
|
||||
{ field: 'language', title: '语言', width: 100 },
|
||||
{ field: 'subscribe_scene', title: '关注场景', minWidth: 150, showOverflow: true },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
180
admin-vben/apps/web-antd/src/views/channel/wechat/user/list.vue
Normal file
180
admin-vben/apps/web-antd/src/views/channel/wechat/user/list.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleSync">
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
同步用户
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #avatar="{ row }">
|
||||
<VbenAvatar :src="row.headimgurl" :alt="row.nickname" size="small" />
|
||||
</template>
|
||||
|
||||
<template #sex="{ row }">
|
||||
<VbenTag :type="getSexColor(row.sex)">
|
||||
{{ sexMap[row.sex] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #subscribe="{ row }">
|
||||
<VbenTag :type="row.subscribe === 1 ? 'success' : 'error'">
|
||||
{{ subscribeMap[row.subscribe] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleSendMessage(row)"
|
||||
>
|
||||
发消息
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleMoveGroup(row)"
|
||||
>
|
||||
移动分组
|
||||
</VbenButton>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<UserEditModal
|
||||
v-model="modalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<SendMessageModal
|
||||
v-model="messageModalVisible"
|
||||
:user="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<MoveGroupModal
|
||||
v-model="groupModalVisible"
|
||||
:user="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { RefreshCw } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenAvatar, VbenButton, VbenMessage, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getWechatUserList, syncWechatUser } from '#/api/core/wechat';
|
||||
import UserEditModal from './modules/user-edit.vue';
|
||||
import SendMessageModal from './modules/send-message.vue';
|
||||
import MoveGroupModal from './modules/move-group.vue';
|
||||
|
||||
import type { UserItem } from './data';
|
||||
import { columns, querySchema, sexMap, subscribeMap } from './data';
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const messageModalVisible = ref(false);
|
||||
const groupModalVisible = ref(false);
|
||||
const currentData = ref<UserItem | null>(null);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: true,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
return await getWechatUserList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
function getSexColor(sex: number) {
|
||||
const colorMap: Record<number, string> = {
|
||||
0: 'default',
|
||||
1: 'blue',
|
||||
2: 'pink',
|
||||
};
|
||||
return colorMap[sex] || 'default';
|
||||
}
|
||||
|
||||
function handleEdit(row: UserItem) {
|
||||
currentData.value = row;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleSendMessage(row: UserItem) {
|
||||
currentData.value = row;
|
||||
messageModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleMoveGroup(row: UserItem) {
|
||||
currentData.value = row;
|
||||
groupModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
try {
|
||||
await syncWechatUser();
|
||||
VbenMessage.success('用户同步成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('用户同步失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<VbenModal
|
||||
v-model="visible"
|
||||
title="移动分组"
|
||||
:width="400"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenForm, VbenMessage, VbenModal, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { getWechatGroupList, moveWechatUserGroup } from '#/api/core/wechat';
|
||||
|
||||
import type { UserItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
user?: UserItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
groupid: 0,
|
||||
});
|
||||
|
||||
const groupList = ref<any[]>([]);
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'groupid',
|
||||
label: '目标分组',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择目标分组',
|
||||
options: groupList.value.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
groupid: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.user,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value.groupid = val.groupid;
|
||||
loadGroupList();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function loadGroupList() {
|
||||
try {
|
||||
const response = await getWechatGroupList();
|
||||
groupList.value = response.data;
|
||||
formSchema[0].componentProps.options = groupList.value.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
VbenMessage.error('获取分组列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.user) return;
|
||||
|
||||
try {
|
||||
await moveWechatUserGroup({
|
||||
openid: props.user.openid,
|
||||
groupid: formModel.value.groupid,
|
||||
});
|
||||
VbenMessage.success('移动分组成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('移动分组失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<VbenModal
|
||||
v-model="visible"
|
||||
title="发送消息"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">发送</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenForm, VbenMessage, VbenModal, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { sendWechatMessage } from '#/api/core/wechat';
|
||||
|
||||
import type { UserItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
user?: UserItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
type: 'text',
|
||||
content: '',
|
||||
media_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
picurl: '',
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '消息类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '文本消息', value: 'text' },
|
||||
{ label: '图片消息', value: 'image' },
|
||||
{ label: '图文消息', value: 'news' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'content',
|
||||
label: '文本内容',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入文本内容',
|
||||
maxlength: 600,
|
||||
rows: 4,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'text',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'media_id',
|
||||
label: '媒体ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入媒体ID',
|
||||
maxlength: 128,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'image',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'title',
|
||||
label: '标题',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入标题',
|
||||
maxlength: 64,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'news',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入描述',
|
||||
maxlength: 200,
|
||||
rows: 3,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'news',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'url',
|
||||
label: '跳转链接',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入跳转链接',
|
||||
maxlength: 256,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'news',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'picurl',
|
||||
label: '图片链接',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入图片链接',
|
||||
maxlength: 256,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['type'],
|
||||
if: () => formModel.value.type === 'news',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
type: 'required',
|
||||
content: 'required|max:600',
|
||||
media_id: 'required|max:128',
|
||||
title: 'required|max:64',
|
||||
description: 'required|max:200',
|
||||
url: 'required|max:256',
|
||||
picurl: 'required|max:256',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.user,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
type: 'text',
|
||||
content: '',
|
||||
media_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
picurl: '',
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.user) return;
|
||||
|
||||
try {
|
||||
const messageData = {
|
||||
openid: props.user.openid,
|
||||
type: formModel.value.type,
|
||||
content: formModel.value.content,
|
||||
media_id: formModel.value.media_id,
|
||||
title: formModel.value.title,
|
||||
description: formModel.value.description,
|
||||
url: formModel.value.url,
|
||||
picurl: formModel.value.picurl,
|
||||
};
|
||||
|
||||
await sendWechatMessage(messageData);
|
||||
VbenMessage.success('消息发送成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('消息发送失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
title="编辑用户"
|
||||
:width="500"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { updateWechatUser } from '#/api/core/wechat';
|
||||
|
||||
import type { UserItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: UserItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
id: 0,
|
||||
openid: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'openid',
|
||||
label: 'OpenID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: 'OpenID',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '昵称',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
maxlength: 200,
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
remark: 'max:200',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
id: val.id,
|
||||
openid: val.openid,
|
||||
remark: val.remark || '',
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await updateWechatUser(formModel.value.id, {
|
||||
remark: formModel.value.remark,
|
||||
});
|
||||
VbenMessage.success('更新成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
293
admin-vben/apps/web-antd/src/views/diy/bottom-nav/data.ts
Normal file
293
admin-vben/apps/web-antd/src/views/diy/bottom-nav/data.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export interface BottomNavParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
status?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface BottomNavForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
list: BottomNavItem[];
|
||||
status: number;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface BottomNavItem {
|
||||
id: number;
|
||||
text: string;
|
||||
link: {
|
||||
url: string;
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
icon: {
|
||||
selected: string;
|
||||
unselected: string;
|
||||
};
|
||||
color: {
|
||||
selected: string;
|
||||
unselected: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const useColumns = (
|
||||
onActionClick: (actionType: string, row: DiyApi.DiyBottomNav) => void,
|
||||
): VxeGridProps['columns'] => {
|
||||
return [
|
||||
{
|
||||
type: 'checkbox',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
title: $t('common.id'),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: $t('diy.bottomNav.name'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'list',
|
||||
title: $t('diy.bottomNav.itemCount'),
|
||||
width: 120,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return row.list?.length || 0;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: $t('common.status'),
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return (
|
||||
<a-switch
|
||||
checked={row.status === 1}
|
||||
onChange={() => onActionClick('status', row)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: $t('common.sort'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: $t('common.createTime'),
|
||||
width: 180,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue ? new Date(cellValue).toLocaleString() : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $t('common.action'),
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return (
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('edit', row)}
|
||||
>
|
||||
{$t('common.edit')}
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title={$t('common.deleteConfirm')}
|
||||
onConfirm={() => onActionClick('delete', row)}
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
{$t('common.delete')}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const useGridFormSchema = (): any[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: $t('diy.bottomNav.name'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseInput'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: $t('common.status'),
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseSelect'),
|
||||
options: [
|
||||
{ label: $t('common.enable'), value: 1 },
|
||||
{ label: $t('common.disable'), value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: $t('common.createTime'),
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
placeholder: [$t('common.startTime'), $t('common.endTime')],
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const useFormSchema = (): any[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: $t('diy.bottomNav.name'),
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.bottomNav.namePlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'list',
|
||||
label: $t('diy.bottomNav.navigationItems'),
|
||||
component: 'Custom',
|
||||
rules: 'required',
|
||||
render: ({ model, field }) => {
|
||||
return (
|
||||
<div class="space-y-2">
|
||||
{model[field]?.map((item: BottomNavItem, index: number) => (
|
||||
<div key={item.id} class="rounded border p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span>{$t('diy.bottomNav.item')} {index + 1}</span>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
model[field].splice(index, 1);
|
||||
}}
|
||||
>
|
||||
<iconify-icon icon="mdi:delete" />
|
||||
</a-button>
|
||||
</div>
|
||||
<a-row gutter={16}>
|
||||
<a-col span={12}>
|
||||
<a-form-item label={$t('diy.bottomNav.text')}>
|
||||
<a-input
|
||||
v-model:value={item.text}
|
||||
placeholder={$t('diy.bottomNav.textPlaceholder')}
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col span={12}>
|
||||
<a-form-item label={$t('diy.bottomNav.link')}>
|
||||
<a-input
|
||||
v-model:value={item.link.url}
|
||||
placeholder={$t('diy.bottomNav.linkPlaceholder')}
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col span={12}>
|
||||
<a-form-item label={$t('diy.bottomNav.selectedIcon')}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<img
|
||||
v-if={item.icon.selected}
|
||||
src={item.icon.selected}
|
||||
class="h-8 w-8 rounded"
|
||||
/>
|
||||
<a-button size="small" onClick={() => selectIcon(item, 'selected')}>
|
||||
{$t('common.select')}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col span={12}>
|
||||
<a-form-item label={$t('diy.bottomNav.unselectedIcon')}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<img
|
||||
v-if={item.icon.unselected}
|
||||
src={item.icon.unselected}
|
||||
class="h-8 w-8 rounded"
|
||||
/>
|
||||
<a-button size="small" onClick={() => selectIcon(item, 'unselected')}>
|
||||
{$t('common.select')}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
))}
|
||||
<a-button
|
||||
type="dashed"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
if (!model[field]) model[field] = [];
|
||||
model[field].push({
|
||||
id: Date.now(),
|
||||
text: '',
|
||||
link: { url: '', type: '', name: '' },
|
||||
icon: { selected: '', unselected: '' },
|
||||
color: { selected: '#333333', unselected: '#999999' },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<iconify-icon icon="mdi:plus" />
|
||||
{$t('diy.bottomNav.addItem')}
|
||||
</a-button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: $t('common.sort'),
|
||||
component: 'InputNumber',
|
||||
defaultValue: 0,
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseInput'),
|
||||
min: 0,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: $t('common.status'),
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 1,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: $t('common.enable'), value: 1 },
|
||||
{ label: $t('common.disable'), value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Icon selector function
|
||||
const selectIcon = (item: BottomNavItem, type: 'selected' | 'unselected') => {
|
||||
// TODO: Implement icon selector
|
||||
console.log('Select icon:', item, type);
|
||||
};
|
||||
132
admin-vben/apps/web-antd/src/views/diy/bottom-nav/list.vue
Normal file
132
admin-vben/apps/web-antd/src/views/diy/bottom-nav/list.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Icon, Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal, Switch } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getDiyBottomNavList,
|
||||
addDiyBottomNav,
|
||||
editDiyBottomNav,
|
||||
deleteDiyBottomNav,
|
||||
modifyDiyBottomNavStatus,
|
||||
} from '#/api/core/diy';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import BottomNavModal from './modules/bottom-nav-modal.vue';
|
||||
|
||||
const [BottomNavModalComponent, bottomNavModalApi] = useVbenModal({
|
||||
connectedComponent: BottomNavModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['updateTime', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDiyBottomNavList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleAdd() {
|
||||
bottomNavModalApi.setData({});
|
||||
bottomNavModalApi.open();
|
||||
}
|
||||
|
||||
function onActionClick(actionType: string, row: DiyApi.DiyBottomNav) {
|
||||
switch (actionType) {
|
||||
case 'edit': {
|
||||
handleEdit(row);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
handleDelete(row);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
handleStatus(row);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(row: DiyApi.DiyBottomNav) {
|
||||
bottomNavModalApi.setData({ ...row });
|
||||
bottomNavModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(row: DiyApi.DiyBottomNav) {
|
||||
Modal.confirm({
|
||||
title: $t('common.prompt'),
|
||||
content: $t('diy.bottomNav.deleteConfirm'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteDiyBottomNav(row.id);
|
||||
message.success($t('diy.bottomNav.deleteSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('diy.bottomNav.deleteError'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStatus(row: DiyApi.DiyBottomNav) {
|
||||
try {
|
||||
await modifyDiyBottomNavStatus({
|
||||
id: row.id,
|
||||
status: row.status === 1 ? 0 : 1,
|
||||
});
|
||||
message.success($t('common.operateSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('common.operateError'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<Plus />
|
||||
{{ $t('diy.bottomNav.addBottomNav') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
<BottomNavModalComponent @success="gridApi.reload()" />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import { addDiyBottomNav, editDiyBottomNav } from '#/api/core/diy';
|
||||
|
||||
interface Props {
|
||||
modalApi: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const formSchema = useFormSchema();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: formSchema,
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const values = await formApi.validate();
|
||||
const isEdit = !!values.id;
|
||||
|
||||
if (isEdit) {
|
||||
await editDiyBottomNav(values);
|
||||
message.success($t('common.editSuccess'));
|
||||
} else {
|
||||
await addDiyBottomNav(values);
|
||||
message.success($t('common.addSuccess'));
|
||||
}
|
||||
|
||||
modalApi.close();
|
||||
props.modalApi?.emit('success');
|
||||
} catch (error) {
|
||||
console.error('Save bottom navigation error:', error);
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<DiyApi.DiyBottomNav>();
|
||||
if (data) {
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="formApi.values.id ? $t('diy.bottomNav.editBottomNav') : $t('diy.bottomNav.addBottomNav')"
|
||||
class="w-[600px]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Content Tab -->
|
||||
<div v-if="editTab === 'content'" class="space-y-4">
|
||||
<div class="border-t-2 border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.imageAdsContent') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Image List -->
|
||||
<a-form-item :label="$t('system.diy.imageList')">
|
||||
<div class="space-y-2">
|
||||
<draggable
|
||||
v-model="localValue.list"
|
||||
item-key="id"
|
||||
class="space-y-2"
|
||||
@end="handleSort"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="flex items-center space-x-2 rounded border p-2">
|
||||
<img
|
||||
:src="element.imageUrl"
|
||||
class="h-16 w-16 rounded object-cover"
|
||||
@click="selectImage(index)"
|
||||
/>
|
||||
<div class="flex-1 space-y-1">
|
||||
<a-input
|
||||
v-model:value="element.title"
|
||||
:placeholder="$t('system.diy.titlePlaceholder')"
|
||||
size="small"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="element.link.url"
|
||||
:placeholder="$t('system.diy.linkPlaceholder')"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="moveUp(index)"
|
||||
:disabled="index === 0"
|
||||
>
|
||||
<iconify-icon icon="mdi:arrow-up" />
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="moveDown(index)"
|
||||
:disabled="index === localValue.list.length - 1"
|
||||
>
|
||||
<iconify-icon icon="mdi:arrow-down" />
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="removeImage(index)"
|
||||
>
|
||||
<iconify-icon icon="mdi:delete" />
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<a-button
|
||||
type="dashed"
|
||||
class="w-full"
|
||||
@click="addImage"
|
||||
>
|
||||
<iconify-icon icon="mdi:plus" />
|
||||
{{ $t('system.diy.addImage') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Image Size -->
|
||||
<a-form-item :label="$t('system.diy.imageSize')">
|
||||
<a-radio-group v-model:value="localValue.imageSize">
|
||||
<a-radio value="medium">{{ $t('system.diy.medium') }}</a-radio>
|
||||
<a-radio value="large">{{ $t('system.diy.large') }}</a-radio>
|
||||
<a-radio value="custom">{{ $t('system.diy.custom') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Custom Size -->
|
||||
<a-form-item
|
||||
v-if="localValue.imageSize === 'custom'"
|
||||
:label="$t('system.diy.customSize')"
|
||||
>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="localValue.imageWidth"
|
||||
:placeholder="$t('system.diy.width')"
|
||||
:min="50"
|
||||
:max="500"
|
||||
/>
|
||||
<span>×</span>
|
||||
<a-input-number
|
||||
v-model:value="localValue.imageHeight"
|
||||
:placeholder="$t('system.diy.height')"
|
||||
:min="50"
|
||||
:max="500"
|
||||
/>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Image Fill -->
|
||||
<a-form-item :label="$t('system.diy.imageFill')">
|
||||
<a-radio-group v-model:value="localValue.imageFill">
|
||||
<a-radio value="cover">{{ $t('system.diy.cover') }}</a-radio>
|
||||
<a-radio value="contain">{{ $t('system.diy.contain') }}</a-radio>
|
||||
<a-radio value="fill">{{ $t('system.diy.fill') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Show Title -->
|
||||
<a-form-item :label="$t('system.diy.showTitle')">
|
||||
<a-switch v-model:checked="localValue.showTitle" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Title Position -->
|
||||
<a-form-item
|
||||
v-if="localValue.showTitle"
|
||||
:label="$t('system.diy.titlePosition')"
|
||||
>
|
||||
<a-radio-group v-model:value="localValue.titlePosition">
|
||||
<a-radio value="bottom">{{ $t('system.diy.bottom') }}</a-radio>
|
||||
<a-radio value="overlay">{{ $t('system.diy.overlay') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style Tab -->
|
||||
<div v-if="editTab === 'style'" class="space-y-4">
|
||||
<ComponentStyleEditor
|
||||
:value="localValue"
|
||||
@update:value="updateLocalValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import draggable from 'vuedraggable';
|
||||
|
||||
import ComponentStyleEditor from './component-style-editor.vue';
|
||||
|
||||
interface Props {
|
||||
value: any;
|
||||
editTab: 'content' | 'style';
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:value', value: any): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const localValue = ref<any>({
|
||||
list: [],
|
||||
imageSize: 'medium',
|
||||
imageWidth: 200,
|
||||
imageHeight: 200,
|
||||
imageFill: 'cover',
|
||||
showTitle: true,
|
||||
titlePosition: 'bottom',
|
||||
margin: {
|
||||
top: 0,
|
||||
bottom: 10,
|
||||
both: 0,
|
||||
},
|
||||
topRounded: 0,
|
||||
bottomRounded: 0,
|
||||
componentStartBgColor: '',
|
||||
componentEndBgColor: '',
|
||||
componentGradientAngle: 'to bottom',
|
||||
componentBgUrl: '',
|
||||
componentBgAlpha: 2,
|
||||
isHidden: false,
|
||||
});
|
||||
|
||||
// Image operations
|
||||
const selectImage = (index: number) => {
|
||||
// TODO: Implement image selector
|
||||
console.log('Select image:', index);
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
localValue.value.list.push({
|
||||
id: Date.now(),
|
||||
imageUrl: '',
|
||||
title: '',
|
||||
link: {
|
||||
url: '',
|
||||
type: '',
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
localValue.value.list.splice(index, 1);
|
||||
};
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const temp = localValue.value.list[index];
|
||||
localValue.value.list[index] = localValue.value.list[index - 1];
|
||||
localValue.value.list[index - 1] = temp;
|
||||
}
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (index < localValue.value.list.length - 1) {
|
||||
const temp = localValue.value.list[index];
|
||||
localValue.value.list[index] = localValue.value.list[index + 1];
|
||||
localValue.value.list[index + 1] = temp;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = () => {
|
||||
// Sorting handled by draggable
|
||||
};
|
||||
|
||||
const updateLocalValue = (newValue: any) => {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...newValue,
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize local value
|
||||
const initLocalValue = () => {
|
||||
if (props.value) {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...props.value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for value changes
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
initLocalValue();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// Watch for local changes
|
||||
watch(
|
||||
localValue,
|
||||
(newValue) => {
|
||||
emit('update:value', newValue);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Initialize
|
||||
initLocalValue();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Content Tab -->
|
||||
<div v-if="editTab === 'content'" class="space-y-4">
|
||||
<div class="border-t-2 border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.richTextContent') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Rich Text Editor -->
|
||||
<a-form-item :label="$t('system.diy.content')">
|
||||
<div class="border rounded">
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
:defaultConfig="toolbarConfig"
|
||||
:mode="'default'"
|
||||
class="border-b"
|
||||
/>
|
||||
<Editor
|
||||
:defaultConfig="editorConfig"
|
||||
:mode="'default'"
|
||||
v-model="localValue.html"
|
||||
@onCreated="handleCreated"
|
||||
class="h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style Tab -->
|
||||
<div v-if="editTab === 'style'" class="space-y-4">
|
||||
<ComponentStyleEditor
|
||||
:value="localValue"
|
||||
@update:value="updateLocalValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch, onBeforeUnmount } from 'vue';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import ComponentStyleEditor from './component-style-editor.vue';
|
||||
|
||||
interface Props {
|
||||
value: any;
|
||||
editTab: 'content' | 'style';
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:value', value: any): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const editorRef = shallowRef();
|
||||
const localValue = ref<any>({
|
||||
html: '',
|
||||
margin: {
|
||||
top: 0,
|
||||
bottom: 10,
|
||||
both: 0,
|
||||
},
|
||||
topRounded: 0,
|
||||
bottomRounded: 0,
|
||||
componentStartBgColor: '',
|
||||
componentEndBgColor: '',
|
||||
componentGradientAngle: 'to bottom',
|
||||
componentBgUrl: '',
|
||||
componentBgAlpha: 2,
|
||||
isHidden: false,
|
||||
});
|
||||
|
||||
const toolbarConfig = {
|
||||
toolbarKeys: [
|
||||
'headerSelect',
|
||||
'blockquote',
|
||||
'|',
|
||||
'bold',
|
||||
'underline',
|
||||
'italic',
|
||||
'color',
|
||||
'bgColor',
|
||||
'|',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'lineHeight',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'|',
|
||||
'insertLink',
|
||||
'insertImage',
|
||||
'|',
|
||||
'emotion',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
};
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
server: '/api/upload/image',
|
||||
fieldName: 'file',
|
||||
maxFileSize: 2 * 1024 * 1024,
|
||||
allowedFileTypes: ['image/*'],
|
||||
meta: {
|
||||
type: 'rich_text',
|
||||
},
|
||||
customInsert: (res: any, insertFn: Function) => {
|
||||
insertFn(res.data.url);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handleCreated = (editor: any) => {
|
||||
editorRef.value = editor;
|
||||
};
|
||||
|
||||
const updateLocalValue = (newValue: any) => {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...newValue,
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize local value
|
||||
const initLocalValue = () => {
|
||||
if (props.value) {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...props.value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for value changes
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
initLocalValue();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// Watch for local changes
|
||||
watch(
|
||||
localValue,
|
||||
(newValue) => {
|
||||
emit('update:value', newValue);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Destroy editor on unmount
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value;
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
initLocalValue();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.w-e-text-container) {
|
||||
min-height: 400px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Content Tab -->
|
||||
<div v-if="editTab === 'content'" class="space-y-4">
|
||||
<div class="border-t-2 border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.content') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Text Content -->
|
||||
<a-form-item :label="$t('system.diy.textContent')">
|
||||
<a-textarea
|
||||
v-model:value="localValue.text"
|
||||
:placeholder="$t('system.diy.textContentPlaceholder')"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Font Size -->
|
||||
<a-form-item :label="$t('system.diy.fontSize')">
|
||||
<a-slider
|
||||
v-model:value="localValue.fontSize"
|
||||
:min="12"
|
||||
:max="48"
|
||||
:step="1"
|
||||
:marks="{ 12: '12px', 24: '24px', 36: '36px', 48: '48px' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Font Weight -->
|
||||
<a-form-item :label="$t('system.diy.fontWeight')">
|
||||
<a-radio-group v-model:value="localValue.fontWeight">
|
||||
<a-radio value="normal">{{ $t('system.diy.normal') }}</a-radio>
|
||||
<a-radio value="bold">{{ $t('system.diy.bold') }}</a-radio>
|
||||
<a-radio value="lighter">{{ $t('system.diy.lighter') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Text Alignment -->
|
||||
<a-form-item :label="$t('system.diy.textAlign')">
|
||||
<a-radio-group v-model:value="localValue.textAlign">
|
||||
<a-radio value="left">{{ $t('system.diy.alignLeft') }}</a-radio>
|
||||
<a-radio value="center">{{ $t('system.diy.alignCenter') }}</a-radio>
|
||||
<a-radio value="right">{{ $t('system.diy.alignRight') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Line Height -->
|
||||
<a-form-item :label="$t('system.diy.lineHeight')">
|
||||
<a-slider
|
||||
v-model:value="localValue.lineHeight"
|
||||
:min="1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:marks="{ 1: '1x', 1.5: '1.5x', 2: '2x', 2.5: '2.5x', 3: '3x' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style Tab -->
|
||||
<div v-if="editTab === 'style'" class="space-y-4">
|
||||
<ComponentStyleEditor
|
||||
:value="localValue"
|
||||
@update:value="updateLocalValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import ComponentStyleEditor from './component-style-editor.vue';
|
||||
|
||||
interface Props {
|
||||
value: any;
|
||||
editTab: 'content' | 'style';
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:value', value: any): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const localValue = ref<any>({
|
||||
text: '',
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
textAlign: 'left',
|
||||
lineHeight: 1.5,
|
||||
textColor: '#333333',
|
||||
margin: {
|
||||
top: 0,
|
||||
bottom: 10,
|
||||
both: 0,
|
||||
},
|
||||
topRounded: 0,
|
||||
bottomRounded: 0,
|
||||
componentStartBgColor: '',
|
||||
componentEndBgColor: '',
|
||||
componentGradientAngle: 'to bottom',
|
||||
componentBgUrl: '',
|
||||
componentBgAlpha: 2,
|
||||
isHidden: false,
|
||||
});
|
||||
|
||||
// Initialize local value
|
||||
const initLocalValue = () => {
|
||||
if (props.value) {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...props.value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for value changes
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
initLocalValue();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// Watch for local changes
|
||||
watch(
|
||||
localValue,
|
||||
(newValue) => {
|
||||
emit('update:value', newValue);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const updateLocalValue = (newValue: any) => {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...newValue,
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize
|
||||
initLocalValue();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
140
admin-vben/apps/web-antd/src/views/diy/design/data.ts
Normal file
140
admin-vben/apps/web-antd/src/views/diy/design/data.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { VxeGridPropTypes } from 'vxe-table';
|
||||
|
||||
export interface ComponentItem {
|
||||
id: string;
|
||||
componentName: string;
|
||||
componentTitle: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
path: string;
|
||||
uses: number;
|
||||
position?: string;
|
||||
ignore?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ComponentGroup {
|
||||
title: string;
|
||||
list: Record<string, ComponentItem>;
|
||||
}
|
||||
|
||||
export interface GlobalConfig {
|
||||
title: string;
|
||||
completeLayout: string;
|
||||
completeAlign: string;
|
||||
borderControl: boolean;
|
||||
pageStartBgColor: string;
|
||||
pageEndBgColor: string;
|
||||
pageGradientAngle: string;
|
||||
bgUrl: string;
|
||||
bgHeightScale: number;
|
||||
imgWidth: string;
|
||||
imgHeight: string;
|
||||
topStatusBar: {
|
||||
control: boolean;
|
||||
isShow: boolean;
|
||||
bgColor: string;
|
||||
rollBgColor: string;
|
||||
style: string;
|
||||
styleName: string;
|
||||
textColor: string;
|
||||
rollTextColor: string;
|
||||
textAlign: string;
|
||||
inputPlaceholder: string;
|
||||
imgUrl: string;
|
||||
link: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
bottomTabBar: {
|
||||
control: boolean;
|
||||
isShow: boolean;
|
||||
};
|
||||
popWindow: {
|
||||
imgUrl: string;
|
||||
imgWidth: string;
|
||||
imgHeight: string;
|
||||
count: string;
|
||||
show: number;
|
||||
link: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
textColor: string;
|
||||
pageStartBgColor: string;
|
||||
pageEndBgColor: string;
|
||||
pageGradientAngle: string;
|
||||
componentBgUrl: string;
|
||||
componentBgAlpha: number;
|
||||
componentStartBgColor: string;
|
||||
componentEndBgColor: string;
|
||||
componentGradientAngle: string;
|
||||
topRounded: number;
|
||||
bottomRounded: number;
|
||||
elementBgColor: string;
|
||||
topElementRounded: number;
|
||||
bottomElementRounded: number;
|
||||
margin: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
both: number;
|
||||
};
|
||||
isHidden: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiyData {
|
||||
id: number;
|
||||
name: string;
|
||||
pageTitle: string;
|
||||
title: string;
|
||||
type: string;
|
||||
typeName: string;
|
||||
templateName: string;
|
||||
isDefault: number;
|
||||
pageMode: string;
|
||||
global: GlobalConfig;
|
||||
value: any[];
|
||||
}
|
||||
|
||||
export interface TemplatePage {
|
||||
title: string;
|
||||
data: {
|
||||
global: GlobalConfig;
|
||||
value: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DesignQuery {
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
back?: string;
|
||||
}
|
||||
|
||||
export const predefineColors = [
|
||||
'#F4391c',
|
||||
'#ff4500',
|
||||
'#ff8c00',
|
||||
'#FFD009',
|
||||
'#ffd700',
|
||||
'#19C650',
|
||||
'#90ee90',
|
||||
'#00ced1',
|
||||
'#1e90ff',
|
||||
'#c71585',
|
||||
'#FF407E',
|
||||
'#CFAF70',
|
||||
'#A253FF',
|
||||
'rgba(255, 69, 0, 0.68)',
|
||||
'rgb(255, 120, 0)',
|
||||
'hsl(181, 100%, 37%)',
|
||||
'hsla(209, 100%, 56%, 0.73)',
|
||||
'#c7158577'
|
||||
];
|
||||
|
||||
export const positionTypes = ['top_fixed', 'right_fixed', 'bottom_fixed', 'left_fixed', 'fixed'];
|
||||
814
admin-vben/apps/web-antd/src/views/diy/design/index.vue
Normal file
814
admin-vben/apps/web-antd/src/views/diy/design/index.vue
Normal file
@@ -0,0 +1,814 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex h-[60px] items-center bg-primary px-5 text-white">
|
||||
<div class="flex cursor-pointer items-center" @click="goBack">
|
||||
<iconify-icon icon="mdi:arrow-left" class="text-sm" />
|
||||
<span class="ml-2 text-sm">{{ $t('common.back') }}</span>
|
||||
</div>
|
||||
<span class="mx-3 text-white/50">|</span>
|
||||
<span class="text-sm">
|
||||
{{ $t('system.diy.decorating') }}:{{ diyStore.typeName }}
|
||||
</span>
|
||||
|
||||
<div v-if="diyStore.type && diyStore.type !== 'DIY_PAGE'" class="ml-4 flex items-center">
|
||||
<span class="mr-2 text-sm">{{ $t('system.diy.templatePagePlaceholder') }}</span>
|
||||
<a-select
|
||||
v-model:value="template"
|
||||
class="w-[180px]"
|
||||
:placeholder="$t('system.diy.templatePagePlaceholder')"
|
||||
@change="changeTemplatePage"
|
||||
>
|
||||
<a-select-option value="">{{ $t('system.diy.templatePageEmpty') }}</a-select-option>
|
||||
<a-select-option
|
||||
v-for="(item, key) in templatePages"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
<a-button @click="preview">{{ $t('common.preview') }}</a-button>
|
||||
<a-button class="ml-2" type="primary" @click="save">{{ $t('common.save') }}</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-1 bg-gray-50">
|
||||
<!-- Component Library -->
|
||||
<div class="w-[290px] bg-white">
|
||||
<div class="h-full overflow-y-auto p-2">
|
||||
<a-collapse v-model:activeKey="activeNames" class="border-0">
|
||||
<a-collapse-panel
|
||||
v-for="(item, key) in componentGroups"
|
||||
:key="key"
|
||||
:header="item.title"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(compItem, compKey) in item.list"
|
||||
:key="compKey"
|
||||
class="flex cursor-pointer flex-col items-center rounded p-2 text-center hover:bg-blue-50 hover:text-blue-600"
|
||||
:title="compItem.title"
|
||||
@click="addComponent(compKey, compItem)"
|
||||
>
|
||||
<iconify-icon
|
||||
v-if="compItem.icon"
|
||||
:icon="compItem.icon"
|
||||
class="mb-1 text-lg"
|
||||
/>
|
||||
<iconify-icon
|
||||
v-else
|
||||
icon="mdi:palette"
|
||||
class="mb-1 text-lg"
|
||||
/>
|
||||
<span class="text-xs">{{ compItem.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Area -->
|
||||
<div class="relative flex-1 overflow-y-auto p-5">
|
||||
<div class="relative mx-auto w-[375px]">
|
||||
<a-button class="absolute right-0 top-0 z-10" @click="changeCurrentIndex(-99)">
|
||||
{{ $t('system.diy.pageSet') }}
|
||||
</a-button>
|
||||
|
||||
<div class="diy-view-wrap w-[375px] rounded-lg bg-white shadow-lg">
|
||||
<!-- Preview Header -->
|
||||
<div
|
||||
class="preview-head relative h-[64px] cursor-pointer bg-cover bg-center bg-no-repeat"
|
||||
:class="[globalConfig.topStatusBar.style]"
|
||||
:style="{ backgroundColor: globalConfig.topStatusBar.bgColor }"
|
||||
@click="changeCurrentIndex(-99)"
|
||||
>
|
||||
<!-- Style 1: Text Only -->
|
||||
<div
|
||||
v-if="globalConfig.topStatusBar.style === 'style-1' && globalConfig.topStatusBar.isShow"
|
||||
class="content-wrap"
|
||||
>
|
||||
<div
|
||||
class="title-wrap flex h-[30px] items-center justify-center"
|
||||
:style="{
|
||||
fontSize: '14px',
|
||||
color: globalConfig.topStatusBar.textColor,
|
||||
textAlign: globalConfig.topStatusBar.textAlign
|
||||
}"
|
||||
>
|
||||
{{ globalConfig.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style 2: Image + Text -->
|
||||
<div
|
||||
v-if="globalConfig.topStatusBar.style === 'style-2' && globalConfig.topStatusBar.isShow"
|
||||
class="content-wrap"
|
||||
>
|
||||
<div class="title-wrap flex h-[30px] items-center">
|
||||
<div
|
||||
v-if="globalConfig.topStatusBar.imgUrl"
|
||||
class="mr-2 flex h-[28px] max-w-[150px] items-center"
|
||||
>
|
||||
<img
|
||||
class="max-h-full max-w-full"
|
||||
:src="globalConfig.topStatusBar.imgUrl"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="truncate" :style="{ color: globalConfig.topStatusBar.textColor }">
|
||||
{{ globalConfig.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style 3: Image + Search -->
|
||||
<div
|
||||
v-if="globalConfig.topStatusBar.style === 'style-3' && globalConfig.topStatusBar.isShow"
|
||||
class="content-wrap flex h-full items-center px-4"
|
||||
>
|
||||
<div
|
||||
v-if="globalConfig.topStatusBar.imgUrl"
|
||||
class="title-wrap mr-3 flex h-[30px] max-w-[85px] items-center"
|
||||
>
|
||||
<img
|
||||
class="max-h-full max-w-full"
|
||||
:src="globalConfig.topStatusBar.imgUrl"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="search relative flex-1 rounded-full bg-white px-8 py-1 text-xs text-gray-500">
|
||||
<iconify-icon icon="mdi:magnify" class="absolute left-2 top-1/2 -translate-y-1/2" />
|
||||
{{ globalConfig.topStatusBar.inputPlaceholder }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style 4: Location -->
|
||||
<div
|
||||
v-if="globalConfig.topStatusBar.style === 'style-4' && globalConfig.topStatusBar.isShow"
|
||||
class="content-wrap flex h-full items-center px-4"
|
||||
>
|
||||
<iconify-icon icon="mdi:map-marker" class="mr-2" :style="{ color: globalConfig.topStatusBar.textColor }" />
|
||||
<div class="title-wrap mr-2 truncate" :style="{ color: globalConfig.topStatusBar.textColor }">
|
||||
我的位置
|
||||
</div>
|
||||
<iconify-icon icon="mdi:chevron-right" :style="{ color: globalConfig.topStatusBar.textColor }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Content -->
|
||||
<div class="relative min-h-[400px]">
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-action absolute -right-[70px] top-5 w-[42px] rounded bg-white shadow-md">
|
||||
<a-tooltip placement="right" :title="$t('system.diy.moveUpComponent')">
|
||||
<div class="flex h-[40px] cursor-pointer items-center justify-center hover:bg-gray-50">
|
||||
<iconify-icon icon="mdi:arrow-up" @click="moveUpComponent" />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip placement="right" :title="$t('system.diy.moveDownComponent')">
|
||||
<div class="flex h-[40px] cursor-pointer items-center justify-center hover:bg-gray-50">
|
||||
<iconify-icon icon="mdi:arrow-down" @click="moveDownComponent" />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip placement="right" :title="$t('system.diy.copyComponent')">
|
||||
<div class="flex h-[40px] cursor-pointer items-center justify-center hover:bg-gray-50">
|
||||
<iconify-icon icon="mdi:content-copy" @click="copyComponent" />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip placement="right" :title="$t('system.diy.delComponent')">
|
||||
<div class="flex h-[40px] cursor-pointer items-center justify-center hover:bg-gray-50">
|
||||
<iconify-icon icon="mdi:delete" @click="delComponent" />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip placement="right" :title="$t('system.diy.resetComponent')">
|
||||
<div class="flex h-[40px] cursor-pointer items-center justify-center hover:bg-gray-50">
|
||||
<iconify-icon icon="mdi:refresh" @click="resetComponent" />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Iframe Preview -->
|
||||
<iframe
|
||||
v-if="loadingIframe"
|
||||
id="previewIframe"
|
||||
:src="wapPreview"
|
||||
class="preview-iframe h-[600px] w-full"
|
||||
frameborder="0"
|
||||
></iframe>
|
||||
|
||||
<!-- Development Mode -->
|
||||
<div v-if="loadingDev" class="p-5">
|
||||
<div class="mb-5 text-xl font-bold">{{ $t('system.diy.developTitle') }}</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-2 text-sm">{{ $t('system.diy.wapDomain') }}</div>
|
||||
<a-input
|
||||
v-model:value="wapDomain"
|
||||
:placeholder="$t('system.diy.wapDomainPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="saveWapDomain">
|
||||
{{ $t('common.confirm') }}
|
||||
</a-button>
|
||||
<a-button @click="settingTips">
|
||||
{{ $t('system.diy.settingTips') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Property Editor -->
|
||||
<div class="w-[400px] bg-white">
|
||||
<div class="h-full overflow-y-auto">
|
||||
<a-card class="border-0 shadow-none">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex-1">
|
||||
{{ currentIndex === -99 ? $t('system.diy.pageSet') : editComponent?.componentTitle }}
|
||||
</span>
|
||||
<div v-if="currentComponent" class="flex rounded-full bg-gray-100 text-sm">
|
||||
<span
|
||||
class="cursor-pointer rounded-full px-4 py-1"
|
||||
:class="{ 'bg-primary text-white': editTab === 'content' }"
|
||||
@click="editTab = 'content'"
|
||||
>
|
||||
{{ $t('system.diy.tabEditContent') }}
|
||||
</span>
|
||||
<span
|
||||
class="cursor-pointer rounded-full px-4 py-1"
|
||||
:class="{ 'bg-primary text-white': editTab === 'style' }"
|
||||
@click="editTab = 'style'"
|
||||
>
|
||||
{{ $t('system.diy.tabEditStyle') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="edit-component-wrap">
|
||||
<!-- Page Settings -->
|
||||
<div v-if="currentIndex === -99">
|
||||
<PageSettingsForm
|
||||
:global-config="globalConfig"
|
||||
@update:global-config="updateGlobalConfig"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Component Editor -->
|
||||
<div v-else-if="currentComponent">
|
||||
<component
|
||||
:is="currentComponent"
|
||||
:value="componentValue"
|
||||
:edit-tab="editTab"
|
||||
@update:value="updateComponentValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { useI18n } from '#/hooks';
|
||||
import { getDiyTemplatePages, addDiyPage, editDiyPage, initDiyPage } from '#/api';
|
||||
|
||||
import type { ComponentGroup, GlobalConfig, TemplatePage, DesignQuery } from './data';
|
||||
import { predefineColors, positionTypes } from './data';
|
||||
|
||||
import PageSettingsForm from './modules/page-settings-form.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Route query parameters
|
||||
const query = computed<DesignQuery>(() => ({
|
||||
id: route.query.id as string,
|
||||
name: route.query.name as string,
|
||||
url: route.query.url as string,
|
||||
type: route.query.type as string,
|
||||
title: route.query.title as string,
|
||||
back: (route.query.back as string) || '/diy/list',
|
||||
}));
|
||||
|
||||
// Component data
|
||||
const template = ref('');
|
||||
const oldTemplate = ref('');
|
||||
const wapDomain = ref('');
|
||||
const wapUrl = ref('');
|
||||
const wapPreview = ref('');
|
||||
const loadingIframe = ref(false);
|
||||
const loadingDev = ref(false);
|
||||
const timeIframe = ref(0);
|
||||
const difference = ref(0);
|
||||
const uniAppLoadStatus = ref(false);
|
||||
const isRepeat = ref(false);
|
||||
|
||||
const componentGroups = ref<Record<string, ComponentGroup>>({});
|
||||
const templatePages = ref<Record<string, TemplatePage>>({});
|
||||
const activeNames = ref<string[]>([]);
|
||||
const editTab = ref<'content' | 'style'>('content');
|
||||
|
||||
// DIY data
|
||||
const diyData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
pageTitle: '',
|
||||
title: '',
|
||||
type: '',
|
||||
typeName: '',
|
||||
templateName: '',
|
||||
isDefault: 0,
|
||||
pageMode: 'diy',
|
||||
global: {} as GlobalConfig,
|
||||
value: [] as any[],
|
||||
});
|
||||
|
||||
const globalConfig = computed<GlobalConfig>(() => diyData.global);
|
||||
const currentIndex = ref(-99);
|
||||
const currentComponent = ref('');
|
||||
const componentValue = computed(() =>
|
||||
currentIndex.value >= 0 ? diyData.value[currentIndex.value] : null
|
||||
);
|
||||
const editComponent = computed(() =>
|
||||
currentIndex.value === -99 ? globalConfig.value : diyData.value[currentIndex.value]
|
||||
);
|
||||
|
||||
// Original data for change detection
|
||||
const originData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
pageTitle: '',
|
||||
title: '',
|
||||
value: '',
|
||||
});
|
||||
|
||||
const isChange = computed(() => {
|
||||
const currentData = {
|
||||
id: diyData.id,
|
||||
name: diyData.name,
|
||||
pageTitle: diyData.pageTitle,
|
||||
title: diyData.global.title,
|
||||
value: JSON.stringify({
|
||||
global: diyData.global,
|
||||
value: diyData.value,
|
||||
}),
|
||||
};
|
||||
return JSON.stringify(currentData) === JSON.stringify(originData);
|
||||
});
|
||||
|
||||
// Load template pages
|
||||
const loadTemplatePages = async (type: string) => {
|
||||
try {
|
||||
const res = await getDiyTemplatePages({
|
||||
type,
|
||||
mode: 'diy',
|
||||
});
|
||||
templatePages.value = res.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to load template pages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize page data
|
||||
const initPageData = async () => {
|
||||
try {
|
||||
const res = await initDiyPage({
|
||||
id: query.value.id,
|
||||
name: query.value.name,
|
||||
url: query.value.url,
|
||||
type: query.value.type,
|
||||
title: query.value.title,
|
||||
});
|
||||
|
||||
const data = res.data;
|
||||
|
||||
// Initialize DIY data
|
||||
diyData.id = data.id || 0;
|
||||
diyData.name = data.name;
|
||||
diyData.pageTitle = data.page_title;
|
||||
diyData.type = data.type;
|
||||
diyData.typeName = data.type_name;
|
||||
diyData.templateName = data.template;
|
||||
diyData.isDefault = data.is_default;
|
||||
diyData.pageMode = data.mode;
|
||||
|
||||
template.value = data.template;
|
||||
|
||||
// Initialize global config
|
||||
if (data.global) {
|
||||
Object.assign(diyData.global, data.global);
|
||||
}
|
||||
|
||||
// Initialize value
|
||||
if (data.value) {
|
||||
const sources = JSON.parse(data.value);
|
||||
diyData.global = sources.global;
|
||||
if (sources.value.length) {
|
||||
diyData.value = sources.value;
|
||||
}
|
||||
} else {
|
||||
diyData.global.title = data.title;
|
||||
}
|
||||
|
||||
// Set original data
|
||||
originData.id = diyData.id;
|
||||
originData.name = diyData.name;
|
||||
originData.pageTitle = diyData.pageTitle;
|
||||
originData.title = diyData.global.title;
|
||||
originData.value = JSON.stringify({
|
||||
global: diyData.global,
|
||||
value: diyData.value,
|
||||
});
|
||||
|
||||
// Load components
|
||||
componentGroups.value = data.component;
|
||||
activeNames.value = Object.keys(data.component);
|
||||
|
||||
// Load template pages
|
||||
await loadTemplatePages(data.type);
|
||||
|
||||
// Setup preview
|
||||
wapDomain.value = data.domain_url.wap_domain;
|
||||
wapUrl.value = data.domain_url.wap_url;
|
||||
|
||||
setupPreview();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize page:', error);
|
||||
message.error(t('system.diy.initPageError'));
|
||||
}
|
||||
};
|
||||
|
||||
// Setup preview
|
||||
const setupPreview = () => {
|
||||
wapPreview.value = `${wapUrl.value}${query.value.url}?mode=decorate`;
|
||||
|
||||
const sendMessage = () => {
|
||||
timeIframe.value = Date.now();
|
||||
postMessageToIframe();
|
||||
};
|
||||
|
||||
// Send initial message
|
||||
sendMessage();
|
||||
|
||||
// Send messages periodically if no response
|
||||
let sendCount = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (uniAppLoadStatus.value || sendCount >= 50) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
sendMessage();
|
||||
sendCount++;
|
||||
}, 200);
|
||||
|
||||
// Show development mode if iframe doesn't load in 10 seconds
|
||||
setTimeout(() => {
|
||||
if (difference.value === 0) {
|
||||
loadingDev.value = true;
|
||||
loadingIframe.value = false;
|
||||
}
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Post message to iframe
|
||||
const postMessageToIframe = () => {
|
||||
const data = JSON.stringify({
|
||||
type: 'appOnReady',
|
||||
message: '加载完成',
|
||||
global: diyData.global,
|
||||
value: diyData.value,
|
||||
currentIndex: currentIndex.value,
|
||||
});
|
||||
|
||||
const iframe = document.getElementById('previewIframe') as HTMLIFrameElement;
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(data, '*');
|
||||
}
|
||||
};
|
||||
|
||||
// Message event handler
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
let data = { type: '' };
|
||||
if (typeof event.data === 'string') {
|
||||
data = JSON.parse(event.data);
|
||||
} else if (typeof event.data === 'object') {
|
||||
data = event.data;
|
||||
}
|
||||
|
||||
if (!data.type) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'appOnLaunch':
|
||||
case 'appOnReady':
|
||||
loadingDev.value = false;
|
||||
loadingIframe.value = true;
|
||||
const loadTime = Date.now();
|
||||
difference.value = loadTime - timeIframe.value;
|
||||
uniAppLoadStatus.value = true;
|
||||
break;
|
||||
case 'init':
|
||||
postMessageToIframe();
|
||||
break;
|
||||
case 'change':
|
||||
changeCurrentIndex(data.index, data.component);
|
||||
break;
|
||||
case 'data':
|
||||
changeCurrentIndex(data.index, data.component);
|
||||
diyData.global = data.global;
|
||||
diyData.value = data.value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Message handling error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Component operations
|
||||
const addComponent = (key: string, component: any) => {
|
||||
// Implementation for adding component
|
||||
console.log('Add component:', key, component);
|
||||
};
|
||||
|
||||
const changeCurrentIndex = (index: number, component?: any) => {
|
||||
currentIndex.value = index;
|
||||
if (index === -99) {
|
||||
currentComponent.value = 'page-settings';
|
||||
} else if (component) {
|
||||
currentComponent.value = component.path;
|
||||
}
|
||||
};
|
||||
|
||||
const moveUpComponent = () => {
|
||||
if (currentIndex.value <= 0) return;
|
||||
// Implementation for moving component up
|
||||
};
|
||||
|
||||
const moveDownComponent = () => {
|
||||
if (currentIndex.value >= diyData.value.length - 1) return;
|
||||
// Implementation for moving component down
|
||||
};
|
||||
|
||||
const copyComponent = () => {
|
||||
if (currentIndex.value < 0) return;
|
||||
// Implementation for copying component
|
||||
};
|
||||
|
||||
const delComponent = () => {
|
||||
if (currentIndex.value < 0) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: t('common.warning'),
|
||||
content: t('system.diy.delComponentTips'),
|
||||
onOk: () => {
|
||||
diyData.value.splice(currentIndex.value, 1);
|
||||
if (diyData.value.length === 0) {
|
||||
currentIndex.value = -99;
|
||||
} else if (currentIndex.value >= diyData.value.length) {
|
||||
currentIndex.value = diyData.value.length - 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetComponent = () => {
|
||||
if (currentIndex.value < 0) return;
|
||||
// Implementation for resetting component
|
||||
};
|
||||
|
||||
const updateGlobalConfig = (config: GlobalConfig) => {
|
||||
diyData.global = config;
|
||||
};
|
||||
|
||||
const updateComponentValue = (value: any) => {
|
||||
if (currentIndex.value >= 0) {
|
||||
diyData.value[currentIndex.value] = value;
|
||||
}
|
||||
};
|
||||
|
||||
// Template page change
|
||||
const changeTemplatePage = (value: string) => {
|
||||
if (diyData.value.length > 0) {
|
||||
Modal.confirm({
|
||||
title: t('common.warning'),
|
||||
content: t('system.diy.changeTemplatePageTips'),
|
||||
onOk: () => {
|
||||
changeCurrentIndex(-99);
|
||||
if (value && templatePages.value[value]) {
|
||||
const data = cloneDeep(templatePages.value[value].data);
|
||||
diyData.global = data.global;
|
||||
diyData.value = data.value;
|
||||
} else {
|
||||
diyData.value = [];
|
||||
diyData.global.title = query.value.title || '';
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
template.value = oldTemplate.value;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
changeCurrentIndex(-99);
|
||||
if (value && templatePages.value[value]) {
|
||||
const data = cloneDeep(templatePages.value[value].data);
|
||||
diyData.global = data.global;
|
||||
diyData.value = data.value;
|
||||
} else {
|
||||
diyData.value = [];
|
||||
diyData.global.title = query.value.title || '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save WAP domain
|
||||
const saveWapDomain = () => {
|
||||
if (!wapDomain.value.trim()) {
|
||||
message.warning(t('system.diy.wapDomainPlaceholder'));
|
||||
return;
|
||||
}
|
||||
|
||||
wapUrl.value = wapDomain.value + '/wap';
|
||||
localStorage.setItem('wap_domain', wapUrl.value);
|
||||
loadingIframe.value = true;
|
||||
loadingDev.value = false;
|
||||
setupPreview();
|
||||
};
|
||||
|
||||
// Preview
|
||||
const preview = () => {
|
||||
save((id: number) => {
|
||||
const pageId = diyData.id || id;
|
||||
const url = router.resolve({
|
||||
path: '/site/preview/wap',
|
||||
query: { page: `${query.value.url}?id=${pageId}` },
|
||||
});
|
||||
window.open(url.href, '_blank');
|
||||
});
|
||||
};
|
||||
|
||||
// Save
|
||||
const save = async (callback?: (id: number) => void) => {
|
||||
// Validate
|
||||
if (!diyData.pageTitle) {
|
||||
message.warning(t('system.diy.diyPageTitlePlaceholder'));
|
||||
changeCurrentIndex(-99);
|
||||
return;
|
||||
}
|
||||
|
||||
if (diyData.global.popWindow.show && !diyData.global.popWindow.imgUrl) {
|
||||
message.warning('请上传弹窗图片');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRepeat.value) return;
|
||||
isRepeat.value = true;
|
||||
|
||||
try {
|
||||
diyData.templateName = template.value;
|
||||
|
||||
const data = {
|
||||
id: diyData.id,
|
||||
name: diyData.name,
|
||||
page_title: diyData.pageTitle,
|
||||
title: diyData.global.title,
|
||||
type: diyData.type,
|
||||
template: diyData.templateName,
|
||||
is_default: diyData.isDefault,
|
||||
is_change: isChange.value ? 0 : 1,
|
||||
value: JSON.stringify({
|
||||
global: diyData.global,
|
||||
value: diyData.value,
|
||||
}),
|
||||
};
|
||||
|
||||
const api = diyData.id ? editDiyPage : addDiyPage;
|
||||
const res = await api(data);
|
||||
|
||||
if (res.code === 1) {
|
||||
if (diyData.id) {
|
||||
// Update existing page
|
||||
message.success(t('common.saveSuccess'));
|
||||
} else {
|
||||
// Create new page
|
||||
router.push(query.value.back);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(res.data.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
message.error(t('common.saveError'));
|
||||
} finally {
|
||||
isRepeat.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Go back
|
||||
const goBack = () => {
|
||||
if (isChange.value) {
|
||||
router.push(query.value.back);
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: t('common.warning'),
|
||||
content: t('system.diy.leavePageContentTips'),
|
||||
onOk: () => {
|
||||
router.push(query.value.back);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const settingTips = () => {
|
||||
window.open('https://www.kancloud.cn/niucloud/niucloud-admin-develop/3213393', '_blank');
|
||||
};
|
||||
|
||||
// Watch template changes
|
||||
watch(template, (newValue, oldValue) => {
|
||||
oldTemplate.value = oldValue;
|
||||
});
|
||||
|
||||
// Watch data changes
|
||||
watch(
|
||||
() => diyData,
|
||||
() => {
|
||||
postMessageToIframe();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleMessage);
|
||||
initPageData();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-collapse-borderless) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse-header) {
|
||||
padding: 12px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse-content) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
background-image: url('/src/assets/images/diy_preview_head.png');
|
||||
}
|
||||
|
||||
.preview-head.style-1 .content-wrap {
|
||||
@apply flex h-full items-center justify-center;
|
||||
}
|
||||
|
||||
.preview-head.style-2 .content-wrap {
|
||||
@apply flex h-full items-center px-4;
|
||||
}
|
||||
|
||||
.preview-head.style-3 .content-wrap {
|
||||
@apply flex h-full items-center px-4;
|
||||
}
|
||||
|
||||
.preview-head.style-4 .content-wrap {
|
||||
@apply flex h-full items-center px-4;
|
||||
}
|
||||
|
||||
.quick-action > div {
|
||||
@apply border-b border-gray-100 last:border-b-0;
|
||||
}
|
||||
|
||||
.diy-view-wrap {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
.edit-component-wrap {
|
||||
@apply p-4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Background Settings -->
|
||||
<div class="border-t-2 border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.background') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Background Color -->
|
||||
<a-form-item v-if="!ignore.includes('componentBgColor')" :label="$t('system.diy.bgColor')">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a-color-picker v-model:value="localValue.componentStartBgColor" show-alpha />
|
||||
<iconify-icon icon="mdi:arrow-right" class="text-gray-400" />
|
||||
<a-color-picker v-model:value="localValue.componentEndBgColor" show-alpha />
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">{{ $t('system.diy.bgColorTips') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Gradient Angle -->
|
||||
<a-form-item v-if="!ignore.includes('componentBgColor')" :label="$t('system.diy.gradientAngle')">
|
||||
<a-radio-group v-model:value="localValue.componentGradientAngle">
|
||||
<a-radio value="to bottom">{{ $t('system.diy.topToBottom') }}</a-radio>
|
||||
<a-radio value="to right">{{ $t('system.diy.leftToRight') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Background Image -->
|
||||
<a-form-item v-if="!ignore.includes('componentBgUrl')" :label="$t('system.diy.bgImage')">
|
||||
<ImageUpload
|
||||
v-model:value="localValue.componentBgUrl"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Background Alpha -->
|
||||
<a-form-item v-if="!ignore.includes('componentBgUrl') && localValue.componentBgUrl" :label="$t('system.diy.bgAlpha')">
|
||||
<a-slider
|
||||
v-model:value="localValue.componentBgAlpha"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="1"
|
||||
:marks="{ 0: '0%', 5: '50%', 10: '100%' }"
|
||||
/>
|
||||
<div class="text-xs text-gray-500">{{ $t('system.diy.bgAlphaTips') }}</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Spacing Settings -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.spacing') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Top Margin -->
|
||||
<a-form-item v-if="!ignore.includes('marginTop')" :label="$t('system.diy.marginTop')">
|
||||
<a-slider
|
||||
v-model:value="localValue.margin.top"
|
||||
:min="-100"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:marks="{ '-100': '-100', 0: '0', 100: '100' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Bottom Margin -->
|
||||
<a-form-item v-if="!ignore.includes('marginBottom')" :label="$t('system.diy.marginBottom')">
|
||||
<a-slider
|
||||
v-model:value="localValue.margin.bottom"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:marks="{ 0: '0', 50: '50', 100: '100' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Side Margin -->
|
||||
<a-form-item v-if="!ignore.includes('marginBoth')" :label="$t('system.diy.marginBoth')">
|
||||
<a-slider
|
||||
v-model:value="localValue.margin.both"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="1"
|
||||
:marks="{ 0: '0', 25: '25', 50: '50' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Border Settings -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.border') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Top Rounded -->
|
||||
<a-form-item v-if="!ignore.includes('topRounded')" :label="$t('system.diy.topRounded')">
|
||||
<a-slider
|
||||
v-model:value="localValue.topRounded"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:marks="{ 0: '0', 50: '50%', 100: '100%' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Bottom Rounded -->
|
||||
<a-form-item v-if="!ignore.includes('bottomRounded')" :label="$t('system.diy.bottomRounded')">
|
||||
<a-slider
|
||||
v-model:value="localValue.bottomRounded"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:marks="{ 0: '0', 50: '50%', 100: '100%' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Element Settings -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.element') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Element Background Color -->
|
||||
<a-form-item v-if="!ignore.includes('elementBgColor')" :label="$t('system.diy.elementBgColor')">
|
||||
<a-color-picker v-model:value="localValue.elementBgColor" show-alpha />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Element Top Rounded -->
|
||||
<a-form-item v-if="!ignore.includes('topElementRounded')" :label="$t('system.diy.elementTopRounded')">
|
||||
<a-slider
|
||||
v-model:value="localValue.topElementRounded"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:marks="{ 0: '0', 50: '50%', 100: '100%' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Element Bottom Rounded -->
|
||||
<a-form-item v-if="!ignore.includes('bottomElementRounded')" :label="$t('system.diy.elementBottomRounded')">
|
||||
<a-slider
|
||||
v-model:value="localValue.bottomElementRounded"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:marks="{ 0: '0', 50: '50%', 100: '100%' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Settings -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.visibility') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Hide Component -->
|
||||
<a-form-item v-if="!ignore.includes('isHidden')" :label="$t('system.diy.hideComponent')">
|
||||
<a-switch v-model:checked="localValue.isHidden" />
|
||||
<div class="mt-1 text-xs text-gray-500">{{ $t('system.diy.hideComponentTips') }}</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
interface Props {
|
||||
value: any;
|
||||
ignore?: string[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:value', value: any): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
ignore: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const localValue = ref<any>({
|
||||
componentStartBgColor: '',
|
||||
componentEndBgColor: '',
|
||||
componentGradientAngle: 'to bottom',
|
||||
componentBgUrl: '',
|
||||
componentBgAlpha: 2,
|
||||
margin: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
both: 0,
|
||||
},
|
||||
topRounded: 0,
|
||||
bottomRounded: 0,
|
||||
elementBgColor: '',
|
||||
topElementRounded: 0,
|
||||
bottomElementRounded: 0,
|
||||
isHidden: false,
|
||||
});
|
||||
|
||||
// Initialize local value
|
||||
const initLocalValue = () => {
|
||||
if (props.value) {
|
||||
localValue.value = {
|
||||
...localValue.value,
|
||||
...props.value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for value changes
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
initLocalValue();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// Watch for local changes
|
||||
watch(
|
||||
localValue,
|
||||
(newValue) => {
|
||||
emit('update:value', newValue);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Initialize
|
||||
initLocalValue();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Page Settings -->
|
||||
<div class="border-t-2 border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.pageSettings') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Page Title -->
|
||||
<a-form-item :label="$t('system.diy.pageTitle')">
|
||||
<a-input
|
||||
v-model:value="localConfig.title"
|
||||
:placeholder="$t('system.diy.pageTitlePlaceholder')"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Page Name -->
|
||||
<a-form-item :label="$t('system.diy.pageName')">
|
||||
<a-input
|
||||
v-model:value="localPageName"
|
||||
:placeholder="$t('system.diy.pageNamePlaceholder')"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Page Mode -->
|
||||
<a-form-item :label="$t('system.diy.pageMode')">
|
||||
<a-radio-group v-model:value="localConfig.completeLayout">
|
||||
<a-radio value="style-1">{{ $t('system.diy.style1') }}</a-radio>
|
||||
<a-radio value="style-2">{{ $t('system.diy.style2') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Alignment -->
|
||||
<a-form-item v-if="localConfig.completeLayout === 'style-2'" :label="$t('system.diy.alignment')">
|
||||
<a-radio-group v-model:value="localConfig.completeAlign">
|
||||
<a-radio value="left">{{ $t('system.diy.alignLeft') }}</a-radio>
|
||||
<a-radio value="right">{{ $t('system.diy.alignRight') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Border Control -->
|
||||
<a-form-item :label="$t('system.diy.borderControl')">
|
||||
<a-switch v-model:checked="localConfig.borderControl" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Page Background -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.pageBackground') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Background Color -->
|
||||
<a-form-item :label="$t('system.diy.bgColor')">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a-color-picker v-model:value="localConfig.pageStartBgColor" show-alpha />
|
||||
<iconify-icon icon="mdi:arrow-right" class="text-gray-400" />
|
||||
<a-color-picker v-model:value="localConfig.pageEndBgColor" show-alpha />
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">{{ $t('system.diy.bgColorTips') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Gradient Angle -->
|
||||
<a-form-item :label="$t('system.diy.gradientAngle')">
|
||||
<a-radio-group v-model:value="localConfig.pageGradientAngle">
|
||||
<a-radio value="to bottom">{{ $t('system.diy.topToBottom') }}</a-radio>
|
||||
<a-radio value="to right">{{ $t('system.diy.leftToRight') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Background Image -->
|
||||
<a-form-item :label="$t('system.diy.bgImage')">
|
||||
<ImageUpload
|
||||
v-model:value="localConfig.bgUrl"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Background Height Scale -->
|
||||
<a-form-item :label="$t('system.diy.bgHeightScale')">
|
||||
<a-slider
|
||||
v-model:value="localConfig.bgHeightScale"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
:marks="{ 0: '0%', 50: '50%', 100: '100%' }"
|
||||
/>
|
||||
<div class="text-xs text-gray-500">{{ $t('system.diy.bgHeightScaleTips') }}</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Top Status Bar -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.topStatusBar') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Show Status Bar -->
|
||||
<a-form-item :label="$t('system.diy.showStatusBar')">
|
||||
<a-switch v-model:checked="localConfig.topStatusBar.isShow" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Background Color -->
|
||||
<a-form-item :label="$t('system.diy.bgColor')">
|
||||
<a-color-picker v-model:value="localConfig.topStatusBar.bgColor" show-alpha />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Text Color -->
|
||||
<a-form-item :label="$t('system.diy.textColor')">
|
||||
<a-color-picker v-model:value="localConfig.topStatusBar.textColor" show-alpha />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Status Bar Style -->
|
||||
<a-form-item :label="$t('system.diy.statusBarStyle')">
|
||||
<a-radio-group v-model:value="localConfig.topStatusBar.style">
|
||||
<a-radio value="style-1">{{ $t('system.diy.style1Text') }}</a-radio>
|
||||
<a-radio value="style-2">{{ $t('system.diy.style2ImageText') }}</a-radio>
|
||||
<a-radio value="style-3">{{ $t('system.diy.style3ImageSearch') }}</a-radio>
|
||||
<a-radio value="style-4">{{ $t('system.diy.style4Location') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Text Alignment -->
|
||||
<a-form-item v-if="localConfig.topStatusBar.style === 'style-1'" :label="$t('system.diy.textAlign')">
|
||||
<a-radio-group v-model:value="localConfig.topStatusBar.textAlign">
|
||||
<a-radio value="left">{{ $t('system.diy.alignLeft') }}</a-radio>
|
||||
<a-radio value="center">{{ $t('system.diy.alignCenter') }}</a-radio>
|
||||
<a-radio value="right">{{ $t('system.diy.alignRight') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Logo Image -->
|
||||
<a-form-item v-if="['style-2', 'style-3'].includes(localConfig.topStatusBar.style)" :label="$t('system.diy.logoImage')">
|
||||
<ImageUpload
|
||||
v-model:value="localConfig.topStatusBar.imgUrl"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Search Placeholder -->
|
||||
<a-form-item v-if="localConfig.topStatusBar.style === 'style-3'" :label="$t('system.diy.searchPlaceholder')">
|
||||
<a-input
|
||||
v-model:value="localConfig.topStatusBar.inputPlaceholder"
|
||||
:placeholder="$t('system.diy.searchPlaceholderTips')"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Link -->
|
||||
<a-form-item :label="$t('system.diy.link')">
|
||||
<LinkSelector
|
||||
v-model:value="localConfig.topStatusBar.link"
|
||||
:placeholder="$t('system.diy.linkPlaceholder')"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Pop Window -->
|
||||
<div class="border-t-2 border-gray-100 pt-4">
|
||||
<h3 class="mb-3 font-medium">{{ $t('system.diy.popWindow') }}</h3>
|
||||
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<!-- Show Pop Window -->
|
||||
<a-form-item :label="$t('system.diy.showPopWindow')">
|
||||
<a-radio-group v-model:value="localConfig.popWindow.count">
|
||||
<a-radio value="-1">{{ $t('system.diy.neverShow') }}</a-radio>
|
||||
<a-radio value="once">{{ $t('system.diy.showOnce') }}</a-radio>
|
||||
<a-radio value="always">{{ $t('system.diy.showAlways') }}</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Pop Window Image -->
|
||||
<a-form-item v-if="localConfig.popWindow.count !== '-1'" :label="$t('system.diy.popImage')">
|
||||
<ImageUpload
|
||||
v-model:value="localConfig.popWindow.imgUrl"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Pop Window Link -->
|
||||
<a-form-item v-if="localConfig.popWindow.count !== '-1'" :label="$t('system.diy.popLink')">
|
||||
<LinkSelector
|
||||
v-model:value="localConfig.popWindow.link"
|
||||
:placeholder="$t('system.diy.popLinkPlaceholder')"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type { GlobalConfig } from '../data';
|
||||
|
||||
interface Props {
|
||||
globalConfig: GlobalConfig;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:globalConfig', config: GlobalConfig): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const localConfig = ref<GlobalConfig>(cloneDeep(props.globalConfig));
|
||||
const localPageName = ref('');
|
||||
|
||||
// Watch for changes and emit
|
||||
watch(
|
||||
localConfig,
|
||||
(newConfig) => {
|
||||
emit('update:globalConfig', newConfig);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch for props changes
|
||||
watch(
|
||||
() => props.globalConfig,
|
||||
(newConfig) => {
|
||||
localConfig.value = cloneDeep(newConfig);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
163
admin-vben/apps/web-antd/src/views/diy/list/data.ts
Normal file
163
admin-vben/apps/web-antd/src/views/diy/list/data.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { Avatar, Button, Icon, Popconfirm, Space, Tag, Tooltip } from '@vben/common-ui';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: $t('diy.list.pageTitle'),
|
||||
rules: 'required|max:50',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.list.pageTitlePlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'type',
|
||||
label: $t('diy.list.pageType'),
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.list.pageTypePlaceholder'),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: $t('diy.list.pageTitle'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'addon_name',
|
||||
label: $t('diy.list.addonName'),
|
||||
componentProps: {
|
||||
placeholder: $t('common.selectPlaceholder'),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'type',
|
||||
label: $t('diy.list.pageType'),
|
||||
componentProps: {
|
||||
placeholder: $t('common.selectPlaceholder'),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns(
|
||||
onActionClick: OnActionClickFn<DiyApi.DiyPage>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{
|
||||
title: $t('diy.list.pageId'),
|
||||
field: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: $t('diy.list.pageTitle'),
|
||||
field: 'title',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
title: $t('diy.list.addonName'),
|
||||
field: 'addon_title',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: $t('diy.list.pageType'),
|
||||
field: 'type_name',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: $t('diy.list.status'),
|
||||
field: 'is_use',
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
if (row.type === 'DIY_PAGE') {
|
||||
return <Tag>-</Tag>;
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
color={row.is_use === 1 ? 'green' : 'default'}
|
||||
class="cursor-pointer"
|
||||
onClick={() => onActionClick('setUse', row)}
|
||||
>
|
||||
{row.is_use === 1 ? $t('common.inUse') : $t('common.notInUse')}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $t('diy.list.updateTime'),
|
||||
field: 'update_time',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: $t('common.action'),
|
||||
field: 'action',
|
||||
width: 250,
|
||||
slots: {
|
||||
default: ({ row }) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('preview', row)}
|
||||
>
|
||||
<Icon icon="ant-design:eye-outlined" />
|
||||
{$t('diy.list.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('share', row)}
|
||||
>
|
||||
<Icon icon="ant-design:share-alt-outlined" />
|
||||
{$t('diy.list.shareSetting')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('edit', row)}
|
||||
>
|
||||
<Icon icon="ant-design:edit-outlined" />
|
||||
{$t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('copy', row)}
|
||||
>
|
||||
<Icon icon="ant-design:copy-outlined" />
|
||||
{$t('diy.list.copy')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={$t('diy.list.deleteConfirm')}
|
||||
onConfirm={() => onActionClick('delete', row)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
{$t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
7
admin-vben/apps/web-antd/src/views/diy/list/index.vue
Normal file
7
admin-vben/apps/web-antd/src/views/diy/list/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<DiyList />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import DiyList from './list.vue';
|
||||
</script>
|
||||
174
admin-vben/apps/web-antd/src/views/diy/list/list.vue
Normal file
174
admin-vben/apps/web-antd/src/views/diy/list/list.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script lang="ts" setup>
|
||||
import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Icon, Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
copyDiyPage,
|
||||
deleteDiyPage,
|
||||
editDiyPageShare,
|
||||
getDiyPageList,
|
||||
getDiyTemplate,
|
||||
setUseDiyPage,
|
||||
} from '#/api/core/diy';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import AddPageModal from './modules/add-page-modal.vue';
|
||||
import ShareSettingModal from './modules/share-setting-modal.vue';
|
||||
|
||||
const [AddPageModalComponent, addPageModalApi] = useVbenModal({
|
||||
connectedComponent: AddPageModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [ShareSettingModalComponent, shareSettingModalApi] = useVbenModal({
|
||||
connectedComponent: ShareSettingModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['updateTime', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDiyPageList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleAdd() {
|
||||
addPageModalApi.setData({});
|
||||
addPageModalApi.open();
|
||||
}
|
||||
|
||||
function onActionClick(actionType: string, row: DiyApi.DiyPage) {
|
||||
switch (actionType) {
|
||||
case 'preview': {
|
||||
handlePreview(row);
|
||||
break;
|
||||
}
|
||||
case 'setUse': {
|
||||
handleSetUse(row);
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
handleShare(row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
handleEdit(row);
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
handleCopy(row);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
handleDelete(row);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview(row: DiyApi.DiyPage) {
|
||||
const previewUrl = `/preview/wap?page=${row.type}_${row.page}?id=${row.id}`;
|
||||
window.open(previewUrl, '_blank');
|
||||
}
|
||||
|
||||
async function handleSetUse(row: DiyApi.DiyPage) {
|
||||
if (row.type === 'DIY_PAGE') {
|
||||
message.warning($t('diy.list.cannotSetUseDiyPage'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setUseDiyPage(row.id);
|
||||
message.success($t('diy.list.setUseSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('diy.list.setUseError'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleShare(row: DiyApi.DiyPage) {
|
||||
shareSettingModalApi.setData({ pageId: row.id, shareData: row.share });
|
||||
shareSettingModalApi.open();
|
||||
}
|
||||
|
||||
function handleEdit(row: DiyApi.DiyPage) {
|
||||
const editUrl = `/decorate/edit?id=${row.id}`;
|
||||
window.open(editUrl, '_blank');
|
||||
}
|
||||
|
||||
async function handleCopy(row: DiyApi.DiyPage) {
|
||||
try {
|
||||
await copyDiyPage(row.id);
|
||||
message.success($t('diy.list.copySuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('diy.list.copyError'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: DiyApi.DiyPage) {
|
||||
Modal.confirm({
|
||||
title: $t('common.prompt'),
|
||||
content: $t('diy.list.deleteConfirm'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteDiyPage(row.id);
|
||||
message.success($t('diy.list.deleteSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('diy.list.deleteError'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<Plus />
|
||||
{{ $t('diy.list.addPage') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
<AddPageModalComponent @success="gridApi.reload()" />
|
||||
<ShareSettingModalComponent @success="gridApi.reload()" />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Select, Spin } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { addDiyPage, getDiyTemplate } from '#/api/core/diy';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const addonList = ref<any[]>([]);
|
||||
const pageTypeList = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const getModalTitle = computed(() => {
|
||||
return $t('diy.list.addPage');
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
modalApi.lock();
|
||||
|
||||
try {
|
||||
await addDiyPage(values);
|
||||
emits('success');
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
formApi.resetForm();
|
||||
await loadTemplateData();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function loadTemplateData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const templates = await getDiyTemplate({ addon: '' });
|
||||
addonList.value = templates.addon_list || [];
|
||||
pageTypeList.value = templates.type_list || [];
|
||||
|
||||
// Update form schema with dynamic options
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'type',
|
||||
componentProps: {
|
||||
options: pageTypeList.value.map(item => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load template data:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddonChange(addon: string) {
|
||||
// Reload page types when addon changes
|
||||
loadTemplateDataForAddon(addon);
|
||||
}
|
||||
|
||||
async function loadTemplateDataForAddon(addon: string) {
|
||||
try {
|
||||
const templates = await getDiyTemplate({ addon });
|
||||
pageTypeList.value = templates.type_list || [];
|
||||
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'type',
|
||||
componentProps: {
|
||||
options: pageTypeList.value.map(item => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Reset type selection
|
||||
formApi.setValues({ type: undefined });
|
||||
} catch (error) {
|
||||
console.error('Failed to load template data for addon:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getModalTitle">
|
||||
<Spin :spinning="loading">
|
||||
<Form>
|
||||
<!-- Addon selection trigger -->
|
||||
<template #addon_name="slotProps">
|
||||
<Select
|
||||
v-bind="slotProps"
|
||||
@change="handleAddonChange"
|
||||
/>
|
||||
</template>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Radio, RadioGroup, Tabs, TabPane, Upload } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { editDiyPageShare } from '#/api/core/diy';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = defineProps<{
|
||||
pageId: number;
|
||||
shareData: any;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const activeTab = ref('wechat');
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'share_title',
|
||||
label: $t('diy.list.shareTitle'),
|
||||
rules: 'required|max:50',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.list.shareTitlePlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'share_desc',
|
||||
label: $t('diy.list.shareDesc'),
|
||||
rules: 'max:200',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.list.shareDescPlaceholder'),
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Upload',
|
||||
fieldName: 'share_image',
|
||||
label: $t('diy.list.shareImage'),
|
||||
componentProps: {
|
||||
accept: 'image/*',
|
||||
maxCount: 1,
|
||||
showUploadList: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: formSchema,
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const getModalTitle = computed(() => {
|
||||
return $t('diy.list.shareSetting');
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
modalApi.lock();
|
||||
|
||||
try {
|
||||
const shareData = {
|
||||
[activeTab.value]: values,
|
||||
};
|
||||
|
||||
await editDiyPageShare(props.pageId, shareData);
|
||||
emits('success');
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
formApi.resetForm();
|
||||
// Load existing share data
|
||||
const currentShareData = props.shareData?.[activeTab.value] || {};
|
||||
formApi.setValues(currentShareData);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleTabChange(tab: string) {
|
||||
activeTab.value = tab;
|
||||
// Reload form data for new tab
|
||||
const currentShareData = props.shareData?.[tab] || {};
|
||||
formApi.setValues(currentShareData);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getModalTitle">
|
||||
<Tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||
<TabPane key="wechat" :tab="$t('diy.list.wechatShare')">
|
||||
<Form />
|
||||
</TabPane>
|
||||
<TabPane key="weapp" :tab="$t('diy.list.weappShare')">
|
||||
<Form />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
</template>
|
||||
251
admin-vben/apps/web-antd/src/views/diy/route/data.ts
Normal file
251
admin-vben/apps/web-antd/src/views/diy/route/data.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export interface RouteParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface RouteForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
path: string;
|
||||
component: string;
|
||||
meta: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
keepAlive?: boolean;
|
||||
requireAuth?: boolean;
|
||||
};
|
||||
status: number;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export const useColumns = (
|
||||
onActionClick: (actionType: string, row: DiyApi.DiyRoute) => void,
|
||||
): VxeGridProps['columns'] => {
|
||||
return [
|
||||
{
|
||||
type: 'checkbox',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
title: $t('common.id'),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: $t('diy.route.name'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'path',
|
||||
title: $t('diy.route.path'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'component',
|
||||
title: $t('diy.route.component'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'meta.title',
|
||||
title: $t('diy.route.title'),
|
||||
minWidth: 150,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return row.meta?.title || '-';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: $t('common.status'),
|
||||
width: 100,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return (
|
||||
<a-switch
|
||||
checked={row.status === 1}
|
||||
onChange={() => onActionClick('status', row)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: $t('common.sort'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: $t('common.createTime'),
|
||||
width: 180,
|
||||
formatter: ({ cellValue }) => {
|
||||
return cellValue ? new Date(cellValue).toLocaleString() : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $t('common.action'),
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
return (
|
||||
<a-space>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('edit', row)}
|
||||
>
|
||||
{$t('common.edit')}
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title={$t('common.deleteConfirm')}
|
||||
onConfirm={() => onActionClick('delete', row)}
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
{$t('common.delete')}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const useGridFormSchema = (): any[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: $t('diy.route.name'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseInput'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'path',
|
||||
label: $t('diy.route.path'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseInput'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: $t('common.status'),
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseSelect'),
|
||||
options: [
|
||||
{ label: $t('common.enable'), value: 1 },
|
||||
{ label: $t('common.disable'), value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: $t('common.createTime'),
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
placeholder: [$t('common.startTime'), $t('common.endTime')],
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const useFormSchema = (): any[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: $t('diy.route.name'),
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.route.namePlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'path',
|
||||
label: $t('diy.route.path'),
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.route.pathPlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'component',
|
||||
label: $t('diy.route.component'),
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.route.componentPlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'meta.title',
|
||||
label: $t('diy.route.title'),
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.route.titlePlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'meta.icon',
|
||||
label: $t('diy.route.icon'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('diy.route.iconPlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'meta.keepAlive',
|
||||
label: $t('diy.route.keepAlive'),
|
||||
component: 'Switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
fieldName: 'meta.requireAuth',
|
||||
label: $t('diy.route.requireAuth'),
|
||||
component: 'Switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: $t('common.sort'),
|
||||
component: 'InputNumber',
|
||||
defaultValue: 0,
|
||||
componentProps: {
|
||||
placeholder: $t('common.pleaseInput'),
|
||||
min: 0,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: $t('common.status'),
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 1,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: $t('common.enable'), value: 1 },
|
||||
{ label: $t('common.disable'), value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
132
admin-vben/apps/web-antd/src/views/diy/route/list.vue
Normal file
132
admin-vben/apps/web-antd/src/views/diy/route/list.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Icon, Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getDiyRouteList,
|
||||
addDiyRoute,
|
||||
editDiyRoute,
|
||||
deleteDiyRoute,
|
||||
modifyDiyRouteStatus,
|
||||
} from '#/api/core/diy';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import RouteModal from './modules/route-modal.vue';
|
||||
|
||||
const [RouteModalComponent, routeModalApi] = useVbenModal({
|
||||
connectedComponent: RouteModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['updateTime', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDiyRouteList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleAdd() {
|
||||
routeModalApi.setData({});
|
||||
routeModalApi.open();
|
||||
}
|
||||
|
||||
function onActionClick(actionType: string, row: DiyApi.DiyRoute) {
|
||||
switch (actionType) {
|
||||
case 'edit': {
|
||||
handleEdit(row);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
handleDelete(row);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
handleStatus(row);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(row: DiyApi.DiyRoute) {
|
||||
routeModalApi.setData({ ...row });
|
||||
routeModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(row: DiyApi.DiyRoute) {
|
||||
Modal.confirm({
|
||||
title: $t('common.prompt'),
|
||||
content: $t('diy.route.deleteConfirm'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteDiyRoute(row.id);
|
||||
message.success($t('diy.route.deleteSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('diy.route.deleteError'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStatus(row: DiyApi.DiyRoute) {
|
||||
try {
|
||||
await modifyDiyRouteStatus({
|
||||
id: row.id,
|
||||
status: row.status === 1 ? 0 : 1,
|
||||
});
|
||||
message.success($t('common.operateSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('common.operateError'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<Plus />
|
||||
{{ $t('diy.route.addRoute') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
<RouteModalComponent @success="gridApi.reload()" />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DiyApi } from '#/api';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import { addDiyRoute, editDiyRoute } from '#/api/core/diy';
|
||||
|
||||
interface Props {
|
||||
modalApi: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const formSchema = useFormSchema();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: formSchema,
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const values = await formApi.validate();
|
||||
const isEdit = !!values.id;
|
||||
|
||||
if (isEdit) {
|
||||
await editDiyRoute(values);
|
||||
message.success($t('common.editSuccess'));
|
||||
} else {
|
||||
await addDiyRoute(values);
|
||||
message.success($t('common.addSuccess'));
|
||||
}
|
||||
|
||||
modalApi.close();
|
||||
props.modalApi?.emit('success');
|
||||
} catch (error) {
|
||||
console.error('Save route error:', error);
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<DiyApi.DiyRoute>();
|
||||
if (data) {
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="formApi.values.id ? $t('diy.route.editRoute') : $t('diy.route.addRoute')"
|
||||
class="w-[600px]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
110
admin-vben/apps/web-antd/src/views/setting/payment/data.ts
Normal file
110
admin-vben/apps/web-antd/src/views/setting/payment/data.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface PaymentItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
payment_type: string;
|
||||
payment_name: string;
|
||||
payment_code: string;
|
||||
config: Record<string, any>;
|
||||
icon: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface PaymentForm {
|
||||
id?: number;
|
||||
site_id: number;
|
||||
payment_type: string;
|
||||
payment_name: string;
|
||||
payment_code: string;
|
||||
config: Record<string, any>;
|
||||
icon: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
export const paymentTypeOptions = [
|
||||
{ label: '微信支付', value: 'wechat' },
|
||||
{ label: '支付宝', value: 'alipay' },
|
||||
{ label: '银联', value: 'unionpay' },
|
||||
{ label: 'PayPal', value: 'paypal' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
|
||||
export const paymentTypeMap = {
|
||||
wechat: '微信支付',
|
||||
alipay: '支付宝',
|
||||
unionpay: '银联',
|
||||
paypal: 'PayPal',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
export const statusMap = {
|
||||
1: '启用',
|
||||
0: '禁用',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'payment_type',
|
||||
label: '支付类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: paymentTypeOptions,
|
||||
placeholder: '请选择支付类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'payment_name',
|
||||
label: '支付名称',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'payment_code',
|
||||
label: '支付编码',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'payment_name', title: '支付名称', minWidth: 150 },
|
||||
{ field: 'payment_type', title: '支付类型', width: 120, slots: { default: 'paymentType' } },
|
||||
{ field: 'payment_code', title: '支付编码', width: 150 },
|
||||
{
|
||||
field: 'icon',
|
||||
title: '图标',
|
||||
width: 100,
|
||||
slots: { default: 'icon' },
|
||||
align: 'center',
|
||||
},
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'create_time', title: '创建时间', width: 180 },
|
||||
{ field: 'update_time', title: '更新时间', width: 180 },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
195
admin-vben/apps/web-antd/src/views/setting/payment/list.vue
Normal file
195
admin-vben/apps/web-antd/src/views/setting/payment/list.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleAdd">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新增支付方式
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #paymentType="{ row }">
|
||||
<VbenTag :type="getPaymentTypeColor(row.payment_type)">
|
||||
{{ paymentTypeMap[row.payment_type] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #icon="{ row }">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
v-if="row.icon"
|
||||
:src="row.icon"
|
||||
:alt="row.payment_name"
|
||||
class="w-8 h-8 object-contain"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-8 h-8 bg-gray-100 rounded flex items-center justify-center text-gray-400"
|
||||
>
|
||||
<CreditCard class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[row.status] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleConfig(row)"
|
||||
>
|
||||
配置
|
||||
</VbenButton>
|
||||
<VbenPopconfirm
|
||||
title="确定删除该支付方式吗?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<VbenButton type="text" size="small" danger>
|
||||
删除
|
||||
</VbenButton>
|
||||
</VbenPopconfirm>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<PaymentEditModal
|
||||
v-model="modalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<PaymentConfigModal
|
||||
v-model="configModalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Plus, RefreshCw, CreditCard } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getPaymentList, deletePayment } from '#/api/core/payment';
|
||||
import PaymentEditModal from './modules/payment-edit.vue';
|
||||
import PaymentConfigModal from './modules/payment-config.vue';
|
||||
|
||||
import type { PaymentItem } from './data';
|
||||
import { columns, querySchema, paymentTypeMap, statusMap } from './data';
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const configModalVisible = ref(false);
|
||||
const currentData = ref<PaymentItem | null>(null);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: false,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-3 lg:grid-cols-4',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
return await getPaymentList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
function getPaymentTypeColor(type: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
wechat: 'green',
|
||||
alipay: 'blue',
|
||||
unionpay: 'orange',
|
||||
paypal: 'purple',
|
||||
other: 'gray',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const target = event.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.nextElementSibling?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentData.value = null;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: PaymentItem) {
|
||||
currentData.value = row;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleConfig(row: PaymentItem) {
|
||||
currentData.value = row;
|
||||
configModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: PaymentItem) {
|
||||
try {
|
||||
await deletePayment(row.id);
|
||||
VbenMessage.success('删除成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
title="支付配置"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { updatePaymentConfig } from '#/api/core/payment';
|
||||
|
||||
import type { PaymentItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: PaymentItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref<Record<string, any>>({});
|
||||
|
||||
const formSchema = computed(() => {
|
||||
if (!props.data) return [];
|
||||
|
||||
const baseSchema = [
|
||||
{
|
||||
fieldName: 'payment_name',
|
||||
label: '支付名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '支付名称',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'payment_type',
|
||||
label: '支付类型',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '支付类型',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 根据支付类型生成不同的配置字段
|
||||
const configSchema = getPaymentConfigSchema(props.data.payment_type);
|
||||
|
||||
return [...baseSchema, ...configSchema];
|
||||
});
|
||||
|
||||
const formRules = computed(() => {
|
||||
const rules: Record<string, any> = {};
|
||||
|
||||
formSchema.value.forEach(field => {
|
||||
if (field.fieldName && field.fieldName !== 'payment_name' && field.fieldName !== 'payment_type') {
|
||||
rules[field.fieldName] = field.rules || 'required';
|
||||
}
|
||||
});
|
||||
|
||||
return rules;
|
||||
});
|
||||
|
||||
function getPaymentConfigSchema(paymentType: string) {
|
||||
const configSchemas: Record<string, any[]> = {
|
||||
wechat: [
|
||||
{
|
||||
fieldName: 'app_id',
|
||||
label: '应用ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入微信支付应用ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'mch_id',
|
||||
label: '商户号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入微信支付商户号',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'api_key',
|
||||
label: 'API密钥',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入微信支付API密钥',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'cert_path',
|
||||
label: '证书路径',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入微信支付证书路径',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'key_path',
|
||||
label: '密钥路径',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入微信支付密钥路径',
|
||||
},
|
||||
},
|
||||
],
|
||||
alipay: [
|
||||
{
|
||||
fieldName: 'app_id',
|
||||
label: '应用ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入支付宝应用ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'public_key',
|
||||
label: '公钥',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入支付宝公钥',
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'private_key',
|
||||
label: '私钥',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入支付宝私钥',
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
unionpay: [
|
||||
{
|
||||
fieldName: 'mer_id',
|
||||
label: '商户号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入银联商户号',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'cert_path',
|
||||
label: '证书路径',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入银联证书路径',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'cert_pwd',
|
||||
label: '证书密码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入银联证书密码',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
],
|
||||
paypal: [
|
||||
{
|
||||
fieldName: 'client_id',
|
||||
label: '客户端ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入PayPal客户端ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'client_secret',
|
||||
label: '客户端密钥',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入PayPal客户端密钥',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sandbox',
|
||||
label: '沙箱模式',
|
||||
component: 'Switch',
|
||||
componentProps: {
|
||||
checkedChildren: '是',
|
||||
unCheckedChildren: '否',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return configSchemas[paymentType] || [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
payment_name: val.payment_name,
|
||||
payment_type: val.payment_type,
|
||||
...val.config,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.data) return;
|
||||
|
||||
try {
|
||||
const configData = { ...formModel.value };
|
||||
delete configData.payment_name;
|
||||
delete configData.payment_type;
|
||||
|
||||
await updatePaymentConfig(props.data.id, configData);
|
||||
VbenMessage.success('配置更新成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('配置更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { createPayment, updatePayment } from '#/api/core/payment';
|
||||
|
||||
import type { PaymentItem } from '../data';
|
||||
import { paymentTypeOptions, statusOptions } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: PaymentItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => (props.data ? '编辑支付方式' : '新增支付方式'));
|
||||
|
||||
const formModel = ref({
|
||||
site_id: 0,
|
||||
payment_type: 'wechat',
|
||||
payment_name: '',
|
||||
payment_code: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'site_id',
|
||||
label: '站点ID',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入站点ID',
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'payment_type',
|
||||
label: '支付类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择支付类型',
|
||||
options: paymentTypeOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'payment_name',
|
||||
label: '支付名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入支付名称',
|
||||
maxlength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'payment_code',
|
||||
label: '支付编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入支付编码',
|
||||
maxlength: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'icon',
|
||||
label: '图标',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入图标URL',
|
||||
maxlength: 255,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
min: 0,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
site_id: 'required',
|
||||
payment_type: 'required',
|
||||
payment_name: 'required|max:50',
|
||||
payment_code: 'required|max:30',
|
||||
icon: 'max:255',
|
||||
sort: 'required',
|
||||
status: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
site_id: val.site_id,
|
||||
payment_type: val.payment_type,
|
||||
payment_name: val.payment_name,
|
||||
payment_code: val.payment_code,
|
||||
icon: val.icon,
|
||||
sort: val.sort,
|
||||
status: val.status,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {
|
||||
site_id: 0,
|
||||
payment_type: 'wechat',
|
||||
payment_name: '',
|
||||
payment_code: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (props.data) {
|
||||
await updatePayment(props.data.id, formModel.value);
|
||||
VbenMessage.success('更新成功');
|
||||
} else {
|
||||
await createPayment(formModel.value);
|
||||
VbenMessage.success('创建成功');
|
||||
}
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
108
admin-vben/apps/web-antd/src/views/setting/sms/data.ts
Normal file
108
admin-vben/apps/web-antd/src/views/setting/sms/data.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface SmsItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
sms_type: string;
|
||||
sms_name: string;
|
||||
sms_code: string;
|
||||
config: Record<string, any>;
|
||||
template_config: Record<string, any>;
|
||||
sign_name: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface SmsForm {
|
||||
id?: number;
|
||||
site_id: number;
|
||||
sms_type: string;
|
||||
sms_name: string;
|
||||
sms_code: string;
|
||||
config: Record<string, any>;
|
||||
template_config: Record<string, any>;
|
||||
sign_name: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
export const smsTypeOptions = [
|
||||
{ label: '阿里云短信', value: 'aliyun' },
|
||||
{ label: '腾讯云短信', value: 'tencent' },
|
||||
{ label: '华为云短信', value: 'huawei' },
|
||||
{ label: '百度云短信', value: 'baidu' },
|
||||
{ label: '京东云短信', value: 'jd' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
|
||||
export const smsTypeMap = {
|
||||
aliyun: '阿里云短信',
|
||||
tencent: '腾讯云短信',
|
||||
huawei: '华为云短信',
|
||||
baidu: '百度云短信',
|
||||
jd: '京东云短信',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
export const statusMap = {
|
||||
1: '启用',
|
||||
0: '禁用',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'sms_type',
|
||||
label: '短信类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: smsTypeOptions,
|
||||
placeholder: '请选择短信类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sms_name',
|
||||
label: '短信名称',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'sms_code',
|
||||
label: '短信编码',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'sms_name', title: '短信名称', minWidth: 150 },
|
||||
{ field: 'sms_type', title: '短信类型', width: 120, slots: { default: 'smsType' } },
|
||||
{ field: 'sms_code', title: '短信编码', width: 150 },
|
||||
{ field: 'sign_name', title: '签名名称', width: 150 },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'create_time', title: '创建时间', width: 180 },
|
||||
{ field: 'update_time', title: '更新时间', width: 180 },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
187
admin-vben/apps/web-antd/src/views/setting/sms/list.vue
Normal file
187
admin-vben/apps/web-antd/src/views/setting/sms/list.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleAdd">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新增短信配置
|
||||
</VbenButton>
|
||||
<VbenButton type="success" @click="handleTest">
|
||||
<MessageCircle class="mr-2 h-4 w-4" />
|
||||
测试发送
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #smsType="{ row }">
|
||||
<VbenTag :type="getSmsTypeColor(row.sms_type)">
|
||||
{{ smsTypeMap[row.sms_type] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[row.status] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleConfig(row)"
|
||||
>
|
||||
配置
|
||||
</VbenButton>
|
||||
<VbenPopconfirm
|
||||
title="确定删除该短信配置吗?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<VbenButton type="text" size="small" danger>
|
||||
删除
|
||||
</VbenButton>
|
||||
</VbenPopconfirm>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<SmsEditModal
|
||||
v-model="modalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<SmsConfigModal
|
||||
v-model="configModalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<SmsTestModal
|
||||
v-model="testModalVisible"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Plus, MessageCircle } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getSmsList, deleteSms } from '#/api/core/sms';
|
||||
import SmsEditModal from './modules/sms-edit.vue';
|
||||
import SmsConfigModal from './modules/sms-config.vue';
|
||||
import SmsTestModal from './modules/sms-test.vue';
|
||||
|
||||
import type { SmsItem } from './data';
|
||||
import { columns, querySchema, smsTypeMap, statusMap } from './data';
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const configModalVisible = ref(false);
|
||||
const testModalVisible = ref(false);
|
||||
const currentData = ref<SmsItem | null>(null);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: false,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-3 lg:grid-cols-4',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
return await getSmsList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
function getSmsTypeColor(type: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
aliyun: 'blue',
|
||||
tencent: 'green',
|
||||
huawei: 'orange',
|
||||
baidu: 'purple',
|
||||
jd: 'red',
|
||||
other: 'gray',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentData.value = null;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: SmsItem) {
|
||||
currentData.value = row;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleConfig(row: SmsItem) {
|
||||
currentData.value = row;
|
||||
configModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleTest() {
|
||||
testModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: SmsItem) {
|
||||
try {
|
||||
await deleteSms(row.id);
|
||||
VbenMessage.success('删除成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
title="短信配置"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { updateSmsConfig } from '#/api/core/sms';
|
||||
|
||||
import type { SmsItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: SmsItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref<Record<string, any>>({});
|
||||
|
||||
const formSchema = computed(() => {
|
||||
if (!props.data) return [];
|
||||
|
||||
const baseSchema = [
|
||||
{
|
||||
fieldName: 'sms_name',
|
||||
label: '短信名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '短信名称',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sms_type',
|
||||
label: '短信类型',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '短信类型',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 根据短信类型生成不同的配置字段
|
||||
const configSchema = getSmsConfigSchema(props.data.sms_type);
|
||||
|
||||
return [...baseSchema, ...configSchema];
|
||||
});
|
||||
|
||||
const formRules = computed(() => {
|
||||
const rules: Record<string, any> = {};
|
||||
|
||||
formSchema.value.forEach(field => {
|
||||
if (field.fieldName && field.fieldName !== 'sms_name' && field.fieldName !== 'sms_type') {
|
||||
rules[field.fieldName] = field.rules || 'required';
|
||||
}
|
||||
});
|
||||
|
||||
return rules;
|
||||
});
|
||||
|
||||
function getSmsConfigSchema(smsType: string) {
|
||||
const configSchemas: Record<string, any[]> = {
|
||||
aliyun: [
|
||||
{
|
||||
fieldName: 'access_key_id',
|
||||
label: 'AccessKey ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云AccessKey ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'access_key_secret',
|
||||
label: 'AccessKey Secret',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云AccessKey Secret',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'region_id',
|
||||
label: '地域ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云地域ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
tencent: [
|
||||
{
|
||||
fieldName: 'secret_id',
|
||||
label: 'Secret ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云Secret ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'secret_key',
|
||||
label: 'Secret Key',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云Secret Key',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'region',
|
||||
label: '地域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云地域',
|
||||
},
|
||||
},
|
||||
],
|
||||
huawei: [
|
||||
{
|
||||
fieldName: 'app_key',
|
||||
label: 'App Key',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入华为云App Key',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'app_secret',
|
||||
label: 'App Secret',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入华为云App Secret',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return configSchemas[smsType] || [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
sms_name: val.sms_name,
|
||||
sms_type: val.sms_type,
|
||||
...val.config,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.data) return;
|
||||
|
||||
try {
|
||||
const configData = { ...formModel.value };
|
||||
delete configData.sms_name;
|
||||
delete configData.sms_type;
|
||||
|
||||
await updateSmsConfig(props.data.id, configData);
|
||||
VbenMessage.success('配置更新成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('配置更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { createSms, updateSms } from '#/api/core/sms';
|
||||
|
||||
import type { SmsItem } from '../data';
|
||||
import { smsTypeOptions, statusOptions } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: SmsItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => (props.data ? '编辑短信配置' : '新增短信配置'));
|
||||
|
||||
const formModel = ref({
|
||||
site_id: 0,
|
||||
sms_type: 'aliyun',
|
||||
sms_name: '',
|
||||
sms_code: '',
|
||||
sign_name: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'site_id',
|
||||
label: '站点ID',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入站点ID',
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sms_type',
|
||||
label: '短信类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择短信类型',
|
||||
options: smsTypeOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sms_name',
|
||||
label: '短信名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入短信名称',
|
||||
maxlength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sms_code',
|
||||
label: '短信编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入短信编码',
|
||||
maxlength: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sign_name',
|
||||
label: '签名名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入签名名称',
|
||||
maxlength: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
min: 0,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
site_id: 'required',
|
||||
sms_type: 'required',
|
||||
sms_name: 'required|max:50',
|
||||
sms_code: 'required|max:30',
|
||||
sign_name: 'required|max:20',
|
||||
sort: 'required',
|
||||
status: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
site_id: val.site_id,
|
||||
sms_type: val.sms_type,
|
||||
sms_name: val.sms_name,
|
||||
sms_code: val.sms_code,
|
||||
sign_name: val.sign_name,
|
||||
sort: val.sort,
|
||||
status: val.status,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {
|
||||
site_id: 0,
|
||||
sms_type: 'aliyun',
|
||||
sms_name: '',
|
||||
sms_code: '',
|
||||
sign_name: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (props.data) {
|
||||
await updateSms(props.data.id, formModel.value);
|
||||
VbenMessage.success('更新成功');
|
||||
} else {
|
||||
await createSms(formModel.value);
|
||||
VbenMessage.success('创建成功');
|
||||
}
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<VbenModal
|
||||
v-model="visible"
|
||||
title="测试短信发送"
|
||||
:width="500"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">发送</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenForm, VbenMessage, VbenModal, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { testSmsSend } from '#/api/core/sms';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
mobile: '',
|
||||
template_code: '',
|
||||
template_params: '',
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'mobile',
|
||||
label: '手机号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入测试手机号',
|
||||
maxlength: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'template_code',
|
||||
label: '模板编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入短信模板编码',
|
||||
maxlength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'template_params',
|
||||
label: '模板参数',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入模板参数,JSON格式',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
mobile: 'required|mobile',
|
||||
template_code: 'required|max:50',
|
||||
template_params: 'json',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
mobile: '',
|
||||
template_code: '',
|
||||
template_params: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
let templateParams = {};
|
||||
if (formModel.value.template_params) {
|
||||
try {
|
||||
templateParams = JSON.parse(formModel.value.template_params);
|
||||
} catch (error) {
|
||||
VbenMessage.error('模板参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await testSmsSend({
|
||||
mobile: formModel.value.mobile,
|
||||
template_code: formModel.value.template_code,
|
||||
template_params: templateParams,
|
||||
});
|
||||
|
||||
VbenMessage.success('短信发送成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('短信发送失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
122
admin-vben/apps/web-antd/src/views/setting/storage/data.ts
Normal file
122
admin-vben/apps/web-antd/src/views/setting/storage/data.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface StorageItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
storage_type: string;
|
||||
storage_name: string;
|
||||
storage_code: string;
|
||||
config: Record<string, any>;
|
||||
is_default: 0 | 1;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface StorageForm {
|
||||
id?: number;
|
||||
site_id: number;
|
||||
storage_type: string;
|
||||
storage_name: string;
|
||||
storage_code: string;
|
||||
config: Record<string, any>;
|
||||
is_default: 0 | 1;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
export const storageTypeOptions = [
|
||||
{ label: '本地存储', value: 'local' },
|
||||
{ label: '阿里云OSS', value: 'aliyun' },
|
||||
{ label: '腾讯云COS', value: 'tencent' },
|
||||
{ label: '华为云OBS', value: 'huawei' },
|
||||
{ label: '百度云BOS', value: 'baidu' },
|
||||
{ label: '七牛云', value: 'qiniu' },
|
||||
{ label: '又拍云', value: 'upyun' },
|
||||
{ label: 'AWS S3', value: 'aws' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
|
||||
export const storageTypeMap = {
|
||||
local: '本地存储',
|
||||
aliyun: '阿里云OSS',
|
||||
tencent: '腾讯云COS',
|
||||
huawei: '华为云OBS',
|
||||
baidu: '百度云BOS',
|
||||
qiniu: '七牛云',
|
||||
upyun: '又拍云',
|
||||
aws: 'AWS S3',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
export const statusMap = {
|
||||
1: '启用',
|
||||
0: '禁用',
|
||||
};
|
||||
|
||||
export const isDefaultOptions = [
|
||||
{ label: '是', value: 1 },
|
||||
{ label: '否', value: 0 },
|
||||
];
|
||||
|
||||
export const isDefaultMap = {
|
||||
1: '是',
|
||||
0: '否',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'storage_type',
|
||||
label: '存储类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: storageTypeOptions,
|
||||
placeholder: '请选择存储类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_name',
|
||||
label: '存储名称',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_code',
|
||||
label: '存储编码',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'storage_name', title: '存储名称', minWidth: 150 },
|
||||
{ field: 'storage_type', title: '存储类型', width: 120, slots: { default: 'storageType' } },
|
||||
{ field: 'storage_code', title: '存储编码', width: 150 },
|
||||
{ field: 'is_default', title: '默认存储', width: 100, slots: { default: 'isDefault' } },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'create_time', title: '创建时间', width: 180 },
|
||||
{ field: 'update_time', title: '更新时间', width: 180 },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
214
admin-vben/apps/web-antd/src/views/setting/storage/list.vue
Normal file
214
admin-vben/apps/web-antd/src/views/setting/storage/list.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleAdd">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新增存储配置
|
||||
</VbenButton>
|
||||
<VbenButton type="success" @click="handleTest">
|
||||
<Upload class="mr-2 h-4 w-4" />
|
||||
测试上传
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #storageType="{ row }">
|
||||
<VbenTag :type="getStorageTypeColor(row.storage_type)">
|
||||
{{ storageTypeMap[row.storage_type] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #isDefault="{ row }">
|
||||
<VbenTag :type="row.is_default === 1 ? 'success' : 'default'">
|
||||
{{ isDefaultMap[row.is_default] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[row.status] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleConfig(row)"
|
||||
>
|
||||
配置
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
v-if="row.is_default === 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleSetDefault(row)"
|
||||
>
|
||||
设为默认
|
||||
</VbenButton>
|
||||
<VbenPopconfirm
|
||||
title="确定删除该存储配置吗?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<VbenButton type="text" size="small" danger>
|
||||
删除
|
||||
</VbenButton>
|
||||
</VbenPopconfirm>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<StorageEditModal
|
||||
v-model="modalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<StorageConfigModal
|
||||
v-model="configModalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
|
||||
<StorageTestModal
|
||||
v-model="testModalVisible"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Plus, Upload } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getStorageList, deleteStorage, setDefaultStorage } from '#/api/core/storage';
|
||||
import StorageEditModal from './modules/storage-edit.vue';
|
||||
import StorageConfigModal from './modules/storage-config.vue';
|
||||
import StorageTestModal from './modules/storage-test.vue';
|
||||
|
||||
import type { StorageItem } from './data';
|
||||
import { columns, querySchema, storageTypeMap, statusMap, isDefaultMap } from './data';
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const configModalVisible = ref(false);
|
||||
const testModalVisible = ref(false);
|
||||
const currentData = ref<StorageItem | null>(null);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: false,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-3 lg:grid-cols-4',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
return await getStorageList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
function getStorageTypeColor(type: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
local: 'blue',
|
||||
aliyun: 'orange',
|
||||
tencent: 'green',
|
||||
huawei: 'red',
|
||||
baidu: 'purple',
|
||||
qiniu: 'pink',
|
||||
upyun: 'cyan',
|
||||
aws: 'indigo',
|
||||
other: 'gray',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentData.value = null;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: StorageItem) {
|
||||
currentData.value = row;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleConfig(row: StorageItem) {
|
||||
currentData.value = row;
|
||||
configModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSetDefault(row: StorageItem) {
|
||||
try {
|
||||
await setDefaultStorage(row.id);
|
||||
VbenMessage.success('设置默认存储成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('设置默认存储失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: StorageItem) {
|
||||
try {
|
||||
await deleteStorage(row.id);
|
||||
VbenMessage.success('删除成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleTest() {
|
||||
testModalVisible.value = true;
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
title="存储配置"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { updateStorageConfig } from '#/api/core/storage';
|
||||
|
||||
import type { StorageItem } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: StorageItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref<Record<string, any>>({});
|
||||
|
||||
const formSchema = computed(() => {
|
||||
if (!props.data) return [];
|
||||
|
||||
const baseSchema = [
|
||||
{
|
||||
fieldName: 'storage_name',
|
||||
label: '存储名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '存储名称',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_type',
|
||||
label: '存储类型',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '存储类型',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 根据存储类型生成不同的配置字段
|
||||
const configSchema = getStorageConfigSchema(props.data.storage_type);
|
||||
|
||||
return [...baseSchema, ...configSchema];
|
||||
});
|
||||
|
||||
const formRules = computed(() => {
|
||||
const rules: Record<string, any> = {};
|
||||
|
||||
formSchema.value.forEach(field => {
|
||||
if (field.fieldName && field.fieldName !== 'storage_name' && field.fieldName !== 'storage_type') {
|
||||
rules[field.fieldName] = field.rules || 'required';
|
||||
}
|
||||
});
|
||||
|
||||
return rules;
|
||||
});
|
||||
|
||||
function getStorageConfigSchema(storageType: string) {
|
||||
const configSchemas: Record<string, any[]> = {
|
||||
local: [
|
||||
{
|
||||
fieldName: 'upload_path',
|
||||
label: '上传路径',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入本地存储上传路径',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'domain',
|
||||
label: '访问域名',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入本地存储访问域名',
|
||||
},
|
||||
},
|
||||
],
|
||||
aliyun: [
|
||||
{
|
||||
fieldName: 'access_key_id',
|
||||
label: 'AccessKey ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云AccessKey ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'access_key_secret',
|
||||
label: 'AccessKey Secret',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云AccessKey Secret',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'bucket',
|
||||
label: '存储桶名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云OSS存储桶名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'endpoint',
|
||||
label: '地域节点',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云OSS地域节点',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'domain',
|
||||
label: '访问域名',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入阿里云OSS访问域名',
|
||||
},
|
||||
},
|
||||
],
|
||||
tencent: [
|
||||
{
|
||||
fieldName: 'secret_id',
|
||||
label: 'Secret ID',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云Secret ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'secret_key',
|
||||
label: 'Secret Key',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云Secret Key',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'bucket',
|
||||
label: '存储桶名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云COS存储桶名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'region',
|
||||
label: '地域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云COS地域',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'domain',
|
||||
label: '访问域名',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入腾讯云COS访问域名',
|
||||
},
|
||||
},
|
||||
],
|
||||
qiniu: [
|
||||
{
|
||||
fieldName: 'access_key',
|
||||
label: 'Access Key',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入七牛云Access Key',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'secret_key',
|
||||
label: 'Secret Key',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入七牛云Secret Key',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'bucket',
|
||||
label: '存储空间',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入七牛云存储空间名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'domain',
|
||||
label: '访问域名',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入七牛云访问域名',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return configSchemas[storageType] || [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
storage_name: val.storage_name,
|
||||
storage_type: val.storage_type,
|
||||
...val.config,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.data) return;
|
||||
|
||||
try {
|
||||
const configData = { ...formModel.value };
|
||||
delete configData.storage_name;
|
||||
delete configData.storage_type;
|
||||
|
||||
await updateStorageConfig(props.data.id, configData);
|
||||
VbenMessage.success('配置更新成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('配置更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { createStorage, updateStorage } from '#/api/core/storage';
|
||||
|
||||
import type { StorageItem } from '../data';
|
||||
import { storageTypeOptions, statusOptions, isDefaultOptions } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: StorageItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => (props.data ? '编辑存储配置' : '新增存储配置'));
|
||||
|
||||
const formModel = ref({
|
||||
site_id: 0,
|
||||
storage_type: 'local',
|
||||
storage_name: '',
|
||||
storage_code: '',
|
||||
is_default: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'site_id',
|
||||
label: '站点ID',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入站点ID',
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_type',
|
||||
label: '存储类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择存储类型',
|
||||
options: storageTypeOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_name',
|
||||
label: '存储名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入存储名称',
|
||||
maxlength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'storage_code',
|
||||
label: '存储编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入存储编码',
|
||||
maxlength: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'is_default',
|
||||
label: '默认存储',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: isDefaultOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
min: 0,
|
||||
max: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
site_id: 'required',
|
||||
storage_type: 'required',
|
||||
storage_name: 'required|max:50',
|
||||
storage_code: 'required|max:30',
|
||||
is_default: 'required',
|
||||
sort: 'required',
|
||||
status: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
site_id: val.site_id,
|
||||
storage_type: val.storage_type,
|
||||
storage_name: val.storage_name,
|
||||
storage_code: val.storage_code,
|
||||
is_default: val.is_default,
|
||||
sort: val.sort,
|
||||
status: val.status,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {
|
||||
site_id: 0,
|
||||
storage_type: 'local',
|
||||
storage_name: '',
|
||||
storage_code: '',
|
||||
is_default: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (props.data) {
|
||||
await updateStorage(props.data.id, formModel.value);
|
||||
VbenMessage.success('更新成功');
|
||||
} else {
|
||||
await createStorage(formModel.value);
|
||||
VbenMessage.success('创建成功');
|
||||
}
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<VbenModal
|
||||
v-model="visible"
|
||||
title="测试文件上传"
|
||||
:width="500"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">上传</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { VbenButton, VbenForm, VbenMessage, VbenModal, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { testStorageUpload } from '#/api/core/storage';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formModel = ref({
|
||||
file: null as File | null,
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'file',
|
||||
label: '选择文件',
|
||||
component: 'Upload',
|
||||
componentProps: {
|
||||
accept: 'image/*,application/pdf,.doc,.docx,.xls,.xlsx',
|
||||
maxCount: 1,
|
||||
beforeUpload: handleBeforeUpload,
|
||||
onChange: handleFileChange,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
file: 'required',
|
||||
};
|
||||
|
||||
function handleBeforeUpload(file: File) {
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if (file.size > maxSize) {
|
||||
VbenMessage.error('文件大小不能超过 10MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFileChange(info: any) {
|
||||
if (info.file.status === 'done') {
|
||||
formModel.value.file = info.file.originFileObj;
|
||||
} else if (info.file.status === 'removed') {
|
||||
formModel.value.file = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value.file = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (!formModel.value.file) {
|
||||
VbenMessage.error('请选择要上传的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', formModel.value.file);
|
||||
|
||||
const result = await testStorageUpload(formData);
|
||||
|
||||
VbenMessage.success('文件上传成功');
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('文件上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
109
admin-vben/apps/web-antd/src/views/setting/system/config/data.ts
Normal file
109
admin-vben/apps/web-antd/src/views/setting/system/config/data.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
export interface ConfigItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
app_module: string;
|
||||
config_key: string;
|
||||
config_value: string;
|
||||
config_desc: string;
|
||||
is_system: 0 | 1;
|
||||
status: 0 | 1;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface ConfigForm {
|
||||
id?: number;
|
||||
site_id: number;
|
||||
app_module: string;
|
||||
config_key: string;
|
||||
config_value: string;
|
||||
config_desc: string;
|
||||
is_system: 0 | 1;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
export const moduleOptions = [
|
||||
{ label: '系统设置', value: 'system' },
|
||||
{ label: '站点设置', value: 'site' },
|
||||
{ label: '上传设置', value: 'upload' },
|
||||
{ label: '邮件设置', value: 'email' },
|
||||
{ label: '短信设置', value: 'sms' },
|
||||
{ label: '支付设置', value: 'pay' },
|
||||
{ label: '缓存设置', value: 'cache' },
|
||||
{ label: '安全设置', value: 'security' },
|
||||
{ label: '日志设置', value: 'log' },
|
||||
{ label: '其他设置', value: 'other' },
|
||||
];
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
export const statusMap = {
|
||||
1: '启用',
|
||||
0: '禁用',
|
||||
};
|
||||
|
||||
export const isSystemOptions = [
|
||||
{ label: '是', value: 1 },
|
||||
{ label: '否', value: 0 },
|
||||
];
|
||||
|
||||
export const isSystemMap = {
|
||||
1: '是',
|
||||
0: '否',
|
||||
};
|
||||
|
||||
export const querySchema = [
|
||||
{
|
||||
fieldName: 'app_module',
|
||||
label: '模块',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: moduleOptions,
|
||||
placeholder: '请选择模块',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'config_key',
|
||||
label: '配置键',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'config_desc',
|
||||
label: '配置描述',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: VxeGridProps['columns'] = [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'app_module', title: '模块', width: 120, slots: { default: 'module' } },
|
||||
{ field: 'config_key', title: '配置键', width: 200 },
|
||||
{ field: 'config_value', title: '配置值', minWidth: 200, showOverflow: true },
|
||||
{ field: 'config_desc', title: '配置描述', minWidth: 200, showOverflow: true },
|
||||
{ field: 'is_system', title: '系统配置', width: 100, slots: { default: 'isSystem' } },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'create_time', title: '创建时间', width: 180 },
|
||||
{ field: 'update_time', title: '更新时间', width: 180 },
|
||||
{
|
||||
field: 'action',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div>
|
||||
<VbenVxeGrid
|
||||
ref="gridRef"
|
||||
:form-options="formOptions"
|
||||
:grid-options="gridOptions"
|
||||
:grid-events="gridEvents"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<VbenButton type="primary" @click="handleAdd">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新增配置
|
||||
</VbenButton>
|
||||
<VbenButton type="success" @click="handleRefresh">
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
刷新缓存
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<template #module="{ row }">
|
||||
<VbenTag :type="getModuleColor(row.app_module)">
|
||||
{{ getModuleLabel(row.app_module) }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #isSystem="{ row }">
|
||||
<VbenTag :type="row.is_system === 1 ? 'success' : 'default'">
|
||||
{{ isSystemMap[row.is_system] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
|
||||
{{ statusMap[row.status] }}
|
||||
</VbenTag>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<VbenButton
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</VbenButton>
|
||||
<VbenPopconfirm
|
||||
title="确定删除该配置吗?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<VbenButton type="text" size="small" danger>
|
||||
删除
|
||||
</VbenButton>
|
||||
</VbenPopconfirm>
|
||||
</template>
|
||||
</VbenVxeGrid>
|
||||
|
||||
<ConfigEditModal
|
||||
v-model="modalVisible"
|
||||
:data="currentData"
|
||||
@reload="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Plus, RefreshCw } from '@vben/icons';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
|
||||
|
||||
import { getSystemConfigList, deleteSystemConfig, refreshSystemConfig } from '#/api/core/system';
|
||||
import ConfigEditModal from './modules/config-edit.vue';
|
||||
|
||||
import type { ConfigItem } from './data';
|
||||
import { columns, querySchema, moduleOptions, statusMap, isSystemMap } from './data';
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const currentData = ref<ConfigItem | null>(null);
|
||||
|
||||
const gridRef = ref();
|
||||
|
||||
const formOptions = computed(() => ({
|
||||
schema: querySchema,
|
||||
showCollapseButton: false,
|
||||
fieldSize: 'medium',
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-3 lg:grid-cols-4',
|
||||
}));
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
limit: page.pageSize,
|
||||
...formValues,
|
||||
};
|
||||
return await getSystemConfigList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
minWidth: 100,
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents = {
|
||||
// 表格事件
|
||||
};
|
||||
|
||||
function getModuleLabel(module: string) {
|
||||
const moduleMap = moduleOptions.reduce((acc, item) => {
|
||||
acc[item.value] = item.label;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
return moduleMap[module] || module;
|
||||
}
|
||||
|
||||
function getModuleColor(module: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
system: 'blue',
|
||||
site: 'green',
|
||||
upload: 'orange',
|
||||
email: 'purple',
|
||||
sms: 'pink',
|
||||
pay: 'red',
|
||||
cache: 'cyan',
|
||||
security: 'indigo',
|
||||
log: 'lime',
|
||||
other: 'gray',
|
||||
};
|
||||
return colorMap[module] || 'default';
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentData.value = null;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: ConfigItem) {
|
||||
currentData.value = row;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: ConfigItem) {
|
||||
try {
|
||||
await deleteSystemConfig(row.id);
|
||||
VbenMessage.success('删除成功');
|
||||
reloadTable();
|
||||
} catch (error) {
|
||||
VbenMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
try {
|
||||
await refreshSystemConfig();
|
||||
VbenMessage.success('缓存刷新成功');
|
||||
} catch (error) {
|
||||
VbenMessage.error('缓存刷新失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
gridRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<VbenDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
:width="600"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<VbenForm
|
||||
:schema="formSchema"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #form-footer>
|
||||
<VbenSpace>
|
||||
<VbenButton @click="handleCancel">取消</VbenButton>
|
||||
<VbenButton type="primary" native-type="submit">确定</VbenButton>
|
||||
</VbenSpace>
|
||||
</template>
|
||||
</VbenForm>
|
||||
</VbenDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { VbenButton, VbenDrawer, VbenForm, VbenMessage, VbenSpace } from '@vben/common-ui';
|
||||
|
||||
import { createSystemConfig, updateSystemConfig } from '#/api/core/system';
|
||||
|
||||
import type { ConfigItem } from '../data';
|
||||
import { moduleOptions, statusOptions, isSystemOptions } from '../data';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
data?: ConfigItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'reload'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => (props.data ? '编辑配置' : '新增配置'));
|
||||
|
||||
const formModel = ref({
|
||||
site_id: 0,
|
||||
app_module: 'system',
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
config_desc: '',
|
||||
is_system: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'site_id',
|
||||
label: '站点ID',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入站点ID',
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'app_module',
|
||||
label: '模块',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择模块',
|
||||
options: moduleOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'config_key',
|
||||
label: '配置键',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置键',
|
||||
maxlength: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'config_value',
|
||||
label: '配置值',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置值',
|
||||
maxlength: 1000,
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'config_desc',
|
||||
label: '配置描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入配置描述',
|
||||
maxlength: 500,
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'is_system',
|
||||
label: '系统配置',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: isSystemOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const formRules = {
|
||||
site_id: 'required',
|
||||
app_module: 'required',
|
||||
config_key: 'required|max:100',
|
||||
config_value: 'required|max:1000',
|
||||
config_desc: 'max:500',
|
||||
is_system: 'required',
|
||||
status: 'required',
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formModel.value = {
|
||||
site_id: val.site_id,
|
||||
app_module: val.app_module,
|
||||
config_key: val.config_key,
|
||||
config_value: val.config_value,
|
||||
config_desc: val.config_desc,
|
||||
is_system: val.is_system,
|
||||
status: val.status,
|
||||
};
|
||||
} else {
|
||||
formModel.value = {
|
||||
site_id: 0,
|
||||
app_module: 'system',
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
config_desc: '',
|
||||
is_system: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (props.data) {
|
||||
await updateSystemConfig(props.data.id, formModel.value);
|
||||
VbenMessage.success('更新成功');
|
||||
} else {
|
||||
await createSystemConfig(formModel.value);
|
||||
VbenMessage.success('创建成功');
|
||||
}
|
||||
handleCancel();
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
VbenMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
181
admin-vben/apps/web-antd/src/views/site/group/data.ts
Normal file
181
admin-vben/apps/web-antd/src/views/site/group/data.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SiteApi } from '#/api';
|
||||
|
||||
import { Avatar, Space, Tooltip } from 'ant-design-vue';
|
||||
import { Icon } from '@vben/icons';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'group_name',
|
||||
label: $t('site.group.groupName'),
|
||||
rules: 'required|max:20',
|
||||
componentProps: {
|
||||
placeholder: $t('site.group.groupNamePlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'group_desc',
|
||||
label: $t('site.group.groupDesc'),
|
||||
rules: 'max:100',
|
||||
componentProps: {
|
||||
placeholder: $t('site.group.groupDescPlaceholder'),
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'CheckboxGroup',
|
||||
fieldName: 'app',
|
||||
label: $t('site.group.mainApp'),
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: $t('site.group.mainAppPlaceholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'CheckboxGroup',
|
||||
fieldName: 'addon',
|
||||
label: $t('site.group.containAddon'),
|
||||
componentProps: {
|
||||
placeholder: $t('site.group.containAddonPlaceholder'),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'keywords',
|
||||
label: $t('site.group.groupName'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns(
|
||||
onActionClick: OnActionClickFn<SiteApi.SiteGroup>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{
|
||||
title: $t('site.group.groupId'),
|
||||
field: 'group_id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: $t('site.group.groupName'),
|
||||
field: 'group_name',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
title: $t('site.group.appName'),
|
||||
field: 'app_list',
|
||||
width: 300,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
if (!row.app_list || row.app_list.length === 0) {
|
||||
return <span class="text-gray-400">{$t('site.group.appListEmpty')}</span>;
|
||||
}
|
||||
|
||||
const displayApps = row.app_list.slice(0, 4);
|
||||
const remainingCount = row.app_list.length - 4;
|
||||
|
||||
return (
|
||||
<div class="flex items-center space-x-2">
|
||||
{displayApps.map((app: any) => (
|
||||
<Tooltip key={app.key} title={app.title}>
|
||||
<Avatar
|
||||
src={app.icon}
|
||||
size="small"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Tooltip title={$t('site.group.moreApps', { count: remainingCount })}>
|
||||
<span class="text-xs text-gray-500 cursor-pointer">
|
||||
+{remainingCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $t('site.group.addonName'),
|
||||
field: 'addon_list',
|
||||
width: 300,
|
||||
slots: {
|
||||
default: ({ row }) => {
|
||||
if (!row.addon_list || row.addon_list.length === 0) {
|
||||
return <span class="text-gray-400">{$t('site.group.addonListEmpty')}</span>;
|
||||
}
|
||||
|
||||
const displayAddons = row.addon_list.slice(0, 4);
|
||||
const remainingCount = row.addon_list.length - 4;
|
||||
|
||||
return (
|
||||
<div class="flex items-center space-x-2">
|
||||
{displayAddons.map((addon: any) => (
|
||||
<Tooltip key={addon.key} title={addon.title}>
|
||||
<Avatar
|
||||
src={addon.icon}
|
||||
size="small"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Tooltip title={$t('site.group.moreAddons', { count: remainingCount })}>
|
||||
<span class="text-xs text-gray-500 cursor-pointer">
|
||||
+{remainingCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $t('site.group.createTime'),
|
||||
field: 'create_time',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: $t('common.action'),
|
||||
field: 'action',
|
||||
width: 150,
|
||||
slots: {
|
||||
default: ({ row }) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onActionClick('edit', row)}
|
||||
>
|
||||
<Icon icon="ant-design:edit-outlined" />
|
||||
{$t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={$t('site.group.deleteConfirm')}
|
||||
onConfirm={() => onActionClick('delete', row)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
{$t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
7
admin-vben/apps/web-antd/src/views/site/group/index.vue
Normal file
7
admin-vben/apps/web-antd/src/views/site/group/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SiteGroupList />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SiteGroupList from './list.vue';
|
||||
</script>
|
||||
99
admin-vben/apps/web-antd/src/views/site/group/list.vue
Normal file
99
admin-vben/apps/web-antd/src/views/site/group/list.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" setup>
|
||||
import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SiteApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Icon, Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Popconfirm } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteSiteGroup, getSiteGroupList } from '#/api/core/site';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSiteGroupList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'group_id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleAdd() {
|
||||
formDrawerApi.setData({});
|
||||
formDrawerApi.open();
|
||||
}
|
||||
|
||||
function onActionClick(actionType: string, row: SiteApi.SiteGroup) {
|
||||
switch (actionType) {
|
||||
case 'edit': {
|
||||
formDrawerApi.setData({ groupId: row.group_id });
|
||||
formDrawerApi.open();
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
handleDelete(row);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: SiteApi.SiteGroup) {
|
||||
try {
|
||||
await deleteSiteGroup(row.group_id);
|
||||
message.success($t('common.deleteSuccess'));
|
||||
gridApi.reload();
|
||||
} catch (error) {
|
||||
message.error($t('common.deleteFailed'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<Plus />
|
||||
{{ $t('site.group.addGroup') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
<FormDrawer />
|
||||
</Page>
|
||||
</template>
|
||||
204
admin-vben/apps/web-antd/src/views/site/group/modules/form.vue
Normal file
204
admin-vben/apps/web-antd/src/views/site/group/modules/form.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SiteApi } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { CheckboxGroup, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { addSiteGroup, editSiteGroup, getInstalledAddonList, getSiteGroupInfo } from '#/api/core/site';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const groupId = ref<number>();
|
||||
const loading = ref(false);
|
||||
const appList = ref<any[]>([]);
|
||||
const addonList = ref<any[]>([]);
|
||||
const selectedApps = ref<string[]>([]);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return groupId.value ? $t('common.edit') : $t('common.add');
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
|
||||
try {
|
||||
if (groupId.value) {
|
||||
await editSiteGroup(groupId.value, values);
|
||||
} else {
|
||||
await addSiteGroup(values);
|
||||
}
|
||||
|
||||
// Refresh menu after successful operation
|
||||
try {
|
||||
await menuRefresh();
|
||||
} catch (error) {
|
||||
console.warn('Menu refresh failed:', error);
|
||||
}
|
||||
|
||||
message.success($t('common.saveSuccess'));
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<{ groupId?: number }>();
|
||||
groupId.value = data?.groupId;
|
||||
|
||||
formApi.resetForm();
|
||||
await loadAddonData();
|
||||
|
||||
if (groupId.value) {
|
||||
await loadGroupInfo();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function loadAddonData() {
|
||||
try {
|
||||
const addons = await getInstalledAddonList();
|
||||
appList.value = addons.filter((item: any) => item.type === 'app');
|
||||
addonList.value = addons.filter((item: any) => item.type === 'addon');
|
||||
|
||||
// Update form schema with dynamic options
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'app',
|
||||
componentProps: {
|
||||
options: appList.value.map(item => ({
|
||||
label: item.title,
|
||||
value: item.key,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'addon',
|
||||
componentProps: {
|
||||
options: addonList.value.map(item => ({
|
||||
label: item.title,
|
||||
value: item.key,
|
||||
disabled: isAddonDisabled(item),
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load addon data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroupInfo() {
|
||||
if (!groupId.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const groupInfo = await getSiteGroupInfo(groupId.value);
|
||||
formApi.setValues(groupInfo);
|
||||
selectedApps.value = groupInfo.app || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load group info:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAddonDisabled(addon: any): boolean {
|
||||
if (!addon.support_app || addon.support_app.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any of the supported apps are selected
|
||||
const hasSupportedApp = addon.support_app.some((app: string) =>
|
||||
selectedApps.value.includes(app)
|
||||
);
|
||||
|
||||
return !hasSupportedApp;
|
||||
}
|
||||
|
||||
function handleAppChange(selectedValues: string[]) {
|
||||
selectedApps.value = selectedValues;
|
||||
|
||||
// Update addon disabled state
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'addon',
|
||||
componentProps: {
|
||||
options: addonList.value.map(item => ({
|
||||
label: item.title,
|
||||
value: item.key,
|
||||
disabled: isAddonDisabled(item),
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Remove selected addons that are no longer supported
|
||||
const currentAddonValues = formApi.getValues().addon || [];
|
||||
const validAddonValues = currentAddonValues.filter((addonKey: string) => {
|
||||
const addon = addonList.value.find(a => a.key === addonKey);
|
||||
return addon && !isAddonDisabled(addon);
|
||||
});
|
||||
|
||||
if (validAddonValues.length !== currentAddonValues.length) {
|
||||
formApi.setValues({ addon: validAddonValues });
|
||||
message.warning($t('site.group.addonRemovedDueToAppDependency'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddonClick(addonKey: string) {
|
||||
const addon = addonList.value.find(a => a.key === addonKey);
|
||||
if (addon && isAddonDisabled(addon)) {
|
||||
message.info($t('site.group.selectAppFirst'));
|
||||
}
|
||||
}
|
||||
|
||||
// Menu refresh function
|
||||
async function menuRefresh() {
|
||||
// This should call the menu refresh API
|
||||
// For now, we'll just wait a bit to simulate the refresh
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Spin :spinning="loading">
|
||||
<Form>
|
||||
<!-- App selection with dependency handling -->
|
||||
<template #app="slotProps">
|
||||
<CheckboxGroup
|
||||
v-bind="slotProps"
|
||||
@change="handleAppChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Addon selection with disabled state -->
|
||||
<template #addon="slotProps">
|
||||
<CheckboxGroup
|
||||
v-bind="slotProps"
|
||||
@click="handleAddonClick"
|
||||
/>
|
||||
</template>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user