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:
wanwu
2025-11-14 02:34:06 +08:00
parent e54041331a
commit de821ae5fd
1501 changed files with 60179 additions and 21496 deletions

View 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 业务逻辑与接口契约,完成后建议运行端到端契约测试以验证路由、参数与响应一致性。

View File

@@ -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'

View File

@@ -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'

View File

@@ -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');
}

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

View File

@@ -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}`);
};

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

View 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[];
}

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

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

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

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

View File

@@ -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');
}

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

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

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

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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..."
}
}
}
}
}

View File

@@ -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": "存储配置"
}
}
}

View File

@@ -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": {

View File

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

View File

@@ -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;
}

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

View File

@@ -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',
},
];

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

View 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' },
};

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

View 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,
},
},
];

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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

View File

@@ -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 };
},
},
},
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' },
},
];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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' },
},
];

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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' },
},
];

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'];

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

View File

@@ -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>

View File

@@ -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>

View 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>
),
},
},
];
}

View File

@@ -0,0 +1,7 @@
<template>
<DiyList />
</template>
<script lang="ts" setup>
import DiyList from './list.vue';
</script>

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

View File

@@ -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>

View File

@@ -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>

View 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 },
],
},
},
];
};

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

View File

@@ -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>

View 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' },
},
];

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

View File

@@ -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>

View File

@@ -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>

View 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' },
},
];

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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' },
},
];

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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' },
},
];

View File

@@ -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>

View File

@@ -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>

View 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>
),
},
},
];
}

View File

@@ -0,0 +1,7 @@
<template>
<SiteGroupList />
</template>
<script lang="ts" setup>
import SiteGroupList from './list.vue';
</script>

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

View 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