Merge remote-tracking branch 'yudao/master'

This commit is contained in:
dhb52 2023-11-23 08:47:06 +08:00
commit c2038b95bf
173 changed files with 14352 additions and 420 deletions

View File

@ -67,6 +67,7 @@ module.exports = defineConfig({
}
],
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off'
'vue/no-v-html': 'off',
'prettier/prettier': 'off' // 芋艿:默认关闭 prettier 的 ESLint 校验,因为我们使用的是 IDE 的 Prettier 插件
}
})

View File

@ -38,15 +38,15 @@
| 框架 | 说明 | 版本 |
|----------------------------------------------------------------------|------------------|--------|
| [Vue](https://staging-cn.vuejs.org/) | Vue 框架 | 3.3.4 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 4.4.11 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.4.0 |
| [Vue](https://staging-cn.vuejs.org/) | Vue 框架 | 3.3.8 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 4.5.0 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.4.2 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 的超集 | 5.2.2 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库 替代 vuex5 | 2.1.7 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 10.5.0 |
| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 9.5.0 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 10.6.1 |
| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 9.6.5 |
| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.2.5 |
| [unocss](https://uno.antfu.me/) | 原子 css | 0.56.5 |
| [unocss](https://uno.antfu.me/) | 原子 css | 0.57.4 |
| [iconify](https://icon-sets.iconify.design/) | 在线图标库 | 3.1.1 |
| [wangeditor](https://www.wangeditor.com/) | 富文本编辑器 | 5.1.23 |

View File

@ -60,18 +60,11 @@ export function createVitePlugins() {
}
}),
Components({
// 要搜索组件的目录的相对路径
dirs: ['src/components'],
// 组件的有效文件扩展名
extensions: ['vue', 'md'],
// 搜索子目录
deep: true,
include: [/\.vue$/, /\.vue\?vue/],
// 生成自定义 `auto-components.d.ts` 全局声明
dts: 'src/types/auto-components.d.ts',
// 自定义组件的解析器
resolvers: [ElementPlusResolver()],
exclude: [/[\\/]node_modules[\\/]/]
globs: ["src/components/**/**.{vue, md}", '!src/components/DiyEditor/components/mobile/**']
}),
EslintPlugin({
cache: false,

View File

@ -31,23 +31,23 @@
"@form-create/element-ui": "^3.1.24",
"@iconify/iconify": "^3.1.1",
"@videojs-player/vue": "^1.0.0",
"@vueuse/core": "^10.5.0",
"@vueuse/core": "^10.6.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.10",
"@zxcvbn-ts/core": "^3.0.4",
"animate.css": "^4.1.1",
"axios": "^1.6.0",
"axios": "^1.6.1",
"benz-amr-recorder": "^1.1.5",
"bpmn-js-token-simulation": "^0.10.0",
"camunda-bpmn-moddle": "^7.0.1",
"cropperjs": "^1.6.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.10",
"diagram-js": "^12.6.0",
"driver.js": "^1.3.0",
"diagram-js": "^12.8.0",
"driver.js": "^1.3.1",
"echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0",
"element-plus": "2.4.1",
"element-plus": "2.4.2",
"fast-xml-parser": "^4.3.2",
"highlight.js": "^11.9.0",
"jsencrypt": "^3.3.2",
@ -62,9 +62,9 @@
"steady-xml": "^0.1.0",
"url": "^0.11.3",
"video.js": "^7.21.5",
"vue": "^3.3.7",
"vue": "^3.3.8",
"vue-dompurify-html": "^4.1.4",
"vue-i18n": "^9.6.2",
"vue-i18n": "^9.6.5",
"vue-router": "^4.2.5",
"vue-types": "^5.1.1",
"vuedraggable": "^4.1.0",
@ -72,49 +72,49 @@
"xml-js": "^1.6.11"
},
"devDependencies": {
"@commitlint/cli": "^18.2.0",
"@commitlint/config-conventional": "^18.1.0",
"@iconify/json": "^2.2.135",
"@intlify/unplugin-vue-i18n": "^1.4.0",
"@commitlint/cli": "^18.4.1",
"@commitlint/config-conventional": "^18.4.0",
"@iconify/json": "^2.2.142",
"@intlify/unplugin-vue-i18n": "^1.5.0",
"@purge-icons/generated": "^0.9.0",
"@types/lodash-es": "^4.17.10",
"@types/node": "^20.8.9",
"@types/nprogress": "^0.2.2",
"@types/qrcode": "^1.5.4",
"@types/qs": "^6.9.9",
"@types/sortablejs": "^1.15.4",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"@unocss/transformer-variant-group": "^0.57.1",
"@unocss/eslint-config": "^0.57.1",
"@types/lodash-es": "^4.17.11",
"@types/node": "^20.9.0",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5",
"@types/qs": "^6.9.10",
"@types/sortablejs": "^1.15.5",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@unocss/transformer-variant-group": "^0.57.4",
"@unocss/eslint-config": "^0.57.4",
"@vitejs/plugin-legacy": "^4.1.1",
"@vitejs/plugin-vue": "^4.4.0",
"@vitejs/plugin-vue": "^4.4.1",
"@vitejs/plugin-vue-jsx": "^3.0.2",
"autoprefixer": "^10.4.16",
"bpmn-js": "8.9.0",
"bpmn-js-properties-panel": "0.46.0",
"consola": "^3.2.3",
"eslint": "^8.52.0",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-define-config": "^1.24.1",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-vue": "^9.18.1",
"lint-staged": "^15.0.2",
"lint-staged": "^15.1.0",
"postcss": "^8.4.31",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.9",
"prettier": "^3.0.3",
"prettier": "^3.1.0",
"rimraf": "^5.0.5",
"rollup": "^4.1.5",
"rollup": "^4.4.1",
"sass": "^1.69.5",
"stylelint": "^15.11.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^13.0.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-order": "^6.0.3",
"terser": "^5.23.0",
"terser": "^5.24.0",
"typescript": "5.2.2",
"unocss": "^0.57.1",
"unocss": "^0.57.4",
"unplugin-auto-import": "^0.16.7",
"unplugin-element-plus": "^0.8.0",
"unplugin-vue-components": "^0.25.2",

View File

@ -0,0 +1,65 @@
/*
* @Author: zyna
* @Date: 2023-11-05 13:34:41
* @LastEditTime: 2023-11-11 16:20:19
* @FilePath: \yudao-ui-admin-vue3\src\api\crm\contact\index.ts
* @Description:
*/
import request from '@/config/axios'
export interface ContactVO {
name: string
nextTime: Date
mobile: string
telephone: string
email: string
post: string
customerId: number
address: string
remark: string
ownerUserId: string
lastTime: Date
id: number
parentId: number
qq: number
webchat: string
sex: number
policyMakers: boolean
creatorName: string
updateTime?: Date
createTime?: Date
customerName: string
}
// 查询crm联系人列表
export const getContactPage = async (params) => {
return await request.get({ url: `/crm/contact/page`, params })
}
// 查询crm联系人详情
export const getContact = async (id: number) => {
return await request.get({ url: `/crm/contact/get?id=` + id })
}
// 新增crm联系人
export const createContact = async (data: ContactVO) => {
return await request.post({ url: `/crm/contact/create`, data })
}
// 修改crm联系人
export const updateContact = async (data: ContactVO) => {
return await request.put({ url: `/crm/contact/update`, data })
}
// 删除crm联系人
export const deleteContact = async (id: number) => {
return await request.delete({ url: `/crm/contact/delete?id=` + id })
}
// 导出crm联系人 Excel
export const exportContact = async (params) => {
return await request.download({ url: `/crm/contact/export-excel`, params })
}
export const simpleAlllist = async () => {
return await request.get({ url: `/crm/contact/simpleAlllist` })
}

View File

@ -1,13 +1,14 @@
import request from '@/config/axios'
export interface CustomerVO {
id: number
id?: number
name: string
industryId: number
level: number
source: number
followUpStatus: boolean
lockStatus: boolean
followUpStatus?: boolean
lockStatus?: boolean
dealStatus?: boolean
mobile: string
telephone: string
website: string
@ -16,13 +17,20 @@ export interface CustomerVO {
email: string
description: string
remark: string
ownerUserId: number
roUserIds: string
rwUserIds: string
areaId: number
ownerUserId?: number
ownerUserName?: string
ownerUserDept?: string
roUserIds?: string
rwUserIds?: string
areaId?: number
areaName?: string
detailAddress: string
contactLastTime: Date
contactLastTime?: Date
contactNextTime: Date
createTime?: Date
updateTime?: Date
creator?: string
creatorName?: string
}
// 查询客户列表

View File

@ -0,0 +1,35 @@
import request from '@/config/axios'
export interface CustomerLimitConfigVO {
id?: number
type?: number
userIds?: string
deptIds?: string
maxCount?: number
dealCountEnabled?: boolean
}
// 查询客户限制配置列表
export const getCustomerLimitConfigPage = async (params) => {
return await request.get({ url: `/crm/customer-limit-config/page`, params })
}
// 查询客户限制配置详情
export const getCustomerLimitConfig = async (id: number) => {
return await request.get({ url: `/crm/customer-limit-config/get?id=` + id })
}
// 新增客户限制配置
export const createCustomerLimitConfig = async (data: CustomerLimitConfigVO) => {
return await request.post({ url: `/crm/customer-limit-config/create`, data })
}
// 修改客户限制配置
export const updateCustomerLimitConfig = async (data: CustomerLimitConfigVO) => {
return await request.put({ url: `/crm/customer-limit-config/update`, data })
}
// 删除客户限制配置
export const deleteCustomerLimitConfig = async (id: number) => {
return await request.delete({ url: `/crm/customer-limit-config/delete?id=` + id })
}

View File

@ -0,0 +1,19 @@
import request from '@/config/axios'
export interface CustomerPoolConfigVO {
enabled?: boolean
contactExpireDays?: number
dealExpireDays?: number
notifyEnabled?: boolean
notifyDays: number
}
// 获取客户公海规则设置
export const getCustomerPoolConfig = async () => {
return await request.get({ url: `/crm/customer-pool-config/get` })
}
// 更新客户公海规则设置
export const updateCustomerPoolConfig = async (data: ConfigVO) => {
return await request.put({ url: `/crm/customer-pool-config/update`, data })
}

View File

@ -0,0 +1,48 @@
import request from '@/config/axios'
export interface PermissionVO {
id?: number // 数据权限编号
userId: number | undefined // 用户编号
bizType: number | undefined // Crm 类型
bizId: number | undefined // Crm 类型数据编号
level: number | undefined // 权限级别
deptName?: string // 部门名称 // 岗位名称数组 TODO @puhui999数组
nickname?: string // 用户昵称
postNames?: string // 岗位名称数组 TODO @puhui999数组
createTime?: Date
}
// 查询团队成员列表
export const getPermissionList = async (params) => {
return await request.get({ url: `/crm/permission/list`, params })
}
// 新增团队成员
export const createPermission = async (data: PermissionVO) => {
return await request.post({ url: `/crm/permission/add`, data })
}
// 修改团队成员权限级别
export const updatePermission = async (data) => {
return await request.put({ url: `/crm/permission/update`, data })
}
// 删除团队成员
export const deletePermission = async (params) => {
return await request.delete({ url: '/crm/permission/delete', params })
}
// 退出团队
export const quitTeam = async (id) => {
return await request.delete({ url: '/crm/permission/quit-team?id=' + id })
}
// 领取公海数据
export const receive = async (data: { bizType: number; bizId: number }) => {
return await request.put({ url: `/crm/permission/receive`, data })
}
// 数据放入公海
export const putPool = async (data: { bizType: number; bizId: number }) => {
return await request.put({ url: `/crm/permission/put-pool`, data })
}

View File

@ -0,0 +1,43 @@
import request from '@/config/axios'
export interface ProductVO {
id: number
name: string
no: string
unit: string
price: number
status: number
categoryId: number
description: string
ownerUserId: number
}
// 查询产品列表
export const getProductPage = async (params) => {
return await request.get({ url: `/crm/product/page`, params })
}
// 查询产品详情
export const getProduct = async (id: number) => {
return await request.get({ url: `/crm/product/get?id=` + id })
}
// 新增产品
export const createProduct = async (data: ProductVO) => {
return await request.post({ url: `/crm/product/create`, data })
}
// 修改产品
export const updateProduct = async (data: ProductVO) => {
return await request.put({ url: `/crm/product/update`, data })
}
// 删除产品
export const deleteProduct = async (id: number) => {
return await request.delete({ url: `/crm/product/delete?id=` + id })
}
// 导出产品 Excel
export const exportProduct = async (params) => {
return await request.download({ url: `/crm/product/export-excel`, params })
}

View File

@ -0,0 +1,33 @@
import request from '@/config/axios'
// TODO @zange挪到 product 下,建个 category 包,挪进去哈;
export interface ProductCategoryVO {
id: number
name: string
parentId: number
}
// 查询产品分类详情
export const getProductCategory = async (id: number) => {
return await request.get({ url: `/crm/product-category/get?id=` + id })
}
// 新增产品分类
export const createProductCategory = async (data: ProductCategoryVO) => {
return await request.post({ url: `/crm/product-category/create`, data })
}
// 修改产品分类
export const updateProductCategory = async (data: ProductCategoryVO) => {
return await request.put({ url: `/crm/product-category/update`, data })
}
// 删除产品分类
export const deleteProductCategory = async (id: number) => {
return await request.delete({ url: `/crm/product-category/delete?id=` + id })
}
// 产品分类列表
export const getProductCategoryList = async (params) => {
return await request.get({ url: `/crm/product-category/list`, params })
}

View File

@ -2,7 +2,7 @@ import request from '@/config/axios'
export interface ReceivablePlanVO {
id: number
indexNo: number
period: number
receivableId: number
status: number
checkStatus: string

View File

@ -67,6 +67,11 @@ export type CodegenCreateListReqVO = {
tableNames: string[]
}
// 查询列表代码生成表定义
export const getCodegenTableList = (dataSourceConfigId: number) => {
return request.get({ url: '/infra/codegen/table/list?dataSourceConfigId=' + dataSourceConfigId })
}
// 查询列表代码生成表定义
export const getCodegenTablePage = (params: PageParam) => {
return request.get({ url: '/infra/codegen/table/page', params })
@ -92,11 +97,6 @@ export const syncCodegenFromDB = (id: number) => {
return request.put({ url: '/infra/codegen/sync-from-db?tableId=' + id })
}
// 基于 SQL 建表语句,同步数据库的表和字段定义
export const syncCodegenFromSQL = (id: number, sql: string) => {
return request.put({ url: '/infra/codegen/sync-from-sql?tableId=' + id + '&sql=' + sql })
}
// 预览生成代码
export const previewCodegen = (id: number) => {
return request.get({ url: '/infra/codegen/preview?tableId=' + id })

View File

@ -0,0 +1,40 @@
import request from '@/config/axios'
export interface Demo01ContactVO {
id: number
name: string
sex: number
birthday: Date
description: string
avatar: string
}
// 查询示例联系人分页
export const getDemo01ContactPage = async (params) => {
return await request.get({ url: `/infra/demo01-contact/page`, params })
}
// 查询示例联系人详情
export const getDemo01Contact = async (id: number) => {
return await request.get({ url: `/infra/demo01-contact/get?id=` + id })
}
// 新增示例联系人
export const createDemo01Contact = async (data: Demo01ContactVO) => {
return await request.post({ url: `/infra/demo01-contact/create`, data })
}
// 修改示例联系人
export const updateDemo01Contact = async (data: Demo01ContactVO) => {
return await request.put({ url: `/infra/demo01-contact/update`, data })
}
// 删除示例联系人
export const deleteDemo01Contact = async (id: number) => {
return await request.delete({ url: `/infra/demo01-contact/delete?id=` + id })
}
// 导出示例联系人 Excel
export const exportDemo01Contact = async (params) => {
return await request.download({ url: `/infra/demo01-contact/export-excel`, params })
}

View File

@ -0,0 +1,37 @@
import request from '@/config/axios'
export interface Demo02CategoryVO {
id: number
name: string
parentId: number
}
// 查询示例分类列表
export const getDemo02CategoryList = async (params) => {
return await request.get({ url: `/infra/demo02-category/list`, params })
}
// 查询示例分类详情
export const getDemo02Category = async (id: number) => {
return await request.get({ url: `/infra/demo02-category/get?id=` + id })
}
// 新增示例分类
export const createDemo02Category = async (data: Demo02CategoryVO) => {
return await request.post({ url: `/infra/demo02-category/create`, data })
}
// 修改示例分类
export const updateDemo02Category = async (data: Demo02CategoryVO) => {
return await request.put({ url: `/infra/demo02-category/update`, data })
}
// 删除示例分类
export const deleteDemo02Category = async (id: number) => {
return await request.delete({ url: `/infra/demo02-category/delete?id=` + id })
}
// 导出示例分类 Excel
export const exportDemo02Category = async (params) => {
return await request.download({ url: `/infra/demo02-category/export-excel`, params })
}

View File

@ -0,0 +1,91 @@
import request from '@/config/axios'
export interface Demo03StudentVO {
id: number
name: string
sex: number
birthday: Date
description: string
}
// 查询学生分页
export const getDemo03StudentPage = async (params) => {
return await request.get({ url: `/infra/demo03-student/page`, params })
}
// 查询学生详情
export const getDemo03Student = async (id: number) => {
return await request.get({ url: `/infra/demo03-student/get?id=` + id })
}
// 新增学生
export const createDemo03Student = async (data: Demo03StudentVO) => {
return await request.post({ url: `/infra/demo03-student/create`, data })
}
// 修改学生
export const updateDemo03Student = async (data: Demo03StudentVO) => {
return await request.put({ url: `/infra/demo03-student/update`, data })
}
// 删除学生
export const deleteDemo03Student = async (id: number) => {
return await request.delete({ url: `/infra/demo03-student/delete?id=` + id })
}
// 导出学生 Excel
export const exportDemo03Student = async (params) => {
return await request.download({ url: `/infra/demo03-student/export-excel`, params })
}
// ==================== 子表(学生课程) ====================
// 获得学生课程分页
export const getDemo03CoursePage = async (params) => {
return await request.get({ url: `/infra/demo03-student/demo03-course/page`, params })
}
// 新增学生课程
export const createDemo03Course = async (data) => {
return await request.post({ url: `/infra/demo03-student/demo03-course/create`, data })
}
// 修改学生课程
export const updateDemo03Course = async (data) => {
return await request.put({ url: `/infra/demo03-student/demo03-course/update`, data })
}
// 删除学生课程
export const deleteDemo03Course = async (id: number) => {
return await request.delete({ url: `/infra/demo03-student/demo03-course/delete?id=` + id })
}
// 获得学生课程
export const getDemo03Course = async (id: number) => {
return await request.get({ url: `/infra/demo03-student/demo03-course/get?id=` + id })
}
// ==================== 子表(学生班级) ====================
// 获得学生班级分页
export const getDemo03GradePage = async (params) => {
return await request.get({ url: `/infra/demo03-student/demo03-grade/page`, params })
}
// 新增学生班级
export const createDemo03Grade = async (data) => {
return await request.post({ url: `/infra/demo03-student/demo03-grade/create`, data })
}
// 修改学生班级
export const updateDemo03Grade = async (data) => {
return await request.put({ url: `/infra/demo03-student/demo03-grade/update`, data })
}
// 删除学生班级
export const deleteDemo03Grade = async (id: number) => {
return await request.delete({ url: `/infra/demo03-student/demo03-grade/delete?id=` + id })
}
// 获得学生班级
export const getDemo03Grade = async (id: number) => {
return await request.get({ url: `/infra/demo03-student/demo03-grade/get?id=` + id })
}

View File

@ -0,0 +1,53 @@
import request from '@/config/axios'
export interface Demo03StudentVO {
id: number
name: string
sex: number
birthday: Date
description: string
}
// 查询学生分页
export const getDemo03StudentPage = async (params) => {
return await request.get({ url: `/infra/demo03-student/page`, params })
}
// 查询学生详情
export const getDemo03Student = async (id: number) => {
return await request.get({ url: `/infra/demo03-student/get?id=` + id })
}
// 新增学生
export const createDemo03Student = async (data: Demo03StudentVO) => {
return await request.post({ url: `/infra/demo03-student/create`, data })
}
// 修改学生
export const updateDemo03Student = async (data: Demo03StudentVO) => {
return await request.put({ url: `/infra/demo03-student/update`, data })
}
// 删除学生
export const deleteDemo03Student = async (id: number) => {
return await request.delete({ url: `/infra/demo03-student/delete?id=` + id })
}
// 导出学生 Excel
export const exportDemo03Student = async (params) => {
return await request.download({ url: `/infra/demo03-student/export-excel`, params })
}
// ==================== 子表(学生课程) ====================
// 获得学生课程列表
export const getDemo03CourseListByStudentId = async (studentId) => {
return await request.get({ url: `/infra/demo03-student/demo03-course/list-by-student-id?studentId=` + studentId })
}
// ==================== 子表(学生班级) ====================
// 获得学生班级
export const getDemo03GradeByStudentId = async (studentId) => {
return await request.get({ url: `/infra/demo03-student/demo03-grade/get-by-student-id?studentId=` + studentId })
}

View File

@ -0,0 +1,53 @@
import request from '@/config/axios'
export interface Demo03StudentVO {
id: number
name: string
sex: number
birthday: Date
description: string
}
// 查询学生分页
export const getDemo03StudentPage = async (params) => {
return await request.get({ url: `/infra/demo03-student/page`, params })
}
// 查询学生详情
export const getDemo03Student = async (id: number) => {
return await request.get({ url: `/infra/demo03-student/get?id=` + id })
}
// 新增学生
export const createDemo03Student = async (data: Demo03StudentVO) => {
return await request.post({ url: `/infra/demo03-student/create`, data })
}
// 修改学生
export const updateDemo03Student = async (data: Demo03StudentVO) => {
return await request.put({ url: `/infra/demo03-student/update`, data })
}
// 删除学生
export const deleteDemo03Student = async (id: number) => {
return await request.delete({ url: `/infra/demo03-student/delete?id=` + id })
}
// 导出学生 Excel
export const exportDemo03Student = async (params) => {
return await request.download({ url: `/infra/demo03-student/export-excel`, params })
}
// ==================== 子表(学生课程) ====================
// 获得学生课程列表
export const getDemo03CourseListByStudentId = async (studentId) => {
return await request.get({ url: `/infra/demo03-student/demo03-course/list-by-student-id?studentId=` + studentId })
}
// ==================== 子表(学生班级) ====================
// 获得学生班级
export const getDemo03GradeByStudentId = async (studentId) => {
return await request.get({ url: `/infra/demo03-student/demo03-grade/get-by-student-id?studentId=` + studentId })
}

View File

@ -27,6 +27,11 @@ export const getTenantIdByName = (name: string) => {
return request.get({ url: '/system/tenant/get-id-by-name?name=' + name })
}
// 使用租户域名,获得租户信息
export const getTenantByWebsite = (website: string) => {
return request.get({ url: '/system/tenant/get-by-website?website=' + website })
}
// 登出
export const loginOut = () => {
return request.post({ url: '/system/auth/logout' })

View File

@ -0,0 +1,12 @@
import request from '@/config/axios'
export interface Favorite {
id?: number
userId?: string // 用户编号
spuId?: number | null // 商品 SPU 编号
}
// 获得 ProductFavorite 列表
export const getFavoritePage = (params: PageParam) => {
return request.get({ url: '/product/favorite/page', params })
}

View File

@ -57,7 +57,7 @@ export const updateCombinationActivity = async (data: CombinationActivityVO) =>
// 关闭拼团活动
export const closeCombinationActivity = async (id: number) => {
return await request.put({ url: '/promotion/bargain-combination/close?id=' + id })
return await request.put({ url: '/promotion/combination-activity/close?id=' + id })
}
// 删除拼团活动

View File

@ -0,0 +1,45 @@
import request from '@/config/axios'
export interface DiyPageVO {
id?: number
templateId?: number
name: string
remark: string
previewImageUrls: string[]
property: string
}
// 查询装修页面列表
export const getDiyPagePage = async (params: any) => {
return await request.get({ url: `/promotion/diy-page/page`, params })
}
// 查询装修页面详情
export const getDiyPage = async (id: number) => {
return await request.get({ url: `/promotion/diy-page/get?id=` + id })
}
// 新增装修页面
export const createDiyPage = async (data: DiyPageVO) => {
return await request.post({ url: `/promotion/diy-page/create`, data })
}
// 修改装修页面
export const updateDiyPage = async (data: DiyPageVO) => {
return await request.put({ url: `/promotion/diy-page/update`, data })
}
// 删除装修页面
export const deleteDiyPage = async (id: number) => {
return await request.delete({ url: `/promotion/diy-page/delete?id=` + id })
}
// 获得装修页面属性
export const getDiyPageProperty = async (id: number) => {
return await request.get({ url: `/promotion/diy-page/get-property?id=` + id })
}
// 更新装修页面属性
export const updateDiyPageProperty = async (data: DiyPageVO) => {
return await request.put({ url: `/promotion/diy-page/update-property`, data })
}

View File

@ -0,0 +1,58 @@
import request from '@/config/axios'
import { DiyPageVO } from '@/api/mall/promotion/diy/page'
export interface DiyTemplateVO {
id?: number
name: string
used: boolean
usedTime?: Date
remark: string
previewImageUrls: string[]
property: string
}
export interface DiyTemplatePropertyVO extends DiyTemplateVO {
pages: DiyPageVO[]
}
// 查询装修模板列表
export const getDiyTemplatePage = async (params: any) => {
return await request.get({ url: `/promotion/diy-template/page`, params })
}
// 查询装修模板详情
export const getDiyTemplate = async (id: number) => {
return await request.get({ url: `/promotion/diy-template/get?id=` + id })
}
// 新增装修模板
export const createDiyTemplate = async (data: DiyTemplateVO) => {
return await request.post({ url: `/promotion/diy-template/create`, data })
}
// 修改装修模板
export const updateDiyTemplate = async (data: DiyTemplateVO) => {
return await request.put({ url: `/promotion/diy-template/update`, data })
}
// 删除装修模板
export const deleteDiyTemplate = async (id: number) => {
return await request.delete({ url: `/promotion/diy-template/delete?id=` + id })
}
// 使用装修模板
export const useDiyTemplate = async (id: number) => {
return await request.put({ url: `/promotion/diy-template/use?id=` + id })
}
// 获得装修模板属性
export const getDiyTemplateProperty = async (id: number) => {
return await request.get<DiyTemplatePropertyVO>({
url: `/promotion/diy-template/get-property?id=` + id
})
}
// 更新装修模板属性
export const updateDiyTemplateProperty = async (data: DiyTemplateVO) => {
return await request.put({ url: `/promotion/diy-template/update-property`, data })
}

View File

@ -0,0 +1,25 @@
import request from '@/config/axios'
export interface DemoTransferVO {
price: number
type: number
userName: string
alipayLogonId: string
openid: string
}
// 创建示例转账单
export function createDemoTransfer(data: DemoTransferVO) {
return request.post({
url: '/pay/demo-transfer/create',
data: data
})
}
// 获得示例订单分页
export function getDemoTransferPage(query: PageParam) {
return request.get({
url: '/pay/demo-transfer/page',
params: query
})
}

View File

@ -0,0 +1,27 @@
import request from '@/config/axios'
export interface TransferVO {
appId: number
channelCode: string
merchantTransferId: string
type: number
price: number
subject: string
userName: string
alipayLogonId: string
openid: string
}
// 新增转账单
export const createTransfer = async (data: TransferVO) => {
return await request.post({ url: `/pay/transfer/create`, data })
}
// 查询转账单列表
export const getTransferPage = async (params) => {
return await request.get({ url: `/pay/transfer/page`, params })
}
export const getTransfer = async (id: number) => {
return await request.get({ url: '/pay/transfer/get?id=' + id })
}

View File

@ -15,8 +15,6 @@ export interface SmsLogVO {
userType: number | null
sendStatus: number | null
sendTime: Date | null
sendCode: number | null
sendMsg: string
apiSendCode: string
apiSendMsg: string
apiRequestId: string

View File

@ -22,6 +22,11 @@ export const getUserPage = (params: PageParam) => {
return request.get({ url: '/system/user/page', params })
}
// 查询所有用户列表
export const getAllUser = () => {
return request.get({ url: '/system/user/all' })
}
// 查询用户详情
export const getUser = (id: number) => {
return request.get({ url: '/system/user/get?id=' + id })

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,34 @@
<template>
<el-input v-model="color">
<template #prepend>
<el-color-picker v-model="color" :predefine="PREDEFINE_COLORS" />
</template>
</el-input>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes'
import { PREDEFINE_COLORS } from '@/utils/color'
//
defineOptions({ name: 'ColorInput' })
const props = defineProps({
modelValue: propTypes.string.def('')
})
const emit = defineEmits(['update:modelValue'])
const color = computed({
get: () => {
return props.modelValue
},
set: (val: string) => {
emit('update:modelValue', val)
}
})
</script>
<style scoped lang="scss">
:deep(.el-input-group__prepend) {
padding: 0;
}
</style>

View File

@ -0,0 +1,235 @@
<template>
<div :class="['component', { active: active }]">
<div
:style="{
...style
}"
>
<component :is="component.id" :property="component.property" />
</div>
<div class="component-wrap">
<!-- 左侧组件名 -->
<div class="component-name" v-if="component.name">
{{ component.name }}
</div>
<!-- 左侧组件操作工具栏 -->
<div class="component-toolbar" v-if="showToolbar && component.name && active">
<VerticalButtonGroup type="primary">
<el-tooltip content="上移" placement="right">
<el-button :disabled="!canMoveUp" @click.stop="handleMoveComponent(-1)">
<Icon icon="ep:arrow-up" />
</el-button>
</el-tooltip>
<el-tooltip content="下移" placement="right">
<el-button :disabled="!canMoveDown" @click.stop="handleMoveComponent(1)">
<Icon icon="ep:arrow-down" />
</el-button>
</el-tooltip>
<el-tooltip content="复制" placement="right">
<el-button @click.stop="handleCopyComponent()">
<Icon icon="ep:copy-document" />
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="right">
<el-button @click.stop="handleDeleteComponent()">
<Icon icon="ep:delete" />
</el-button>
</el-tooltip>
</VerticalButtonGroup>
</div>
</div>
</div>
</template>
<script lang="ts">
//
import { components } from '../components/mobile/index'
export default {
components: { ...components }
}
</script>
<script setup lang="ts">
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
import { propTypes } from '@/utils/propTypes'
import { object } from 'vue-types'
/**
* 组件容器
* 用于包裹组件为组件提供 背景外边距内边距边框等样式
*/
defineOptions({ name: 'ComponentContainer' })
type DiyComponentWithStyle = DiyComponent<any> & { property: { style?: ComponentStyle } }
const props = defineProps({
component: object<DiyComponentWithStyle>().isRequired,
active: propTypes.bool.def(false),
canMoveUp: propTypes.bool.def(false),
canMoveDown: propTypes.bool.def(false),
showToolbar: propTypes.bool.def(true)
})
/**
* 组件样式
*/
const style = computed(() => {
let componentStyle = props.component.property.style
if (!componentStyle) {
return {}
}
return {
marginTop: `${componentStyle.marginTop || 0}px`,
marginBottom: `${componentStyle.marginBottom || 0}px`,
marginLeft: `${componentStyle.marginLeft || 0}px`,
marginRight: `${componentStyle.marginRight || 0}px`,
paddingTop: `${componentStyle.paddingTop || 0}px`,
paddingRight: `${componentStyle.paddingRight || 0}px`,
paddingBottom: `${componentStyle.paddingBottom || 0}px`,
paddingLeft: `${componentStyle.paddingLeft || 0}px`,
borderTopLeftRadius: `${componentStyle.borderTopLeftRadius || 0}px`,
borderTopRightRadius: `${componentStyle.borderTopRightRadius || 0}px`,
borderBottomRightRadius: `${componentStyle.borderBottomRightRadius || 0}px`,
borderBottomLeftRadius: `${componentStyle.borderBottomLeftRadius || 0}px`,
overflow: 'hidden',
background:
componentStyle.bgType === 'color' ? componentStyle.bgColor : `url(${componentStyle.bgImg})`
}
})
const emits = defineEmits<{
(e: 'move', direction: number): void
(e: 'copy'): void
(e: 'delete'): void
}>()
/**
* 移动组件
* @param direction 移动方向
*/
const handleMoveComponent = (direction: number) => {
emits('move', direction)
}
/**
* 复制组件
*/
const handleCopyComponent = () => {
emits('copy')
}
/**
* 删除组件
*/
const handleDeleteComponent = () => {
emits('delete')
}
</script>
<style scoped lang="scss">
$active-border-width: 2px;
$hover-border-width: 1px;
$name-position: -85px;
$toolbar-position: -55px;
/* 组件 */
.component {
position: relative;
cursor: move;
.component-wrap {
position: absolute;
top: 0;
left: -$active-border-width;
display: block;
width: 100%;
height: 100%;
/* 鼠标放到组件上时 */
&:hover {
border: $hover-border-width dashed var(--el-color-primary);
box-shadow: 0 0 5px 0 rgb(24 144 255 / 30%);
.component-name {
top: $hover-border-width;
/* 防止加了边框之后,位置移动 */
left: $name-position - $hover-border-width;
}
}
/* 左侧:组件名称 */
.component-name {
position: absolute;
top: $active-border-width;
left: $name-position;
display: block;
width: 80px;
height: 25px;
font-size: 12px;
line-height: 25px;
text-align: center;
background: #fff;
box-shadow:
0 0 4px #00000014,
0 2px 6px #0000000f,
0 4px 8px 2px #0000000a;
/* 右侧小三角 */
&::after {
position: absolute;
top: 7.5px;
right: -10px;
width: 0;
height: 0;
border: 5px solid transparent;
border-left-color: #fff;
content: ' ';
}
}
/* 右侧:组件操作工具栏 */
.component-toolbar {
position: absolute;
top: 0;
right: $toolbar-position;
display: none;
/* 左侧小三角 */
&::before {
position: absolute;
top: 10px;
left: -10px;
width: 0;
height: 0;
border: 5px solid transparent;
border-right-color: #2d8cf0;
content: ' ';
}
}
}
/* 组件选中时 */
&.active {
margin-bottom: 4px;
.component-wrap {
margin-bottom: $active-border-width + $active-border-width;
border: $active-border-width solid var(--el-color-primary) !important;
box-shadow: 0 0 10px 0 rgb(24 144 255 / 30%);
.component-name {
top: 0 !important;
/* 防止加了边框之后,位置移动 */
left: $name-position - $active-border-width !important;
color: #fff;
background: var(--el-color-primary);
&::after {
border-left-color: var(--el-color-primary);
}
}
.component-toolbar {
display: block;
}
}
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<el-tabs stretch>
<el-tab-pane label="内容">
<slot></slot>
</el-tab-pane>
<el-tab-pane label="样式" lazy>
<el-card header="组件样式" class="property-group">
<el-form :model="formData" label-width="80px">
<el-form-item label="组件背景" prop="bgType">
<el-radio-group v-model="formData.bgType">
<el-radio label="color">纯色</el-radio>
<el-radio label="img">图片</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="选择颜色" prop="bgColor" v-if="formData.bgType === 'color'">
<ColorInput v-model="formData.bgColor" />
</el-form-item>
<el-form-item label="上传图片" prop="bgImg" v-else>
<UploadImg v-model="formData.bgImg" :limit="1">
<template #tip>建议宽度 750px</template>
</UploadImg>
</el-form-item>
<el-tree :data="treeData" :expand-on-click-node="false">
<template #default="{ node, data }">
<el-form-item
:label="data.label"
:prop="data.prop"
:label-width="node.level === 1 ? '80px' : '62px'"
class="w-full m-b-0!"
>
<el-slider
v-model="formData[data.prop]"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
@input="handleSliderChange(data.prop)"
/>
</el-form-item>
</template>
</el-tree>
<slot name="style" :formData="formData"></slot>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import { ComponentStyle, usePropertyForm } from '@/components/DiyEditor/util'
/**
* 组件容器属性
* 用于包裹组件为组件提供 背景外边距内边距边框等样式
*/
defineOptions({ name: 'ComponentContainer' })
const props = defineProps<{ modelValue: ComponentStyle }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
const treeData = [
{
label: '外部边距',
prop: 'margin',
children: [
{
label: '上',
prop: 'marginTop'
},
{
label: '右',
prop: 'marginRight'
},
{
label: '下',
prop: 'marginBottom'
},
{
label: '左',
prop: 'marginLeft'
}
]
},
{
label: '内部边距',
prop: 'padding',
children: [
{
label: '上',
prop: 'paddingTop'
},
{
label: '右',
prop: 'paddingRight'
},
{
label: '下',
prop: 'paddingBottom'
},
{
label: '左',
prop: 'paddingLeft'
}
]
},
{
label: '边框圆角',
prop: 'borderRadius',
children: [
{
label: '上左',
prop: 'borderTopLeftRadius'
},
{
label: '上右',
prop: 'borderTopRightRadius'
},
{
label: '下右',
prop: 'borderBottomRightRadius'
},
{
label: '下左',
prop: 'borderBottomLeftRadius'
}
]
}
]
const handleSliderChange = (prop: string) => {
switch (prop) {
case 'margin':
formData.value.marginTop = formData.value.margin
formData.value.marginRight = formData.value.margin
formData.value.marginBottom = formData.value.margin
formData.value.marginLeft = formData.value.margin
break
case 'padding':
formData.value.paddingTop = formData.value.padding
formData.value.paddingRight = formData.value.padding
formData.value.paddingBottom = formData.value.padding
formData.value.paddingLeft = formData.value.padding
break
case 'borderRadius':
formData.value.borderTopLeftRadius = formData.value.borderRadius
formData.value.borderTopRightRadius = formData.value.borderRadius
formData.value.borderBottomRightRadius = formData.value.borderRadius
formData.value.borderBottomLeftRadius = formData.value.borderRadius
break
}
}
</script>
<style scoped lang="scss">
:deep(.el-slider__runway) {
margin-right: 16px;
}
:deep(.el-input-number) {
width: 50px;
}
</style>

View File

@ -0,0 +1,205 @@
<template>
<el-aside class="editor-left" width="261px">
<el-scrollbar>
<el-collapse v-model="extendGroups">
<el-collapse-item
v-for="group in groups"
:key="group.name"
:name="group.name"
:title="group.name"
>
<draggable
class="component-container"
ghost-class="draggable-ghost"
item-key="index"
:list="group.components"
:sort="false"
:group="{ name: 'component', pull: 'clone', put: false }"
:clone="handleCloneComponent"
:animation="200"
:force-fallback="true"
>
<template #item="{ element }">
<div>
<div class="drag-placement">组件放置区域</div>
<div class="component">
<Icon :icon="element.icon" :size="32" />
<span class="mt-4px text-12px">{{ element.name }}</span>
</div>
</div>
</template>
</draggable>
</el-collapse-item>
</el-collapse>
</el-scrollbar>
</el-aside>
</template>
<script setup lang="ts">
import draggable from 'vuedraggable'
import { componentConfigs } from '../components/mobile/index'
import { cloneDeep } from 'lodash-es'
import { DiyComponent, DiyComponentLibrary } from '@/components/DiyEditor/util'
/** 组件库 */
defineOptions({ name: 'ComponentLibrary' })
//
const props = defineProps<{
list: DiyComponentLibrary[]
}>()
const groups = reactive<any[]>([])
//
const extendGroups = reactive<string[]>([])
watch(
() => props.list,
() => {
//
extendGroups.length = 0
groups.length = 0
//
props.list.forEach((group) => {
//
if (group.extended) {
extendGroups.push(group.name)
}
//
const components = group.components
.map((name) => componentConfigs[name] as DiyComponent<any>)
.filter((component) => component)
if (components.length > 0) {
groups.push({
name: group.name,
components
})
}
})
},
{
immediate: true
}
)
//
const handleCloneComponent = (component: DiyComponent<any>) => {
return cloneDeep(component)
}
</script>
<style scoped lang="scss">
.editor-left {
z-index: 1;
flex-shrink: 0;
box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
:deep(.el-collapse) {
border-top: none;
}
:deep(.el-collapse-item__wrap) {
border-bottom: none;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
:deep(.el-collapse-item__header) {
height: 32px;
padding: 0 24px;
line-height: 32px;
background-color: var(--el-bg-color-page);
border-bottom: none;
}
.component-container {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.component {
display: flex;
width: 86px;
height: 86px;
cursor: move;
border-right: 1px solid var(--el-border-color-lighter);
border-bottom: 1px solid var(--el-border-color-lighter);
flex-direction: column;
align-items: center;
justify-content: center;
.el-icon {
margin-bottom: 4px;
color: gray;
}
}
.component.active,
.component:hover {
color: var(--el-color-white);
background: var(--el-color-primary);
.el-icon {
color: var(--el-color-white);
}
}
.component:nth-of-type(3n) {
border-right: none;
}
}
/* 拖拽占位提示,默认不显示 */
.drag-placement {
display: none;
color: #fff;
}
.drag-area {
/* 拖拽到手机区域时的样式 */
.draggable-ghost {
display: flex;
width: 100%;
height: 40px;
/* 条纹背景 */
background: linear-gradient(
45deg,
#91a8d5 0,
#91a8d5 10%,
#94b4eb 10%,
#94b4eb 50%,
#91a8d5 50%,
#91a8d5 60%,
#94b4eb 60%,
#94b4eb
);
background-size: 1rem 1rem;
transition: all 0.5s;
justify-content: center;
align-items: center;
span {
display: inline-block;
width: 140px;
height: 25px;
font-size: 12px;
line-height: 25px;
color: #fff;
text-align: center;
background: #5487df;
}
/* 拖拽时隐藏组件 */
.component {
display: none;
}
/* 拖拽时显示占位提示 */
.drag-placement {
display: block;
}
}
}
</style>

View File

@ -0,0 +1,50 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 轮播图属性 */
export interface CarouselProperty {
// 类型:默认 | 卡片
type: 'default' | 'card'
// 指示器样式:点 | 数字
indicator: 'dot' | 'number'
// 是否自动播放
autoplay: boolean
// 播放间隔
interval: number
// 轮播内容
items: CarouselItemProperty[]
// 组件样式
style: ComponentStyle
}
// 轮播内容属性
export interface CarouselItemProperty {
// 类型:图片 | 视频
type: 'img' | 'video'
// 图片链接
imgUrl: string
// 视频链接
videoUrl: string
// 跳转链接
url: string
}
// 定义组件
export const component = {
id: 'Carousel',
name: '轮播图',
icon: 'system-uicons:carousel',
property: {
type: 'default',
indicator: 'dot',
autoplay: false,
interval: 3,
items: [
{ type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg', videoUrl: '' },
{ type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg', videoUrl: '' }
] as CarouselItemProperty[],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<CarouselProperty>

View File

@ -0,0 +1,43 @@
<template>
<!-- 无图片 -->
<div
class="h-250px flex items-center justify-center bg-gray-3"
v-if="property.items.length === 0"
>
<Icon icon="tdesign:image" class="text-gray-8 text-120px!" />
</div>
<div v-else class="relative">
<el-carousel
height="174px"
:type="property.type === 'card' ? 'card' : ''"
:autoplay="property.autoplay"
:interval="property.interval * 1000"
:indicator-position="property.indicator === 'number' ? 'none' : undefined"
@change="handleIndexChange"
>
<el-carousel-item v-for="(item, index) in property.items" :key="index">
<el-image class="h-full w-full" :src="item.imgUrl" />
</el-carousel-item>
</el-carousel>
<div
v-if="property.indicator === 'number'"
class="absolute bottom-10px right-10px rounded-xl bg-black p-x-8px p-y-2px text-10px text-white opacity-40"
>{{ currentIndex }} / {{ property.items.length }}</div
>
</div>
</template>
<script setup lang="ts">
import { CarouselProperty } from './config'
/** 轮播图 */
defineOptions({ name: 'Carousel' })
defineProps<{ property: CarouselProperty }>()
const currentIndex = ref(0)
const handleIndexChange = (index: number) => {
currentIndex.value = index + 1
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,142 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="样式设置" class="property-group" shadow="never">
<el-form-item label="样式" prop="type">
<el-radio-group v-model="formData.type">
<el-tooltip class="item" content="默认" placement="bottom">
<el-radio-button label="default">
<Icon icon="system-uicons:carousel" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="卡片" placement="bottom">
<el-radio-button label="card">
<Icon icon="ic:round-view-carousel" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="指示器" prop="indicator">
<el-radio-group v-model="formData.indicator">
<el-radio label="dot">小圆点</el-radio>
<el-radio label="number">数字</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否轮播" prop="autoplay">
<el-switch v-model="formData.autoplay" />
</el-form-item>
<el-form-item label="播放间隔" prop="interval" v-if="formData.autoplay">
<el-slider
v-model="formData.interval"
:max="10"
:min="0.5"
:step="0.5"
show-input
input-size="small"
:show-input-controls="false"
/>
<el-text type="info">单位</el-text>
</el-form-item>
</el-card>
<el-card header="内容设置" class="property-group" shadow="never">
<el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
<template v-if="formData.items[0]">
<draggable
:list="formData.items"
:force-fallback="true"
:animation="200"
handle=".drag-icon"
class="m-t-8px"
item-key="index"
>
<template #item="{ element, index }">
<div class="content mb-4px flex flex-col gap-4px rounded bg-gray-50 p-8px">
<div
class="m--8px m-b-8px flex flex-row items-center justify-between bg-gray-100 p-8px"
>
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<Icon
icon="ep:delete"
class="cursor-pointer text-red-5"
@click="handleDeleteImage(index)"
v-if="formData.items.length > 1"
/>
</div>
<el-form-item label="类型" prop="type" class="m-b-8px!" label-width="50px">
<el-radio-group v-model="element.type">
<el-radio label="img">图片</el-radio>
<el-radio label="video">视频</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="图片"
class="m-b-8px!"
label-width="50px"
v-if="element.type === 'img'"
>
<UploadImg
v-model="element.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
/>
</el-form-item>
<template v-else>
<el-form-item label="封面" class="m-b-8px!" label-width="50px">
<UploadImg
v-model="element.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
/>
</el-form-item>
<el-form-item label="视频" class="m-b-8px!" label-width="50px">
<UploadFile
v-model="element.videoUrl"
:file-type="['mp4']"
:limit="1"
:file-size="100"
class="min-w-80px"
/>
</el-form-item>
</template>
<el-form-item label="链接" class="m-b-8px!" label-width="50px">
<el-input placeholder="链接" v-model="element.url" />
</el-form-item>
</div>
</template>
</draggable>
</template>
<el-button @click="handleAddImage" type="primary" plain class="w-full">
添加图片
</el-button>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import draggable from 'vuedraggable' //
import { CarouselItemProperty, CarouselProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'CarouselProperty' })
const props = defineProps<{ modelValue: CarouselProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
//
const handleAddImage = () => {
formData.value.items.push({} as CarouselItemProperty)
}
//
const handleDeleteImage = (index: number) => {
formData.value.items.splice(index, 1)
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,29 @@
import { DiyComponent } from '@/components/DiyEditor/util'
/** 分割线属性 */
export interface DividerProperty {
// 高度
height: number
// 线宽
lineWidth: number
// 边距类型
paddingType: 'none' | 'horizontal'
// 颜色
lineColor: string
// 类型
borderType: 'solid' | 'dashed' | 'dotted' | 'none'
}
// 定义组件
export const component = {
id: 'Divider',
name: '分割线',
icon: 'tdesign:component-divider-vertical',
property: {
height: 30,
lineWidth: 1,
paddingType: 'none',
lineColor: '#dcdfe6',
borderType: 'solid'
}
} as DiyComponent<DividerProperty>

View File

@ -0,0 +1,29 @@
<template>
<div
class="flex items-center"
:style="{
height: property.height + 'px'
}"
>
<div
class="w-full"
:style="{
borderTopStyle: property.borderType,
borderTopColor: property.lineColor,
borderTopWidth: `${property.lineWidth}px`,
margin: property.paddingType === 'none' ? '0' : '0px 16px'
}"
></div>
</div>
</template>
<script setup lang="ts">
import { DividerProperty } from './config'
/** 页面顶部导航栏 */
defineOptions({ name: 'Divider' })
defineProps<{ property: DividerProperty }>()
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,80 @@
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="高度" prop="height">
<el-slider v-model="formData.height" :min="1" :max="100" show-input input-size="small" />
</el-form-item>
<el-form-item label="选择样式" prop="borderType">
<el-radio-group v-model="formData!.borderType">
<el-tooltip
placement="top"
v-for="(item, index) in BORDER_TYPES"
:key="index"
:content="item.text"
>
<el-radio-button :label="item.type">
<Icon :icon="item.icon" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<template v-if="formData.borderType !== 'none'">
<el-form-item label="线宽" prop="lineWidth">
<el-slider v-model="formData.lineWidth" :min="1" :max="30" show-input input-size="small" />
</el-form-item>
<el-form-item label="左右边距" prop="paddingType">
<el-radio-group v-model="formData!.paddingType">
<el-tooltip content="无边距" placement="top">
<el-radio-button label="none">
<Icon icon="tabler:box-padding" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="左右留边" placement="top">
<el-radio-button label="horizontal">
<Icon icon="vaadin:padding" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="颜色">
<!-- 分割线颜色 -->
<ColorInput v-model="formData.lineColor" />
</el-form-item>
</template>
</el-form>
</template>
<script setup lang="ts">
import { DividerProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'DividerProperty' })
const props = defineProps<{ modelValue: DividerProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
//线
const BORDER_TYPES = [
{
icon: 'vaadin:line-h',
text: '实线',
type: 'solid'
},
{
icon: 'tabler:line-dashed',
text: '虚线',
type: 'dashed'
},
{
icon: 'tabler:line-dotted',
text: '点线',
type: 'dotted'
},
{
icon: 'entypo:progress-empty',
text: '无',
type: 'none'
}
]
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,27 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 图片展示属性 */
export interface ImageBarProperty {
// 图片链接
imgUrl: string
// 跳转链接
url: string
// 组件样式
style: ComponentStyle
}
// 定义组件
export const component = {
id: 'ImageBar',
name: '图片展示',
icon: 'ep:picture',
property: {
imgUrl: '',
url: '',
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<ImageBarProperty>

View File

@ -0,0 +1,24 @@
<template>
<!-- 无图片 -->
<div class="h-50px flex items-center justify-center bg-gray-3" v-if="!property.imgUrl">
<Icon icon="ep:picture" class="text-gray-8 text-30px!" />
</div>
<el-image class="min-h-30px" v-else :src="property.imgUrl" />
</template>
<script setup lang="ts">
import { ImageBarProperty } from './config'
/** 图片展示 */
defineOptions({ name: 'ImageBar' })
defineProps<{ property: ImageBarProperty }>()
</script>
<style scoped lang="scss">
/* 图片 */
img {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-form-item label="上传图片" prop="imgUrl">
<UploadImg
v-model="formData.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
>
<template #tip> 建议宽度750 </template>
</UploadImg>
</el-form-item>
<el-form-item label="链接" prop="url">
<el-input placeholder="链接" v-model="formData.url" />
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import { ImageBarProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'ImageBarProperty' })
const props = defineProps<{ modelValue: ImageBarProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,48 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 广告魔方属性 */
export interface MagicCubeProperty {
// 上圆角
borderRadiusTop: number
// 下圆角
borderRadiusBottom: number
// 间隔
space: number
// 导航菜单列表
list: MagicCubeItemProperty[]
// 组件样式
style: ComponentStyle
}
/** 广告魔方项目属性 */
export interface MagicCubeItemProperty {
// 图标链接
imgUrl: string
// 链接
url: string
// 宽
width: number
// 高
height: number
// 上
top: number
// 左
left: number
}
// 定义组件
export const component = {
id: 'MagicCube',
name: '广告魔方',
icon: 'bi:columns',
property: {
borderRadiusTop: 0,
borderRadiusBottom: 0,
space: 0,
list: [],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<MagicCubeProperty>

View File

@ -0,0 +1,73 @@
<template>
<div
class="relative"
:style="{ height: `${rowCount * CUBE_SIZE}px`, width: `${4 * CUBE_SIZE}px` }"
>
<div
v-for="(item, index) in property.list"
:key="index"
class="absolute"
:style="{
width: `${item.width * CUBE_SIZE - property.space * 2}px`,
height: `${item.height * CUBE_SIZE - property.space * 2}px`,
margin: `${property.space}px`,
top: `${item.top * CUBE_SIZE}px`,
left: `${item.left * CUBE_SIZE}px`
}"
>
<el-image
class="h-full w-full"
fit="cover"
:src="item.imgUrl"
:style="{
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`
}"
>
<template #error>
<div class="image-slot">
<div
class="flex items-center justify-center"
:style="{
width: `${item.width * CUBE_SIZE}px`,
height: `${item.height * CUBE_SIZE}px`
}"
>
<Icon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
</div>
</div>
</template>
</el-image>
</div>
</div>
</template>
<script setup lang="ts">
import { MagicCubeProperty } from './config'
/** 广告魔方 */
defineOptions({ name: 'MagicCube' })
const props = defineProps<{ property: MagicCubeProperty }>()
//
const CUBE_SIZE = 93.75
/**
* 计算方块的行数
* 行数用于计算魔方的总体高度存在以下情况
* 1. 没有数据时默认就只显示一行的高度
* 2. 底部的空白不算高度例如只有第一行有数据那么就只显示一行的高度
* 3. 顶部及中间的空白算高度例如一共有四行只有最后一行有数据那么也显示四行的高度
*/
const rowCount = computed(() => {
let count = 0
if (props.property.list.length > 0) {
//
count = Math.max(...props.property.list.map((item) => item.bottom))
}
// 0 1
return count + 1
})
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,76 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-text tag="p"> 魔方设置 </el-text>
<el-text type="info" size="small"> 每格尺寸187 * 187 </el-text>
<MagicCubeEditor
class="m-y-16px"
v-model="formData.list"
:rows="4"
:cols="4"
@hot-area-selected="handleHotAreaSelected"
/>
<template v-for="(hotArea, index) in formData.list" :key="index">
<template v-if="selectedHotAreaIndex === index">
<el-form-item label="上传图片" :prop="`list[${index}].imgUrl`">
<UploadImg v-model="hotArea.imgUrl" height="80px" width="80px" />
</el-form-item>
<el-form-item label="链接" :prop="`list[${index}].url`">
<el-input v-model="hotArea.url" placeholder="请输入链接" />
</el-form-item>
</template>
</template>
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import { usePropertyForm } from '@/components/DiyEditor/util'
import { MagicCubeProperty } from '@/components/DiyEditor/components/mobile/MagicCube/config'
/** 广告魔方属性面板 */
defineOptions({ name: 'MagicCubeProperty' })
const props = defineProps<{ modelValue: MagicCubeProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
//
const selectedHotAreaIndex = ref(-1)
const handleHotAreaSelected = (_: any, index: number) => {
selectedHotAreaIndex.value = index
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,78 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
import { cloneDeep } from 'lodash-es'
/** 宫格导航属性 */
export interface MenuGridProperty {
// 列数
column: number
// 导航菜单列表
list: MenuGridItemProperty[]
// 组件样式
style: ComponentStyle
}
/** 宫格导航项目属性 */
export interface MenuGridItemProperty {
// 图标链接
iconUrl: string
// 标题
title: string
// 标题颜色
titleColor: string
// 副标题
subtitle: string
// 副标题颜色
subtitleColor: string
// 链接
url: string
// 角标
badge: {
// 是否显示
show: boolean
// 角标文字
text: string
// 角标文字颜色
textColor: string
// 角标背景颜色
bgColor: string
}
}
export const EMPTY_MENU_GRID_ITEM_PROPERTY = {
title: '标题',
titleColor: '#333',
subtitle: '副标题',
subtitleColor: '#bbb',
badge: {
show: false,
textColor: '#fff',
bgColor: '#FF6000'
}
} as MenuGridItemProperty
// 定义组件
export const component = {
id: 'MenuGrid',
name: '宫格导航',
icon: 'bi:grid-3x3-gap',
property: {
column: 3,
list: [cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY)],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
marginLeft: 8,
marginRight: 8,
padding: 8,
paddingTop: 8,
paddingRight: 8,
paddingBottom: 8,
paddingLeft: 8,
borderRadius: 8,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
borderBottomLeftRadius: 8
} as ComponentStyle
}
} as DiyComponent<MenuGridProperty>

View File

@ -0,0 +1,35 @@
<template>
<div class="flex flex-row flex-wrap">
<div
v-for="(item, index) in property.list"
:key="index"
class="relative flex flex-col items-center p-b-14px p-t-20px"
:style="{ width: `${100 * (1 / property.column)}%` }"
>
<!-- 右上角角标 -->
<span
v-if="item.badge?.show"
class="absolute left-50% top-10px z-1 h-20px rounded-50% p-x-6px text-center text-12px leading-20px"
:style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
>
{{ item.badge.text }}
</span>
<el-image v-if="item.iconUrl" class="h-28px w-28px" :src="item.iconUrl" />
<span class="m-t-8px h-16px text-12px leading-16px" :style="{ color: item.titleColor }">
{{ item.title }}
</span>
<span class="m-t-6px h-12px text-10px leading-12px" :style="{ color: item.subtitleColor }">
{{ item.subtitle }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { MenuGridProperty } from './config'
/** 宫格导航 */
defineOptions({ name: 'MenuGrid' })
defineProps<{ property: MenuGridProperty }>()
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,96 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-form-item label="每行数量" prop="column">
<el-radio-group v-model="formData.column">
<el-radio :label="3">3</el-radio>
<el-radio :label="4">4</el-radio>
</el-radio-group>
</el-form-item>
<el-text tag="p"> 菜单设置 </el-text>
<el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
<template v-if="formData.list.length">
<VueDraggable
class="m-t-8px"
:list="formData.list"
item-key="index"
handle=".drag-icon"
:forceFallback="true"
:animation="200"
>
<template #item="{ element, index }">
<div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
<div class="flex flex-row justify-between">
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
</div>
<el-form-item label="图标" prop="iconUrl">
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
<template #tip> 建议尺寸44 * 44 </template>
</UploadImg>
</el-form-item>
<el-form-item label="标题" prop="title">
<InputWithColor v-model="element.title" v-model:color="element.titleColor" />
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
</el-form-item>
<el-form-item label="链接" prop="url">
<el-input v-model="element.url" />
</el-form-item>
<el-form-item label="显示角标" prop="badge.show">
<el-switch v-model="element.badge.show" />
</el-form-item>
<template v-if="element.badge.show">
<el-form-item label="角标内容" prop="badge.text">
<InputWithColor
v-model="element.badge.text"
v-model:color="element.badge.textColor"
/>
</el-form-item>
<el-form-item label="背景颜色" prop="badge.bgColor">
<ColorInput v-model="element.badge.bgColor" />
</el-form-item>
</template>
</div>
</template>
</VueDraggable>
</template>
<el-form-item label-width="0">
<el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
<Icon icon="ep:plus" class="mr-5px" /> 添加菜单
</el-button>
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import VueDraggable from 'vuedraggable'
import { usePropertyForm } from '@/components/DiyEditor/util'
import {
EMPTY_MENU_GRID_ITEM_PROPERTY,
MenuGridProperty
} from '@/components/DiyEditor/components/mobile/MenuGrid/config'
import { cloneDeep } from 'lodash-es'
/** 宫格导航属性面板 */
defineOptions({ name: 'MenuGridProperty' })
const props = defineProps<{ modelValue: MenuGridProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
/* 添加菜单 */
const handleAddMenu = () => {
formData.value.list.push(cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY))
}
/* 删除菜单 */
const handleDeleteMenu = (index: number) => {
formData.value.list.splice(index, 1)
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,47 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
import { cloneDeep } from 'lodash-es'
/** 列表导航属性 */
export interface MenuListProperty {
// 导航菜单列表
list: MenuListItemProperty[]
// 组件样式
style: ComponentStyle
}
/** 列表导航项目属性 */
export interface MenuListItemProperty {
// 图标链接
iconUrl: string
// 标题
title: string
// 标题颜色
titleColor: string
// 副标题
subtitle: string
// 副标题颜色
subtitleColor: string
// 链接
url: string
}
export const EMPTY_MENU_LIST_ITEM_PROPERTY = {
title: '标题',
titleColor: '#333',
subtitle: '副标题',
subtitleColor: '#bbb'
}
// 定义组件
export const component = {
id: 'MenuList',
name: '列表导航',
icon: 'fa-solid:list',
property: {
list: [cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY)],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<MenuListProperty>

View File

@ -0,0 +1,31 @@
<template>
<div class="min-h-42px flex flex-col">
<div
v-for="(item, index) in property.list"
:key="index"
class="item h-42px flex flex-row items-center justify-between gap-4px p-x-12px"
>
<div class="flex flex-1 flex-row items-center gap-8px">
<el-image v-if="item.iconUrl" class="h-16px w-16px" :src="item.iconUrl" />
<span class="text-16px" :style="{ color: item.titleColor }">{{ item.title }}</span>
</div>
<div class="item-center flex flex-row justify-center gap-4px">
<span class="text-12px" :style="{ color: item.subtitleColor }">{{ item.subtitle }}</span>
<Icon icon="ep-arrow-right" color="#000" :size="16" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MenuListProperty } from './config'
/** 列表导航 */
defineOptions({ name: 'MenuList' })
defineProps<{ property: MenuListProperty }>()
</script>
<style scoped lang="scss">
.item + .item {
border-top: 1px solid #eee;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<el-text tag="p"> 菜单设置 </el-text>
<el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
<!-- 表单 -->
<el-form label-width="60px" :model="formData" class="m-t-8px">
<div v-if="formData.list.length">
<VueDraggable
:list="formData.list"
item-key="index"
handle=".drag-icon"
:forceFallback="true"
:animation="200"
>
<template #item="{ element, index }">
<div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
<div class="flex flex-row justify-between">
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
</div>
<el-form-item label="图标" prop="iconUrl">
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
<template #tip> 建议尺寸44 * 44 </template>
</UploadImg>
</el-form-item>
<el-form-item label="标题" prop="title">
<InputWithColor v-model="element.title" v-model:color="element.titleColor" />
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
</el-form-item>
<el-form-item label="链接" prop="url">
<el-input v-model="element.url" />
</el-form-item>
</div>
</template>
</VueDraggable>
</div>
<el-form-item label-width="0">
<el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
<Icon icon="ep:plus" class="mr-5px" /> 添加菜单
</el-button>
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import VueDraggable from 'vuedraggable'
import { usePropertyForm } from '@/components/DiyEditor/util'
import {
EMPTY_MENU_LIST_ITEM_PROPERTY,
MenuListProperty
} from '@/components/DiyEditor/components/mobile/MenuList/config'
import { cloneDeep } from 'lodash-es'
/** 列表导航属性面板 */
defineOptions({ name: 'MenuListProperty' })
const props = defineProps<{ modelValue: MenuListProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
/* 添加菜单 */
const handleAddMenu = () => {
formData.value.list.push(cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY))
}
/* 删除菜单 */
const handleDeleteMenu = (index: number) => {
formData.value.list.splice(index, 1)
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,66 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
import { cloneDeep } from 'lodash-es'
/** 菜单导航属性 */
export interface MenuSwiperProperty {
// 布局: 图标+文字 | 图标
layout: 'iconText' | 'icon'
// 行数
row: number
// 列数
column: number
// 导航菜单列表
list: MenuSwiperItemProperty[]
// 组件样式
style: ComponentStyle
}
/** 菜单导航项目属性 */
export interface MenuSwiperItemProperty {
// 图标链接
iconUrl: string
// 标题
title: string
// 标题颜色
titleColor: string
// 链接
url: string
// 角标
badge: {
// 是否显示
show: boolean
// 角标文字
text: string
// 角标文字颜色
textColor: string
// 角标背景颜色
bgColor: string
}
}
export const EMPTY_MENU_SWIPER_ITEM_PROPERTY = {
title: '标题',
titleColor: '#333',
badge: {
show: false,
textColor: '#fff',
bgColor: '#FF6000'
}
} as MenuSwiperItemProperty
// 定义组件
export const component = {
id: 'MenuSwiper',
name: '菜单导航',
icon: 'bi:grid-3x2-gap',
property: {
layout: 'iconText',
row: 1,
column: 3,
list: [cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<MenuSwiperProperty>

View File

@ -0,0 +1,119 @@
<template>
<el-carousel
:height="`${carouselHeight}px`"
:autoplay="false"
arrow="hover"
indicator-position="outside"
>
<el-carousel-item v-for="(page, pageIndex) in pages" :key="pageIndex">
<div class="flex flex-row flex-wrap">
<div
v-for="(item, index) in page"
:key="index"
class="relative flex flex-col items-center justify-center"
:style="{ width: columnWidth, height: `${rowHeight}px` }"
>
<!-- 图标 + 角标 -->
<div class="relative" :class="`h-${ICON_SIZE}px w-${ICON_SIZE}px`">
<!-- 右上角角标 -->
<span
v-if="item.badge?.show"
class="absolute right--10px top--10px z-1 h-20px rounded-10px p-x-6px text-center text-12px leading-20px"
:style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
>
{{ item.badge.text }}
</span>
<el-image v-if="item.iconUrl" :src="item.iconUrl" class="h-full w-full" />
</div>
<!-- 标题 -->
<span
v-if="property.layout === 'iconText'"
class="text-14px"
:style="{
color: item.titleColor,
height: `${TITLE_HEIGHT}px`,
lineHeight: `${TITLE_HEIGHT}px`
}"
>
{{ item.title }}
</span>
</div>
</div>
</el-carousel-item>
</el-carousel>
</template>
<script setup lang="ts">
import { MenuSwiperProperty, MenuSwiperItemProperty } from './config'
/** 菜单导航 */
defineOptions({ name: 'MenuSwiper' })
const props = defineProps<{ property: MenuSwiperProperty }>()
//
const TITLE_HEIGHT = 20
//
const ICON_SIZE = 50
//
const SPACE_Y = 16
//
const pages = ref<MenuSwiperItemProperty[][]>([])
//
const carouselHeight = ref(0)
//
const rowHeight = ref(0)
//
const columnWidth = ref('')
watch(
() => props.property,
() => {
//
columnWidth.value = `${100 * (1 / props.property.column)}%`
// + 0 + * 2
rowHeight.value =
(props.property.layout === 'iconText' ? ICON_SIZE + TITLE_HEIGHT : ICON_SIZE) + SPACE_Y * 2
// *
carouselHeight.value = props.property.row * rowHeight.value
// *
const pageSize = props.property.row * props.property.column
//
pages.value = []
//
let pageItems: MenuSwiperItemProperty[] = []
for (const item of props.property.list) {
//
if (pageItems.length === pageSize) {
pageItems = []
}
//
if (pageItems.length === 0) {
pages.value.push(pageItems)
}
//
pageItems.push(item)
}
},
{ immediate: true, deep: true }
)
</script>
<style lang="scss">
// APP
:root {
.el-carousel__indicator {
padding-top: 0;
padding-bottom: 0;
.el-carousel__button {
--el-carousel-indicator-height: 6px;
--el-carousel-indicator-width: 6px;
--el-carousel-indicator-out-color: #ff6000;
border-radius: 6px;
}
}
.el-carousel__indicator.is-active {
.el-carousel__button {
--el-carousel-indicator-width: 12px;
}
}
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-form-item label="布局" prop="layout">
<el-radio-group v-model="formData.layout">
<el-radio label="iconText">图标+文字</el-radio>
<el-radio label="icon">仅图标</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="行数" prop="row">
<el-radio-group v-model="formData.row">
<el-radio :label="1">1</el-radio>
<el-radio :label="2">2</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="列数" prop="column">
<el-radio-group v-model="formData.column">
<el-radio :label="3">3</el-radio>
<el-radio :label="4">4</el-radio>
<el-radio :label="5">5</el-radio>
</el-radio-group>
</el-form-item>
<el-text tag="p"> 菜单设置 </el-text>
<el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
<template v-if="formData.list.length">
<VueDraggable
class="m-t-8px"
:list="formData.list"
item-key="index"
handle=".drag-icon"
:forceFallback="true"
:animation="200"
>
<template #item="{ element, index }">
<div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
<div class="flex flex-row justify-between">
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
</div>
<el-form-item label="图标" prop="iconUrl">
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
<template #tip> 建议尺寸98 * 98 </template>
</UploadImg>
</el-form-item>
<el-form-item label="标题" prop="title">
<InputWithColor v-model="element.title" v-model:color="element.titleColor" />
</el-form-item>
<el-form-item label="链接" prop="url">
<el-input v-model="element.url" />
</el-form-item>
<el-form-item label="显示角标" prop="badge.show">
<el-switch v-model="element.badge.show" />
</el-form-item>
<template v-if="element.badge.show">
<el-form-item label="角标内容" prop="badge.text">
<InputWithColor
v-model="element.badge.text"
v-model:color="element.badge.textColor"
/>
</el-form-item>
<el-form-item label="背景颜色" prop="badge.bgColor">
<ColorInput v-model="element.badge.bgColor" />
</el-form-item>
</template>
</div>
</template>
</VueDraggable>
</template>
<el-form-item label-width="0">
<el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
<Icon icon="ep:plus" class="mr-5px" /> 添加菜单
</el-button>
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import VueDraggable from 'vuedraggable'
import { usePropertyForm } from '@/components/DiyEditor/util'
import {
EMPTY_MENU_SWIPER_ITEM_PROPERTY,
MenuSwiperProperty
} from '@/components/DiyEditor/components/mobile/MenuSwiper/config'
import { cloneDeep } from 'lodash-es'
/** 菜单导航属性面板 */
defineOptions({ name: 'MenuSwiperProperty' })
const props = defineProps<{ modelValue: MenuSwiperProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
/* 添加菜单 */
const handleAddMenu = () => {
formData.value.list.push(cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY))
}
/* 删除菜单 */
const handleDeleteMenu = (index: number) => {
formData.value.list.splice(index, 1)
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,38 @@
import { DiyComponent } from '@/components/DiyEditor/util'
/** 顶部导航栏属性 */
export interface NavigationBarProperty {
// 页面标题
title: string
// 页面描述
description: string
// 顶部导航高度
navBarHeight: number
// 页面背景颜色
backgroundColor: string
// 页面背景图片
backgroundImage: string
// 样式类型:默认 | 沉浸式
styleType: 'default' | 'immersion'
// 常驻显示
alwaysShow: boolean
// 是否显示返回按钮
showGoBack: boolean
}
// 定义组件
export const component = {
id: 'NavigationBar',
name: '顶部导航栏',
icon: 'tabler:layout-navbar',
property: {
title: '页面标题',
description: '',
navBarHeight: 35,
backgroundColor: '#fff',
backgroundImage: '',
styleType: 'default',
alwaysShow: true,
showGoBack: true
}
} as DiyComponent<NavigationBarProperty>

View File

@ -0,0 +1,62 @@
<template>
<div
class="navigation-bar"
:style="{
height: `${property.navBarHeight}px`,
backgroundColor: property.backgroundColor,
backgroundImage: `url(${property.backgroundImage})`
}"
>
<!-- 左侧 -->
<div class="left">
<Icon icon="ep:arrow-left" v-show="property.showGoBack" />
</div>
<!-- 中间 -->
<div
class="center"
:style="{
height: `${property.navBarHeight}px`,
lineHeight: `${property.navBarHeight}px`
}"
>
{{ property.title }}
</div>
<!-- 右侧 -->
<div class="right"></div>
</div>
</template>
<script setup lang="ts">
import { NavigationBarProperty } from './config'
/** 页面顶部导航栏 */
defineOptions({ name: 'NavigationBar' })
defineProps<{ property: NavigationBarProperty }>()
</script>
<style lang="scss" scoped>
.navigation-bar {
display: flex;
height: 35px;
background: #fff;
justify-content: space-between;
align-items: center;
/* 左边 */
.left {
margin-left: 8px;
}
.center {
font-size: 14px;
line-height: 35px;
color: #333;
text-align: center;
flex: 1;
}
/* 右边 */
.right {
margin-right: 8px;
}
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<el-form label-width="80px" :model="formData" :rules="rules">
<el-form-item label="页面标题" prop="title">
<el-input v-model="formData!.title" placeholder="页面标题" maxlength="25" show-word-limit />
</el-form-item>
<el-form-item label="页面描述" prop="description">
<el-input
type="textarea"
v-model="formData!.description"
placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
/>
</el-form-item>
<el-form-item label="样式" prop="styleType">
<el-radio-group v-model="formData!.styleType">
<el-radio label="default">默认</el-radio>
<el-radio label="immersion">沉浸式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="常驻显示" prop="alwaysShow" v-if="formData.styleType === 'immersion'">
<el-radio-group v-model="formData!.alwaysShow">
<el-radio :label="false">关闭</el-radio>
<el-radio :label="true">开启</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="高度" prop="navBarHeight">
<el-slider
v-model="formData!.navBarHeight"
:max="100"
:min="35"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="返回按钮" prop="showGoBack">
<el-switch v-model="formData!.showGoBack" />
</el-form-item>
<el-form-item label="背景颜色" prop="backgroundColor">
<ColorInput v-model="formData!.backgroundColor" />
</el-form-item>
<el-form-item label="背景图片" prop="backgroundImage">
<UploadImg v-model="formData!.backgroundImage" :limit="1">
<template #tip>建议宽度 750px</template>
</UploadImg>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { NavigationBarProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'NavigationBarProperty' })
//
const rules = {
name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }]
}
const props = defineProps<{ modelValue: NavigationBarProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,39 @@
import { DiyComponent } from '@/components/DiyEditor/util'
/** 公告栏属性 */
export interface NoticeBarProperty {
// 图标地址
iconUrl: string
// 公告内容列表
contents: NoticeContentProperty[]
// 背景颜色
backgroundColor: string
// 文字颜色
textColor: string
}
/** 内容属性 */
export interface NoticeContentProperty {
// 内容文字
text: string
// 链接地址
url: string
}
// 定义组件
export const component = {
id: 'NoticeBar',
name: '公告栏',
icon: 'ep:bell',
property: {
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/xinjian.png',
contents: [
{
text: '',
url: ''
}
],
backgroundColor: '#fff',
textColor: '#333'
}
} as DiyComponent<NoticeBarProperty>

View File

@ -0,0 +1,26 @@
<template>
<div
class="flex items-center p-y-4px text-12px"
:style="{ backgroundColor: property.backgroundColor, color: property.textColor }"
>
<el-image :src="property.iconUrl" class="h-18px" />
<el-divider direction="vertical" />
<el-carousel height="24px" direction="vertical" :autoplay="true" class="flex-1 p-r-8px">
<el-carousel-item v-for="(item, index) in property.contents" :key="index">
<div class="h-24px truncate leading-24px">{{ item.text }}</div>
</el-carousel-item>
</el-carousel>
<Icon icon="ep:arrow-right" />
</div>
</template>
<script setup lang="ts">
import { NoticeBarProperty } from './config'
/** 公告栏 */
defineOptions({ name: 'NoticeBar' })
defineProps<{ property: NoticeBarProperty }>()
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,77 @@
<template>
<el-form label-width="80px" :model="formData" :rules="rules">
<el-form-item label="公告图标" prop="iconUrl">
<UploadImg v-model="formData.iconUrl" height="48px">
<template #tip>建议尺寸24 * 24</template>
</UploadImg>
</el-form-item>
<el-form-item label="背景颜色" prop="backgroundColor">
<ColorInput v-model="formData.backgroundColor" />
</el-form-item>
<el-form-item label="文字颜色" prop="文字颜色">
<ColorInput v-model="formData.textColor" />
</el-form-item>
<el-text tag="p"> 公告内容 </el-text>
<el-text type="info" size="small"> 拖动左上角的小圆点可以调整热词顺序 </el-text>
<template v-if="formData.contents.length">
<VueDraggable
:list="formData.contents"
item-key="index"
handle=".drag-icon"
:forceFallback="true"
:animation="200"
class="m-t-8px"
>
<template #item="{ element, index }">
<div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
<div class="flex flex-col items-start justify-between">
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<Icon
icon="ep:delete"
class="cursor-pointer text-red-5"
@click="handleDeleteContent(index)"
v-if="formData.contents.length > 1"
/>
</div>
<div class="w-full flex flex-col gap-8px">
<el-input v-model="element.text" placeholder="请输入公告" />
<el-input v-model="element.url" placeholder="请输入链接" />
</div>
</div>
</template>
</VueDraggable>
</template>
<el-form-item label-width="0">
<el-button @click="handleAddContent" type="primary" plain class="m-t-8px w-full">
添加内容
</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { NoticeBarProperty, NoticeContentProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
import VueDraggable from 'vuedraggable'
//
defineOptions({ name: 'NoticeBarProperty' })
//
const rules = {
content: [{ required: true, message: '请输入公告', trigger: 'blur' }]
}
const props = defineProps<{ modelValue: NoticeBarProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
/* 添加公告 */
const handleAddContent = () => {
formData.value.contents.push({} as NoticeContentProperty)
}
/* 删除公告 */
const handleDeleteContent = (index: number) => {
formData.value.contents.splice(index, 1)
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,23 @@
import { DiyComponent } from '@/components/DiyEditor/util'
/** 页面设置属性 */
export interface PageConfigProperty {
// 页面描述
description: string
// 页面背景颜色
backgroundColor: string
// 页面背景图片
backgroundImage: string
}
// 定义页面组件
export const component = {
id: 'PageConfig',
name: '页面设置',
icon: 'ep:document',
property: {
description: '',
backgroundColor: '#f5f5f5',
backgroundImage: ''
}
} as DiyComponent<PageConfigProperty>

View File

@ -0,0 +1,34 @@
<template>
<el-form label-width="80px" :model="formData" :rules="rules">
<el-form-item label="页面描述" prop="description">
<el-input
type="textarea"
v-model="formData!.description"
placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
/>
</el-form-item>
<el-form-item label="背景颜色" prop="backgroundColor">
<ColorInput v-model="formData!.backgroundColor" />
</el-form-item>
<el-form-item label="背景图片" prop="backgroundImage">
<UploadImg v-model="formData!.backgroundImage" :limit="1">
<template #tip>建议宽度 750px</template>
</UploadImg>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { PageConfigProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'PageConfigProperty' })
//
const rules = {}
const props = defineProps<{ modelValue: PageConfigProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,97 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 商品卡片属性 */
export interface ProductCardProperty {
// 布局类型:单列大图 | 单列小图 | 双列
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
// 商品字段
fields: {
// 商品名称
name: ProductCardFieldProperty
// 商品简介
introduction: ProductCardFieldProperty
// 商品价格
price: ProductCardFieldProperty
// 商品市场价
marketPrice: ProductCardFieldProperty
// 商品销量
salesCount: ProductCardFieldProperty
// 商品库存
stock: ProductCardFieldProperty
}
// 角标
badge: {
// 是否显示
show: boolean
// 角标图片
imgUrl: string
}
// 按钮
btnBuy: {
// 类型:文字 | 图片
type: 'text' | 'img'
// 文字
text: string
// 文字按钮:背景渐变起始颜色
bgBeginColor: string
// 文字按钮:背景渐变结束颜色
bgEndColor: string
// 图片按钮:图片地址
imgUrl: string
}
// 上圆角
borderRadiusTop: number
// 下圆角
borderRadiusBottom: number
// 间距
space: number
// 商品编号列表
spuIds: number[]
// 组件样式
style: ComponentStyle
}
// 商品字段
export interface ProductCardFieldProperty {
// 是否显示
show: boolean
// 颜色
color: string
}
// 定义组件
export const component = {
id: 'ProductCard',
name: '商品卡片',
icon: 'system-uicons:carousel',
property: {
layoutType: 'oneColBigImg',
fields: {
name: { show: true, color: '#000' },
introduction: { show: true, color: '#999' },
price: { show: true, color: '#ff3000' },
marketPrice: { show: true, color: '#c4c4c4' },
salesCount: { show: true, color: '#c4c4c4' },
stock: { show: false, color: '#c4c4c4' }
},
badge: { show: false, imgUrl: '' },
btnBuy: {
type: 'text',
text: '立即购买',
// todo: @owen 根据主题色配置
bgBeginColor: '#FF6000',
bgEndColor: '#FE832A',
imgUrl: ''
},
borderRadiusTop: 8,
borderRadiusBottom: 8,
space: 8,
spuIds: [],
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<ProductCardProperty>

View File

@ -0,0 +1,165 @@
<template>
<div :class="`box-content min-h-30px w-full flex flex-row flex-wrap`" ref="containerRef">
<div
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
:style="{
...calculateSpace(index),
...calculateWidth(),
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`
}"
v-for="(spu, index) in spuList"
:key="index"
>
<!-- 角标 -->
<div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
<el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" />
</div>
<!-- 商品封面图 -->
<div
:class="[
'h-140px',
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-140px': property.layoutType === 'oneColSmallImg'
}
]"
>
<el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
:class="[
' flex flex-col gap-8px p-8px box-border',
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
}
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
:class="[
'text-14px ',
{
truncate: property.layoutType !== 'oneColSmallImg',
'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
}
]"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<!-- 商品简介 -->
<div
v-if="property.fields.introduction.show"
class="truncate text-12px"
:style="{ color: property.fields.introduction.color }"
>
{{ spu.introduction }}
</div>
<div>
<!-- 价格 -->
<span
v-if="property.fields.price.show"
class="text-16px"
:style="{ color: property.fields.price.color }"
>
{{ spu.price }}
</span>
<!-- 市场价 -->
<span
v-if="property.fields.marketPrice.show && spu.marketPrice"
class="ml-4px text-10px line-through"
:style="{ color: property.fields.marketPrice.color }"
>{{ spu.marketPrice }}</span
>
</div>
<div class="text-12px">
<!-- 销量 -->
<span
v-if="property.fields.salesCount.show"
:style="{ color: property.fields.salesCount.color }"
>
已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}
</span>
<!-- 库存 -->
<span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
库存{{ spu.stock || 0 }}
</span>
</div>
</div>
<!-- 购买按钮 -->
<div class="absolute bottom-8px right-8px">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="rounded-full p-x-12px p-y-4px text-12px text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<el-image
v-else
class="h-28px w-28px rounded-full"
fit="cover"
:src="property.btnBuy.imgUrl"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ProductCardProperty } from './config'
import * as ProductSpuApi from '@/api/mall/product/spu'
/** 商品卡片 */
defineOptions({ name: 'ProductCard' })
//
const props = defineProps<{ property: ProductCardProperty }>()
//
const spuList = ref<ProductSpuApi.Spu[]>([])
watch(
() => props.property.spuIds,
async () => {
spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds)
},
{
immediate: true,
deep: true
}
)
/**
* 计算商品的间距
* @param index 商品索引
*/
const calculateSpace = (index: number) => {
//
const columns = props.property.layoutType === 'twoCol' ? 2 : 1
//
const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
//
const marginTop = index < columns ? '0' : props.property.space + 'px'
return { marginLeft, marginTop }
}
//
const containerRef = ref()
//
const calculateWidth = () => {
let width = '100%'
// - / 2
if (props.property.layoutType === 'twoCol') {
width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
}
return { width }
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,149 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="商品列表" class="property-group" shadow="never">
<SpuShowcase v-model="formData.spuIds" />
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="布局" prop="type">
<el-radio-group v-model="formData.layoutType">
<el-tooltip class="item" content="单列大图" placement="bottom">
<el-radio-button label="oneColBigImg">
<Icon icon="fluent:text-column-one-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="单列小图" placement="bottom">
<el-radio-button label="oneColSmallImg">
<Icon icon="fluent:text-column-two-left-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="双列" placement="bottom">
<el-radio-button label="twoCol">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="商品名称" prop="fields.name.show">
<div class="flex gap-8px">
<ColorInput v-model="formData.fields.name.color" />
<el-checkbox v-model="formData.fields.name.show" />
</div>
</el-form-item>
<el-form-item label="商品简介" prop="fields.introduction.show">
<div class="flex gap-8px">
<ColorInput v-model="formData.fields.introduction.color" />
<el-checkbox v-model="formData.fields.introduction.show" />
</div>
</el-form-item>
<el-form-item label="商品价格" prop="fields.price.show">
<div class="flex gap-8px">
<ColorInput v-model="formData.fields.price.color" />
<el-checkbox v-model="formData.fields.price.show" />
</div>
</el-form-item>
<el-form-item label="市场价" prop="fields.marketPrice.show">
<div class="flex gap-8px">
<ColorInput v-model="formData.fields.marketPrice.color" />
<el-checkbox v-model="formData.fields.marketPrice.show" />
</div>
</el-form-item>
<el-form-item label="商品销量" prop="fields.salesCount.show">
<div class="flex gap-8px">
<ColorInput v-model="formData.fields.salesCount.color" />
<el-checkbox v-model="formData.fields.salesCount.show" />
</div>
</el-form-item>
<el-form-item label="商品库存" prop="fields.stock.show">
<div class="flex gap-8px">
<ColorInput v-model="formData.fields.stock.color" />
<el-checkbox v-model="formData.fields.stock.show" />
</div>
</el-form-item>
</el-card>
<el-card header="角标" class="property-group" shadow="never">
<el-form-item label="角标" prop="badge.show">
<el-switch v-model="formData.badge.show" />
</el-form-item>
<el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
<UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
<template #tip> 建议尺寸36 * 22 </template>
</UploadImg>
</el-form-item>
</el-card>
<el-card header="按钮" class="property-group" shadow="never">
<el-form-item label="按钮类型" prop="btnBuy.type">
<el-radio-group v-model="formData.btnBuy.type">
<el-radio-button label="text">文字</el-radio-button>
<el-radio-button label="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="formData.btnBuy.type === 'text'">
<el-form-item label="按钮文字" prop="btnBuy.text">
<el-input v-model="formData.btnBuy.text" />
</el-form-item>
<el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
</el-form-item>
<el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
<ColorInput v-model="formData.btnBuy.bgEndColor" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="图片" prop="btnBuy.imgUrl">
<UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
<template #tip> 建议尺寸56 * 56 </template>
</UploadImg>
</el-form-item>
</template>
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import { ProductCardProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
//
defineOptions({ name: 'ProductCardProperty' })
const props = defineProps<{ modelValue: ProductCardProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,43 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 搜索框属性 */
export interface SearchProperty {
height: number // 搜索栏高度
showScan: boolean // 显示扫一扫
borderRadius: number // 框体样式
placeholder: string // 占位文字
placeholderPosition: PlaceholderPosition // 占位文字位置
backgroundColor: string // 框体颜色
textColor: string // 字体颜色
hotKeywords: string[] // 热词
style: ComponentStyle
}
// 文字位置
export type PlaceholderPosition = 'left' | 'center'
// 定义组件
export const component = {
id: 'SearchBar',
name: '搜索框',
icon: 'ep:search',
property: {
height: 28,
showScan: false,
borderRadius: 0,
placeholder: '搜索商品',
placeholderPosition: 'left',
backgroundColor: 'rgb(238, 238, 238)',
textColor: 'rgb(150, 151, 153)',
hotKeywords: [],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
paddingTop: 8,
paddingRight: 8,
paddingBottom: 8,
paddingLeft: 8
} as ComponentStyle
}
} as DiyComponent<SearchProperty>

View File

@ -0,0 +1,75 @@
<template>
<div
class="search-bar"
:style="{
color: property.textColor
}"
>
<!-- 搜索框 -->
<div
class="inner"
:style="{
height: `${property.height}px`,
background: property.backgroundColor,
borderRadius: `${property.borderRadius}px`
}"
>
<div
class="placeholder"
:style="{
justifyContent: property.placeholderPosition
}"
>
<Icon icon="ep:search" />
<span>{{ property.placeholder || '搜索商品' }}</span>
</div>
<div class="right">
<!-- 搜索热词 -->
<span v-for="(keyword, index) in property.hotKeywords" :key="index">{{ keyword }}</span>
<!-- 扫一扫 -->
<Icon icon="ant-design:scan-outlined" v-show="property.showScan" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SearchProperty } from './config'
/** 搜索框 */
defineOptions({ name: 'SearchBar' })
defineProps<{ property: SearchProperty }>()
</script>
<style scoped lang="scss">
.search-bar {
/* 搜索框 */
.inner {
position: relative;
display: flex;
min-height: 28px;
font-size: 14px;
align-items: center;
.placeholder {
display: flex;
width: 100%;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
white-space: nowrap;
align-items: center;
gap: 2px;
}
.right {
position: absolute;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
}
}
</style>

View File

@ -0,0 +1,99 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<el-text tag="p"> 搜索热词 </el-text>
<el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text>
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<div v-if="formData.hotKeywords.length">
<VueDraggable
:list="formData.hotKeywords"
item-key="index"
handle=".drag-icon"
:forceFallback="true"
:animation="200"
>
<template #item="{ index }">
<div class="mb-4px flex flex-row items-center gap-4px rounded bg-gray-100 p-8px">
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
<Icon icon="ep:delete" class="text-red-500" @click="deleteHotWord(index)" />
</div>
</template>
</VueDraggable>
</div>
<el-form-item label-width="0">
<el-button @click="handleAddHotWord" type="primary" plain class="m-t-8px w-full">
添加热词
</el-button>
</el-form-item>
<el-form-item label="框体样式">
<el-radio-group v-model="formData!.borderRadius">
<el-tooltip content="方形" placement="top">
<el-radio-button :label="0">
<Icon icon="tabler:input-search" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="圆形" placement="top">
<el-radio-button :label="10">
<Icon icon="iconoir:input-search" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="提示文字" prop="placeholder">
<el-input v-model="formData.placeholder" />
</el-form-item>
<el-form-item label="文本位置" prop="placeholderPosition">
<el-radio-group v-model="formData!.placeholderPosition">
<el-tooltip content="居左" placement="top">
<el-radio-button label="left">
<Icon icon="ant-design:align-left-outlined" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="居中" placement="top">
<el-radio-button label="center">
<Icon icon="ant-design:align-center-outlined" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="扫一扫" prop="showScan">
<el-switch v-model="formData!.showScan" />
</el-form-item>
<el-form-item label="框体高度" prop="height">
<el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
</el-form-item>
<el-form-item label="框体颜色" prop="backgroundColor">
<ColorInput v-model="formData.backgroundColor" />
</el-form-item>
<el-form-item class="lef" label="文本颜色" prop="textColor">
<ColorInput v-model="formData.textColor" />
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import VueDraggable from 'vuedraggable'
import { usePropertyForm } from '@/components/DiyEditor/util'
import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config'
/** 搜索框属性面板 */
defineOptions({ name: 'SearchProperty' })
const props = defineProps<{ modelValue: SearchProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
/* 添加热词 */
const handleAddHotWord = () => {
formData.value.hotKeywords.push('')
}
/* 删除热词 */
const deleteHotWord = (index: number) => {
formData.value.hotKeywords.splice(index, 1)
}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,97 @@
import { DiyComponent } from '@/components/DiyEditor/util'
/** 底部导航菜单属性 */
export interface TabBarProperty {
// 选项列表
items: TabBarItemProperty[]
// 主题
theme: string
// 样式
style: TabBarStyle
}
// 选项属性
export interface TabBarItemProperty {
// 标签文字
text: string
// 链接
url: string
// 默认图标链接
iconUrl: string
// 选中的图标链接
activeIconUrl: string
}
// 样式
export interface TabBarStyle {
// 背景类型
bgType: 'color' | 'img'
// 背景颜色
bgColor: string
// 图片链接
bgImg: string
// 默认颜色
color: string
// 选中的颜色
activeColor: string
}
// 定义组件
export const component = {
id: 'TabBar',
name: '底部导航',
icon: 'fluent:table-bottom-row-16-filled',
property: {
theme: 'red',
style: {
bgType: 'color',
bgColor: '#fff',
color: '#282828',
activeColor: '#fc4141'
},
items: [
{
text: '首页',
url: '/pages/index/index',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-002.png'
},
{
text: '分类',
url: '/pages/index/category?id=3',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-002.png'
},
{
text: '购物车',
url: '/pages/index/cart',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-002.png'
},
{
text: '我的',
url: '/pages/index/user',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-002.png'
}
]
}
} as DiyComponent<TabBarProperty>
export const THEME_LIST = [
{ id: 'red', name: '中国红', icon: 'icon-park-twotone:theme', color: '#d10019' },
{ id: 'orange', name: '桔橙', icon: 'icon-park-twotone:theme', color: '#f37b1d' },
{ id: 'gold', name: '明黄', icon: 'icon-park-twotone:theme', color: '#fbbd08' },
{ id: 'green', name: '橄榄绿', icon: 'icon-park-twotone:theme', color: '#8dc63f' },
{ id: 'cyan', name: '天青', icon: 'icon-park-twotone:theme', color: '#1cbbb4' },
{ id: 'blue', name: '海蓝', icon: 'icon-park-twotone:theme', color: '#0081ff' },
{ id: 'purple', name: '姹紫', icon: 'icon-park-twotone:theme', color: '#6739b6' },
{ id: 'brightRed', name: '嫣红', icon: 'icon-park-twotone:theme', color: '#e54d42' },
{ id: 'forestGreen', name: '森绿', icon: 'icon-park-twotone:theme', color: '#39b54a' },
{ id: 'mauve', name: '木槿', icon: 'icon-park-twotone:theme', color: '#9c26b0' },
{ id: 'pink', name: '桃粉', icon: 'icon-park-twotone:theme', color: '#e03997' },
{ id: 'brown', name: '棕褐', icon: 'icon-park-twotone:theme', color: '#a5673f' },
{ id: 'grey', name: '玄灰', icon: 'icon-park-twotone:theme', color: '#8799a3' },
{ id: 'gray', name: '草灰', icon: 'icon-park-twotone:theme', color: '#aaaaaa' },
{ id: 'black', name: '墨黑', icon: 'icon-park-twotone:theme', color: '#333333' }
]

View File

@ -0,0 +1,59 @@
<template>
<div class="tab-bar">
<div
class="tab-bar-bg"
:style="{
background:
property.style.bgType === 'color'
? property.style.bgColor
: `url(${property.style.bgImg})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat'
}"
>
<div v-for="(item, index) in property.items" :key="index" class="tab-bar-item">
<img :src="index === 0 ? item.activeIconUrl : item.iconUrl" alt="" />
<span :style="{ color: index === 0 ? property.style.activeColor : property.style.color }">
{{ item.text }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { TabBarProperty } from './config'
/** 页面底部导航栏 */
defineOptions({ name: 'TabBar' })
defineProps<{ property: TabBarProperty }>()
</script>
<style lang="scss" scoped>
.tab-bar {
z-index: 2;
width: 100%;
.tab-bar-bg {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
padding: 8px 0;
.tab-bar-item {
display: flex;
width: 100%;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
img {
width: 26px;
height: 26px;
border-radius: 4px;
}
}
}
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<div class="tab-bar">
<!-- 表单 -->
<el-form :model="formData" label-width="80px">
<el-form-item label="主题" prop="theme">
<el-select v-model="formData!.theme" @change="handleThemeChange">
<el-option
v-for="(theme, index) in THEME_LIST"
:key="index"
:label="theme.name"
:value="theme.id"
>
<template #default>
<div class="flex items-center justify-between">
<Icon :icon="theme.icon" :color="theme.color" />
<span>{{ theme.name }}</span>
</div>
</template>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="默认颜色">
<ColorInput v-model="formData!.style.color" />
</el-form-item>
<el-form-item label="选中颜色">
<ColorInput v-model="formData!.style.activeColor" />
</el-form-item>
<el-form-item label="导航背景">
<el-radio-group v-model="formData!.style.bgType">
<el-radio-button label="color">纯色</el-radio-button>
<el-radio-button label="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="选择颜色" v-if="formData!.style.bgType === 'color'">
<ColorInput v-model="formData!.style.bgColor" />
</el-form-item>
<el-form-item label="选择图片" v-if="formData!.style.bgType === 'img'">
<UploadImg v-model="formData!.style.bgImg" width="100%" height="50px" class="min-w-200px">
<template #tip> 建议尺寸 375 * 50 </template>
</UploadImg>
</el-form-item>
<el-text tag="p">图标设置</el-text>
<el-text type="info" size="small"> 拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44 </el-text>
<draggable
:list="formData!.items"
item-key="index"
:forceFallback="true"
:animation="200"
handle=".drag-icon"
class="m-t-8px"
>
<template #item="{ element, index }">
<div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
<div class="flex flex-col items-start justify-between">
<Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
<Icon
icon="ep:delete"
class="cursor-pointer text-red-5"
@click="handleDeleteItem(index)"
v-if="formData.items.length > 1"
/>
</div>
<div class="w-full flex flex-col">
<div class="m-b-8px flex items-center justify-around">
<div class="flex flex-col items-center justify-between">
<UploadImg
v-model="element.iconUrl"
width="40px"
height="40px"
:show-delete="false"
:show-btn-text="false"
/>
<el-text size="small">默认图片</el-text>
</div>
<div>
<UploadImg
v-model="element.activeIconUrl"
width="40px"
height="40px"
:show-delete="false"
:show-btn-text="false"
/>
<el-text>选中图片</el-text>
</div>
</div>
<el-form-item prop="text" label-width="0" class="m-b-8px!">
<el-input v-model="element.text" placeholder="请输入文字" />
</el-form-item>
<el-form-item prop="url" label-width="0" class="m-b-0!">
<el-input v-model="element.url" placeholder="请选择链接" />
</el-form-item>
</div>
</div>
</template>
</draggable>
<el-form-item label-width="0">
<!-- 添加导航按钮 -->
<el-tooltip content="最多添加5个">
<el-button
@click="handleAddItem"
class="m-b-16px w-full"
type="primary"
plain
:disabled="formData!.items.length >= 5"
>
添加导航
</el-button>
</el-tooltip>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import draggable from 'vuedraggable' //
import { TabBarItemProperty, TabBarProperty, THEME_LIST } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'TabBarProperty' })
const props = defineProps<{ modelValue: TabBarProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
/** 添加导航项 */
const handleAddItem = () => {
formData?.value?.items?.push({} as TabBarItemProperty)
}
/** 删除导航项 */
const handleDeleteItem = (index: number) => {
formData?.value?.items?.splice(index, 1)
}
//
const handleThemeChange = () => {
const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme)
if (theme?.color) {
formData.value.style.activeColor = theme.color
}
}
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,65 @@
import { DiyComponent } from '@/components/DiyEditor/util'
/** 标题栏属性 */
export interface TitleBarProperty {
// 主标题
title: string
// 副标题
description: string
// 标题大小
titleSize: number
// 描述大小
descriptionSize: number
// 标题粗细
titleWeight: number
// 显示位置
position: 'left' | 'center'
// 描述粗细
descriptionWeight: number
// 标题颜色
titleColor: string
// 描述颜色
descriptionColor: string
// 背景颜色
backgroundColor: string
// 底部分割线
showBottomBorder: false
// 查看更多
more: {
// 是否显示查看更多
show: false
// 样式选择
type: 'text' | 'icon' | 'all'
// 自定义文字
text: string
// 链接
url: string
}
}
// 定义组件
export const component = {
id: 'TitleBar',
name: '标题栏',
icon: 'material-symbols:line-start',
property: {
title: '主标题',
description: '副标题',
titleSize: 16,
descriptionSize: 12,
titleWeight: 400,
position: 'left',
descriptionWeight: 200,
titleColor: 'rgba(50, 50, 51, 10)',
descriptionColor: 'rgba(150, 151, 153, 10)',
backgroundColor: 'rgba(255, 255, 255, 10)',
showBottomBorder: false,
more: {
//查看更多
show: false,
type: 'icon',
text: '查看更多',
url: ''
}
}
} as DiyComponent<TitleBarProperty>

View File

@ -0,0 +1,80 @@
<template>
<div
class="title-bar"
:style="{
background: property.backgroundColor,
borderBottom: property.showBottomBorder ? '1px solid #F9F9F9' : '1px solid #fff'
}"
>
<div>
<!-- 标题 -->
<div
:style="{
fontSize: `${property.titleSize}px`,
fontWeight: property.titleWeight,
color: property.titleColor,
textAlign: property.position
}"
v-if="property.title"
>
{{ property.title }}
</div>
<!-- 副标题 -->
<div
:style="{
fontSize: `${property.descriptionSize}px`,
fontWeight: property.descriptionWeight,
color: property.descriptionColor,
textAlign: property.position
}"
class="m-t-8px"
v-if="property.description"
>
{{ property.description }}
</div>
</div>
<!-- 更多 -->
<div
class="more"
v-show="property.more.show"
:style="{
color: property.more.type === 'text' ? '#38f' : ''
}"
>
{{ property.more.type === 'icon' ? '' : property.more.text }}
<Icon icon="ep:arrow-right" v-if="property.more.type !== 'text'" />
</div>
</div>
</template>
<script setup lang="ts">
import { TitleBarProperty } from './config'
/** 标题栏 */
defineOptions({ name: 'TitleBar' })
defineProps<{ property: TitleBarProperty }>()
</script>
<style scoped lang="scss">
.title-bar {
position: relative;
width: 100%;
min-height: 20px;
padding: 8px 16px;
border: 2px solid #fff;
box-sizing: border-box;
/* 更多 */
.more {
position: absolute;
top: 0;
right: 8px;
bottom: 0;
display: flex;
margin: auto;
font-size: 10px;
color: #969799;
align-items: center;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<section class="title-bar">
<el-form label-width="85px" :model="formData" :rules="rules">
<el-form-item label="主标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入主标题"
show-word-limit
maxlength="20"
/>
</el-form-item>
<el-form-item label="副标题" prop="description">
<el-input
type="textarea"
v-model="formData.description"
placeholder="请输入副标题"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="显示位置" prop="position">
<el-radio-group v-model="formData!.position">
<el-tooltip content="居左" placement="top">
<el-radio-button label="left">
<Icon icon="ant-design:align-left-outlined" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="居中" placement="top">
<el-radio-button label="center">
<Icon icon="ant-design:align-center-outlined" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="标题大小" prop="titleSize">
<el-slider v-model="formData.titleSize" :max="60" :min="10" show-input input-size="small" />
</el-form-item>
<el-form-item label="副标题大小" prop="descriptionSize">
<el-slider
v-model="formData.descriptionSize"
:max="60"
:min="10"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="标题粗细" prop="titleWeight">
<el-slider
v-model="formData.titleWeight"
:min="100"
:max="900"
:step="100"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="副标题粗细" prop="descriptionWeight">
<el-slider
v-model="formData.descriptionWeight"
:min="100"
:max="900"
:step="100"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="标题颜色" prop="titleColor">
<ColorInput v-model="formData.titleColor" />
</el-form-item>
<el-form-item label="副标题颜色" prop="descriptionColor">
<ColorInput v-model="formData.descriptionColor" />
</el-form-item>
<el-form-item label="背景颜色" prop="backgroundColor">
<ColorInput v-model="formData.backgroundColor" />
</el-form-item>
<el-form-item label="底部分割线" prop="showBottomBorder">
<el-switch v-model="formData!.showBottomBorder" />
</el-form-item>
<el-form-item label="查看更多" prop="more.show">
<el-checkbox v-model="formData.more.show" />
</el-form-item>
<!-- 更多样式选择 -->
<template v-if="formData.more.show">
<el-form-item label="样式" prop="more.type">
<el-radio-group v-model="formData.more.type">
<el-radio label="text">文字</el-radio>
<el-radio label="icon">图标</el-radio>
<el-radio label="all">文字+图标</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="更多文字" prop="more.text" v-show="formData.more.type !== 'icon'">
<el-input v-model="formData.more.text" />
</el-form-item>
<el-form-item label="跳转链接" prop="more.url">
<el-input v-model="formData.more.url" placeholder="请输入跳转链接" />
</el-form-item>
</template>
</el-form>
</section>
</template>
<script setup lang="ts">
import { TitleBarProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'TitleBarProperty' })
const props = defineProps<{ modelValue: TitleBarProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
//
const rules = {}
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,37 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 视频播放属性 */
export interface VideoPlayerProperty {
// 视频链接
videoUrl: string
// 封面链接
posterUrl: string
// 是否自动播放
autoplay: boolean
// 组件样式
style: VideoPlayerStyle
}
// 视频播放样式
export interface VideoPlayerStyle extends ComponentStyle {
// 视频高度
height: number
}
// 定义组件
export const component = {
id: 'VideoPlayer',
name: '视频播放',
icon: 'ep:video-play',
property: {
videoUrl: '',
posterUrl: '',
autoplay: false,
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
height: 300
} as ComponentStyle
}
} as DiyComponent<VideoPlayerProperty>

View File

@ -0,0 +1,30 @@
<template>
<div class="w-full" :style="{ height: `${property.style.height}px` }">
<el-image class="w-full w-full" :src="property.posterUrl" v-if="property.posterUrl" />
<video
v-else
class="w-full w-full"
:src="property.videoUrl"
:poster="property.posterUrl"
:autoplay="property.autoplay"
controls
></video>
</div>
</template>
<script setup lang="ts">
import { VideoPlayerProperty } from './config'
/** 视频播放 */
defineOptions({ name: 'VideoPlayer' })
defineProps<{ property: VideoPlayerProperty }>()
</script>
<style scoped lang="scss">
/* 图片 */
img {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,55 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<template #style="{ formData }">
<el-form-item label="高度" prop="height">
<el-slider
v-model="formData.height"
:max="500"
:min="100"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</template>
<el-form label-width="80px" :model="formData">
<el-form-item label="上传视频" prop="videoUrl">
<UploadFile
v-model="formData.videoUrl"
:file-type="['mp4']"
:limit="1"
:file-size="100"
class="min-w-80px"
/>
</el-form-item>
<el-form-item label="上传封面" prop="posterUrl">
<UploadImg
v-model="formData.posterUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
>
<template #tip> 建议宽度750 </template>
</UploadImg>
</el-form-item>
<el-form-item label="自动播放" prop="autoplay">
<el-switch v-model="formData.autoplay" />
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<script setup lang="ts">
import { VideoPlayerProperty } from './config'
import { usePropertyForm } from '@/components/DiyEditor/util'
//
defineOptions({ name: 'VideoPlayerProperty' })
const props = defineProps<{ modelValue: VideoPlayerProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,61 @@
/*
*
*
*
* 1.
* 2. config.ts
* 3. index.vue Page
* 4. property.vue
*
*
* ID以config.ts中配置的id为准ID保持一致
*/
// 导入组件界面模块
const viewModules: Record<string, any> = import.meta.glob('./*/*.vue')
// 导入配置模块
const configModules: Record<string, any> = import.meta.glob('./*/config.ts', { eager: true })
// 界面模块
const components = {}
// 组件配置模块
const componentConfigs = {}
// 组件界面的类型
type ViewType = 'index' | 'property'
/**
*
*
* @param componentId ID
* @param configPath
* @param viewType
*/
const registerComponentViewModule = (
componentId: string,
configPath: string,
viewType: ViewType
) => {
const viewPath = configPath.replace('config.ts', `${viewType}.vue`)
const viewModule = viewModules[viewPath]
if (viewModule) {
// 定义异步组件
components[componentId] = defineAsyncComponent(viewModule)
}
}
// 注册
Object.keys(configModules).forEach((modulePath: string) => {
const component = configModules[modulePath].component
const componentId = component?.id
if (componentId) {
// 注册组件
componentConfigs[componentId] = component
// 注册预览界面
registerComponentViewModule(componentId, modulePath, 'index')
// 注册属性配置表单
registerComponentViewModule(`${componentId}Property`, modulePath, 'property')
}
})
export { components, componentConfigs }

View File

@ -0,0 +1,470 @@
<template>
<el-container class="editor">
<!-- 顶部工具栏 -->
<el-header class="editor-header">
<!-- 左侧操作区 -->
<slot name="toolBarLeft"></slot>
<!-- 中心操作区 -->
<div class="header-center flex flex-1 items-center justify-center">
<span>{{ title }}</span>
</div>
<!-- 右侧操作区 -->
<el-button-group class="header-right">
<el-tooltip content="重置">
<el-button @click="handleReset">
<Icon icon="system-uicons:reset-alt" :size="24" />
</el-button>
</el-tooltip>
<el-tooltip content="预览">
<el-button @click="handlePreview">
<Icon icon="ep:view" :size="24" />
</el-button>
</el-tooltip>
<el-tooltip content="保存">
<el-button @click="handleSave">
<Icon icon="ep:check" :size="24" />
</el-button>
</el-tooltip>
</el-button-group>
</el-header>
<!-- 中心区域 -->
<el-container class="editor-container">
<!-- 左侧组件库 -->
<ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" />
<!-- 中心设计区域 -->
<div class="editor-center page-prop-area" @click="handlePageSelected">
<!-- 手机顶部 -->
<div class="editor-design-top">
<!-- 手机顶部状态栏 -->
<img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" />
<!-- 手机顶部导航栏 -->
<ComponentContainer
v-if="showNavigationBar"
:component="navigationBarComponent"
:show-toolbar="false"
:active="selectedComponent?.id === navigationBarComponent.id"
@click="handleNavigationBarSelected"
class="cursor-pointer!"
/>
</div>
<!-- 手机页面编辑区域 -->
<el-scrollbar
height="100%"
wrap-class="editor-design-center page-prop-area"
view-class="phone-container"
:view-style="{
backgroundColor: pageConfigComponent.property.backgroundColor,
backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`
}"
>
<draggable
class="page-prop-area drag-area"
v-model="pageComponents"
item-key="index"
:animation="200"
filter=".component-toolbar"
ghost-class="draggable-ghost"
:force-fallback="true"
group="component"
@change="handleComponentChange"
>
<template #item="{ element, index }">
<ComponentContainer
:component="element"
:active="selectedComponentIndex === index"
:can-move-up="index > 0"
:can-move-down="index < pageComponents.length - 1"
@move="(direction) => handleMoveComponent(index, direction)"
@copy="handleCopyComponent(index)"
@delete="handleDeleteComponent(index)"
@click="handleComponentSelected(element, index)"
/>
</template>
</draggable>
</el-scrollbar>
<!-- 手机底部导航 -->
<div v-if="showTabBar" :class="['editor-design-bottom', 'component', 'cursor-pointer!']">
<ComponentContainer
:component="tabBarComponent"
:show-toolbar="false"
:active="selectedComponent?.id === tabBarComponent.id"
@click="handleTabBarSelected"
/>
</div>
</div>
<!-- 右侧属性面板 -->
<el-aside class="editor-right" width="350px" v-if="selectedComponent?.property">
<el-card
shadow="never"
body-class="h-[calc(100%-var(--el-card-padding)-var(--el-card-padding))]"
class="h-full"
>
<!-- 组件名称 -->
<template #header>
<div class="flex items-center gap-8px">
<Icon :icon="selectedComponent.icon" color="gray" />
<span>{{ selectedComponent.name }}</span>
</div>
</template>
<el-scrollbar
class="m-[calc(0px-var(--el-card-padding))]"
view-class="p-[var(--el-card-padding)] p-b-[calc(var(--el-card-padding)+var(--el-card-padding))] property"
>
<component
:is="selectedComponent.id + 'Property'"
v-model="selectedComponent.property"
/>
</el-scrollbar>
</el-card>
</el-aside>
</el-container>
</el-container>
</template>
<script lang="ts">
//
import { components } from './components/mobile/index'
export default {
components: { ...components }
}
</script>
<script lang="ts" setup>
import draggable from 'vuedraggable'
import ComponentLibrary from './components/ComponentLibrary.vue'
import { cloneDeep, includes } from 'lodash-es'
import { component as PAGE_CONFIG_COMPONENT } from '@/components/DiyEditor/components/mobile/PageConfig/config'
import { component as NAVIGATION_BAR_COMPONENT } from './components/mobile/NavigationBar/config'
import { component as TAB_BAR_COMPONENT } from './components/mobile/TabBar/config'
import { isString } from '@/utils/is'
import { DiyComponent, DiyComponentLibrary, PageConfig } from '@/components/DiyEditor/util'
import { componentConfigs } from '@/components/DiyEditor/components/mobile'
/** 页面装修详情页 */
defineOptions({ name: 'DiyPageDetail' })
//
const message = useMessage()
//
const componentLibrary = ref()
//
const pageConfigComponent = ref<DiyComponent<any>>(cloneDeep(PAGE_CONFIG_COMPONENT))
//
const navigationBarComponent = ref<DiyComponent<any>>(cloneDeep(NAVIGATION_BAR_COMPONENT))
//
const tabBarComponent = ref<DiyComponent<any>>(cloneDeep(TAB_BAR_COMPONENT))
//
const selectedComponent = ref<DiyComponent<any>>()
//
const selectedComponentIndex = ref<number>(-1)
//
const pageComponents = ref<DiyComponent<any>[]>([])
//
const props = defineProps<{
// Json
modelValue: string | PageConfig
//
title: string
//
libs: DiyComponentLibrary[]
//
showNavigationBar: boolean
//
showTabBar: boolean
//
showPageConfig: boolean
}>()
//
watch(
() => props.modelValue,
() => {
const modelValue = isString(props.modelValue)
? (JSON.parse(props.modelValue) as PageConfig)
: props.modelValue
pageConfigComponent.value.property = modelValue?.page || PAGE_CONFIG_COMPONENT.property
navigationBarComponent.value.property =
modelValue?.navigationBar || NAVIGATION_BAR_COMPONENT.property
tabBarComponent.value.property = modelValue?.tabBar || TAB_BAR_COMPONENT.property
//
pageComponents.value = (modelValue?.components || []).map((item) => {
const component = componentConfigs[item.id]
return { ...component, property: item.property }
})
},
{
immediate: true
}
)
//
const handleSave = () => {
const pageConfig = {
page: pageConfigComponent.value.property,
navigationBar: navigationBarComponent.value.property,
tabBar: tabBarComponent.value.property,
components: pageComponents.value.map((component) => {
// APP
return { id: component.id, property: component.property }
})
} as PageConfig
if (!props.showTabBar) {
delete pageConfig.tabBar
}
//
const modelValue = isString(props.modelValue) ? JSON.stringify(pageConfig) : pageConfig
emits('update:modelValue', modelValue)
//
emits('save', pageConfig)
}
//
const handlePageSelected = (event: any) => {
if (!props.showPageConfig) return
// page-prop-area
if (includes(event?.target?.classList, 'page-prop-area')) {
handleComponentSelected(unref(pageConfigComponent))
}
}
/**
* 选中组件
*
* @param component 组件
* @param index 组件的索引
*/
const handleComponentSelected = (component: DiyComponent<any>, index: number = -1) => {
selectedComponent.value = component
selectedComponentIndex.value = index
}
//
const handleNavigationBarSelected = () => {
handleComponentSelected(unref(navigationBarComponent))
}
//
const handleTabBarSelected = () => {
handleComponentSelected(unref(tabBarComponent))
}
//
const handleComponentChange = (dragEvent: any) => {
//
if (dragEvent.added) {
const { element, newIndex } = dragEvent.added
handleComponentSelected(element, newIndex)
} else if (dragEvent.moved) {
//
const { newIndex } = dragEvent.moved
//
selectedComponentIndex.value = newIndex
}
}
//
const swapComponent = (oldIndex: number, newIndex: number) => {
;[pageComponents.value[oldIndex], pageComponents.value[newIndex]] = [
pageComponents.value[newIndex],
pageComponents.value[oldIndex]
]
//
selectedComponentIndex.value = newIndex
}
/** 移动组件 */
const handleMoveComponent = (index: number, direction: number) => {
const newIndex = index + direction
if (newIndex < 0 || newIndex >= pageComponents.value.length) return
swapComponent(index, newIndex)
}
/** 复制组件 */
const handleCopyComponent = (index: number) => {
const component = cloneDeep(pageComponents.value[index])
pageComponents.value.splice(index + 1, 0, component)
}
/**
* 删除组件
* @param index 当前组件index
*/
const handleDeleteComponent = (index: number) => {
//
pageComponents.value.splice(index, 1)
if (index < pageComponents.value.length) {
// 1.
let bottomIndex = index
handleComponentSelected(pageComponents.value[bottomIndex], bottomIndex)
} else if (pageComponents.value.length > 0) {
// 2.
let topIndex = index - 1
handleComponentSelected(pageComponents.value[topIndex], topIndex)
} else {
// 3.
handleComponentSelected(unref(pageConfigComponent))
}
}
//
const emits = defineEmits(['reset', 'preview', 'save', 'update:modelValue'])
//
const handleReset = () => {
message.warning('开发中~')
emits('reset')
}
//
const handlePreview = () => {
message.warning('开发中~')
emits('preview')
}
//
const setDefaultSelectedComponent = () => {
if (props.showPageConfig) {
selectedComponent.value = unref(pageConfigComponent)
} else if (props.showNavigationBar) {
selectedComponent.value = unref(navigationBarComponent)
} else if (props.showTabBar) {
selectedComponent.value = unref(tabBarComponent)
}
}
watch(
() => [props.showPageConfig, props.showNavigationBar, props.showTabBar],
() => setDefaultSelectedComponent()
)
onMounted(() => setDefaultSelectedComponent())
</script>
<style lang="scss" scoped>
/* 手机宽度 */
$phone-width: 375px;
$toolbar-height: 42px;
/* 根节点样式 */
.editor {
display: flex;
height: 100%;
margin: calc(0px - var(--app-content-padding));
flex-direction: column;
/* 顶部:工具栏 */
.editor-header {
display: flex;
height: $toolbar-height;
padding: 0;
background-color: var(--el-bg-color);
border-bottom: solid 1px var(--el-border-color);
align-items: center;
justify-content: space-between;
/* 工具栏:右侧按钮 */
.header-right {
height: 100%;
.el-button {
height: 100%;
}
}
/* 隐藏工具栏按钮的边框 */
:deep(.el-radio-button__inner),
:deep(.el-button) {
border-top: none !important;
border-bottom: none !important;
border-radius: 0 !important;
}
}
/* 中心操作区 */
.editor-container {
height: calc(
100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) -
$toolbar-height
);
/* 右侧属性面板 */
.editor-right {
overflow: hidden;
box-shadow: -8px 0 8px -8px rgb(0 0 0 / 12%);
flex-shrink: 0;
/* 属性面板顶部:减少内边距 */
:deep(.el-card__header) {
padding: 8px 16px;
}
/* 属性面板分组 */
:deep(.property-group) {
margin: 0 -20px;
&.el-card {
border: none;
}
/* 属性分组名称 */
.el-card__header {
padding: 8px 32px;
background: var(--el-bg-color-page);
border: none;
}
.el-card__body {
border: none;
}
}
}
/* 中心区域 */
.editor-center {
position: relative;
display: flex;
width: 100%;
margin: 16px 0 0;
overflow: hidden;
background-color: var(--app-content-bg-color);
flex: 1 1 0;
flex-direction: column;
justify-content: center;
/* 手机顶部 */
.editor-design-top {
display: flex;
width: $phone-width;
margin: 0 auto;
flex-direction: column;
/* 手机顶部状态栏 */
.status-bar {
width: $phone-width;
height: 20px;
background-color: #fff;
}
}
/* 手机底部导航 */
.editor-design-bottom {
width: $phone-width;
margin: 0 auto;
}
/* 手机页面编辑区域 */
:deep(.editor-design-center) {
width: 100%;
/* 主体内容 */
.phone-container {
position: relative;
width: $phone-width;
height: 100%;
margin: 0 auto;
background-repeat: no-repeat;
background-size: 100% 100%;
.drag-area {
width: 100%;
height: 100%;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,117 @@
import { ref, Ref } from 'vue'
import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/PageConfig/config'
import { NavigationBarProperty } from '@/components/DiyEditor/components/mobile/NavigationBar/config'
import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/config'
// 页面装修组件
export interface DiyComponent<T> {
// 组件唯一标识
id: string
// 组件名称
name: string
// 组件图标
icon: string
// 组件属性
property: T
}
// 页面装修组件库
export interface DiyComponentLibrary {
// 组件库名称
name: string
// 是否展开
extended: boolean
// 组件列表
components: string[]
}
// 组件样式
export interface ComponentStyle {
// 背景类型
bgType: 'color' | 'img'
// 背景颜色
bgColor: string
// 背景图片
bgImg: string
// 外边距
margin: number
marginTop: number
marginRight: number
marginBottom: number
marginLeft: number
// 内边距
padding: number
paddingTop: number
paddingRight: number
paddingBottom: number
paddingLeft: number
// 边框圆角
borderRadius: number
borderTopLeftRadius: number
borderTopRightRadius: number
borderBottomRightRadius: number
borderBottomLeftRadius: number
}
// 页面配置
export interface PageConfig {
// 页面属性
page: PageConfigProperty
// 顶部导航栏属性
navigationBar: NavigationBarProperty
// 底部导航菜单属性
tabBar?: TabBarProperty
// 页面组件列表
components: PageComponent[]
}
// 页面组件只保留组件ID组件属性
export interface PageComponent extends Pick<DiyComponent<any>, 'id' | 'property'> {}
// 属性表单监听
export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: Ref<T> } {
const formData = ref<T>()
// 监听属性数据变动
watch(
() => modelValue,
() => {
formData.value = modelValue
},
{
deep: true,
immediate: true
}
)
// 监听表单数据变动
watch(
() => formData.value,
() => {
emit('update:modelValue', formData.value)
},
{
deep: true
}
)
return { formData }
}
// 页面组件库
export const PAGE_LIBS = [
{
name: '基础组件',
extended: true,
components: ['SearchBar', 'NoticeBar', 'MenuSwiper', 'MenuGrid', 'MenuList']
},
{
name: '图文组件',
extended: true,
components: ['ImageBar', 'Carousel', 'TitleBar', 'VideoPlayer', 'Divider', 'MagicCube']
},
{ name: '商品组件', extended: true, components: ['ProductCard'] },
{
name: '会员组件',
extended: true,
components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
},
{ name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
] as DiyComponentLibrary[]

View File

@ -0,0 +1,59 @@
<template>
<el-input v-model="valueRef" v-bind="$attrs">
<template #append>
<el-color-picker v-model="colorRef" :predefine="PREDEFINE_COLORS" />
</template>
</el-input>
</template>
<script lang="ts" setup>
import { propTypes } from '@/utils/propTypes'
import { PREDEFINE_COLORS } from '@/utils/color'
/**
* 带颜色选择器输入框
*/
defineOptions({ name: 'InputWithColor' })
const props = defineProps({
modelValue: propTypes.string.def('').isRequired,
color: propTypes.string.def('').isRequired
})
watch(
() => props.modelValue,
(val: string) => {
if (val === unref(valueRef)) return
valueRef.value = val
}
)
const emit = defineEmits(['update:modelValue', 'update:color'])
//
const valueRef = ref(props.modelValue)
watch(
() => valueRef.value,
(val: string) => {
emit('update:modelValue', val)
}
)
//
const colorRef = ref(props.color)
watch(
() => colorRef.value,
(val: string) => {
emit('update:color', val)
}
)
</script>
<style scoped lang="scss">
:deep(.el-input-group__append) {
padding: 0;
.el-color-picker__trigger {
padding: 0;
border-left: none;
border-radius: 0 var(--el-input-border-radius) var(--el-input-border-radius) 0;
}
}
</style>

View File

@ -0,0 +1,270 @@
<template>
<div class="relative">
<table class="cube-table">
<!-- 底层魔方矩阵 -->
<tbody>
<tr v-for="(rowCubes, row) in cubes" :key="row">
<td
v-for="(cube, col) in rowCubes"
:key="col"
:class="['cube', { active: cube.active }]"
:style="{
width: `${cubeSize}px`,
height: `${cubeSize}px`
}"
@click="handleCubeClick(row, col)"
@mouseenter="handleCellHover(row, col)"
>
<Icon icon="ep-plus" />
</td>
</tr>
</tbody>
<!-- 顶层热区 -->
<div
v-for="(hotArea, index) in hotAreas"
:key="index"
class="hot-area"
:style="{
top: `${cubeSize * hotArea.top}px`,
left: `${cubeSize * hotArea.left}px`,
height: `${cubeSize * hotArea.height}px`,
width: `${cubeSize * hotArea.width}px`
}"
@click="handleHotAreaSelected(hotArea, index)"
@mouseover="exitHotAreaSelectMode"
>
<!-- 右上角热区删除按钮 -->
<div
v-if="selectedHotAreaIndex === index"
class="btn-delete"
@click="handleDeleteHotArea(index)"
>
<Icon icon="ep:circle-close-filled" />
</div>
{{ `${hotArea.width}×${hotArea.height}` }}
</div>
</table>
</div>
</template>
<script lang="ts" setup>
import { propTypes } from '@/utils/propTypes'
import * as vueTypes from 'vue-types'
import { Point, Rect, isContains, isOverlap, createRect } from './util'
//
//
// 1.
//
// 1.1
// 1.2
// 1.3
//
// 2.
defineOptions({ name: 'MagicCubeEditor' })
/**
* 方块
* @property active 是否激活
*/
type Cube = Point & { active: boolean }
//
const props = defineProps({
//
modelValue: vueTypes.array<any>().isRequired,
// 4
rows: propTypes.number.def(4),
// 4
cols: propTypes.number.def(4),
// px75px
cubeSize: propTypes.number.def(75)
})
//
const cubes = ref<Cube[][]>([])
//
watch(
() => [props.rows, props.cols],
() => {
//
cubes.value = []
if (!props.rows || !props.cols) return
//
for (let row = 0; row < props.rows; row++) {
cubes.value[row] = []
for (let col = 0; col < props.cols; col++) {
cubes.value[row].push({ x: col, y: row, active: false })
}
}
},
{ immediate: true }
)
//
const hotAreas = ref<Rect[]>([])
//
watch(
() => props.modelValue,
() => (hotAreas.value = props.modelValue || []),
{ immediate: true }
)
//
const hotAreaBeginCube = ref<Cube>()
//
const isHotAreaSelectMode = () => !!hotAreaBeginCube.value
/**
* 处理鼠标点击方块
*
* @param currentRow 当前行号
* @param currentCol 当前列号
*/
const handleCubeClick = (currentRow: number, currentCol: number) => {
const currentCube = cubes.value[currentRow][currentCol]
// 1
if (!isHotAreaSelectMode()) {
hotAreaBeginCube.value = currentCube
hotAreaBeginCube.value.active = true
return
}
// 2
hotAreas.value.push(createRect(hotAreaBeginCube.value!, currentCube))
//
exitHotAreaSelectMode()
//
let hotAreaIndex = hotAreas.value.length - 1
handleHotAreaSelected(hotAreas.value[hotAreaIndex], hotAreaIndex)
//
emitUpdateModelValue()
}
/**
* 处理鼠标经过方块
*
* @param currentRow 当前行号
* @param currentCol 当前列号
*/
const handleCellHover = (currentRow: number, currentCol: number) => {
//
if (!isHotAreaSelectMode()) return
//
const currentSelectedArea = createRect(
hotAreaBeginCube.value!,
cubes.value[currentRow][currentCol]
)
//
for (const hotArea of hotAreas.value) {
//
if (isOverlap(hotArea, currentSelectedArea)) {
//
exitHotAreaSelectMode()
return
}
}
//
eachCube((_, __, cube) => {
cube.active = isContains(currentSelectedArea, cube)
})
}
/**
* 处理热区删除
*
* @param index 热区索引
*/
const handleDeleteHotArea = (index: number) => {
hotAreas.value.splice(index, 1)
//
exitHotAreaSelectMode()
//
emitUpdateModelValue()
}
//
const emit = defineEmits(['update:modelValue', 'hotAreaSelected'])
//
const emitUpdateModelValue = () => emit('update:modelValue', hotAreas)
//
const selectedHotAreaIndex = ref(-1)
const handleHotAreaSelected = (hotArea: Rect, index: number) => {
selectedHotAreaIndex.value = index
emit('hotAreaSelected', hotArea, index)
}
/**
* 结束热区选择模式
*/
function exitHotAreaSelectMode() {
//
eachCube((_, __, cube) => {
if (cube.active) {
cube.active = false
}
})
//
hotAreaBeginCube.value = undefined
}
/**
* 迭代魔方矩阵
* @param callback 回调
*/
const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
for (let x = 0; x < cubes.value.length; x++) {
for (let y = 0; y < cubes.value[x].length; y++) {
callback(x, y, cubes.value[x][y])
}
}
}
</script>
<style lang="scss" scoped>
.cube-table {
position: relative;
border-spacing: 0;
border-collapse: collapse;
.cube {
border: 1px solid var(--el-border-color);
text-align: center;
color: var(--el-text-color-secondary);
cursor: pointer;
box-sizing: border-box;
&.active {
background: var(--el-color-primary-light-9);
}
}
.hot-area {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--el-color-primary);
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
box-sizing: border-box;
border-spacing: 0;
border-collapse: collapse;
cursor: pointer;
.btn-delete {
z-index: 1;
position: absolute;
top: -8px;
right: -8px;
height: 16px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
}
}
}
</style>

View File

@ -0,0 +1,72 @@
// 坐标点
export interface Point {
x: number
y: number
}
// 矩形
export interface Rect {
// 左上角 X 轴坐标
left: number
// 左上角 Y 轴坐标
top: number
// 右下角 X 轴坐标
right: number
// 右下角 Y 轴坐标
bottom: number
// 矩形宽度
width: number
// 矩形高度
height: number
}
/**
*
* @param a A
* @param b B
*/
export const isOverlap = (a: Rect, b: Rect): boolean => {
return (
a.left < b.left + b.width &&
a.left + a.width > b.left &&
a.top < b.top + b.height &&
a.height + a.top > b.top
)
}
/**
*
* @param hotArea
* @param point
*/
export const isContains = (hotArea: Rect, point: Point): boolean => {
return (
point.x >= hotArea.left &&
point.x < hotArea.right &&
point.y >= hotArea.top &&
point.y < hotArea.bottom
)
}
/**
*
*
*
* 1. 1
* 2. X 1
* 3. Y 1
* 4.
*
* @param a
* @param b
*/
export const createRect = (a: Point, b: Point): Rect => {
// 计算矩形的范围
const [left, left2] = [a.x, b.x].sort()
const [top, top2] = [a.y, b.y].sort()
const right = left2 + 1
const bottom = top2 + 1
const height = bottom - top
const width = right - left
return { left, right, top, bottom, height, width }
}

View File

@ -33,11 +33,10 @@
</div>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { getAccessToken, getTenantId } from '@/utils/auth'
import type { UploadInstance, UploadUserFile, UploadProps, UploadRawFile } from 'element-plus'
import { isArray, isString } from '@/utils/is'
defineOptions({ name: 'UploadFile' })
@ -45,10 +44,7 @@ const message = useMessage() // 消息弹窗
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {
type: Array as PropType<UploadUserFile[]>,
required: true
},
modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
title: propTypes.string.def('文件上传'),
updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // , ['png', 'jpg', 'jpeg']
@ -62,7 +58,7 @@ const props = defineProps({
const valueRef = ref(props.modelValue)
const uploadRef = ref<UploadInstance>()
const uploadList = ref<UploadUserFile[]>([])
const fileList = ref<UploadUserFile[]>(props.modelValue)
const fileList = ref<UploadUserFile[]>([])
const uploadNumber = ref<number>(0)
const uploadHeaders = ref({
Authorization: 'Bearer ' + getAccessToken(),
@ -109,7 +105,7 @@ const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => {
fileList.value = fileList.value.concat(uploadList.value)
uploadList.value = []
uploadNumber.value = 0
emit('update:modelValue', listToString(fileList.value))
emitUpdateModelValue()
}
}
//
@ -125,20 +121,49 @@ const handleRemove = (file) => {
const findex = fileList.value.map((f) => f.name).indexOf(file.name)
if (findex > -1) {
fileList.value.splice(findex, 1)
emit('update:modelValue', listToString(fileList.value))
emitUpdateModelValue()
}
}
const handlePreview: UploadProps['onPreview'] = (uploadFile) => {
console.log(uploadFile)
}
//
const listToString = (list: UploadUserFile[], separator?: string) => {
let strs = ''
separator = separator || ','
for (let i in list) {
strs += list[i].url + separator
//
watch(
() => props.modelValue,
() => {
const files: string[] = []
// 1
if (isString(props.modelValue)) {
// 1.1
if (props.modelValue.includes(',')) {
files.concat(props.modelValue.split(','))
} else if (props.modelValue.length > 0) {
files.push(props.modelValue)
}
} else if (isArray(props.modelValue)) {
// 2
files.concat(props.modelValue)
} else if (props.modelValue == null) {
// 3undefined
} else {
throw new Error('不支持的 modelValue 类型')
}
fileList.value = files.map((url: string) => {
return { url, name: url.substring(url.lastIndexOf('/') + 1) } as UploadUserFile
})
},
{ immediate: true }
)
//
const emitUpdateModelValue = () => {
// 1
let result: string | string[] = fileList.value.map((file) => file.url!)
// 2
if (isString(props.modelValue)) {
result = result.join(',')
}
return strs != '' ? strs.substr(0, strs.length - 1) : ''
emit('update:modelValue', result)
}
</script>
<style scoped lang="scss">

View File

@ -18,15 +18,15 @@
<div class="upload-handle" @click.stop>
<div class="handle-icon" @click="editImg">
<Icon icon="ep:edit" />
<span>{{ t('action.edit') }}</span>
<span v-if="showBtnText">{{ t('action.edit') }}</span>
</div>
<div class="handle-icon" @click="imgViewVisible = true">
<Icon icon="ep:zoom-in" />
<span>{{ t('action.detail') }}</span>
<span v-if="showBtnText">{{ t('action.detail') }}</span>
</div>
<div class="handle-icon" @click="deleteImg">
<div class="handle-icon" @click="deleteImg" v-if="showDelete">
<Icon icon="ep:delete" />
<span>{{ t('action.del') }}</span>
<span v-if="showBtnText">{{ t('action.del') }}</span>
</div>
</div>
</template>
@ -81,7 +81,11 @@ const props = defineProps({
fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // ==> ["image/jpeg", "image/png", "image/gif"]
height: propTypes.string.def('150px'), // ==> 150px
width: propTypes.string.def('150px'), // ==> 150px
borderradius: propTypes.string.def('8px') // ==> 8px
borderradius: propTypes.string.def('8px'), // ==> 8px
//
showDelete: propTypes.bool.def(true),
//
showBtnText: propTypes.bool.def(true)
})
const { t } = useI18n() //
const message = useMessage() //

View File

@ -277,7 +277,7 @@ const end = () => {
endMovetime.value = +new Date()
//
if (status.value && isEnd.value == false) {
var moveLeftDistance = parseInt((moveBlockLeft.value || '').replace('px', ''))
var moveLeftDistance = parseInt((moveBlockLeft.value || '0').replace('px', ''))
moveLeftDistance = (moveLeftDistance * 310) / parseInt(setSize.imgWidth)
let data = {
captchaType: captchaType.value,

View File

@ -0,0 +1,44 @@
<template>
<el-button-group v-bind="$attrs">
<slot></slot>
</el-button-group>
</template>
<script setup lang="ts">
/**
* 垂直按钮组
* Element官方的按钮组只支持水平显示通过重写样式实现垂直布局
*/
defineOptions({ name: 'VerticalButtonGroup' })
</script>
<style scoped lang="scss">
.el-button-group {
display: inline-flex;
flex-direction: column;
}
.el-button-group > :deep(.el-button:first-child) {
border-bottom-color: var(--el-button-divide-border-color);
border-top-right-radius: var(--el-border-radius-base);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.el-button-group > :deep(.el-button:last-child) {
border-top-color: var(--el-button-divide-border-color);
border-top-right-radius: 0;
border-bottom-left-radius: var(--el-border-radius-base);
border-top-left-radius: 0;
}
.el-button-group :deep(.el-button--primary:not(:first-child, :last-child)) {
border-top-color: var(--el-button-divide-border-color);
border-bottom-color: var(--el-button-divide-border-color);
}
.el-button-group > :deep(.el-button:not(:last-child)) {
margin-right: 0;
margin-bottom: -1px;
}
</style>

View File

@ -217,6 +217,10 @@ const refreshToken = async () => {
const handleAuthorized = () => {
const { t } = useI18n()
if (!isRelogin.show) {
// 如果已经到重新登录页面则不进行弹窗提示
if (window.location.href.includes('login?redirect=')) {
return
}
isRelogin.show = true
ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), {
showCancelButton: false,

View File

@ -19,6 +19,6 @@ const title = computed(() => appStore.getTitle)
:class="prefixCls"
class="h-[var(--app-footer-height)] bg-[var(--app-content-bg-color)] text-center leading-[var(--app-footer-height)] text-[var(--el-text-color-placeholder)] dark:bg-[var(--el-bg-color)]"
>
<p style="font-size: 14px">Copyright ©2022-{{ title }}</p>
<span class="text-14px">Copyright ©2022-{{ title }}</span>
</div>
</template>

View File

@ -88,8 +88,8 @@ onMounted(() => {
}
.message-list {
height: 400px;
display: flex;
height: 400px;
flex-direction: column;
.message-item {

View File

@ -459,6 +459,62 @@ const remainingRouter: AppRouteRecordRaw[] = [
component: () => import('@/views/pay/cashier/index.vue')
}
]
},
{
path: '/diy',
name: 'DiyCenter',
meta: { hidden: true },
component: Layout,
children: [
{
path: 'template/decorate/:id',
name: 'DiyTemplateDecorate',
meta: {
title: '模板装修',
noCache: true,
hidden: true
},
component: () => import('@/views/mall/promotion/diy/template/decorate.vue')
},
{
path: 'page/decorate/:id',
name: 'DiyPageDecorate',
meta: {
title: '页面装修',
noCache: true,
hidden: true
},
component: () => import('@/views/mall/promotion/diy/page/decorate.vue')
}
]
},
{
path: '/crm',
component: Layout,
name: 'CrmCenter',
meta: { hidden: true },
children: [
{
path: 'customer/detail/:id',
name: 'CrmCustomerDetail',
meta: {
title: '客户详情',
noCache: true,
hidden: true
},
component: () => import('@/views/crm/customer/detail/index.vue')
},
{
path: 'contact/detail/:id',
name: 'CrmContactDetail',
meta: {
title: '联系人详情',
noCache: true,
hidden: true
},
component: () => import('@/views/crm/contact/detail/index.vue')
}
]
}
]

View File

@ -151,3 +151,24 @@ const subtractLight = (color: string, amount: number) => {
const c = cc < 0 ? 0 : cc
return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
}
// 预设颜色
export const PREDEFINE_COLORS = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'#409EFF',
'#909399',
'#C0C4CC',
'#b7390b',
'#ff7800',
'#fad400',
'#5b8c5f',
'#00babd',
'#1f73c3',
'#711f57'
]

View File

@ -144,6 +144,8 @@ export enum DICT_TYPE {
PAY_REFUND_STATUS = 'pay_refund_status', // 退款订单状态
PAY_NOTIFY_STATUS = 'pay_notify_status', // 商户支付回调状态
PAY_NOTIFY_TYPE = 'pay_notify_type', // 商户支付回调状态
PAY_TRANSFER_STATUS = 'pay_transfer_status', // 转账订单状态
PAY_TRANSFER_TYPE = 'pay_transfer_type', // 转账订单状态
// ========== MP 模块 ==========
MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
@ -192,5 +194,6 @@ export enum DICT_TYPE {
CRM_RETURN_TYPE = 'crm_return_type',
CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry',
CRM_CUSTOMER_LEVEL = 'crm_customer_level',
CRM_CUSTOMER_SOURCE = 'crm_customer_source'
CRM_CUSTOMER_SOURCE = 'crm_customer_source',
CRM_PRODUCT_STATUS = 'crm_product_status'
}

View File

@ -193,10 +193,10 @@ const loginData = reactive({
})
const socialList = [
{ icon: 'ant-design:github-filled', type: 0 },
{ icon: 'ant-design:wechat-filled', type: 30 },
{ icon: 'ant-design:alipay-circle-filled', type: 0 },
{ icon: 'ant-design:dingtalk-circle-filled', type: 20 }
{ icon: 'ant-design:dingtalk-circle-filled', type: 20 },
{ icon: 'ant-design:github-filled', type: 0 },
{ icon: 'ant-design:alipay-circle-filled', type: 0 }
]
//
@ -210,7 +210,7 @@ const getCode = async () => {
verify.value.show()
}
}
//ID
// ID
const getTenantId = async () => {
if (loginData.tenantEnable === 'true') {
const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
@ -230,6 +230,15 @@ const getCookie = () => {
}
}
}
//
const getTenantByWebsite = async () => {
const website = location.host
const res = await LoginApi.getTenantByWebsite(website)
if (res) {
loginData.loginForm.tenantName = res.name
authUtil.setTenantId(res.id)
}
}
const loading = ref() // ElLoading.service
//
const handleLogin = async (params) => {
@ -278,10 +287,15 @@ const doSocialLogin = async (type: number) => {
} else {
loginLoading.value = true
if (loginData.tenantEnable === 'true') {
await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => {
const res = await LoginApi.getTenantIdByName(value)
authUtil.setTenantId(res)
})
// tenantName
await getTenantId()
//
if (!authUtil.getTenantId()) {
await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => {
const res = await LoginApi.getTenantIdByName(value)
authUtil.setTenantId(res)
})
}
}
// redirectUri
// tricky: typeredirectencode
@ -307,6 +321,7 @@ watch(
)
onMounted(() => {
getCookie()
getTenantByWebsite()
})
</script>

View File

@ -66,7 +66,6 @@ const bindSocial = () => {
socialBind(type, code, state).then(() => {
message.success('绑定成功')
emit('update:activeName', 'userSocial')
initSocial()
})
}

View File

@ -10,7 +10,7 @@
<el-form-item label="线索名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入线索名称" />
</el-form-item>
<!-- TODO 客户选择 -->
<!-- TODO wanwan 客户选择 -->
<el-form-item label="客户" prop="customerId">
<el-input v-model="formData.customerId" placeholder="请选择客户" />
</el-form-item>
@ -31,7 +31,7 @@
<el-form-item label="地址" prop="address">
<el-input v-model="formData.address" placeholder="请输入地址" />
</el-form-item>
<!-- TODO 负责人选择 -->
<!-- TODO wanwan 负责人选择 -->
<el-form-item label="负责人" prop="ownerUserId">
<el-input v-model="formData.ownerUserId" placeholder="请输入负责人" />
</el-form-item>
@ -46,7 +46,6 @@
</Dialog>
</template>
<script setup lang="ts">
import { DICT_TYPE, getBoolDictOptions } from '@/utils/dict'
import * as ClueApi from '@/api/crm/clue'
const { t } = useI18n() //

View File

@ -96,7 +96,7 @@
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center">
<el-table-column label="操作" align="center" min-width="110" fixed="right">
<template #default="scope">
<el-button
link

View File

@ -0,0 +1,115 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="30%">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item v-if="formType === 'create'" label="选择人员" prop="userId">
<el-select v-model="formData.userId">
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.nickname"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="权限级别" prop="level">
<el-radio-group v-model="formData.level">
<!-- TODO @puhui999搞个字典配置然后这里 remove 掉负责人 -->
<el-radio :label="CrmPermissionLevelEnum.READ">只读</el-radio>
<el-radio :label="CrmPermissionLevelEnum.WRITE">读写</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as UserApi from '@/api/system/user'
import * as PermissionApi from '@/api/crm/permission'
import { CrmPermissionLevelEnum } from './index'
defineOptions({ name: 'CrmPermissionForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const userOptions = ref<UserApi.UserVO[]>([]) //
const formData = ref<PermissionApi.PermissionVO & { ids?: number[] }>({
userId: undefined, //
bizType: undefined, // Crm
bizId: undefined, // Crm
level: undefined //
})
const formRules = reactive({
userId: [{ required: true, message: '人员不能为空', trigger: 'blur' }],
level: [{ required: true, message: '权限级别不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: 'create' | 'update', bizType: number, bizId: number, ids?: number[]) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type) + '团队成员'
formType.value = type
resetForm(bizType, bizId)
//
if (ids) {
formData.value.ids = ids
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
//
formLoading.value = true
try {
const data = formData.value
if (formType.value === 'create') {
await PermissionApi.createPermission(unref(data))
message.success(t('common.createSuccess'))
} else {
await PermissionApi.updatePermission(unref(data))
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = (bizType: number, bizId: number) => {
formRef.value?.resetFields()
formData.value = {
userId: undefined, //
bizType, // Crm
bizId, // Crm
level: undefined //
}
}
onMounted(async () => {
//
// TODO
userOptions.value = await UserApi.getSimpleUserList()
})
</script>

View File

@ -0,0 +1,156 @@
<template>
<!-- 操作栏 -->
<el-row justify="end">
<el-button type="primary" @click="handleAdd">
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
<el-button @click="handleEdit">
<Icon class="mr-5px" icon="ep:edit" />
编辑
</el-button>
<el-button @click="handleRemove">
<Icon class="mr-5px" icon="ep:delete" />
移除
</el-button>
<el-button type="danger" @click="handleQuit"> 退出团队</el-button>
</el-row>
<!-- 团队成员展示 -->
<el-table
v-loading="loading"
:data="list"
:show-overflow-tooltip="true"
:stripe="true"
class="mt-20px"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column align="center" label="姓名" prop="nickname" />
<el-table-column align="center" label="部门" prop="deptName" />
<el-table-column align="center" label="岗位" prop="postNames" />
<el-table-column align="center" label="权限级别" prop="level">
<template #default="{ row }">
<el-tag>{{ getLevelName(row.level) }}</el-tag>
</template>
</el-table-column>
<el-table-column :formatter="dateFormatter" align="center" label="加入时间" prop="createTime" />
</el-table>
<CrmPermissionForm ref="crmPermissionFormRef" />
</template>
<script lang="ts" setup>
// TODO @puhui999 CrmPermissionList
import { dateFormatter } from '@/utils/formatTime'
import { ElTable } from 'element-plus'
import * as PermissionApi from '@/api/crm/permission'
import { useUserStoreWithOut } from '@/store/modules/user'
import CrmPermissionForm from './CrmPermissionForm.vue'
import { CrmPermissionLevelEnum } from './index'
defineOptions({ name: 'CrmTeam' })
const message = useMessage() //
const props = defineProps<{
bizType: number
bizId: number
}>()
const loading = ref(true) //
const list = ref<PermissionApi.PermissionVO[]>([
// TODO
{
id: 1, //
userId: 1, //
bizType: 1, // Crm
bizId: 1, // Crm
level: 1, //
deptName: '研发部门', //
nickname: '芋道源码', //
postNames: '全栈开发工程师', //
createTime: new Date()
}
]) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await PermissionApi.getPermissionList({
bizType: props.bizType,
bizId: props.bizId
})
list.value = data
} finally {
loading.value = false
}
}
// TODO @puhui999
/**
* 获得权限级别名称
* @param level 权限级别
*/
const getLevelName = computed(() => (level: number) => {
switch (level) {
case CrmPermissionLevelEnum.OWNER:
return '负责人'
case CrmPermissionLevelEnum.READ:
return '只读'
case CrmPermissionLevelEnum.WRITE:
return '读写'
default:
break
}
})
// TODO @puhui999
const multipleSelection = ref<PermissionApi.PermissionVO[]>([])
const handleSelectionChange = (val: PermissionApi.PermissionVO[]) => {
multipleSelection.value = val
}
// TODO @puhui999 index.vue
const crmPermissionFormRef = ref<InstanceType<typeof CrmPermissionForm>>()
const handleEdit = () => {
if (multipleSelection.value?.length === 0) {
message.warning('请先选择团队成员后操作!')
return
}
const ids = multipleSelection.value?.map((item) => item.id)
crmPermissionFormRef.value?.open('update', props.bizType, props.bizId, ids)
}
const handleRemove = async () => {
if (multipleSelection.value?.length === 0) {
message.warning('请先选择团队成员后操作!')
return
}
await message.delConfirm()
const ids = multipleSelection.value?.map((item) => item.id)
await PermissionApi.deletePermission({
bizType: props.bizType,
bizId: props.bizId,
ids
})
}
const handleAdd = () => {
crmPermissionFormRef.value?.open('create', props.bizType, props.bizId)
}
const userStore = useUserStoreWithOut()
const handleQuit = async () => {
const permission = list.value.find(
(item) => item.userId === userStore.getUser.id && item.level === CrmPermissionLevelEnum.OWNER
)
if (permission) {
message.warning('负责人不能退出团队!')
return
}
const userPermission = list.value.find((item) => item.userId === userStore.getUser.id)
await PermissionApi.quitTeam(userPermission?.id)
}
watch(
() => props.bizId,
() => {
getList()
},
{ immediate: true, deep: true }
)
</script>

View File

@ -0,0 +1,17 @@
import CrmTeam from './CrmTeamList.vue'
enum CrmBizTypeEnum {
CRM_LEADS = 1, // 线索
CRM_CUSTOMER = 2, // 客户
CRM_CONTACTS = 3, // 联系人
CRM_BUSINESS = 5, // 商机
CRM_CONTRACT = 6 // 合同
}
enum CrmPermissionLevelEnum {
OWNER = 1, // 负责人
READ = 2, // 读
WRITE = 3 // 写
}
export { CrmTeam, CrmBizTypeEnum, CrmPermissionLevelEnum }

Some files were not shown because too many files have changed in this diff Show More