This commit is contained in:
ShawnPhang 2023-07-18 15:03:15 +08:00
parent 3b2bb39c2e
commit c25bd8b3b0
184 changed files with 84355 additions and 2 deletions

40
.eslintrc.js Normal file
View File

@ -0,0 +1,40 @@
/*
* @Author: ShawnPhang
* @Date: 2021-07-13 18:46:45
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2021-07-30 14:11:34
*/
module.exports = {
extends: [
'alloy',
'alloy/vue',
// 'alloy/typescript',
'@vue/typescript',
],
env: {
// 你的环境变量(包含多个预定义的全局变量)
//
// browser: true,
// node: true,
// mocha: true,
// jest: true,
// jquery: true
},
globals: {
// 你的全局变量(设置为 false 表示它不允许被重新赋值)
//
// myGlobal: false
},
rules: {
// 自定义你的规则
'vue/component-tags-order': ['off'],
'vue/no-multiple-template-root': ['off'],
// 'no-undef': 'off', // 禁止使用未定义的变量会把TS声明视为变量暂时关闭
},
parserOptions: {
ecmaFeatures: {
legacyDecorators: true, // 配置允许注解
},
},
}

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
.DS_Store
node_modules
/dist
config.json
screenshot/node_modules/
screenshot/dist/
screenshot/_apidoc/
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

43
.prettierrc.js Normal file
View File

@ -0,0 +1,43 @@
// .prettierrc.js 代码美化规则配置
module.exports = {
// 一行最多 n 字符
printWidth: 1000,
// 使用 2 个空格缩进
tabWidth: 2,
// 不使用缩进符,而使用空格
useTabs: false,
// 行尾需要有分号
semi: false,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾需要有逗号
trailingComma: 'all',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'always',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// vue 文件中的 script 和 style 内不用缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf
endOfLine: 'lf',
// 格式化嵌入的内容
embeddedLanguageFormatting: 'auto',
}

211
README.md
View File

@ -1,5 +1,212 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-07-14 10:44:31
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-18 15:01:01
-->
[在线体验](https://design.palxp.com/) | [中文文档](https://xp.palxp.com/)
## 迅排设计 ## 迅排设计
一款漂亮的简易在线海报设计器,仿稿定设计。 一款漂亮且功能强大的在线海报图片设计器,仿稿定设计。
重构版本7月份开源敬请期待~ 让你轻松实现创意,迅速进行排版,感受云上设计带来的乐趣!
> 环境需求:**Node.js v16** 以上版本
### 拉取源码
```
git clone https://github.com/palxiao/poster-design.git
cd poster-design
```
### 安装依赖
```
npm run prepared
```
### 本地运行
```
npm run serve
```
> 将会同时运行前端界面与图片生成服务:
>
> ![](http://127.0.0.1:3366/images/2023-7-16-1689498291322.png)
### 运行结果
![](http://127.0.0.1:3366/images/2023-7-16-1689500112694.gif)
合成图片时本地会启动一个 Chrome 浏览器实例。
### 打包前端页面
```
npm run v-build
```
### 打包图片生成服务
```
cd sreenshot
npm run build
```
### 服务端
可参考接口 API 文档自行实现服务端。
### 服务器配置
在 Linux 环境下npm 自动安装的 Chrome 浏览器有可能会出错,所以推荐从外部安装好浏览器,再给项目中的配置文件 `config.js` 设置好路径,例如:
```js
exports.executablePath = '/opt/google/chrome-unstable/chrome'
```
一些可能用到的 linux 命令参考:
```shell
google-chrome --version # 查看浏览器版本号
apt-get update
apt-get install -y google-chrome-stable // 安装最新稳定版谷歌浏览器
```
## Docker 容器
可以通过 Docker 运行一个带 Linux 浏览器的容器,然后暴露一个截图服务以供使用,我所使用的基础镜像为:
```
docker pull howard86/puppeteer_node:12
```
运行容器命令参考(其中映射了 `/cache` 为临时目录,放生成图片用):
```
docker run -itd -v /data/docker-home:/home -v /data/cache:/cache -p 7001:7001 --name screenshot howard86/puppeteer_node:12
```
运行后可以手动进入容器中查看谷歌浏览器版本,看需不需要升级,然后安装 pm2 作为服务管理工具,服务启动/重部署相关脚本命令可参考:
```shell
docker exec screenshot /bin/bash -c 'pm2 delete screenshot-service'
docker exec screenshot /bin/bash -c 'cd /home/ && yarn'
docker exec screenshot /bin/bash -c 'pm2 start /home/screenshot-service.js'
docker exec screenshot /bin/bash -c 'pm2 flush'
```
如果不想像上面这样直接操作容器,可以在本地/服务器先运行镜像,进入容器中照例配置好 pm2然后把该容器导出为新的镜像例如new-design/screenshot命令运行参考
```
docker run -itd -u root -v ~/data/tmp/screenshot:/cache -p 9001:9001 --name screenshot2 new-design/screenshot /bin/sh -c "/usr/local/bin/pm2 start /home/dist/server.js && /usr/local/bin/pm2 flush"
```
这种方式只需要一个镜像以及一个启动命令即可部署,重新跑一遍命令也就相当于重启整个容器。> 环境需求:**Node.js v16** 以上版本
### 拉取源码
```
git clone https://github.com/palxiao/poster-design.git
cd poster-design
```
### 安装依赖
```
npm run prepared
```
### 本地运行
```
npm run serve
```
> 将会同时运行前端界面与图片生成服务:
>
> ![](http://127.0.0.1:3366/images/2023-7-16-1689498291322.png)
### 运行结果
![](http://127.0.0.1:3366/images/2023-7-16-1689500112694.gif)
合成图片时本地会启动一个 Chrome 浏览器实例。
### 打包前端页面
```
npm run v-build
```
### 打包图片生成服务
```
cd sreenshot
npm run build
```
### 服务端
可参考接口 API 文档自行实现服务端。
### 服务器配置
在 Linux 环境下npm 自动安装的 Chrome 浏览器有可能会出错,所以推荐从外部安装好浏览器,再给项目中的配置文件 `config.js` 设置好路径,例如:
```js
exports.executablePath = '/opt/google/chrome-unstable/chrome'
```
一些可能用到的 linux 命令参考:
```shell
google-chrome --version # 查看浏览器版本号
apt-get update
apt-get install -y google-chrome-stable // 安装最新稳定版谷歌浏览器
```
## Docker 容器
可以通过 Docker 运行一个带 Linux 浏览器的容器,然后暴露一个截图服务以供使用,我所使用的基础镜像为:
```
docker pull howard86/puppeteer_node:12
```
运行容器命令参考(其中映射了 `/cache` 为临时目录,放生成图片用):
```
docker run -itd -v /data/docker-home:/home -v /data/cache:/cache -p 7001:7001 --name screenshot howard86/puppeteer_node:12
```
运行后可以手动进入容器中查看谷歌浏览器版本,看需不需要升级,然后安装 pm2 作为服务管理工具,服务启动/重部署相关脚本命令可参考:
```shell
docker exec screenshot /bin/bash -c 'pm2 delete screenshot-service'
docker exec screenshot /bin/bash -c 'cd /home/ && yarn'
docker exec screenshot /bin/bash -c 'pm2 start /home/screenshot-service.js'
docker exec screenshot /bin/bash -c 'pm2 flush'
```
如果不想像上面这样直接操作容器,可以在本地/服务器先运行镜像,进入容器中照例配置好 pm2然后把该容器导出为新的镜像例如new-design/screenshot命令运行参考
```
docker run -itd -u root -v ~/data/tmp/screenshot:/cache -p 9001:9001 --name screenshot2 new-design/screenshot /bin/sh -c "/usr/local/bin/pm2 start /home/dist/server.js && /usr/local/bin/pm2 flush"
```
这种方式只需要一个镜像以及一个启动命令即可部署,重新跑一遍命令也就相当于重启整个容器。
## 感谢
本项目使用或参考了以下优秀开源项目:
- [moveable](https://github.com/daybrush/moveable): 提供了画布中选择、拖动缩放等能力
- [html2canvas](https://github.com/niklasvh/html2canvas): 前端生成图片兜底方案
- [qr-code-styling](https://qr-code-styling.com/): 风格化二维码
- [sky](https://github.com/cfour-hi/sky): 参考了其 PSD 解析的实现

5
babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

23
index.html Normal file
View File

@ -0,0 +1,23 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-01-17 16:06:30
* @Description: Design Palxp
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-13 10:32:13
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>迅排设计 - 轻松创意,迅捷排版,感受云上设计带来的乐趣!</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script defer src="/snap.svg-min.js"></script>
<!-- <script defer src="https://cdn.palxp.com/snap.svg-min.js"></script> -->
</body>
</html>

28999
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "xunpai-design",
"version": "1.0.0",
"private": true,
"author": "ShawnPhang",
"scripts": {
"prepared": "npm i --force && cd screenshot && npm i",
"serve": "npm run dev & cd screenshot && npm run dev",
"dev": "cross-env NODE_ENV=development vite",
"v-build": "cross-env NODE_ENV=production && vite build",
"v-build-hard": "cross-env NODE_ENV=production vue-tsc --noEmit && vite build",
"build": "node script/set config.json && npm run v-build && sh script/reverse.sh",
"publish": "sh script/publish.sh",
"publish-fast": "git add . && git commit -m 'build: auto publish' && sh script/publish.sh"
},
"dependencies": {
"@gradio/client": "^0.1.4",
"@palxp/color-picker": "^1.2.5",
"@scena/guides": "^0.18.1",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"dayjs": "^1.10.7",
"element-plus": "^2.3.7",
"fontfaceobserver": "^2.1.0",
"html2canvas": "^1.4.1",
"moveable": "^0.26.0",
"moveable-helper": "^0.4.0",
"nanoid": "^3.1.23",
"normalize.css": "^8.0.1",
"qr-code-styling": "^1.6.0-rc.1",
"selecto": "^1.13.0",
"throttle-debounce": "^3.0.1",
"v-lazy-image": "^2.1.1",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0",
"vuedraggable": "^4.1.0",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@types/node": "^16.3.1",
"@types/throttle-debounce": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^4.28.3",
"@typescript-eslint/parser": "^4.28.3",
"@vitejs/plugin-vue": "^1.2.4",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.1.4",
"@vue/eslint-config-typescript": "^7.0.0",
"autoprefixer": "^10.3.1",
"babel-eslint": "^10.1.0",
"cross-env": "^7.0.3",
"esbuild-loader": "^2.13.1",
"eslint": "^7.29.0",
"eslint-config-alloy": "^4.1.0",
"eslint-plugin-vue": "^7.12.1",
"less": "^4.1.1",
"sass": "^1.63.6",
"typescript": "~4.1.5",
"unplugin-element-plus": "^0.7.1",
"vite": "^2.4.1",
"vite-plugin-compression": "^0.3.0",
"vue-cli-plugin-norm": "~1.2.2",
"vue-eslint-parser": "^7.6.0",
"vue-tsc": "^0.2.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

25
postcss.config.js Normal file
View File

@ -0,0 +1,25 @@
/*
* @Author: ShawnPhang
* @Date: 2022-10-11 14:00:18
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-26 20:48:18
*/
module.exports = {
plugins: {
// "postcss-import": {},
// "postcss-url": {},
// to edit target browsers: use "browserslist" field in package.json
autoprefixer: {
overrideBrowserslist: [
'Android 4.1',
'iOS 7.1',
'Chrome > 31',
'ff > 31',
'ie >= 8',
'last 10 versions', // 所有主流浏览器最近10版本用
],
grid: true,
},
},
}

1
public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg t="1647329483504" class="icon" viewBox="0 0 1260 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4962" width="32" height="32" data-spm-anchor-id="a313x.7781069.0.i13"><path d="M166.137 564.657a35.604 35.604 0 1 1 71.168 0 35.604 35.604 0 0 1-71.168 0z m54.745-179.83a55.847 55.847 0 1 1 111.695 0 55.847 55.847 0 0 1-111.656 0z m206.297-146.314a72.074 72.074 0 1 1 144.108 0 72.074 72.074 0 0 1-144.108 0z m272.541 0a90.466 90.466 0 1 1 180.972 0 90.466 90.466 0 0 1-180.972 0z m549.022-88.103a31.783 31.783 0 0 1 3.15 44.82L771.597 748.308l-108.74 75.658 60.612-119.139 479.39-552.093a31.783 31.783 0 0 1 44.859-3.151l0.945 0.787z m-82.157 307.003a32.414 32.414 0 0 1 32.296 34.973h0.118c-2.56 29.46-73.059 298.536-269.982 415.35-328.192 194.718-543.35 48.759-586.358-20.755-29.696-48.01-52.381-100.864-69.71-120.793-27.215-31.192-150.056 43.245-233.945-50.845-83.929-94.13-58.526-470.45 343.276-651.028 367.931-165.337 631.414 36.155 673.162 71.286a31.429 31.429 0 1 1-38.99 49.35C917.241 102.4 719.53 28.198 487.357 89.796c-331.815 86.41-465.683 435.554-408.97 570.25 30.523 72.467 175.853 6.223 233.315 53.996 21.425 17.802 59.037 91.254 83.732 134.774 44.898 79.281 286.72 140.997 497.979 3.82 195.82-127.133 240.718-365.331 240.718-365.331h0.119a32.414 32.414 0 0 1 32.295-29.893z" fill="#1195db" p-id="4963" data-spm-anchor-id="a313x.7781069.0.i12" class="selected"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

32082
public/psd.js Normal file

File diff suppressed because it is too large Load Diff

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

21
public/snap.svg-min.js vendored Normal file

File diff suppressed because one or more lines are too long

70
screenshot/.gitignore vendored Normal file
View File

@ -0,0 +1,70 @@
# 创建自己的业务配置,不提交到库
# config.*
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
**/.DS_Store
static
config.json
config.js
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
dist/
jspm_packages/
_apidoc/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next

21
screenshot/README.md Normal file
View File

@ -0,0 +1,21 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-02-01 13:41:59
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-04 19:04:44
-->
# Node截图服务
### npm run dev
ts-node 直接运行,并监听文件修改自动热重启
### npm run build
使用 webpack 打包文件
### npm run serve (不使用)
webpack/tsc 编译 tssupervisor/pm2 监听编译后文件变动重启服务gulp 自动化任务

30
screenshot/gulpfile.js Normal file
View File

@ -0,0 +1,30 @@
var gulp = require('gulp');
var exec = require('child_process').exec;
var spawn = require('child_process').spawn;
var path = require('path');
gulp.task('clean', function() {
return spawn('rm', ['-rf', path.join(__dirname, 'dist')])
});
gulp.task('build-ts', function(){
return exec('webpack --watch',(error,stdout,stderr)=>{
console.log(`build ts====>stdout: ${stdout}`);
console.log(`build ts====>stderr: ${stderr}`);
if (error !== null) {
console.log(`exec error: ${error}`);
}
});
});
//自动重启服务器
gulp.task('restart',function(){
return exec('supervisor -w dist ./dist/main.js',(error,stdout,stderr)=>{
console.log(`restart=====>stdout: ${stdout}`);
console.log(`restart=====>stderr: ${stderr}`);
if (error !== null) {
console.log(`exec error: ${error}`);
}
});
});
gulp.task('default',gulp.series('clean',gulp.parallel('build-ts','restart')));

53
screenshot/package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "screenshot-node",
"version": "1.0.0",
"description": "",
"main": "./src/main",
"scripts": {
"dev": "ts-node-dev src/main",
"test": "cd ./static && hs --cors",
"build": "cross-env NODE_ENV=production webpack",
"serve": "ts-node src/main",
"serve-test": "gulp",
"start": "webpack --watch",
"serverstart": "pm2 start ./dist/server.js --watch",
"tscstart": "tsc -w",
"serverstart2": "supervisor -w www ./www/main.js",
"build:apidoc": "apidoc -i src/ -o _apidoc/",
"publish": "rm -rf ./static && rm -rf ./_apidoc && sh script/publish.sh",
"publish-fast": "git add . && git commit -m 'build: auto publish' && npm run publish"
},
"author": "ShawnPhang",
"license": "ISC",
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"gif-encoder-2": "^1.0.5",
"images": "^3.2.4",
"png-js": "^1.0.0",
"puppeteer": "^10.4.0"
},
"devDependencies": {
"@types/node": "^12.6.9",
"cross-env": "^7.0.3",
"express-graphql": "^0.9.0",
"graphql": "^14.5.4",
"gulp": "^4.0.2",
"http-server": "^14.0.0",
"speed-measure-webpack-plugin": "^1.5.0",
"supervisor": "^0.12.0",
"ts-loader": "^6.0.4",
"ts-node": "^8.3.0",
"ts-node-dev": "^1.0.0-pre.40",
"typescript": "^3.5.3",
"webpack": "^4.39.1",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^3.3.6",
"webpack-node-externals": "^3.0.0"
},
"apidoc": {
"title": "自动api接口文档",
"url": "http://localhost:9999/",
"sampleUrl": "http://localhost:9999/"
}
}

View File

@ -0,0 +1,18 @@
{
"name": "screenshot-node",
"version": "1.0.0",
"description": "",
"main": "./src/main",
"scripts": {},
"author": "ShawnPhang",
"license": "ISC",
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"puppeteer": "^10.4.0",
"images": "3.2.3",
"png-js": "^1.0.0",
"gif-encoder-2": "^1.0.5"
},
"devDependencies": {}
}

36
screenshot/src/configs.ts Normal file
View File

@ -0,0 +1,36 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-01 13:41:59
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-06 14:53:05
*/
const isDev = process.env.NODE_ENV === 'development'
exports.servicePort = 7001
/**
* chrome浏览器位置
*/
exports.executablePath = '/opt/google/chrome-unstable/chrome',
/**
*
*/
exports.maxNum = 2
/**
*
*/
exports.upperLimit = 20
/**
*
*/
exports.releaseTime = 300
/**
*
*/
exports.filePath = isDev ? process.cwd() + `/static/` : '/cache/'
// exports.filePath = process.cwd() + `/static/`

View File

@ -0,0 +1,14 @@
/*
* @Author: ShawnPhang
* @Date: 2020-07-22 20:13:14
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-06 16:00:09
*/
let path = '/api';
module.exports = {
GETIMAGE: path + '/get_img',
SCREENGHOT: path + '/screenshots',
PRINTSCREEN: path + '/printscreen'
};

View File

@ -0,0 +1,18 @@
/*
* @Author: ShawnPhang
* @Date: 2020-07-22 20:13:14
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-06 15:59:58
*/
const rExpress = require('express');
const screenshots = require('../service/screenshots.ts');
const api = require('./api.ts');
const rRouter = rExpress.Router();
rRouter.get(api.SCREENGHOT, screenshots.screenshots);
rRouter.get(api.PRINTSCREEN, screenshots.printscreen);
rRouter.get(api.GETIMAGE, screenshots.getImg);
module.exports = rRouter;

52
screenshot/src/main.ts Normal file
View File

@ -0,0 +1,52 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-01 13:41:59
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-06 10:19:18
*/
const express = require('express')
const bodyParser = require('body-parser')
const fs = require('fs')
// const path = require('path')
const router = require('./control/router.ts')
const { filePath, servicePort } = require('./configs.ts')
const handleTimeout = require('./utils/timeout.ts')
const port = process.env.PORT || servicePort
const app = express()
// 创建目录
const createFolder = (folder: string) => {
try {
fs.accessSync(folder)
} catch (e) {
fs.mkdirSync(folder)
}
}
createFolder(filePath)
app.all('*', (req: any, res: any, next: any) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization,Content-Length,Content-Size');
res.header('Access-Control-Allow-Methods', '*');
res.header('Content-Type', 'application/json;charset=utf-8');
next();
});
app.use('/static', express.static('static'))
// app.use('/cache', express.static('cache'))
app.use(handleTimeout)
app.use((req: any, res: any, next: any) => {
console.log(req.path)
next()
})
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.use(router)
app.listen(port, () => console.log(`devServer start on port:${port}`))

View File

@ -0,0 +1,134 @@
/*
* @Author: ShawnPhang
* @Date: 2020-07-22 20:13:14
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-17 18:03:13
*/
const { saveScreenshot } = require('../utils/download-single.ts')
const { filePath, upperLimit } = require('../configs.ts')
const { queueRun, queueList } = require('../utils/node-queue.ts')
// const path = require('path')
const fs = require('fs')
module.exports = {
async getImg(req: any, res: any) {
/**
* @api {get} api/get_img
* @apiVersion 1.0.0
* @apiGroup screenShot
*
* @apiParam {String|Number} id () id
* @apiParam {String} type , file源文件cover封面图
*/
const isDev = process.env.NODE_ENV === 'development'
let { id, type = 'file' } = req.query
const path = filePath + `${id}-screenshot.png`
const thumbPath = type === 'cover' ? filePath + `${id}-cover.jpg` : null
if (id) {
try {
fs.statSync(path)
res.setHeader('Content-Type', 'image/jpg')
type === 'file' ? res.sendFile(path) : res.sendFile(thumbPath)
} catch (error) {
res.json({ code: 500, msg: '请求图片不存在' })
}
} else {
res.json({ code: 500, msg: '缺少参数,请检查' })
}
},
async screenshots(req: any, res: any) {
/**
* @api {get} api/screenshots
* @apiVersion 1.0.0
* @apiGroup screenShot
*
* @apiParam {String|Number} id () id
* @apiParam {String|Number} tempid () idid时取该值
* @apiParam {String} width ()
* @apiParam {String} height ()
* @apiParam {String} screenshot_url
* @apiParam {String} type , file正常截图返回cover封面生成file
* @apiParam {String} size ,
* @apiParam {String} quality ,
*/
const isDev = process.env.NODE_ENV === 'development'
let { id, tempid, width, height, screenshot_url, type = 'file', size, quality } = req.query
const defaultUrl = isDev ? 'http://localhost:3000/draw' : 'https://design.palxp.com/draw'
const url = (screenshot_url || defaultUrl) + `${id ? '?id=' : '?tempid='}`
id = id || '_' + tempid
const path = filePath + `${id}-screenshot.png`
const thumbPath = type === 'cover' ? filePath + `${id}-cover.jpg` : null
if (id && width && height) {
if (queueList.length > upperLimit) {
res.json({ code: 200, msg: '服务器表示顶不住啊,等等再来吧~' })
return
}
// console.log(url + id, path, thumbPath);
queueRun(saveScreenshot, url + id, { width, height, path, thumbPath, size, quality })
.then(() => {
res.setHeader('Content-Type', 'image/jpg')
// const stats = fs.statSync(path)
// res.setHeader('Cache-Control', stats.size)
type === 'file' ? res.sendFile(path) : res.sendFile(thumbPath)
})
.catch((e: any) => {
res.json({ code: 500, msg: '图片生成错误' })
})
} else {
res.json({ code: 500, msg: '缺少参数,请检查' })
}
},
async printscreen(req: any, res: any) {
/**
* @api {get} api/printscreen
* @apiVersion 1.0.0
* @apiGroup screenShot
*
* @apiParam {String} url () link
* @apiParam {String} width ()
* @apiParam {String} height ()
* @apiParam {Boolean} prevent (, false) true: 使
* @apiParam {String} type (, file) file: 返回二进制文件cover: 立即返回地址(path, thumbPath)
* @apiParam {String} size (type=cover生效, eg:300300jpeg
* @apiParam {String} quality (size生效, eg:75)压缩质量:1-100
* @apiParam {Number} wait () ms
* @apiParam {String} ua () eg: 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'
* @apiParam {String} devices () uawidthheight均会失效eg: iPhone 6 /src/utils/widget/Device.js
* @apiParam {Number} scale () (DPR) 1~41
*/
let { width = 375, height = 0, url, type = 'file', size, quality, prevent = false, ua, devices, scale, wait } = req.query
const path = filePath + `screenshot_${new Date().getTime()}.png`
const thumbPath = type === 'cover' ? path.replace('.png', '.jpg') : null
if (url) {
const sign = new Date().getTime() + ''
req._queueSign = sign
// console.log(url + id, path, thumbPath);
if (queueList.length > upperLimit) {
res.json({ code: 200, msg: '业务繁忙,等等再来吧~' })
return
}
queueRun(saveScreenshot, url, { width, height, path, thumbPath, size, quality, prevent, ua, devices, scale, wait }, sign)
.then(() => {
if (!res.headersSent) {
res.setHeader('Content-Type', 'image/jpg')
// const stats = fs.statSync(path)
// res.setHeader('Cache-Control', stats.size)
type === 'file' ? res.sendFile(path) : res.sendFile(thumbPath)
}
})
.catch((e: any) => {
res.json({ code: 500, msg: '图片生成错误!' })
})
} else {
res.json({ code: 500, msg: '缺少参数,请检查' })
}
},
}
export {}

6
screenshot/src/shims-my.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace Type {
export interface Object {
[propName: string]: any
}
}

View File

@ -0,0 +1,122 @@
/*
* @Author: ShawnPhang
* @Date: 2021-09-30 14:47:22
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-17 18:03:57
*/
const isDev = process.env.NODE_ENV === 'development'
const puppeteer = require('puppeteer')
const images = require('images')
const { executablePath } = require('../configs.ts')
const forceTimeOut = 60 // 强制超时时间,单位:秒
const saveScreenshot = async (url: string, { path, width, height, thumbPath, size = 0, quality = 0, prevent, ua, devices, scale, wait }: any) => {
return new Promise(async (resolve: Function) => {
// 启动浏览器
const browser = await puppeteer.launch({
headless: !isDev,
executablePath: isDev ? null : executablePath,
ignoreHTTPSErrors: true, // 忽略https安全提示
args: ['no-first-run', 'single-process', 'disable-gpu', 'no-zygote', 'disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', `--window-size=${width},${height}`], // 优化配置
defaultViewport: null,
})
// 打开页面
const page = await browser.newPage()
// 设置浏览器视窗
page.setViewport({
width: Number(width),
height: Number(height),
deviceScaleFactor: !isNaN(scale) ? (+scale > 4 ? 4 : +scale) : 1,
})
ua && page.setUserAgent(ua)
if (devices) {
devices = puppeteer.devices[devices]
devices && (await page.emulate(devices))
}
// 自动模式下页面加载完毕立即截图
if (prevent === false) {
page.on('load', async () => {
await autoScroll()
await sleep(wait)
// await waitTillHTMLRendered(page)
await page.screenshot({ path, fullPage: true })
// 关闭浏览器
await browser.close()
compress()
clearTimeout(regulators)
resolve()
})
}
// 主动模式下注入全局方法
await page.exposeFunction('loadFinishToInject', async () => {
// console.log('-> 开始截图')
await page.screenshot({ path })
// 关闭浏览器
await browser.close()
compress()
// console.log('浏览器已释放');
clearTimeout(regulators)
resolve()
})
// 地址栏输入网页地址
await page.goto(url, { waitUntil: 'domcontentloaded' })
// 截图: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagescreenshotoptions
const regulators = setTimeout(() => {
browser && browser.close()
console.log('强制释放浏览器')
resolve()
}, forceTimeOut * 1000)
function compress() {
// 压缩图片
try {
thumbPath &&
images(path)
.size(+size || 300)
.save(thumbPath, { quality: +quality || 70 })
} catch (err) {
console.log(err)
}
}
async function autoScroll() {
await page.evaluate(async () => {
await new Promise((resolve: any, reject: any) => {
try {
const maxScroll = Number.MAX_SAFE_INTEGER
let lastScroll = 0
const interval = setInterval(() => {
window.scrollBy(0, 100)
const scrollTop = document.documentElement.scrollTop || window.scrollY
if (scrollTop === maxScroll || scrollTop === lastScroll) {
clearInterval(interval)
resolve()
} else {
lastScroll = scrollTop
}
}, 100)
} catch (err) {
console.log(err)
reject(err)
}
})
})
}
function sleep(timeout: number = 1) {
return new Promise((resolve: any) => {
setTimeout(() => {
resolve()
}, timeout)
})
}
})
}
module.exports = { saveScreenshot }
export {}

View File

@ -0,0 +1,128 @@
/*
* @Author: ShawnPhang
* @Date: 2021-09-30 14:47:22
* @Description: 使
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-17 18:05:31
*/
const isDev = process.env.NODE_ENV === 'development'
const puppeteer = require('puppeteer')
const images = require('images')
const { executablePath, releaseTime } = require('../configs.ts')
const forceTimeOut = 60 // 强制超时时间,单位:秒
let browser: typeof puppeteer = null
let release: any = null
const saveScreenshot = async (url: string, { path, width, height, thumbPath, size = 0, quality = 0, prevent, ua, devices, scale, wait }: any) => {
return new Promise(async (resolve: Function) => {
// 启动浏览器
if (!browser) {
browser = await puppeteer.launch({
headless: !isDev,
executablePath: isDev ? null : executablePath,
ignoreHTTPSErrors: true, // 忽略https安全提示
args: ['no-first-run', 'single-process', 'disable-gpu', 'no-zygote', 'disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', `--window-size=${width},${height}`], // 优化配置
defaultViewport: null,
})
}
// 打开页面
const page = await browser.newPage()
// 设置浏览器视窗
page.setViewport({
width: Number(width),
height: Number(height),
deviceScaleFactor: !isNaN(scale) ? (+scale > 4 ? 4 : +scale) : 1,
})
ua && page.setUserAgent(ua)
if (devices) {
devices = puppeteer.devices[devices]
devices && (await page.emulate(devices))
}
// 自动模式下页面加载完毕立即截图
if (prevent === false) {
page.on('load', async () => {
clearTimeout(regulators)
await autoScroll(page)
await sleep(wait)
// await waitTillHTMLRendered(page)
await page.screenshot({ path, fullPage: true })
await page.close()
thumbPath && compress()
resolve()
})
} else {
// 主动模式下注入全局方法
await page.exposeFunction('loadFinishToInject', async () => {
clearTimeout(regulators)
await page.screenshot({ path }) // console.log('-> 开始截图')
await page.close()
thumbPath && compress()
resolve()
})
}
// 地址栏输入网页地址
await page.goto(url, { waitUntil: 'domcontentloaded' })
// 截图: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagescreenshotoptions
const regulators = setTimeout(() => {
page.close()
console.log('任务超时,已失败')
resolve()
}, forceTimeOut * 1000)
clearTimeout(release)
release = setTimeout(() => {
browser && browser.close()
browser = null
}, releaseTime * 1000);
function compress() {
// 压缩图片
try {
images(path)
.size(+size || 300)
.save(thumbPath, { quality: +quality || 70 })
} catch (err) {
console.log(err)
}
}
})
}
function sleep(timeout: number = 1) {
return new Promise((resolve: any) => {
setTimeout(() => {
resolve()
}, timeout)
})
}
async function autoScroll(page: any) {
await page.evaluate(async () => {
await new Promise((resolve: any, reject: any) => {
try {
const maxScroll = Number.MAX_SAFE_INTEGER
let lastScroll = 0
const interval = setInterval(() => {
window.scrollBy(0, 100)
const scrollTop = document.documentElement.scrollTop || window.scrollY
if (scrollTop === maxScroll || scrollTop === lastScroll) {
clearInterval(interval)
resolve()
} else {
lastScroll = scrollTop
}
}, 60)
} catch (err) {
console.log(err)
reject(err)
}
})
})
}
module.exports = { saveScreenshot }
export {}

View File

@ -0,0 +1,41 @@
/*
* @Author: ShawnPhang
* @Date: 2021-12-24 18:09:35
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-06 10:19:40
*/
interface Queue {
Fn: Function
sign?: string | number
}
const { maxNum } = require('../configs.ts')
const queueList: any = [] // 任务队列
let curNum = 0 // 当前执行的任务数
function queueRun(business: Function, ...arg: any) {
return new Promise(async (resolve) => {
const Fn = async () => resolve(await business(...arg))
const sign = { ...arg }[2]
if (curNum >= maxNum) {
queueList.push({ sign, Fn })
} else {
await run(Fn)
}
})
}
function run(Fn: Function) {
curNum++
Fn().then((res: any) => {
curNum--
if (queueList.length > 0) {
const Task: Queue = queueList.shift()
run(Task.Fn)
}
return res
})
}
module.exports = { queueRun, queueList }

View File

@ -0,0 +1,26 @@
/*
* @Author: ShawnPhang
* @Date: 2021-12-24 17:51:15
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-05 20:17:00
*/
module.exports = async (req: any, res: any, next: any) => {
const { queueList } = require('../utils/node-queue.ts')
const time = 30000 // 设置所有HTTP请求的服务器响应超时时间
res.setTimeout(time, () => {
const statusCode = 408
const index = queueList.findIndex((x: any) => x.sign === req._queueSign)
if (index !== -1) {
queueList.splice(index, 1)
if (!res.headersSent) {
res.status(statusCode).json({
statusCode,
message: '响应超时,任务已取消,请重试',
})
}
}
})
next()
}

View File

@ -0,0 +1,76 @@
/*
* @Author: ShawnPhang
* @Date: 2022-06-09 17:54:26
* @Description: 设备预设列表
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-28 00:22:30
*/
const DevicesNames = [
'Blackberry PlayBook',
'Blackberry PlayBook landscape',
'BlackBerry Z30',
'BlackBerry Z30 landscape',
'Galaxy Note 3',
'Galaxy Note 3 landscape',
'Galaxy Note II',
'Galaxy Note II landscape',
'Galaxy S III',
'Galaxy S III landscape',
'Galaxy S5',
'Galaxy S5 landscape',
'iPad',
'iPad landscape',
'iPad Mini',
'iPad Mini landscape',
'iPad Pro',
'iPad Pro landscape',
'iPhone 4',
'iPhone 4 landscape',
'iPhone 5',
'iPhone 5 landscape',
'iPhone 6',
'iPhone 6 landscape',
'iPhone 6 Plus',
'iPhone 6 Plus landscape',
'iPhone 7',
'iPhone 7 landscape',
'iPhone 7 Plus',
'iPhone 7 Plus landscape',
'iPhone 8',
'iPhone 8 landscape',
'iPhone 8 Plus',
'iPhone 8 Plus landscape',
'iPhone SE',
'iPhone SE landscape',
'iPhone X',
'iPhone X landscape',
'Kindle Fire HDX',
'Kindle Fire HDX landscape',
'LG Optimus L70',
'LG Optimus L70 landscape',
'Microsoft Lumia 550',
'Microsoft Lumia 950',
'Microsoft Lumia 950 landscape',
'Nexus 10',
'Nexus 10 landscape',
'Nexus 4',
'Nexus 4 landscape',
'Nexus 5',
'Nexus 5 landscape',
'Nexus 5X',
'Nexus 5X landscape',
'Nexus 6',
'Nexus 6 landscape',
'Nexus 6P',
'Nexus 6P landscape',
'Nexus 7',
'Nexus 7 landscape',
'Nokia Lumia 520',
'Nokia Lumia 520 landscape',
'Nokia N9',
'Nokia N9 landscape',
'Pixel 2',
'Pixel 2 landscape',
'Pixel 2 XL',
'Pixel 2 XL landscape'
]

View File

@ -0,0 +1,13 @@
/**
* @apiDefine needToken
* @apiHeader {String} Authorization=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
*/
/**
* @apiDefine SuccessExampleName
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "message": "ok"
* }
*/

67
screenshot/test/gif.js Normal file
View File

@ -0,0 +1,67 @@
/*
* @Author: ShawnPhang
* @Date: 2022-04-19 14:19:13
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-21 18:38:10
*/
// const fs2 = require('fs')
const path = require('path')
const puppeteer = require('puppeteer')
const GIFEncoder = require('gif-encoder-2')
const { createWriteStream } = require('fs')
const PNG = require('png-js')
const params = { width: 1242/3, height: 2208/3 }
function decode(png) {
return new Promise((r) => {
png.decode((pixels) => r(pixels))
})
}
async function gifAddFrame(page, encoder) {
const pngBuffer = await page.screenshot({ clip: { width: params.width, height: params.height, x: 0, y: 0 } })
const png = new PNG(pngBuffer)
await decode(png).then((pixels) => encoder.addFrame(pixels))
}
;(async () => {
const browser = await puppeteer.launch({
headless: true,
slowMo: 0,
})
const page = await browser.newPage()
page.setViewport({ width: params.width, height: params.height })
await page.goto('http://localhost:3000/draw?tempid=519', {
waitUntil: ['networkidle0'],
timeout: 60000,
})
const dstPath = path.join(__dirname, `test.gif`)
// create a write stream for GIF data
const writeStream = createWriteStream(dstPath)
writeStream.on('close', () => {
console.log('create is done')
})
// createWriteStream().pipe(fs2.createWriteStream('test.gif'))
// setting gif encoder
// record gif
var encoder = new GIFEncoder(params.width, params.height)
encoder.createReadStream().pipe(writeStream)
encoder.start()
encoder.setRepeat(0)
encoder.setDelay(200)
// encoder.setQuality(10) // default
for (let i = 0; i < 5; i++) {
await gifAddFrame(page, encoder)
}
// finish encoder, test.gif saved
encoder.finish()
await browser.close()
})()

16
screenshot/test/images.js Normal file
View File

@ -0,0 +1,16 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-28 15:35:59
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-02-28 16:52:35
*/
const path = '/Users/mac/Documents/workSpace/Products/Management-Center/screenshot-service/static/screenshot-1-new.jpg'
const images = require('images')
const path233 = require('path')
// let tinyJpg = images(path233.resolve(__dirname, `../static/screenshot-1.png`)).size(300).encode('jpg', { quality: 20 })
// images(tinyJpg).save(path)
let tinyJpg = images(path233.resolve(__dirname, `../static/screenshot-1.png`)).size(300).save(path, { quality: 30 })

72
screenshot/tsconfig.json Normal file
View File

@ -0,0 +1,72 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ESNEXT", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": ["dom", "es2015"], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true,//
// "declarationDir": "./dist/types/",// /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": {
"@/*": ["src/*"]
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
"typeRoots": ["./node_modules/@types"], /* List of folders to include type definitions from. */
"types": ["node"], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,42 @@
/*
* @Author: ShawnPhang
* @Date: 2021-12-27 10:15:07
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2021-12-27 11:22:29
*/
'use strict'
const path = require('path')
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: process.env.NODE_ENV,
target: 'node',
externals: [nodeExternals()],
entry: './src/main.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'server.js',
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true,
// 指定特定的ts编译配置为了区分脚本的ts配置
configFile: path.resolve(__dirname, './tsconfig.json'),
},
},
],
exclude: /node_modules/,
},
],
},
// plugins: [new BundleAnalyzerPlugin()],
}

4984
screenshot/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<div id="appindex">
<div class="viewWrap">
<router-view></router-view>
</div>
</div>
</template>
<style lang="less">
#appindex {
min-width: 1180px;
.viewWrap {
background-color: #ffffff;
min-height: calc(110vh - 110px);
min-width: 1170px;
}
}
.fade-enter-active {
transition: opacity 0.3s;
}
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter {
opacity: 0;
}
.fade-leave-to {
opacity: 0;
}
</style>

10
src/api/ai.ts Normal file
View File

@ -0,0 +1,10 @@
/*
* @Author: ShawnPhang
* @Date: 2023-07-12 09:51:07
* @Description: AI接口
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-13 16:59:21
*/
import fetch from '@/utils/axios'
export const kt = (params: Type.Object = {}) => fetch('https://kt.palxp.com/api/remove', params, 'post', {}, { responseType: 'arraybuffer' })

39
src/api/album.ts Normal file
View File

@ -0,0 +1,39 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-26 12:47:40
* @Description: api
* @LastEditors: ShawnPhang
* @LastEditTime: 2021-08-30 10:45:49
*/
import fetch from '@/utils/axios'
import _config from '@/config'
const prefix = _config.API_URL + '/'
const API = {
init: prefix + 'pic/init',
getList: prefix + 'pic/list',
getToken: prefix + 'pic/getToken',
delOne: prefix + 'pic/delOne',
rename: prefix + 'pic/rename',
del: prefix + 'pic/del',
}
export const init = (params: Type.Object = {}) => fetch(API.init, params, 'post')
export const getPicList = (params: Type.Object = {}) => fetch(API.getList, params)
export const getToken = (params: Type.Object = {}) => fetch(API.getToken, params)
export const deletePic = (params: Type.Object = {}) => fetch(API.delOne, params, 'post')
export const delPics = (params: Type.Object = {}) => fetch(API.del, params, 'post')
export const reName = (params: Type.Object = {}) => fetch(API.rename, params, 'post')
export default {
init,
getPicList,
getToken,
deletePic,
delPics,
reName,
}

47
src/api/gaoding.ts Normal file
View File

@ -0,0 +1,47 @@
/*
* @Author: ShawnPhang
* @Date: 2022-01-22 10:22:53
* @Description: 稿
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-27 10:38:51
*/
import httpRequest from '@/utils/http-request'
export const CORS = 'https://juejin.palxp.com/cors?url='
export const GAODING_API_V2 = 'https://www.gaoding.com/api/v2'
export const GAODING_FONTER = 'https://fonter.dancf.com'
export const getFonts = async (pageNum: number): Promise<any> => {
return await httpRequest.get(`${CORS}${GAODING_API_V2}/fonts`, {
params: {
type: 'font',
page_size: 100,
page_num: pageNum,
region_id: 1,
biz_code: 1,
endpoint: 4,
},
})
}
export const searchFonts = async (name: string): Promise<any> => {
const url = `${CORS}https://www.gaoding.com/api/v2/font-fallbacks?font_name=${name}`
return await httpRequest.get(url, {})
}
interface GetSubsetFontParams {
font_id: number
content: string
url: string
}
export async function getSubsetFont(params: GetSubsetFontParams) {
return await httpRequest.get(`${GAODING_FONTER}/subset`, {
params: {
from_site: 'gaoding',
type: 'woff',
...params,
},
responseType: 'blob',
})
}

40
src/api/github.ts Normal file
View File

@ -0,0 +1,40 @@
/*
* @Author: ShawnPhang
* @Date: 2023-07-13 17:01:37
* @Description: github api
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-14 00:01:05
*/
import fetch from '@/utils/axios'
const reader = new FileReader()
function getBase64(file: File) {
return new Promise((resolve) => {
reader.onload = function (event: any) {
const fileContent = event.target.result
resolve(fileContent.split(',')[1])
}
reader.readAsDataURL(file)
})
}
const putPic = async (file: any) => {
const content = typeof file === 'string' ? file : await getBase64(file)
const repo = 'shawnphang/files'
const d = new Date()
const path = `${d.getFullYear()}/${d.getMonth()}/${d.getTime()}${file.name?.split('.').pop() || '.png'}`
const imageUrl = 'https://api.github.com/repos/' + repo + '/contents/' + path
const body = {
branch: 'main',
message: 'upload',
content,
path,
}
await fetch(imageUrl, body, 'put', {
Authorization: 'token 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',
'Content-Type': 'application/json; charset=utf-8',
})
return `https://fastly.jsdelivr.net/gh/shawnphang/files@main/${path}`
}
export default { putPic }

37
src/api/home.ts Normal file
View File

@ -0,0 +1,37 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-19 18:43:22
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-13 18:19:14
*/
import fetch from '@/utils/axios'
import _config from '@/config'
// const screenshot_url = window.location.protocol + '//' + window.location.host + '/draw'
export const download = (params: Type.Object = {}) => `${_config.SCREEN_URL}/api/screenshots?id=${params.id}&width=${params.width}&height=${params.height}`
// 获取模板列表
export const getTempList = (params: Type.Object = {}) => fetch('design/list', params, 'get')
export const getTempDetail = (params: Type.Object = {}) => fetch('design/temp', params, 'get')
export const getCategories = (params: Type.Object = {}) => fetch('design/cate', params, 'get')
// 保存模板
export const saveTemp = (params: Type.Object = {}) => fetch('design/edit', params, 'post')
// export const delTemp = (params: Type.Object = {}) => fetch('/api/template/temp_del', params)
// 组件相关接口
export const getCompList = (params: Type.Object = {}) => fetch('design/list', params, 'get')
export const removeComp = (params: Type.Object = {}) => fetch('design/del', params, 'post')
// export const getCompDetail = (params: Type.Object = {}) => fetch('/api/template/temp_info', params, 'get')
// 保存作品
export const saveWorks = (params: Type.Object = {}) => fetch('design/save', params, 'post')
// 保存个人模板
export const saveMyTemp = (params: Type.Object = {}) => fetch('design/user/temp', params, 'post')
// 获取作品
export const getWorks = (params: Type.Object = {}) => fetch('design/poster', params, 'get')
// 作品列表
export const getMyDesign = (params: Type.Object = {}) => fetch('design/my', params, 'get')

16
src/api/index.ts Normal file
View File

@ -0,0 +1,16 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-19 18:43:22
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-01-22 18:22:13
*/
import * as home from './home'
import * as material from './material'
import * as gaoding from './gaoding'
export default {
home,
material,
gaoding,
}

27
src/api/material.ts Normal file
View File

@ -0,0 +1,27 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-27 14:42:15
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 09:50:00
*/
import fetch from '@/utils/axios'
// 获取素材分类:
export const getKinds = (params: Type.Object = {}) => fetch('design/cate', params)
// 获取素材列表:
export const getList = (params: Type.Object = {}) => fetch('design/material', params)
// 获取字体
export const getFonts = (params: Type.Object = {}) => fetch('design/fonts', params)
// 图库列表
export const getImagesList = (params: Type.Object = {}) => fetch('design/imgs', params, 'get')
// 我的上传列表
export const getMyPhoto = (params: Type.Object = {}) => fetch('design/user/image', params)
export const deleteMyPhoto = (params: Type.Object = {}) => fetch('design/user/image/del', params, 'post')
// 添加图片
export const addMyPhoto = (params: Type.Object = {}) => fetch('design/user/add_image', params)

View File

@ -0,0 +1,21 @@
/*
* @Author: ShawnPhang
* @Date: 2022-04-15 10:51:50
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-15 10:51:51
*/
export default [
{
key: 'zIndex',
icon: 'icon-layer-up',
tip: '上一层',
value: 1,
},
{
key: 'zIndex',
icon: 'icon-layer-down',
tip: '下一层',
value: -1,
},
]

View File

@ -0,0 +1,45 @@
/*
* @Author: ShawnPhang
* @Date: 2022-03-16 11:38:48
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-03-23 16:00:11
*/
export default {
dotColorTypes: [
{
key: 'single',
value: '单色',
},
{
key: 'gradient',
value: '渐变色',
},
],
dotTypes: [
{
key: 'dots',
value: '圆点风格',
},
{
key: 'rounded',
value: '圆润风格',
},
{
key: 'classy',
value: '经典风格',
},
{
key: 'classy-rounded',
value: '圆角风格',
},
{
key: 'square',
value: '方形风格',
},
{
key: 'extra-rounded',
value: '特殊风格',
},
],
}

View File

@ -0,0 +1,114 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-02 18:27:27
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-02-25 10:30:38
*/
export const styleIconList1 = [
{
key: 'fontWeight',
icon: 'icon-bold',
tip: '加粗',
value: ['normal', 'bold'],
select: false,
},
{
key: 'fontStyle',
icon: 'icon-italic',
tip: '斜体',
value: ['normal', 'italic'],
select: false,
},
{
key: 'textDecoration',
icon: 'icon-underline',
tip: '下划线',
value: ['none', 'underline'],
select: false,
},
{
key: 'textDecoration',
icon: 'icon-strikethrough',
tip: '删除线',
value: ['none', 'line-through'],
select: false,
},
{
key: 'writingMode',
icon: 'icon-textorientation',
tip: '竖版文字',
value: ['horizontal-tb', 'vertical-rl'], // tb-rl
select: false,
},
]
export const styleIconList2 = [
{
key: 'textAlign',
icon: 'icon-align-left-text',
tip: '左对齐',
value: 'left',
select: false,
},
{
key: 'textAlign',
icon: 'icon-align-center-text',
tip: '居中对齐',
value: 'center',
select: false,
},
{
key: 'textAlign',
icon: 'icon-align-right-text',
tip: '右对齐',
value: 'right',
select: false,
},
{
key: 'textAlign',
icon: 'icon-align-justify-text',
tip: '两端对齐',
value: 'justify',
select: false,
},
]
export const alignIconList = [
{
key: 'align',
icon: 'icon-align-left',
tip: '左对齐',
value: 'left',
},
{
key: 'align',
icon: 'icon-align-center-horiz',
tip: '水平居中对齐',
value: 'ch',
},
{
key: 'align',
icon: 'icon-align-right',
tip: '右对齐',
value: 'right',
},
{
key: 'align',
icon: 'icon-align-top',
tip: '上对齐',
value: 'top',
},
{
key: 'align',
icon: 'icon-align-center-verti',
tip: '垂直居中对齐',
value: 'cv',
},
{
key: 'align',
icon: 'icon-align-bottom',
tip: '下对齐',
value: 'bottom',
},
]

View File

@ -0,0 +1,45 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-12 11:08:57
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-02-12 11:09:41
*/
export default [
{
key: 'align',
icon: 'icon-align-left',
tip: '左对齐',
value: 'left',
},
{
key: 'align',
icon: 'icon-align-center-horiz',
tip: '水平居中对齐',
value: 'ch',
},
{
key: 'align',
icon: 'icon-align-right',
tip: '右对齐',
value: 'right',
},
{
key: 'align',
icon: 'icon-align-top',
tip: '上对齐',
value: 'top',
},
{
key: 'align',
icon: 'icon-align-center-verti',
tip: '垂直居中对齐',
value: 'cv',
},
{
key: 'align',
icon: 'icon-align-bottom',
tip: '下对齐',
value: 'bottom',
},
]

View File

@ -0,0 +1,45 @@
/*
* @Author: ShawnPhang
* @Date: 2021-07-17 11:20:22
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 21:52:29
*/
export default [
{
name: '模板',
icon: 'icon-moban',
show: false,
},
{
name: '素材',
icon: 'icon-sucai',
show: false,
},
{
name: '文字',
icon: 'icon-wenzi',
show: false,
style: { fontWeight: 600 },
},
{
name: '照片',
icon: 'icon-gallery',
show: false,
},
{
name: '背景',
icon: 'icon-beijing',
show: false,
},
{
name: '工具',
icon: 'icon-zujian01',
show: false,
},
{
name: '我的',
icon: 'icon-shangchuan',
show: false,
},
]

Binary file not shown.

Binary file not shown.

125
src/assets/styles/base.less Normal file
View File

@ -0,0 +1,125 @@
// element UI fix
.el-collapse-item__header {
padding: 0px 10px;
user-select: none;
}
.el-collapse-item__wrap {
padding: 0px 10px;
}
.el-collapse {
border: 0px;
border-top: none !important;
}
.el-pagination {
padding: 0;
.btn-prev {
border-radius: 5px;
border: 1px solid #e5e5e5;
height: 30px;
margin-right: 10px;
width: 30px;
}
.btn-next {
border-radius: 5px;
border: 1px solid #e5e5e5;
height: 30px;
margin-right: 10px;
width: 30px;
}
.el-pager > li {
border-radius: 5px;
border: 1px solid #e5e5e5;
border: none;
color: #a5a5a5;
font-size: 14px;
height: 30px;
margin-right: 10px;
}
.el-pager {
.active {
background: #24b9ff;
border: none;
color: #ffffff;
}
}
.el-icon-arrow-left {
color: #a5a5a5;
font-size: 14px;
}
.el-icon-arrow-right {
color: #a5a5a5;
font-size: 14px;
}
.el-select {
.el-input {
input {
border-radius: 5px;
color: #262c33;
font-size: 14px;
height: 30px;
}
}
}
}
.el-select {
.el-tag {
line-height: 23px !important;
}
}
.el-pagination__jump {
font-size: 14px !important;
.el-pagination__editor {
border-radius: 5px;
color: #262c33;
font-size: 14px;
height: 30px;
}
}
// other
.clearfix {
&:after {
clear: both;
content: ' ';
display: table;
}
&:before {
content: ' ';
display: table;
}
}
.line-break {
white-space: pre-wrap;
word-wrap: break-word;
}
// 点击选择框样式
.layer {
&:hover {
outline: 2px dashed @main-color !important;
}
}
.layer-no-hover {
&:hover {
cursor: move;
outline: 0px !important;
}
}
// .layer-active {
// outline: 1px dashed #000000 !important;
// &:hover {
// outline: 1px dashed #000000 !important;
// }
// }
.layer-hover {
outline: 2px dashed @main-color !important;
&:hover {
outline: 2px dashed @main-color !important;
}
}
// 锁定不可选中
.layer-lock {
pointer-events: none;
}

View File

@ -0,0 +1,12 @@
// @color-main: #1b1634;
@color-main: #ffffff;
@color-white: #ffffff;
@color-black: #000000;
@color-light-gray: #3e4651;
@color-dark-gray: #262c33;
@color-transparent: #ffffff00;
// @active-text-color: rgb(250, 131, 52);
// @main-color: rgb(250, 131, 52);
@active-text-color: #2254f4; // #1195db;
@main-color: #2254f4; // #1195db;

View File

@ -0,0 +1,133 @@
@import './color.less';
// design index page
// Color variables (appears count calculates by raw css)
@color4: #50555b; // Appears 2 times
@color5: #808080; // Appears 2 times
// Width variables (appears count calculates by raw css)
@width0: 1180px; // Appears 3 times
@width1: 120px; // Appears 2 times
@width2: 100%; // Appears 8 times
@height2: 54px; // Appears 5 times
#page-design-index {
background-color: #4b678c;
// background-color: #4682b4;
display: flex;
flex-direction: column;
height: 100%;
min-height: 500px;
min-width: @width0;
position: absolute;
width: @width2;
.top-nav {
height: @height2;
min-width: @width0;
position: relative;
width: @width2;
.top-nav-wrap {
border-bottom: 1px solid rgba(202, 119, 119, 0.1);
align-items: center;
background-color: @color-main;
display: flex;
height: @height2;
min-width: @width0;
position: fixed;
width: @width2;
.top-left {
display: flex;
align-items: center;
color: @color-black;
.name {
margin: 0 1rem;
// font-weight: bold;
cursor: pointer;
letter-spacing: 2px;
color: #333;
font-size: 22px;
font-family: TitleFont, PingFangSC-Semibold, PingFang SC;
}
.operation {
display: flex;
&-item {
padding: 1rem;
cursor: pointer;
}
.disable {
cursor: not-allowed;
color: #c2c2c2;
}
}
}
// .top-title {
// color: @color-black;
// cursor: pointer;
// flex: 1;
// padding-left: 3.2rem;
// // font-weight: bold;
// .input-wrap {
// width: 15rem;
// :deep(input) {
// border: none;
// }
// }
// }
// .top-icon-wrap {
// display: flex;
// align-items: center;
// padding-right: 20px;
// height: @height2;
// .top-icon {
// background-color: rgba(0, 0, 0, 0.4);
// border-radius: 5px;
// color: @color-white;
// cursor: pointer;
// font-weight: bold;
// margin: 8px;
// padding: 5px 8px;
// &:hover {
// background-color: rgba(0, 0, 0, 0.5);
// }
// }
// }
}
}
.page-design-index-wrap {
display: flex;
flex-direction: row;
flex: 1;
height: 100%;
overflow: hidden;
width: @width2;
.page-design-wrap {
flex: 1;
}
}
// .page-design-index-wrap ::-webkit-scrollbar {
// display: none; /* Chrome Safari */
// }
.extra-operation {
cursor: pointer;
position: relative;
margin-left: 52px;
}
.extra-operation::after {
position: absolute;
content: '';
left: -50px;
background: #e8eaec;
height: 17px;
width: 1px;
margin: 7px 0 0 18px;
}
.shelter {
position: absolute;
box-shadow: 0 0 0 5000px rgba(255, 255, 255, 0.95);
z-index: 8;
pointer-events: none;
}
}

View File

@ -0,0 +1,3 @@
@import './main.less';
@import './layout.less';
@import './base.less';

View File

@ -0,0 +1,27 @@
.editable-text {
outline: none;
word-break: break-word;
white-space: pre;
margin: 0;
}
.line-clamp-2 {
// display: -webkit-box !important;
// -webkit-box-orient: vertical !important;
// overflow: hidden !important;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box; //作为弹性伸缩盒子模型显示。
-webkit-box-orient: vertical; //设置伸缩盒子的子元素排列方式--从上到下垂直排列
}
.line-clamp-1 {
// -webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.line-clamp-2 {
-webkit-line-clamp: 2;
}

108
src/assets/styles/main.less Normal file
View File

@ -0,0 +1,108 @@
@font-face {
font-family: TitleFont;
src: url(../fonts/xpsj.subset.woff2) format('woff2');
}
body {
--el-color-primary: #2254f4;
cursor: -webkit-image-set(
url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAABpNJREFUeF7tml9oW1Ucx391zs31zx770IEvG4M+jMyXvagLLCP4EIZtDCMjbBGEGEtLC4UxFToM1K50OLeQbjKnVNPG0r7UzEDukrU1WeKqLH3IS1Lmw4Z9cthU7XRz8r3cU8+uTe5NctbmYi5ckjb3npzf5/f9/TnnpoFKHw1EpD5xxxPuZH/zrxrD1s7HMK7YwQx/joi25fP5B42NjdtjsdiXJ06ceJeI/lYg4JW9BxhDgdACAOOfJ6Lty8vLD1pbW/GelpeXH0Wj0fF0Oj3k9/t/IqLHygkADIYhQJQCAONxvkBEO+Px+I9ms/klXi6FQuFJPB5PhsPhd65cuXKXiB4ZDYQWAHh8BxHt8vv9A16v15NOp+nChQvU09NDhw4dknkARCwWu3X9+nWP0UBoAdiuAGiyWq37I5FIDAYfPHiQVlZWZABGB6EFAArYSURNRNSSyWS+P3DgQLPH46FoNLoeDcVALCwsfOjz+b6r5dDQAwAh0AgAoVBo3OFwvDw9PU39/f3/qR5qELhgdnb2riRJ3loFoQVgm5IEAaC5r6/PPjIy8tG9e/fo8OHDRevnnj175NDo6OhYv6ZWQWiVQQBAHngRAKCCQqGQaWpqarDZbJTNZkt2NEYAoQWA9QHreUCSpOiRI0fafD4fXbt2TVdLVwxENpv91Ov1XtzKHKEFAJ9DBet5IBAIDHs8nmMoh06nUxcAdhEDcfToUWpuhqCIFhcXf00kEkNbBaIUAMyPAUAzhDBoMZvN++Px+Ld8OSyLAgZpaSG32y2fWw1CDwCEAfIAwgBu253L5Rb27t27Q10OjQhCDwBcw/oBVIPdoVDoq1LlUASIfD6/lkqlvna5XFh4sRabX3SxhVe5X/fU9XoBPFUOe3t73zx//vygVjksd2YsNDo7O6mtrU2+HQsvSZKCLpfLWwREuV9TFgA+D/DlcHehULijtxxWMkNAQC/Bg4hEIp+43e73FBDqJXglXyMnOa0D1wgph1pfpP68vb2dAoEAoXqwo6GhoYWI/lRBKHfof8fTcSfbGBFWDkt9J8IAHSQqBG/40tLSmiRJVz0ezwdEtEZEf3FLbx1mbHyJHgU8s3LIT8lisZDdbif0COxYXV3FfkN+YmJiKBgMJonoN+X8Q1EB243aFADCyyE8fOrUKdlo3tvJZPKX+fn5yOnTpwOKoQ+J6Hfu3BIFCCmHkDjzNttQUbL943A4nLp8+fLF27dv31ckDpkj3gEARrNzU3MACwG+K5RXh+WWw+7ubjm2AQEHJJ5KpX6empq6Ojo6KimJDUYzw2Eo/579jb5g06rAegJW1gUVlUOUNADAkclkVufm5ubHxsa+UHl7I+PxPxjMmiFswAoxnnlWbwKpuBzC47Ozs7LnBwcHJ86cOTOqeBZGwau8pxkEluWZt9Xb75vSCfJwKi6Hw8PDcmmD500m0+tKTCOumfG8tJnhzNMbtb9CjC9XARWVQ2R3eB+Hw+HwTk5OpokIZQwJjfc+DC7mbeYIYYbzca03BBiAssphMBiUd49v3Lhx32KxHONqOStlzOPFJC7caLWsywWguxzCcABAtrdarW8kk8k8kr8CASHAG8+eNz4zb29kqN5OkFeM7nI4MzND6OdDodCd48ePv0VEKwoANDWQP5/R8R3P1NsiAPB5oGQ5xGru3LlzWM4+ttvtHYlEIoeHSIr3mfyFlbNyZFxNCPB54KmHJurNUiQ+JEC/3/9NV1fX+4rxkD/zPhKeWvaV2lHxfeWGgLor3HCzFBumaHry+fzDffv2vaLInvc+/xC14smLuLESAJrlEM8N0fQMDAx8fPbs2c8477NVHGJ/y71fSR/AJ0N1OWzJ5XI/YLOUtbsmk+k1zvuQPjI/38eLcGJVY1SjAFYOYbD88DQUCgWxWYoZOZ3Ot8fHxxMKAMR+zSS+apOgOg/gmcEuQDh58uSrly5d+vzmzZs5m83WyTU9NZX4RADYqCsEBJwAgviG3BHzvPRZ7FclW5E3VxoCvArk3xApD04QDngPAGwjg+/4aiLxiVIAXw3YU2QYj+SIA8mOreXVHZ9IJ1Y1VjUK4FUg/5ROMZ6NiS6PX9Li+k1vdbXoVAuAh8D2C/DKDK35n8yJAKDuJ3gANel1kTlAS2E1/7koBdS8ocUmWAdgWNcJmnhdAYJAGnaYugIM6zpBE68rQBBIww5TV4BhXSdo4nUFCAJp2GHqCjCs6wRNvK4AQSANO0xdAYZ1naCJ/+8V8A+ZVYxuf/8kfgAAAABJRU5ErkJggg==)
2x
)
6 2,
default;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
color: #333333;
font-family: Hiragino Sans GB, Hiragino Sans GB W3, Arial, Microsoft Yahei, STHeiti, sans-serif;
}
* {
box-sizing: border-box;
// font-size: 14px;
scrollbar-width: none; /* 火狐滚动条无法自定义宽度,只能通过此属性使滚动条宽度变细 */
-ms-overflow-style: none; /* 隐藏滚动条在IE和Edge两个浏览器中很难更改样式固采取隐藏方式 */
}
// html ::-webkit-scrollbar {
// display: none; /* Chrome Safari */
// }
&::-webkit-scrollbar {
-webkit-appearance: none;
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: #ccd4de;
/*box-shadow: 0 0 1px hsl(0deg 0% 100% / 50%);*/
}
&::-webkit-scrollbar-track {
//background-color: #f0f1f3;
background-color: transparent;
border-radius: 3px;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus input:-webkit-autofill,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
//取消浏览器记住密码的样式
transition: background-color 5000s ease-in-out 0s;
}
a {
text-decoration: none;
}
ul,
ol,
p,
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
padding: 0;
}
li {
list-style: none;
}
img {
-webkit-user-drag: none;
user-select: none;
}
input {
outline: 0;
&:-moz-focusring {
outline: 0;
}
}
button {
outline: 0;
&:-moz-focusring {
outline: 0;
}
}
textarea {
outline: 0;
}
p {
max-height: 100%;
}
.drag_active {
cursor: grabbing;
}
.flutter {
position: fixed;
z-index: 99999;
pointer-events: none;
}
.hide {
opacity: 0;
}

View File

@ -0,0 +1,118 @@
/*
* @Author: ShawnPhang
* @Date: 2023-07-10 14:58:48
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-11 11:10:03
*/
import store from '@/store'
export default class dragHelper {
private cloneEl: any = null
private dragging: boolean = false
private initial: any = {}
private queue: any = []
constructor() {
window.addEventListener('mousemove', (e) => {
if (this.dragging && this.cloneEl) {
const { offsetX, offsetY, width, height } = this.initial
// this.moveFlutter(e.pageX - offsetX, e.pageY - offsetY, this.distance(e))
this.moveFlutter(e.pageX - width / 2, e.pageY - height / 2, this.distance(e))
}
})
// 鼠标抬起
window.addEventListener('mouseup', (e: any) => {
;(window as any).document.getElementById('app').classList.remove('drag_active')
const cl = e.target.classList
if (e.target?.id === 'page-design-canvas' || cl.contains('target') || cl.contains('drop__mask') || cl.contains('edit-text')) {
setTimeout(() => {
this.finish(true)
}, 10)
} else this.finish()
})
// 鼠标离开了视窗
document.addEventListener('mouseleave', (e) => {
this.finish()
})
// 用户可能离开了浏览器
window.onblur = () => {
this.finish()
}
}
/**
* mousedown
*/
public start(e: any, finallySize: any) {
if (!this.cloneEl) {
store.commit('setDraging', true)
;(window as any).document.getElementById('app').classList.add('drag_active') // 整个鼠标全局变成抓取
// 选中了元素
this.cloneEl = e.target.cloneNode(true)
this.cloneEl.classList.add('flutter')
// 初始化数据
this.init(e, e.target, finallySize || e.target.offsetWidth, Math.random())
// 加载原图
// simulate(cloneEl.src, initial.flag)
this.cloneEl.style.width = e.target.offsetWidth
// e.target.parentElement.parentElement.appendChild(this.cloneEl)
;(window as any).document.getElementById('widget-panel').appendChild(this.cloneEl)
this.dragging = true
e.target.classList.add('hide') // 放在最后
this.queue.push(() => {
e.target.classList.remove('hide')
})
}
}
// 开始拖动初始化
private init({ offsetX, offsetY, pageX, pageY, x, y }: any, { offsetWidth: width, offsetHeight: height }: any, finallySize: number, flag: any) {
this.initial = { offsetX, offsetY, pageX, pageY, width, height, finallySize, flag, x, y }
// store.commit('setDragInitData', { offsetX: 0, offsetY: 0 })
this.moveFlutter(pageX - offsetX, pageY - offsetY, 0, 0.3)
setTimeout(() => {
this.moveFlutter(pageX - width / 2, pageY - height / 2, 0, 0.3)
}, 10)
}
// 改变漂浮元素(合并多个操作)
private moveFlutter(x: number, y: number, d = 0, lazy = 0) {
const { width, height, finallySize } = this.initial
let scale: any = null
if (width > finallySize) {
scale = d ? (width - d >= finallySize ? `transform: scale(${(width - d) / width});` : null) : null
} else scale = d ? (width + d <= finallySize ? `transform: scale(${(width + d) / width})` : null) : null
const options = [`left: ${x}px`, `top: ${y}px`, `width: ${width}px`, `height: ${height}px`]
scale && options.push(scale)
options.push(`transition: all ${lazy}s`)
this.changeStyle(options)
}
private changeStyle(arr: any) {
const original = this.cloneEl.style.cssText.split(';')
original.pop()
this.cloneEl.style.cssText = original.concat(arr).join(';') + ';'
}
// 结束/完成处理(动画)
private finish(done = false) {
this.dragging = false
store.commit('setDraging', false)
store.commit('selectItem', {})
if (!this.cloneEl) {
return
}
if (!done) {
const { pageX, offsetX, pageY, offsetY } = this.initial
this.changeStyle([`left: ${pageX - offsetX}px`, `top: ${pageY - offsetY}px`, 'transform: scale(1)', 'transition: all 0.3s'])
}
setTimeout(
() => {
this.queue.length && this.queue.shift()()
this.cloneEl && this.cloneEl.remove()
this.cloneEl = null
},
done ? 0 : 300,
)
}
// 计算两点之间距离
private distance({ pageX, pageY }: any) {
const { pageX: x, pageY: y } = this.initial
return Math.hypot(pageX - x, pageY - y)
}
}

View File

@ -0,0 +1,35 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-22 15:06:14
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-03-07 14:57:51
*/
import store from '@/store'
export default async function setCompData(item: any) {
const group = typeof item === 'string' ? JSON.parse(item) : JSON.parse(JSON.stringify(item))
let parent: any = {}
Array.isArray(group) &&
group.forEach((element: any) => {
element.type === 'w-group' && (parent = element)
})
const { width: screenWidth, height: screenHeight } = store.getters.dPage
const { width: imgWidth, height: imgHeight } = parent
let ratio = 1
// 先限制在画布内,保证不超过边界
if (imgWidth > screenWidth || imgHeight > screenHeight) {
ratio = Math.min(screenWidth / imgWidth, screenHeight / imgHeight)
}
// 根据画布缩放比例再进行一次调整
if (ratio < 1) {
ratio *= store.getters.dZoom / 100
group.forEach((element: any) => {
element.fontSize && (element.fontSize *= ratio)
element.width *= ratio
element.height *= ratio
element.left *= ratio
element.top *= ratio
})
}
return group
}

View File

@ -0,0 +1,34 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-22 15:06:14
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-10 17:37:27
*/
import store from '@/store'
import { getImage } from '../getImgDetail'
export default async function setItem2Data(item: any) {
const cloneItem = JSON.parse(JSON.stringify(item))
const { width: screenWidth, height: screenHeight } = store.getters.dPage
let { width: imgWidth, height: imgHeight } = item
if (!imgWidth || !imgHeight) {
const actual: any = await getImage(item.url)
cloneItem.width = imgWidth = actual.width
cloneItem.height = imgHeight = actual.height
}
let ratio = 1
// 先限制在画布内,保证不超过边界
if (imgWidth > screenWidth || imgHeight > screenHeight) {
ratio = Math.min(screenWidth / imgWidth, screenHeight / imgHeight)
}
// 根据画布缩放比例再进行一次调整
if (ratio < 1) {
cloneItem.width = cloneItem.width * ratio * (store.getters.dZoom / 100)
cloneItem.height = cloneItem.height * ratio * (store.getters.dZoom / 100)
}
cloneItem.canvasWidth = cloneItem.width * (store.getters.dZoom / 100)
// cloneItem.canvasHeight = cloneItem.height * (store.getters.dZoom / 100)
return cloneItem
}

View File

@ -0,0 +1,47 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-22 15:06:14
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-03 10:10:53
*/
// import store from '@/store'
// import { getImage } from '../getImgDetail'
import setImageData from '@/common/methods/DesignFeatures/setImage'
import wText from '@/components/modules/widgets/wText/wText.vue'
import wImage from '@/components/modules/widgets/wImage/wImage.vue'
import wSvg from '@/components/modules/widgets/wSvg/wSvg.vue'
export default async function(type: string, item: any, data: any) {
let setting = data
if (type === 'text') {
!item.fontFamily && !item.color ? (setting = JSON.parse(JSON.stringify(wText.setting))) : (setting = item)
!setting.text ? (setting.text = '双击编辑文字') : (setting.text = decodeURIComponent(setting.text)) // item.text
setting.fontSize = item.fontSize
setting.width = item.width || item.fontSize * setting.text.length
setting.fontWeight = item.fontWeight
}
if (type === 'image' || type === 'mask') {
setting = JSON.parse(JSON.stringify(wImage.setting))
const img = await setImageData(item.value)
setting.width = img.width
setting.height = img.height // parseInt(100 / item.value.ratio, 10)
setting.imgUrl = item.value.url
}
if (type === 'mask') {
setting.mask = item.value.url
}
if (type === 'svg') {
setting = JSON.parse(JSON.stringify(wSvg.setting))
const img = await setImageData(item.value)
setting.width = img.width
setting.height = img.height // parseInt(100 / item.value.ratio, 10)
setting.svgUrl = item.value.url
const models = JSON.parse(item.value.model)
for (const key in models) {
if (Object.hasOwnProperty.call(models, key)) {
setting[key] = models[key]
}
}
}
return setting
}

View File

@ -0,0 +1,71 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-29 20:35:31
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-11 22:02:12
*/
import dayjs from 'dayjs'
import api from '@/api/album'
interface Options {
bucket: string
prePath?: string
fullPath?: string
}
export default {
upload: async (file: File, options: Options, cb?: Function) => {
const win: any = window
let name = ''
const suffix = file.type.split('/')[1] // 文件后缀
if (!options.fullPath) {
// const DT: any = await exifGetTime(file) // 照片时间
const DT: any = new Date()
const YM = `${dayjs(DT).format('YYYY')}/${dayjs(DT).format('MM')}/` // 文件时间分类
const keyName = YM + new Date(DT).getTime()
const prePath = options.prePath ? options.prePath + '/' : ''
name = `${prePath}${keyName}` + `.${suffix}` // 文件名
} else name = options.fullPath + `.${suffix}` // 文件名
const token = await api.getToken({ bucket: options.bucket, name })
const exOption = {
useCdnDomain: true, // 使用cdn加速
}
const observable = win.qiniu.upload(file, name, token, {}, exOption)
return new Promise((resolve: Function, reject: Function) => {
observable.subscribe({
next: (result: any) => {
cb && cb(result) // result.total.percent -> 展示进度
},
error: (e: any) => {
reject(e)
},
complete: (result: any) => {
resolve(result)
// cb && cb(result) // result.total.percent -> 展示进度
},
})
})
},
}
// function exifGetTime(img: any) {
// const win = window as any
// return new Promise((resolve) => {
// const file = img.originFileObj || img
// win.EXIF.getData(file, function() {
// const DT = win.EXIF.getAllTags(this).DateTimeOriginal || win.EXIF.getAllTags(this).DateTime
// if (DT) {
// if (DT.split(' ').length > 1) {
// const date = DT.split(' ')[0].replace(/:/g, '/')
// const time = DT.split(' ')[1]
// resolve(dayjs(`${date} ${time}`).isValid() ? `${date} ${time}` : date)
// } else {
// resolve(DT)
// }
// } else {
// resolve(new Date())
// }
// })
// })
// }

View File

@ -0,0 +1,36 @@
/*
* @Author: ShawnPhang
* @Date: 2022-03-25 13:43:07
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-03-25 14:32:19
*/
import store from '@/store'
export default function(el: Element | string, cb: Function, altLimit: boolean = true) {
const box = typeof el === 'string' ? document.getElementById(el) : el
addEvent(box, 'mousewheel', (e: any) => {
const ev = e || window.event
const down = ev.wheelDelta ? ev.wheelDelta < 0 : ev.detail > 0
// if (down) {
// console.log('鼠标滚轮向下---------')
// } else {
// console.log('鼠标滚轮向上++++++++++')
// }
if (altLimit && store.getters.dAltDown) {
ev.preventDefault()
cb(down)
} else if (!altLimit) {
ev.preventDefault()
cb(down)
}
return false
})
}
function addEvent(obj: any, xEvent: string, fn: Function) {
if (obj.attachEvent) {
obj.attachEvent('on' + xEvent, fn)
} else {
obj.addEventListener(xEvent, fn, false)
}
}

View File

@ -0,0 +1,23 @@
/*
* @Author: ShawnPhang
* @Date: 2022-02-03 16:30:18
* @Description: Type: success / info / warning / error
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-02-03 16:43:01
*/
import { ElMessageBox } from 'element-plus'
export default (title: string = '提示', message: string = '', type: any = 'success') => {
return new Promise((resolve: Function) => {
ElMessageBox.confirm(message, title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type,
})
.then(() => {
resolve(true)
})
.catch(() => {
resolve(false)
})
})
}

View File

@ -0,0 +1,79 @@
/*
* @Author: ShawnPhang
* @Date: 2021-09-30 15:52:59
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 16:54:51
*/
export default (src: string, cb: Function) => {
return new Promise((resolve: any) => {
// const image = new Image()
// // 解决跨域 Canvas 污染问题
// image.setAttribute('crossOrigin', 'anonymous')
// image.onload = function() {
// const canvas = document.createElement('canvas')
// canvas.width = image.width
// canvas.height = image.height
// const context = canvas.getContext('2d')
// context?.drawImage(image, 0, 0, image.width, image.height)
// const url = canvas.toDataURL('image/jpg')
// const a = document.createElement('a')
// const event = new MouseEvent('click')
// a.download = String(new Date().getTime())
// a.href = url
// // 触发a的单击事件
// a.dispatchEvent(event)
// resolve()
// }
fetchImageDataFromUrl(src, (progress: number, xhr: XMLHttpRequest) => {
cb(progress, xhr)
}).then((res: any) => {
const reader = new FileReader()
reader.onload = function (event) {
const txt: any = event?.target?.result
// image.src = txt
const a = document.createElement('a')
const mE = new MouseEvent('click')
// TODO: 部分浏览器会丢失后缀,所以补上
a.download = String(new Date().getTime()) + '.png'
a.href = txt
// 触发a的单击事件
a.dispatchEvent(mE)
resolve()
}
if (!res) {
resolve()
return
}
reader.readAsDataURL(res)
})
})
}
function fetchImageDataFromUrl(url: string, cb: Function) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
let totalLength: any = ''
xhr.open('GET', url)
xhr.responseType = 'blob'
xhr.onreadystatechange = function () {
totalLength = Number(xhr.getResponseHeader('content-length')) // 'cache-control'
}
xhr.onprogress = function (event) {
cb((event.loaded / totalLength) * 100, xhr)
}
xhr.onload = function () {
if (xhr.status < 400) resolve(this.response)
else resolve(null)
}
xhr.onerror = function (e) {
resolve(null)
}
xhr.send()
})
}

View File

@ -0,0 +1,13 @@
/*
* @Author: ShawnPhang
* @Date: 2023-07-12 19:37:14
* @Description: base64
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 19:37:39
*/
export default (base64Data: string, fileName: string) => {
const link = document.createElement('a')
link.href = base64Data
link.download = fileName
link.click()
}

View File

@ -0,0 +1,15 @@
/*
* @Author: ShawnPhang
* @Date: 2023-07-12 19:36:16
* @Description: blob
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 19:36:41
*/
export default (blob: Blob, fileName: string) => {
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}

View File

@ -0,0 +1,14 @@
/*
* @Author: ShawnPhang
* @Date: 2023-07-12 16:54:12
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 19:38:09
*/
import downloadImg from './download'
import downloadBase64File from './downloadBase64File'
export default {
downloadImg,
downloadBase64File,
}

View File

@ -0,0 +1,110 @@
/*
* @Author: ShawnPhang
* @Date: 2022-01-08 09:43:37
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-03-30 14:13:26
*/
// import { getFonts } from '@/api/gaoding'
// import { isSupportFontFamily, blob2Base64 } from './utils'
import { getFonts } from '@/api/material'
// import store from '@/store'
const fontList: any = []
// const download: any = {}
export const useFontStore = {
list: fontList,
// download,
async init() {
this.list = []
const localFonts: any = localStorage.getItem('FONTS') ? JSON.parse(localStorage.getItem('FONTS') || '') : []
if (localFonts.length > 0) {
this.list.push(...localFonts)
}
if (this.list.length === 0) {
const res = await getFonts({ pageSize: 400 })
this.list.unshift(
...res.list.map((x: any) => {
const { alias, oid, value, preview, woff, lang } = x
return { id: oid, value, preview, alias, url: woff, lang }
}),
)
localStorage.setItem('FONTS', JSON.stringify(this.list))
}
// store.dispatch('setFonts', this.list)
},
}
// export const useFontStore = () => {
// return {
// list: fontList,
// download,
// async init() {
// this.list = []
// const localFonts: any = localStorage.getItem('FONTS') ? JSON.parse(localStorage.getItem('FONTS') || '') : []
// if (localFonts.length > 0) {
// this.list.push(...localFonts)
// }
// if (this.list.length === 0) {
// const res = await getFonts({ pageSize: 400 })
// this.list.unshift(
// ...res.map((x: any) => {
// const { content, id, name, preview } = x
// return { id, name, preview: preview.url, alias: content.alias, family: content.family, lang: content.lang, ttf: content.ttf, url: content.woff }
// }),
// )
// localStorage.setItem('FONTS', JSON.stringify(this.list))
// }
// console.log(this.list)
// },
// getList() {
// return fontList
// },
// }
// }
// export const useFontStore = () => {
// return {
// list: fontList,
// download,
// async init() {
// this.list = []
// const localFonts: any = localStorage.getItem('FONTS') ? JSON.parse(localStorage.getItem('FONTS') || '') : []
// if (localFonts.length > 0) {
// this.list.push(...localFonts)
// }
// if (this.list.length === 0) {
// for (let i = 1; i < 99; i += 1) {
// const res = await getFonts(i)
// this.list.unshift(
// ...res.map((x: any) => {
// const { content, id, name, preview } = x
// return { id, name, preview: preview.url, alias: content.alias, family: content.family, lang: content.lang, ttf: content.ttf, url: content.woff }
// }),
// )
// if (res.length < 100) break
// }
// localStorage.setItem('FONTS', JSON.stringify(this.list))
// }
// },
// async addFont2Style(name: string, url: string) {
// // if (this.download[name]) return;
// if (isSupportFontFamily(name)) return
// const response = await fetch(url, { headers: { responseType: 'blob' } })
// const blob = await response.blob()
// const ff = new FontFace(name, `url(${URL.createObjectURL(blob)})`)
// const f = await ff.load()
// ;(document.fonts as FontFaceSet).add(f)
// const b64 = await blob2Base64(blob)
// // 使用 base64 是为了方便将 DOM 生成图片
// this.download[name] = b64
// // document.head.appendChild(generateFontStyle(name, b64));
// },
// }
// }

View File

@ -0,0 +1,124 @@
interface TextItem {
text: string
[porpname: string]: any
}
interface CloudText {
fontFamily: string
fontSize: number
textAlign: string
color: string
textDecoration: string
writingMode: string
fontWeight: string | number
fontStyle: string
lineHeight: number
shadows: Record<string, unknown>[]
strokes: Record<string, unknown>[]
text: string
texts: TextItem[]
type: string
letterSpacing: number
[propsName: string]: unknown
}
const CLOUD_TYPE = {
text: 'text',
image: 'image',
}
// https://www.zhangxinxu.com/wordpress/2018/02/js-detect-suppot-font-family/
export const isSupportFontFamily = (f: string) => {
if (typeof f != 'string') {
return false
}
const h = 'Arial'
if (f.toLowerCase() == h.toLowerCase()) {
return true
}
const e = 'a'
const d = 100
const a = 100
const i = 100
const c = document.createElement('canvas')
const b = c.getContext('2d') as CanvasRenderingContext2D
c.width = a
c.height = i
b.textAlign = 'center'
b.fillStyle = 'black'
b.textBaseline = 'middle'
const g = (j: string) => {
b.clearRect(0, 0, a, i)
b.font = d + 'px ' + j + ', ' + h
b.fillText(e, a / 2, i / 2)
const k = b.getImageData(0, 0, a, i).data
return [].slice.call(k).filter((l) => {
return l != 0
})
}
return g(h).join('') !== g(f).join('')
}
// 生成字体 style
export function generateFontStyle(name: string, url: string): HTMLStyleElement {
const el = document.createElement('style')
el.id = name
// el.classList.add('font-face');
el.innerHTML = `@font-face { font-family: "${name}"; src: local("${name}"), url("${url}"); }`
return el
}
// 找到使用到的所有字体
export function filterSkyFonts() {
const fonts: string[] = []
// const textClouds = sky.state.clouds.filter(
// (cloud) => cloud.type === CLOUD_TYPE.text,
// );
const textClouds: any = []
;((textClouds as unknown) as CloudText[]).forEach((cloud) => {
// 找到文字组件字体
if (cloud.fontFamily && !fonts.includes(cloud.fontFamily)) {
fonts.push(cloud.fontFamily)
}
// 找到文字组件子级字体
cloud.texts.forEach((text) => {
if (text.fontFamily && !fonts.includes(text.fontFamily)) {
fonts.push(text.fontFamily)
}
})
})
return fonts
}
export function base642Blob(b64Data: string, contentType = '', sliceSize = 512) {
const byteCharacters = atob(b64Data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
const blob = new Blob(byteArrays, { type: contentType })
return blob
}
export async function blob2Base64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = () => {
resolve(fileReader.result as string)
}
fileReader.onerror = reject
fileReader.readAsDataURL(blob)
})
}

View File

@ -0,0 +1,27 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-23 17:25:35
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-02-24 00:16:59
*/
export const getImage = (imgItem: string | File) => {
// 创建对象
const img = new Image()
// 改变图片的src
const url = window.URL || window.webkitURL
img.src = typeof imgItem === 'string' ? imgItem : url.createObjectURL(imgItem)
return new Promise((resolve) => {
// 判断是否有缓存
if (img.complete) {
resolve(img)
} else {
// 加载完成执行
img.onload = function() {
resolve(img)
}
}
})
}

View File

@ -0,0 +1,35 @@
/*
* @Author: ShawnPhang
* @Date: 2022-01-31 10:45:53
* @Description: transform字符串
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-02-18 16:54:13
*/
export function getTransformAttribute(target: any, attr: string = '') {
const tf = target.style.transform
const iof = tf.indexOf(attr)
const half = tf.substring(iof + attr.length + 1)
return half.substring(0, half.indexOf(')'))
}
export function setTransformAttribute(target: any, attr: string, value: string | number = 0) {
const tf = target?.style.transform
if (!tf) {
return
}
const iof = tf.indexOf(attr)
const FRONT = tf.slice(0, iof + attr.length + 1)
const half = tf.substring(iof + attr.length + 1)
const END = half.substring(half.indexOf(')'))
target.style.transform = FRONT + value + END
}
export function getMatrix(params: any) {
const result = []
for (const key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
result.push(params[key])
}
}
return result
}

View File

@ -0,0 +1,39 @@
/*
* @Author: ShawnPhang
* @Date: 2022-04-15 11:16:20
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-15 11:22:49
*/
/**
*
* @param content
* @param tooltipVisible
* @returns
*/
export function toolTip(content: string) {
const tooltip = drawTooltip(content)
document.body.appendChild(tooltip)
setTimeout(() => tooltip?.parentNode?.removeChild(tooltip), 2000)
}
function drawTooltip(content: string, tooltipVisible = true) {
const tooltip: any = document.createElement('div')
tooltip.id = 'color-pipette-tooltip-container'
tooltip.innerHTML = content
tooltip.style = `
position: fixed;
left: 50%;
top: 9%;
z-index: 10002;
display: ${tooltipVisible ? 'flex' : 'none'};
align-items: center;
background-color: rgba(0,0,0,0.4);
padding: 6px 12px;
border-radius: 4px;
color: #fff;
font-size: 18px;
pointer-events: none;
`
return tooltip
}

View File

@ -0,0 +1,18 @@
/*
* @Author: ShawnPhang
* @Date: 2021-09-30 16:28:40
* @Description: /
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-01-20 17:46:20
*/
import { ElLoading } from 'element-plus'
export default (text: string = 'loading') => {
const loading = ElLoading.service({
lock: true,
text,
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)',
})
return loading
// loading.close()
}

View File

@ -0,0 +1,15 @@
/*
* @Author: ShawnPhang
* @Date: 2021-09-30 16:28:40
* @Description: /
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-01-20 18:19:20
*/
import { ElNotification } from 'element-plus'
export default (title: string, message: string = '', type: any = 'success') => {
ElNotification({
title,
message,
type,
})
}

View File

@ -0,0 +1,64 @@
/*
* @Author: ShawnPhang
* @Date: 2021-08-10 15:42:12
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-03-13 16:17:54
*/
// TODO: Group类型比较特殊所以需要全量循环并判断是否为group
const arr = ['w-text', 'w-image', 'w-svg', 'w-group', 'w-qrcode']
export function getTarget(currentTarget: any) {
let collector: any[] = []
let groupTarger: any = null
let saveTarger: any = null
return new Promise((resolve) => {
function findTarget(target: any) {
if (!target || target.id === 'page-design') {
if (collector.length > 1) {
resolve(groupTarger)
} else {
resolve(saveTarger || currentTarget)
}
return
}
const t = Array.from(target.classList)
collector = collector.concat(
t.filter((x) => {
arr.includes(x) && (saveTarger = target)
x === 'w-group' && (groupTarger = target)
return arr.includes(x)
}),
)
findTarget(target.parentElement)
}
findTarget(currentTarget)
})
}
export function getFinalTarget(currentTarget: any) {
let collector: any[] = []
let groupTarger: any = null
let saveTarger: any = null
return new Promise((resolve) => {
function findTarget(target: any) {
if (!target || target.id === 'page-design') {
resolve(target)
return
}
const t = Array.from(target.classList)
collector = collector.concat(
t.filter((x) => {
arr.includes(x) && (saveTarger = target)
x === 'w-group' && (groupTarger = target)
return arr.includes(x)
}),
)
findTarget(target.parentElement)
}
findTarget(currentTarget)
})
}

View File

@ -0,0 +1,106 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-09-28 20:06:25
* @Description: 裁剪组件
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-29 17:58:00
-->
<template>
<el-dialog v-model="dialogVisible" title="裁剪图片" width="80%" :before-close="handleClose" @close="cancel">
<div id="wrap" v-loading="loading" style="height: 50vh">
<img v-show="url" ref="imgBox" style="visibility: hidden" :src="url" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel">取消</el-button>
<el-button :loading="loading" plain type="primary" @click="ok">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts">
import api from '@/api'
import { ElDialog } from 'element-plus'
import { ref, defineComponent, toRefs, reactive, nextTick } from 'vue'
import { useStore } from 'vuex'
import 'cropperjs/dist/cropper.css'
import Cropper from 'cropperjs'
export default defineComponent({
components: { ElDialog },
emits: ['done'],
setup(props, context) {
const store = useStore()
const state = reactive({
loading: false,
url: '',
})
const dialogVisible = ref(false)
const imgBox = ref<HTMLImageElement | any>()
let cropData: any = null
let cropper: any = null
const handleClose = (done: any) => {
done()
}
const open = async (item: any, data = {}) => {
state.loading = true
item.rawImg = item.rawImg ? item.rawImg : item.imgUrl
cropData = data
state.url = item.rawImg
store.commit('setShowMoveable', false)
dialogVisible.value = true
await nextTick()
setEdit()
}
const setEdit = () => {
cropper = new Cropper(imgBox.value, {
// aspectRatio: imgBox.value.width / imgBox.value.height,
dragMode: 'move',
viewMode: 1,
cropBoxMovable: false,
// cropBoxResizable: false,
highlight: false,
background: true,
// crop(event) {
// console.log(event);
// },
})
imgBox.value.addEventListener('ready', function() {
state.loading = false
if (this.cropper === cropper) {
cropData && cropper.setData(cropData)
}
})
}
const ok = () => {
state.loading = true
setTimeout(async () => {
const newImg = cropper.getCroppedCanvas({ maxWidth: 4096, minWidth: 4096 }).toDataURL('image/jpeg', 0.8)
const { width, height } = cropper.getCropBoxData()
const { preview_url } = await api.material.uploadBase64({ file: newImg })
context.emit('done', { newImg: preview_url, data: cropper.getData(), width, height })
cancel()
}, 100)
}
const cancel = () => {
store.commit('setShowMoveable', true)
dialogVisible.value = false
state.url = ''
cropData = null
state.loading = false
cropper.destroy()
}
return {
...toRefs(state),
dialogVisible,
handleClose,
open,
ok,
cancel,
imgBox,
}
},
})
</script>

View File

@ -0,0 +1,172 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-07-11 23:50:22
* @Description: 抠图组件
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-14 09:10:31
-->
<template>
<el-dialog v-model="show" title="AI抠图测试版" width="650" @close="handleClose">
<uploader v-if="!rawImage" :hold="true" :drag="true" :multiple="true" class="uploader" @load="selectFile">
<div class="uploader__box">
<upload-filled style="width: 64px; height: 64px" />
<div class="el-upload__text">在此拖入或选择<em>上传图片</em></div>
</div>
<div class="el-upload__tip">支持 jpg png 格式的文件大小不超过 2M</div>
</uploader>
<div class="content">
<div v-show="rawImage" v-loading="!cutImage" :style="{ width: offsetWidth ? offsetWidth + 'px' : '100%' }" class="scan-effect">
<img ref="raw" :src="rawImage" alt="" />
<div :style="{ right: 100 - percent + '%' }" class="bg"></div>
<img v-show="cutImage" :src="cutImage" alt="结果图像" @mousemove="mousemove" />
<div v-show="cutImage" :style="{ left: percent + '%' }" class="scan-line"></div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-show="rawImage" @click="clear">重新选择图片</el-button>
<el-button v-show="cutImage" type="primary" plain @click="download"> 下载 </el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, onMounted, nextTick } from 'vue'
import { useStore } from 'vuex'
import { UploadFilled } from '@element-plus/icons-vue'
// import { client } from '@gradio/client'
// import * as api from '@/api/ai'
import uploader from '@/components/common/Uploader/index.vue'
import _dl from '@/common/methods/download'
export default defineComponent({
components: { uploader, UploadFilled },
setup() {
const store = useStore()
const state: any = reactive({
show: false,
rawImage: '',
cutImage: '',
raw: null,
offsetWidth: 0,
percent: 0,
})
let app: any = null
let fileName: string = 'unknow'
let isRuning: boolean = false
onMounted(async () => {
app = await store.getters.app
})
const selectFile = async (file: File) => {
if (file.size > 1024 * 1024 * 2) {
alert('上传的文件大小超过了限制!')
return false
}
//
state.raw.addEventListener('load', () => {
state.offsetWidth = state.raw.offsetWidth
})
state.rawImage = URL.createObjectURL(file)
fileName = file.name
//
const result = await app.predict('/predict', [
file, // blob in 'Input' Image component
'u2netp', // string (Option from: ['isnet-anime', 'isnet-general-use', 'sam', 'silueta', 'u2net', 'u2net_cloth_seg', 'u2net_custom', 'u2net_human_seg', 'u2netp']) in 'Models' Dropdown component
'',
])
state.rawImage && (state.cutImage = result?.data[0])
requestAnimationFrame(run)
}
const open = () => {
state.show = true
store.commit('setShowMoveable', false)
}
const handleClose = () => {
store.commit('setShowMoveable', true)
}
const mousemove = (e: MouseEvent) => {
!isRuning && (state.percent = (e.offsetX / (e.target as any).width) * 100)
}
const download = () => {
_dl.downloadBase64File(state.cutImage, fileName)
}
const clear = () => {
URL.revokeObjectURL(state.rawImage)
state.rawImage = ''
// URL.revokeObjectURL(state.cutImage)
state.cutImage = ''
state.percent = 0
state.offsetWidth = 0
}
const run = () => {
state.percent += 1
isRuning = true
state.percent < 100 ? requestAnimationFrame(run) : (isRuning = false)
}
return {
clear,
download,
mousemove,
selectFile,
open,
handleClose,
...toRefs(state),
}
},
})
</script>
<style lang="less" scoped>
.uploader {
&__box {
color: #333333;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
}
.content {
position: relative;
display: flex;
justify-content: center;
}
.scan-effect {
position: relative;
height: 50vh;
overflow: hidden;
img {
// width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
}
.bg {
width: 100%;
height: 100%;
background: #ffffff;
position: absolute;
}
}
.scan-line {
position: absolute;
top: 0;
width: 1.5px;
height: 100%;
background: rgba(255, 255, 255, 0.7);
// background-image: linear-gradient(to top, transparent, rgba(255, 255, 255, 0.7), transparent);
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
</style>

View File

@ -0,0 +1,9 @@
/*
* @Author: ShawnPhang
* @Date: 2022-10-08 10:07:10
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 00:05:48
*/
import index from './ImageCutout.vue'
export default index

View File

@ -0,0 +1,500 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-08-04 11:46:39
* @Description: 原版movable插件
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-17 11:53:30
-->
<template>
<div id="empty" class="moveable__remove-item zk-moveable-style"></div>
</template>
<script lang="ts">
import { defineComponent, nextTick } from 'vue'
import Moveable, { EVENTS } from 'moveable' // PROPERTIES, METHODS,
import MoveableHelper from 'moveable-helper'
import { mapGetters, mapActions } from 'vuex'
// import { setTransformAttribute } from '@/common/methods/handleTransform'
import useSelecto from './Selecto'
export default defineComponent({
setup() {},
computed: mapGetters(['dSelectWidgets', 'dActiveElement', 'activeMouseEvent', 'showMoveable', 'showRotatable', 'dWidgets', 'updateRect', 'updateSelect', 'guidelines']),
watch: {
async dActiveElement(val) {
if (!val.record) {
return
}
//
if (val.uuid != -1) {
await nextTick()
const target = `[id="${val.uuid}"]`
this._target = `[id="${val.uuid}"]`
this.moveable.rotatable = true //
//
// this.moveable.renderDirections = val.type === 'w-text' ? ['e', 'se'] : 'w-image' ? ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'] : ['nw', 'ne', 'sw', 'se']
switch (val.type) {
case 'w-text':
this.moveable.renderDirections = ['e', 'se']
break
case 'w-image':
this.moveable.renderDirections = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
break
// case 'w-svg':
// this.moveable.renderDirections = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
// break
default:
this.moveable.renderDirections = ['nw', 'ne', 'sw', 'se']
break
}
// // Set Move Auto
this.moveable.setState({ target: this._target }, () => {
// mouseevent
if (this.activeMouseEvent) {
this.moveable.dragStart(this.activeMouseEvent)
// TODO 使mouseevent
this.$store.commit('setMouseEvent', null)
}
})
// // End
this.$store.commit('setShowMoveable', true)
// 线
if (!this.moveable.elementGuidelines.includes(target)) {
this.moveable.elementGuidelines.push(target)
}
} else {
this.moveable.target = `[id="empty"]`
if (this.moveable.target !== `[id="empty"]`) {
setTimeout(() => {
this.moveable.target = `[id="empty"]`
}, 210)
}
// feature: 线
this.moveable.elementGuidelines.length = 0
}
},
showMoveable(val) {
if (val) {
this.moveable.target = this._target
} else {
this.moveable.target = `[id="empty"]`
}
},
showRotatable(val) {
// TODO:
this.moveable.renderDirections = val ? ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'] : []
this.moveable.resizable = val
this.moveable.scalable = val
document.getElementsByClassName('moveable-rotation')[0].style.display = val ? 'block' : 'none'
},
updateRect(val) {
this.moveable.updateRect()
},
updateSelect() {
const items = this.$store.getters.dSelectWidgets
setTimeout(async () => {
this.moveable.updateRect()
await this.$nextTick()
for (let i = 0; i < items.length; i++) {
console.log(items[i].uuid)
document.getElementById(items[i].uuid)?.classList.add('widget-selected')
}
this.moveable.renderDirections = []
this.moveable.rotatable = false
const targetCollector = [].slice.call(document.querySelectorAll('.widget-selected'))
console.log(targetCollector)
this.moveable.target = targetCollector
for (let i = 0; i < items.length; i++) {
document.getElementById(items[i].uuid)?.classList.remove('widget-selected')
}
}, 400)
},
//
dSelectWidgets: {
handler(items) {
const alt = this.$store.getters.dAltDown
// if (items.length > 1) {
// console.log('')
// }
if (alt) {
for (let i = 0; i < items.length; i++) {
document.getElementById(items[i].uuid)?.classList.add('widget-selected')
}
this.moveable.renderDirections = []
this.moveable.rotatable = false
const targetCollector = [].slice.call(document.querySelectorAll('.widget-selected'))
// this.moveable.target = `[id="empty"]`
this.moveable.target = targetCollector
for (let i = 0; i < items.length; i++) {
document.getElementById(items[i].uuid)?.classList.remove('widget-selected')
}
}
},
deep: true,
},
// 线
guidelines(lines) {
console.log(lines)
this.moveable.verticalGuidelines = lines.verticalGuidelines
this.moveable.horizontalGuidelines = lines.horizontalGuidelines
},
},
mounted() {
let holdGroupPosition: any = null
const moveableOptions: any = {
target: document.querySelector(`[id="empty"]`),
// container: document.querySelector('#page-design'),
zoom: 0.8,
draggable: true,
clippable: false, //
throttleDrag: 0,
resizable: true,
throttleResize: 0,
scalable: false,
throttleScale: 0,
keepRatio: true,
rotatable: true,
throttleRotate: 0,
renderDirections: ['nw', 'ne', 'sw', 'se'], // ['nw', 'ne', 'sw', 'se'] // 'e'
pinchable: true, // ["draggable", "resizable", "scalable", "rotatable"]
origin: false,
defaultGroupOrigin: '0% 0%',
//
rotationPosition: 'bottom',
className: 'zk-moveable-style',
// -- Start --
snappable: true,
elementGuidelines: [],
verticalGuidelines: [],
horizontalGuidelines: [],
snapThreshold: 4,
isDisplaySnapDigit: true,
snapGap: false,
snapElement: true,
snapVertical: true,
snapHorizontal: true,
snapCenter: false,
snapDigit: 0,
// snapDirections={{"top":true,"right":true,"bottom":true,"left":true}}
// elementSnapDirections={{}}
// -- END --
triggerAblesSimultaneously: true,
}
const moveable = new Moveable(document.body, moveableOptions)
this.moveable = moveable
const helper: any = new MoveableHelper()
EVENTS.forEach((event) => {
let helperEvent = event.replace(event[0], 'on' + event[0].toUpperCase())
// console.log(event)
// 'resizeStart', 'resize', 'resizeEnd', rotate, onScale, onScaleStart
if (['resizeStart', 'rotate', 'resize'].includes(event)) {
moveable.on(event, (...args) => {
// this.$emit(event, ...args)
helper[helperEvent] && helper[helperEvent](...args)
})
}
})
/* draggable */
let resizeStartWidth = 0
moveable
.on('dragStart', ({ inputEvent, target, stop }) => {
if (inputEvent.target.nodeName === 'PRE') {
this.dActiveElement.editable && stop()
}
this.dActiveElement.lock && stop()
})
.on('drag', ({ target, transform, left, top, inputEvent }) => {
// target!.style.transform = transform]
target!.style.left = `${left}px`
target!.style.top = `${top}px`
this.holdPosition = { left, top }
})
.on('dragEnd', ({ target, isDrag, inputEvent }) => {
// console.log('onDragEnd', inputEvent)
// TODO mouseevent
this.$store.commit('setMouseEvent', null)
inputEvent.stopPropagation()
inputEvent.preventDefault()
// console.log(this.holdPosition, inputEvent.pageX, inputEvent.pageY)
if (this.holdPosition) {
this.updateWidgetData({
uuid: this.dActiveElement.uuid,
key: 'left',
value: this.holdPosition?.left,
})
this.updateWidgetData({
uuid: this.dActiveElement.uuid,
key: 'top',
value: this.holdPosition?.top,
})
this.holdPosition = null // important
setTimeout(() => {
this.pushHistory()
}, 100)
}
})
// .on('keyUp', (e) => {
// moveable.updateRect()
// })
.on('rotate', ({ target, beforeDist, dist, transform }: any) => {
// console.log('onRotate', Number(this.dActiveElement.rotate) + Number(beforeDist + dist))
// target.style.transform = transform
console.log(target.style.transform)
})
.on('rotateEnd', (e: any) => {
const tf = e.target.style.transform
const iof = tf.indexOf('rotate')
let rotate = ''
if (iof != -1) {
const index = iof + 'rotate'.length
const half = tf.substring(index + 1)
rotate = half.slice(0, half.indexOf(')'))
}
rotate &&
this.updateWidgetData({
uuid: this.dActiveElement.uuid,
key: 'rotate',
value: rotate,
})
})
.on('resizeStart', (args) => {
console.log(args.target.style.transform)
this.moveable.snappable = false
if (this.dActiveElement.type === 'w-text') {
if (String(args.direction) === '1,0') {
moveable.keepRatio = false
moveable.scalable = false
}
if (String(args.direction) === '1,1') {
moveable.keepRatio = false
resizeStartWidth = args.target.offsetWidth
this.startHL = Number(args.target!.style.lineHeight.replace('px', ''))
this.startLS = Number(args.target!.style.letterSpacing.replace('px', ''))
this.resetRatio = 1
}
} else if (this.dActiveElement.type === 'w-image' || this.dActiveElement.type === 'w-qrcode' || this.dActiveElement.type === 'w-svg') {
const dirs = ['1,0', '0,-1', '-1,0', '0,1']
dirs.includes(String(args.direction)) && (moveable.keepRatio = false)
}
})
.on('resize', (args: any) => {
const { target, width, height, dist, delta, clientX, clientY, direction } = args
console.log(2, args)
if (this.dActiveElement.type === 'w-text') {
if (String(direction) === '1,1') {
this.resetRatio = width / resizeStartWidth
target!.style.fontSize = this.dActiveElement.fontSize * this.resetRatio + 'px'
target!.style.letterSpacing = this.startLS * this.resetRatio + 'px'
target!.style.lineHeight = this.startHL * this.resetRatio + 'px'
}
target.style.width = width
target.style.height = height
this.resizeTempData = { width, height }
// moveable.updateRect()
target.style.backgroundImage = 'none'
// moveable.keepRatio !== this.resetRatio > 1 && (moveable.keepRatio = this.resetRatio > 1)
} else if (this.dActiveElement.type == 'w-image' || this.dActiveElement.type === 'w-qrcode' || this.dActiveElement.type === 'w-svg') {
this.resizeTempData = { width, height }
} else if (this.dActiveElement.type == 'w-group') {
// let record = this.dActiveElement.record
// this.dActiveElement.tempScale = width / record.width
this.$store.commit('resize', { width: width, height: height })
// this.resizeTempData = { width, height }
// let record = this.dActiveElement.record
// setTransformAttribute(target, 'scale', width / record.width)
} else {
this.$store.commit('resize', { width: width, height: height })
}
this.dActiveElement.rotate && (target!.style.transform = target!.style.transform.replace('(0deg', `(${this.dActiveElement.rotate}`))
})
.on('resizeEnd', (e: any) => {
moveable.resizable = true
// moveable.scalable = true
moveable.snappable = true
if (e.lastEvent) {
// setTimeout(() => {
// if (this.dActiveElement.type === 'w-group') {
// //
// return
// }
console.log('重置translate', this.dActiveElement)
//
// if (this.dActiveElement.cache && this.dActiveElement.cache.recordLeft) {
// const left = e.lastEvent.drag.translate[0] + Number(this.dActiveElement.cache.recordLeft)
// const top = e.lastEvent.drag.translate[1] + Number(this.dActiveElement.cache.recordTop)
// this.dActiveElement.cache = { left, top }
// } else {
// const left = e.lastEvent.drag.translate[0] + Number(this.dActiveElement.left)
// const top = e.lastEvent.drag.translate[1] + Number(this.dActiveElement.top)
// this.dActiveElement.cache = { left, top }
// }
const left = e.lastEvent.drag.translate[0]
const top = e.lastEvent.drag.translate[1]
this.updateWidgetMultiple({
uuid: this.dActiveElement.uuid,
data: [
{
key: 'left',
value: Number(this.dActiveElement.left) + left,
},
{
key: 'top',
value: Number(this.dActiveElement.top) + top,
},
],
})
// translate
const tf = e.target.style.transform
const iof = tf.indexOf('translate')
const FRONT = tf.slice(0, iof + 'translate'.length + 1)
const half = tf.substring(iof + 'translate'.length + 1)
const END = half.substring(half.indexOf(')'))
e.target.style.transform = FRONT + '0, 0' + END
// this.moveable.updateRect()
// }, 10)
}
if (this.resizeTempData) {
this.$store.commit('resize', this.resizeTempData)
this.resizeTempData = null
setTimeout(async () => {
await this.$nextTick()
this.moveable.updateRect()
}, 10)
}
try {
if (this.dActiveElement.type === 'w-text') {
const d = e.direction || e.lastEvent.direction
String(d) === '1,1' && (this.dActiveElement.fontSize = this.dActiveElement.fontSize * this.resetRatio)
}
} catch (err) {}
moveable.keepRatio = true
})
.on('scaleStart', (e) => {
if (this.dActiveElement.type === 'w-text') {
this.startHL = Number(e.target!.style.lineHeight.replace('px', ''))
this.startLS = Number(e.target!.style.letterSpacing.replace('px', ''))
this.resetRatio = 1
} else {
moveable.scalable = false
}
})
.on('scale', (e) => {
moveable.resizable = false
const { target, scale, transform } = e
this.resetRatio = scale[0]
target!.style.transform = transform
this.dActiveElement.rotate && (target!.style.transform = target!.style.transform.replace('0deg', this.dActiveElement.rotate))
})
.on('scaleEnd', (e) => {
moveable.resizable = true
// moveable.scalable = true
moveable.keepRatio = true
console.log(e.target.style.transform)
try {
if (this.dActiveElement.type === 'w-text') {
const d = e.direction || e.lastEvent.direction
String(d) === '1,1' && (this.dActiveElement.fontSize = this.dActiveElement.fontSize * this.resetRatio)
}
} catch (err) {}
})
.on('dragGroup', (e) => {
e.inputEvent.stopPropagation()
e.inputEvent.preventDefault()
holdGroupPosition = {}
const events = e.events
for (let i = 0; i < events.length; i++) {
const ev = events[i]
const currentWidget = this.dWidgets.find((item: any) => item.uuid === ev.target.getAttribute('data-uuid'))
const left = Number(currentWidget.left) + ev.beforeTranslate[0]
// debug -- start --
if (i === 1) {
console.log(Number(currentWidget.left), ev.beforeTranslate[0])
}
// debug -- end --
const top = Number(currentWidget.top) + ev.beforeTranslate[1]
ev.target.style.left = `${left}px`
ev.target.style.top = `${top}px`
holdGroupPosition[`${ev.target.getAttribute('data-uuid')}`] = { left, top }
}
})
.on('dragGroupEnd', (e) => {
for (const key in holdGroupPosition) {
if (Object.prototype.hasOwnProperty.call(holdGroupPosition, key)) {
const item = holdGroupPosition[key]
this.updateWidgetData({
uuid: key,
key: 'left',
value: item.left,
})
this.updateWidgetData({
uuid: key,
key: 'top',
value: item.top,
})
}
}
holdGroupPosition = null
// background: linear-gradient(to right, #ccc 0%, #ccc 50%, transparent 50%);
// background-size: 12px 1px;
})
.on('resizeGroupStart', ({ events }: any) => {
console.log(events)
// events.forEach((ev, i) => {
// const frame = this.frames[i];
// // Set origin if transform-origin use %.
// ev.setOrigin(["%", "%"]);
// // If cssSize and offsetSize are different, set cssSize.
// const style = window.getComputedStyle(ev.target);
// const cssWidth = parseFloat(style.width);
// const cssHeight = parseFloat(style.height);
// ev.set([cssWidth, cssHeight]);
// // If a drag event has already occurred, there is no dragStart.
// ev.dragStart && ev.dragStart.set(frame.translate);
// });
})
.on('resizeGroup', (e: any) => {
// events.forEach(({ target, width, height, drag }, i) => {
// const frame = this.frames[i];
// target.style.width = `${width}px`;
// target.style.height = `${height}px`;
// // get drag event
// frame.translate = drag.beforeTranslate;
// target.style.transform
// = `translate(${drag.beforeTranslate[0]}px, ${drag.beforeTranslate[1]}px)`;
// });
})
.on('resizeGroupEnd', ({ targets, isDrag }: any) => {
console.log('onResizeGroupEnd', targets, isDrag)
})
// -- Start --
useSelecto(this.moveable)
// -- END --
},
async created() {
await nextTick()
const Ele = document.getElementById('page-design')
// TODO
Ele?.addEventListener('scroll', () => {
this.moveable.updateRect()
})
},
methods: {
...mapActions(['updateWidgetData', 'updateWidgetMultiple', 'pushHistory']),
},
})
</script>
<style lang="less">
@import url('./style/index.less');
</style>

View File

@ -0,0 +1,41 @@
import Selecto from 'selecto'
import { getElementInfo } from 'moveable'
import store from '@/store'
export default function(moveable: any) {
const selecto = new Selecto({
container: document.getElementById('page-design'),
selectableTargets: ['.layer'],
selectByClick: false,
// 是否从内部目标中选择(default: true)
selectFromInside: false,
// 选择后,是否与所选目标一起选择下一个目标
continueSelect: false,
// Determines which key to continue selecting the next target via keydown and keyup.
toggleContinueSelect: 'shift',
// The container for keydown and keyup events
keyContainer: document.getElementById('page-design'),
// 目标与要选择的拖动区域重叠的速率。(默认:100)
hitRate: 5,
getElementRect: getElementInfo,
})
selecto.on('select', (e) => {
e.added.forEach((el) => {
if (!Array.from(el.classList).includes('layer-lock') && !el.hasAttribute('child')) {
el.classList.add('widget-selected')
store.dispatch('selectWidgetsInOut', {
uuid: el.getAttribute('data-uuid'),
})
}
})
e.removed.forEach((el) => {
el.classList.remove('widget-selected')
store.dispatch('selectWidgetsInOut', {
uuid: el.getAttribute('data-uuid'),
})
})
moveable.renderDirections = [] // ['nw', 'ne', 'sw', 'se'] // []
moveable.rotatable = false
moveable.target = [].slice.call(document.querySelectorAll('.widget-selected'))
})
}

View File

@ -0,0 +1,70 @@
// 2层 .zk-moveable-style 增加css权重层级避免太多 important
.zk-moveable-style.zk-moveable-style {
--moveable-color: #6ccfff;
// 缩放圆点
.moveable-control {
background: #fff;
box-sizing: border-box;
display: block;
border: 1px solid #c0c5cf;
box-shadow: 0 0 2px 0 rgb(86, 90, 98, 0.2);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
// &.moveable-n,
// &.moveable-s,
// &.moveable-e,
// &.moveable-w {
// display: none;
// }
// 上下缩放点
&.moveable-n,
&.moveable-s {
width: 16px;
height: 8px;
margin-top: -4px;
margin-left: -8px;
border-radius: 6px;
}
// 左右缩放点
&.moveable-e,
&.moveable-w {
width: 8px;
height: 16px;
margin-left: -4px;
margin-top: -8px;
border-radius: 6px;
}
}
// 旋转按钮
.moveable-rotation {
height: 35px;
display: block;
.moveable-rotation-control {
border: none;
background-image: url('./rotation-icon.svg');
width: 24px;
height: 24px;
background-size: 100% 100%;
display: block;
margin-left: -11px;
// margin-top: -11px;
}
// 旋转的操作条
.moveable-rotation-line {
display: none;
}
}
}
.moveable__remove-item {
position: fixed;
left: -9999px;
top: -9999px;
}

View File

@ -0,0 +1 @@
<svg width='24' height='24' xmlns='http://www.w3.org/2000/svg' fill='#757575'><g fill='none' fill-rule='evenodd'><circle stroke='#CCD1DA' fill='#FFF' cx='12' cy='12' r='11.5'/><path d='M16.242 12.012a4.25 4.25 0 00-5.944-4.158L9.696 6.48a5.75 5.75 0 018.048 5.532h1.263l-2.01 3.002-2.008-3.002h1.253zm-8.484-.004a4.25 4.25 0 005.943 3.638l.6 1.375a5.75 5.75 0 01-8.046-5.013H5.023L7.02 9.004l1.997 3.004h-1.26z' fill='#000' fill-rule='nonzero'/></g></svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@ -0,0 +1,9 @@
/*
* @Author: ShawnPhang
* @Date: 2022-10-08 10:07:10
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-26 20:48:29
*/
import index from './index.vue'
export default index

View File

@ -0,0 +1,100 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-10-08 10:07:19
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-29 17:57:46
-->
<template>
<el-dialog v-model="dialogVisible" title="选择图片" @close="close">
<el-tabs tab-position="left" style="height: 60vh" class="demo-tabs">
<el-tab-pane label="个人素材">
<div class="pic__box">
<photo-list ref="imgListComp" :isDone="isDone" :listData="imgList" @load="load" @select="selectImg" />
</div>
</el-tab-pane>
<el-tab-pane label="照片库"></el-tab-pane>
</el-tabs>
<!-- <template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button plain type="primary" @click="dialogVisible = false"
>Confirm</el-button
>
</span>
</template> -->
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, toRefs, reactive } from 'vue'
import { useStore } from 'vuex'
import { ElTabPane, ElTabs } from 'element-plus'
import api from '@/api'
export default defineComponent({
components: { [ElTabPane.name]: ElTabPane, [ElTabs.name]: ElTabs },
emits: ['select'],
setup(props, context) {
const store = useStore()
const state: any = reactive({
dialogVisible: false,
imgList: [],
isDone: false,
})
let loading = false
let page = 0
let listPage = 0
const load = (init?: boolean) => {
if (init) {
state.imgList = []
page = 0
state.isDone = false
}
if (state.isDone || loading) {
return
}
loading = true
page += 1
api.material.getMyPhoto({ page }).then(({ list }: any) => {
list.length <= 0 ? (state.isDone = true) : (state.imgList = state.imgList.concat(list))
setTimeout(() => {
loading = false
}, 100)
})
}
const open = () => {
state.dialogVisible = true
load()
store.commit('setShowMoveable', false)
}
const close = () => {
store.commit('setShowMoveable', true)
}
const selectImg = (index: number) => {
const item: any = state.imgList[index]
context.emit('select', item)
state.dialogVisible = false
}
return {
...toRefs(state),
open,
close,
load,
selectImg,
}
},
})
</script>
<style lang="less" scoped>
.pic__box {
width: 100%;
height: 70vh;
}
</style>

View File

@ -0,0 +1,9 @@
/*
* @Author: ShawnPhang
* @Date: 2022-03-16 09:14:43
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-03-16 09:16:19
*/
import index from './index.vue'
export default index

View File

@ -0,0 +1,109 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-03-16 09:15:52
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-08 17:53:00
-->
<template>
<div ref="qrCodeDom" class="qrcode__wrap"></div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, watch, nextTick } from 'vue'
import QRCodeStyling, { DrawType, TypeNumber, Mode, ErrorCorrectionLevel, DotType, CornerSquareType, CornerDotType, Extension } from 'qr-code-styling'
import { debounce } from 'throttle-debounce'
export default defineComponent({
props: {
width: {
default: 300,
},
height: {
default: 300,
},
image: {},
value: {},
dotsOptions: {
default: () => {
return {}
},
},
},
setup(props) {
let options = {}
watch(
() => [props.width, props.height, props.dotsOptions],
() => {
render()
},
)
const render = debounce(300, false, async () => {
options = {
width: props.width,
height: props.height,
type: 'canvas' as DrawType, // canvas svg
data: props.value,
image: props.image, // /favicon.svg
margin: 2,
qrOptions: {
typeNumber: 3 as TypeNumber,
mode: 'Byte' as Mode,
errorCorrectionLevel: 'M' as ErrorCorrectionLevel,
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.4,
margin: 6,
crossOrigin: 'anonymous',
},
backgroundOptions: {
color: '#ffffff',
},
dotsOptions: {
color: '#41b583',
type: 'rounded' as DotType,
...props.dotsOptions,
},
cornersSquareOptions: {
color: props.dotsOptions.color,
type: '',
// type: 'extra-rounded' as CornerSquareType,
// gradient: {
// type: 'linear', // 'radial'
// rotation: 180,
// colorStops: [{ offset: 0, color: '#25456e' }, { offset: 1, color: '#4267b2' }]
// },
},
cornersDotOptions: {
color: props.dotsOptions.color,
type: 'square' as CornerDotType,
// gradient: {
// type: 'linear', // 'radial'
// rotation: 180,
// colorStops: [{ offset: 0, color: '#00266e' }, { offset: 1, color: '#4060b3' }]
// },
},
}
if (props.value) {
qrCode.update(options)
await nextTick()
qrCodeDom.value.firstChild.style = 'width: 100%;' //
}
})
const qrCode = new QRCodeStyling(options)
const qrCodeDom = ref<HTMLElement>()
onMounted(() => {
render()
qrCode.append(qrCodeDom.value)
})
return {
qrCodeDom,
}
},
})
</script>

View File

@ -0,0 +1,41 @@
/*
* @Author: ShawnPhang
* @Date: 2021-07-30 17:38:50
* @Description:
* @LastEditors: ShawnPhang
* @LastEditTime: 2021-07-30 18:15:22
*/
export const menuList: any = {
left: 0,
top: 0,
list: [],
}
export const widgetMenu = [
{
type: 'copy',
text: '复制',
},
{
type: 'paste',
text: '粘贴',
},
{
type: 'index-up',
text: '上移一层',
},
{
type: 'index-down',
text: '下移一层',
},
{
type: 'del',
text: '删除',
},
]
export const pageMenu = [
{
type: 'paste',
text: '粘贴',
},
]

View File

@ -0,0 +1,165 @@
<template>
<div v-show="showMenuBg" id="menu-bg" class="menu-bg" @click="closeMenu">
<ul ref="menuList" class="menu-list" :style="styleObj">
<li v-for="(item, index) in menuList.list" :key="index" :class="{ 'menu-item': true, 'disable-menu': dCopyElement.length === 0 && item.type === 'paste' }" @click.stop="selectMenu(item.type)">
{{ item.text }}
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { mapGetters, mapActions } from 'vuex'
import { widgetMenu, pageMenu, menuList } from './rc-menu-data'
import { getTarget } from '@/common/methods/target'
export default defineComponent({
setup() {},
data() {
return {
menuList,
showMenuBg: false,
widgetMenu,
pageMenu,
}
},
computed: {
...mapGetters(['dActiveElement', 'dAltDown', 'dWidgets', 'dCopyElement']),
styleObj() {
return {
left: this.menuList.left + 'px',
top: this.menuList.top + 'px',
}
},
},
mounted() {
document.oncontextmenu = this.mouseRightClick
},
methods: {
...mapActions(['selectWidget', 'copyWidget', 'pasteWidget', 'updateLayerIndex', 'deleteWidget', 'ungroup']),
async mouseRightClick(e: any) {
e.stopPropagation()
e.preventDefault()
if (this.showMenuBg) {
this.showMenuBg = false
return
}
// let target = e.target
let target = await getTarget(e.target)
let type = target.getAttribute('data-type')
if (type) {
let uuid = target.getAttribute('data-uuid') //
if (uuid !== '-1' && !this.dAltDown) {
let widget = this.dWidgets.find((item: any) => item.uuid === uuid)
if (widget.parent !== '-1' && widget.parent !== this.dActiveElement.uuid && widget.parent !== this.dActiveElement.parent) {
uuid = widget.parent
}
}
this.selectWidget({
uuid: uuid || '-1',
})
this.showMenu(e)
}
},
showMenu(e: any) {
let isPage = this.dActiveElement.uuid === '-1'
this.menuList.list = isPage ? this.pageMenu : this.widgetMenu
if (this.dActiveElement.isContainer) {
let ungroup = [
{
type: 'ungroup',
text: '取消组合',
},
]
this.menuList.list = ungroup.concat(this.menuList.list)
}
this.showMenuBg = true
// document.getElementById('menu-bg').addEventListener('click', this.closeMenu, false)
let mx = e.pageX
let my = e.pageY
let listWidth = 120
if (mx + listWidth > window.innerWidth) {
mx -= listWidth
}
let listHeight = (14 + 10) * this.menuList.list.length + 10
if (my + listHeight > window.innerHeight) {
my -= listHeight
}
this.menuList.left = mx
this.menuList.top = my
},
closeMenu() {
this.showMenuBg = false
// document.getElementById('menu-bg').removeEventListener('click', this.closeMenu, false)
},
selectMenu(type) {
switch (type) {
case 'copy':
this.copyWidget()
break
case 'paste':
if (this.dCopyElement.length === 0) {
return
}
this.pasteWidget()
break
case 'index-up':
this.updateLayerIndex({
uuid: this.dActiveElement.uuid,
value: 1,
isGroup: this.dActiveElement.isContainer,
})
break
case 'index-down':
this.updateLayerIndex({
uuid: this.dActiveElement.uuid,
value: -1,
isGroup: this.dActiveElement.isContainer,
})
break
case 'del':
this.deleteWidget()
break
case 'ungroup':
this.ungroup(this.dActiveElement.uuid)
break
}
this.closeMenu()
},
},
})
</script>
<style lang="less" scoped>
.menu-bg {
height: 100%;
position: absolute;
width: 100%;
z-index: 99999;
.menu-list {
background-color: @color-white;
box-shadow: 1px 0px 10px 3px rgba(0, 0, 0, 0.1);
padding: 5px;
position: absolute;
width: 120px;
.menu-item {
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 5px 15px;
width: 100%;
&:hover {
background-color: #ececec;
}
}
.menu-item.disable-menu {
background-color: @color-white;
color: #aaaaaa;
cursor: not-allowed;
}
}
}
</style>

View File

@ -0,0 +1,91 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-08-01 11:12:17
* @Description: 前端出图 - 生成封面
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-11 19:47:24
-->
<template>
<div id="cover-wrap">
<img id="cover" />
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, watch, getCurrentInstance, ComponentInternalInstance } from 'vue'
import { mapGetters, mapActions } from 'vuex'
import html2canvas from 'html2canvas'
import api from '@/api'
import Qiniu from '@/common/methods/QiNiu'
export default defineComponent({
props: ['modelValue'],
emits: ['update:modelValue'],
setup(props, context) {
let gridSizeIndex: number = 0
// function fileOrBlobToDataURL(obj: any, cb: Function) {
// let a = new FileReader()
// a.readAsDataURL(obj)
// a.onload = (e: any) => {
// cb(e.target.result)
// }
// }
// function blobToImage(blob: any) {
// return new Promise((resolve) => {
// fileOrBlobToDataURL(blob, (dataurl: string) => {
// resolve(dataurl)
// })
// })
// }
const { proxy }: any = getCurrentInstance() as ComponentInternalInstance
async function createCover(cb: any) {
const nowGrideSizeIndex = gridSizeIndex
const nowZoom = proxy?.dZoom
//
proxy?.selectWidget({
uuid: '-1',
})
gridSizeIndex = 0
proxy?.updateZoom(100)
const opts = {
useCORS: true, //
scale: 0.2,
}
setTimeout(async () => {
html2canvas(document.getElementById('page-design-canvas'), opts).then((canvas: any) => {
canvas.toBlob(
async function (blobObj: Blob) {
const result: any = await Qiniu.upload(blobObj, { bucket: 'cloud-design', prePath: 'cover/user' })
cb(result)
},
'image/jpeg',
0.1,
)
gridSizeIndex = nowGrideSizeIndex
proxy?.updateZoom(nowZoom)
})
}, 10)
}
return {
createCover,
}
},
computed: {
...mapGetters(['dZoom']),
},
methods: {
...mapActions(['selectWidget', 'updateZoom']),
},
})
</script>
<style lang="less" scoped>
#cover-wrap {
position: absolute;
left: -9999px;
}
</style>

View File

@ -0,0 +1,253 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-08-01 11:12:17
* @Description: 前端出图 - 已废弃
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-11 16:58:34
-->
<template>
<div v-show="fillInfoing" class="fill-info-wrap">
<div v-loading="loading" class="fill-info-content">
<div class="fill-info-step">
<div id="cover-wrap">
<img id="cover" />
</div>
<div class="publish-btn" @click="saveImg">
<span v-show="!publishing">下载图片</span>
<i v-show="publishing" class="el-icon-loading"></i>
</div>
<div class="close-publish" @click="closePublish">关闭</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, watch, getCurrentInstance, ComponentInternalInstance } from 'vue'
import { mapGetters, mapActions } from 'vuex'
import html2canvas from 'html2canvas'
import api from '@/api'
import useLoading from '@/common/methods/loading'
export default defineComponent({
props: ['modelValue'],
emits: ['update:modelValue'],
setup(props, context) {
let gridSizeIndex: number = 0
const state = reactive({
fillInfoing: false,
loading: false,
publishing: false,
})
watch(
() => props.modelValue,
(open) => {
if (open) {
save()
}
},
)
const closePublish = () => {
state.fillInfoing = false
context.emit('update:modelValue', false)
}
function fileOrBlobToDataURL(obj: any, cb: Function) {
let a = new FileReader()
a.readAsDataURL(obj)
a.onload = (e: any) => {
cb(e.target.result)
}
}
function blobToImage(blob: any) {
return new Promise((resolve) => {
fileOrBlobToDataURL(blob, (dataurl: string) => {
resolve(dataurl)
})
})
}
const { proxy }: any = getCurrentInstance() as ComponentInternalInstance
async function save() {
// state.loading = true
// state.fillInfoing = true
let nowGrideSizeIndex = gridSizeIndex
let nowZoom = proxy?.dZoom
//
proxy?.selectWidget({
uuid: '-1',
})
gridSizeIndex = 0
proxy?.updateZoom(100)
const opts = {
useCORS: true, //
scale: 0.4,
}
setTimeout(async () => {
html2canvas(document.getElementById('page-design-canvas'), opts).then((canvas) => {
canvas.toBlob(
async (blob: any) => {
const data = await blobToImage(blob)
document.getElementById('cover').src = data
state.loading = false
gridSizeIndex = nowGrideSizeIndex
proxy?.updateZoom(nowZoom)
proxy?.saveImg()
},
'image/jpeg',
0.2,
)
})
}, 300)
}
function saveImg() {
if (state.publishing) {
return
}
state.publishing = true
let image = new Image()
// Canvas
image.setAttribute('crossOrigin', 'anonymous')
image.onload = function () {
let canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const context = canvas.getContext('2d')
context?.drawImage(image, 0, 0, image.width, image.height)
let url = canvas.toDataURL('image/png')
let a = document.createElement('a')
let event = new MouseEvent('click')
// adownloadname使
a.download = name || '生成图片'
a.href = url
// a
a.dispatchEvent(event)
}
image.src = document.getElementById('cover').src
state.publishing = false
state.fillInfoing = false
}
async function createImg(cb: any) {
let loading: any = useLoading('保存封面中')
let nowGrideSizeIndex = gridSizeIndex
let nowZoom = proxy?.dZoom
//
proxy?.selectWidget({
uuid: '-1',
})
gridSizeIndex = 0
proxy?.updateZoom(100)
const opts = {
useCORS: true, //
scale: 0.4,
}
setTimeout(async () => {
html2canvas(document.getElementById('page-design-canvas'), opts).then(async (canvas) => {
cb(canvas.toDataURL('image/jpeg', 0.6))
gridSizeIndex = nowGrideSizeIndex
proxy?.updateZoom(nowZoom)
loading.close()
})
}, 301)
}
return {
save,
closePublish,
saveImg,
createImg,
...toRefs(state),
}
},
computed: {
...mapGetters(['dZoom', 'dPage']),
},
methods: {
...mapActions(['selectWidget', 'updateZoom']),
},
})
</script>
<style lang="less" scoped>
// Width variables (appears count calculates by raw css)
@width00: 400px; // Appears 2 times
@width20: 100%; // Appears 2 times
// Height variables (appears count calculates by raw css)
@height20: 400px; // Appears 2 times
.fill-info-wrap {
background-color: rgba(0, 0, 0, 0.8);
height: 100%;
padding: 50px;
position: absolute;
width: @width20;
z-index: 9999;
.fill-info-content {
background-color: @color-white;
border-radius: 10px;
display: flex;
flex-direction: column;
margin: 0 auto;
max-height: 861px;
min-height: 600px;
padding: 20px;
width: 600px;
.fill-info-step {
flex: 1;
width: @width20;
#cover-wrap {
align-items: center;
display: flex;
height: @height20;
justify-content: center;
margin: 20px auto;
width: @width00;
#cover {
box-shadow: 1px 1px 10px 3px rgba(0, 0, 0, 0.1);
max-height: @height20;
max-width: @width00;
}
}
.publish-btn {
background-color: @color-main;
border-radius: 5px;
color: @color-white;
cursor: pointer;
margin: 20px auto;
padding: 10px;
text-align: center;
&:hover {
background-color: @color-dark-gray;
}
}
.close-publish {
background-color: @color-white;
border-radius: 5px;
color: @color-main;
cursor: pointer;
margin-bottom: 0px;
margin: 20px auto;
outline: 1px solid @color-main;
padding: 10px;
text-align: center;
&:hover {
background-color: @color-dark-gray;
color: @color-white;
}
}
}
}
}
</style>

View File

@ -0,0 +1,40 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-04-10 12:12:57
* @Description: tooltip提示
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-10 12:42:02
-->
<template>
<el-popover ref="popover" :placement="position" :title="title" :width="width" trigger="hover" :content="content">
<template #reference>
<slot />
</template>
</el-popover>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
title: {
default: '',
},
width: {
default: 0,
},
content: {
default: '',
},
// top/top-start/top-end/bottom/bottom-start/bottom-end/left/left-start/left-end/right/right-start/right-end
position: {
default: 'bottom',
},
// offset: {
// default: 0,
// },
},
setup() {},
})
</script>

View File

@ -0,0 +1,89 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-12-28 09:29:42
* @Description: 百分比进度条
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-13 23:05:29
-->
<template>
<div v-if="percent" class="mask">
<div class="content">
<div class="text">{{ text }}</div>
<el-progress style="width: 100%" :text-inside="true" :percentage="percent" />
<div class="text btn" @click="cancel">{{ cancelText }}</div>
<div class="text info">{{ msg }}</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, watch } from 'vue'
import { ElProgress } from 'element-plus'
export default defineComponent({
components: { ElProgress },
props: ['percent', 'text', 'cancelText', 'msg'],
emits: ['done', 'cancel'],
setup(props, context) {
watch(
() => props.percent,
(num) => {
if (num >= 100) {
setTimeout(() => {
context.emit('done')
}, 1000)
}
},
)
const cancel = () => {
context.emit('cancel')
}
return { cancel }
},
})
</script>
<style lang="less" scoped>
:deep(.el-progress-bar__innerText) {
opacity: 0;
}
.mask {
display: flex;
justify-content: center;
flex-direction: column;
padding: 0 24%;
width: 100%;
height: 100%;
position: fixed;
z-index: 99999;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.7);
}
.content {
background: #ffffff;
border-radius: 8px;
padding: 2rem 4rem;
}
.text {
margin: 2rem 0;
font-size: 20px;
font-weight: bold;
width: 100%;
text-align: center;
color: #333333;
}
.btn {
font-weight: 400;
font-size: 16px;
cursor: pointer;
color: #3771e5;
}
.info {
font-weight: 400;
font-size: 16px;
color: #777777;
}
</style>

View File

@ -0,0 +1,3 @@
import upload from './index.vue'
export default upload

View File

@ -0,0 +1,130 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-08-29 18:17:13
* @Description: 二次封装上传组件
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 15:12:07
-->
<template>
<el-upload action="" accept="image/*" :http-request="upload" :show-file-list="false" multiple>
<slot>
<el-button size="small">上传图片<i class="el-icon-upload el-icon--right"></i></el-button>
</slot>
</el-upload>
</template>
<script lang="ts">
import { defineComponent, onMounted, nextTick } from 'vue'
import { ElUpload } from 'element-plus'
import Qiniu from '@/common/methods/QiNiu'
import { getImage } from '@/common/methods/getImgDetail'
import _config from '@/config'
export default defineComponent({
components: { ElUpload },
props: {
modelValue: {},
options: {
default: () => {
return { bucket: 'cloud-design', prePath: 'user' }
},
},
hold: {
default: false, //
},
},
emits: ['done', 'update:modelValue', 'load'],
setup(props, context) {
let uploading: boolean = false // Flag
let timer: any = null
let uploadList: any[] = [] //
let index: number = 0 //
let count: number = 0 //
let tempSimpleRes: any = null //
onMounted(async () => {
await nextTick()
setTimeout(() => {
//
const link_element = document.createElement('script')
link_element.setAttribute('src', _config.QINIUYUN_PLUGIN)
document.head.appendChild(link_element)
}, 1000)
})
const upload = ({ file }: any) => {
if (props.hold) {
context.emit('load', file)
return
}
uploadList.push(file)
clearTimeout(timer)
count++
updatePercent(null)
uploadQueue()
}
//
const uploadQueue = async () => {
if (!uploading) {
uploading = true
if (uploadList[0]) {
tempSimpleRes = await qiNiuUpload(uploadList[0]) //
const { width, height }: any = await getImage(uploadList[0])
context.emit('done', { width, height, url: _config.IMG_URL + tempSimpleRes.key }) //
uploading = false
handleRemove() //
index++
updatePercent(null)
uploadQueue()
} else {
uploading = false
timer = setTimeout(() => {
index = count = 0
updatePercent(0)
}, 3000)
}
}
}
const qiNiuUpload = async (file: File) => {
updatePercent(0)
return new Promise(async (resolve: Function) => {
if (props.hold) {
context.emit('load', file)
resolve()
} else {
const result: any = await Qiniu.upload(file, props.options, (res: Type.Object) => {
updatePercent(res.total.percent)
})
resolve(result)
}
})
}
//
const updatePercent = (p?: number | null) => {
const num = typeof p === 'number' ? String(p) : p
const percent = { ...props.modelValue }
percent.num = num ? Number(num).toFixed(0) : percent.num
percent.ratio = count ? `${index} / ${count}` : ''
context.emit('update:modelValue', percent)
}
const handleRemove = () => {
if (uploadList.length <= 0) {
return
}
uploadList.splice(0, 1)
}
return {
upload,
}
},
})
</script>
<style lang="less" scoped>
:deep(.el-upload) {
display: inherit;
}
</style>

View File

@ -0,0 +1,30 @@
/*
* @Author: ShawnPhang
* @Date: 2021-07-13 22:51:29
* @Description: require.context自动引用
* @LastEditors: ShawnPhang
* @LastEditTime: 2022-04-08 10:28:47
*/
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
// 排除要全局引入的组件,可以是目录名也可以是文件名
const exclude = ['settings', 'layout']
const regex = RegExp('.*^(?!.*?(' + exclude.join('|') + ')).*\\.vue$')
// const requireComponent = require.context('.', true, /\.vue$/) // 找到components文件夹下以.vue命名的文件
const requireComponent = import.meta.globEager('./**/*.vue')
function guide(Vue: Type.Object) {
for (const fileName in requireComponent) {
if (regex.test(fileName)) {
const componentConfig = requireComponent[fileName]
const componentName = capitalizeFirstLetter(fileName.replace(/^\..*\//, '').replace(/\.\w+$/, ''))
Vue.component(componentName, componentConfig.default || componentConfig)
}
}
}
export default guide

View File

@ -0,0 +1,300 @@
<template>
<div id="page-design" ref="page-design" :style="{ paddingTop: dPaddingTop + 'px' }">
<div
id="out-page"
class="out-page"
:style="{
width: (dPage.width * dZoom) / 100 + 120 + 'px',
height: (dPage.height * dZoom) / 100 + 120 + 'px',
opacity: 1 - (dZoom < 100 ? dPage.tag : 0),
}"
>
<slot />
<div
:id="pageDesignCanvasId"
class="design-canvas"
:data-type="dPage.type"
:data-uuid="dPage.uuid"
:style="{
width: dPage.width + 'px',
height: dPage.height + 'px',
transform: 'scale(' + dZoom / 100 + ')',
transformOrigin: (dZoom >= 100 ? 'center' : 'left') + ' top',
backgroundColor: dPage.backgroundColor,
backgroundImage: `url(${dPage?.backgroundImage})`,
backgroundSize: dPage?.backgroundTransform?.x ? 'auto' : 'cover',
backgroundPositionX: (dPage?.backgroundTransform?.x || 0) + 'px',
backgroundPositionY: (dPage?.backgroundTransform?.y || 0) + 'px',
opacity: dPage.opacity + (dZoom < 100 ? dPage.tag : 0),
}"
@mousemove="dropOver($event)"
@drop="drop($event)"
@mouseup="drop($event)"
>
<!-- <grid-size /> -->
<!-- :class="{
layer: true,
'layer-active': getIsActive(layer.uuid),
'layer-hover': layer.uuid === dHoverUuid || dActiveElement.parent === layer.uuid,
}" -->
<component :is="layer.type" v-for="layer in getlayers()" :id="layer.uuid" :key="layer.uuid" :class="['layer', { 'layer-hover': layer.uuid === dHoverUuid || dActiveElement.parent === layer.uuid, 'layer-no-hover': dActiveElement.uuid === layer.uuid }]" :data-title="layer.type" :params="layer" :parent="dPage" :data-type="layer.type" :data-uuid="layer.uuid">
<template v-if="layer.isContainer">
<!-- :class="{
layer: true,
'layer-active': getIsActive(widget.uuid),
'layer-no-hover': dActiveElement.uuid !== widget.parent && dActiveElement.parent !== widget.parent,
'layer-hover': widget.uuid === dHoverUuid,
}" -->
<component :is="widget.type" v-for="widget in getChilds(layer.uuid)" :key="widget.uuid" child :class="['layer', { 'layer-no-hover': dActiveElement.uuid !== widget.parent && dActiveElement.parent !== widget.parent }]" :data-title="widget.type" :params="widget" :parent="layer" :data-type="widget.type" :data-uuid="widget.uuid" />
</template>
</component>
<!-- <ref-line v-if="dSelectWidgets.length === 0" /> -->
<!-- <size-control v-if="dSelectWidgets.length === 0" /> -->
</div>
</div>
</div>
</template>
<script>
import { defineComponent, nextTick } from 'vue'
import { mapGetters, mapActions } from 'vuex'
import { getTarget } from '@/common/methods/target'
import setWidgetData from '@/common/methods/DesignFeatures/setWidgetData'
import PointImg from '@/utils/plugins/pointImg'
import getComponentsData from '@/common/methods/DesignFeatures/setComponents'
import { debounce } from 'throttle-debounce'
//
const NAME = 'page-design'
import { move, moveInit } from '@/mixins/move'
export default defineComponent({
name: NAME,
// components: {lineGuides},
mixins: [moveInit],
props: ['pageDesignCanvasId'],
data() {
return {}
},
computed: {
...mapGetters(['dPaddingTop', 'dPage', 'dZoom', 'dScreen', 'dWidgets', 'dActiveElement', 'dHoverUuid', 'dSelectWidgets', 'dAltDown', 'dDraging', 'showRotatable']),
},
mounted() {
this.getScreen()
document.getElementById('page-design').addEventListener('mousedown', this.handleSelection, false)
document.getElementById('page-design').addEventListener('mousemove', debounce(100, false, this.handleMouseMove), false)
},
beforeUnmount() {},
methods: {
...mapActions(['updateScreen', 'selectWidget', 'deleteWidget', 'addWidget', 'addGroup']),
async dropOver(e) {
if (this.dActiveElement.editable || this.dActiveElement.lock) {
return false
}
e.preventDefault()
let { data, type } = this.$store.getters.selectItem
if (type !== 'image') {
return
}
const target = await getTarget(e.target)
const uuid = target.getAttribute('data-uuid')
this.$store.dispatch('setDropOver', uuid)
if (e.target.getAttribute('putIn')) {
this._dropIn = uuid
const imgUrl = data.value.thumb || data.value.url
!this._srcCache && (this._srcCache = target.firstElementChild.firstElementChild.src)
target.firstElementChild.firstElementChild.src = imgUrl
} else {
this._srcCache && (target.firstElementChild.firstElementChild.src = this._srcCache)
this._srcCache = ''
this._dropIn = ''
}
},
async drop(e) {
if (!this.dDraging) {
return
}
this.$store.commit('setDraging', false)
const dropIn = this._dropIn
this._dropIn = ''
this.$store.dispatch('setDropOver', '-1')
this.$store.commit('setShowMoveable', false) //
let lost = e.target.className !== 'design-canvas' // className === 'design-canvas' , id: "page-design-canvas"
// e.stopPropagation()
e.preventDefault()
let { data: item, type } = JSON.parse(JSON.stringify(this.$store.getters.selectItem))
//
this.$store.commit('selectItem', {})
let setting = {}
if (!type) {
return
}
//
setting = await setWidgetData(type, item, setting)
//
const lostX = e.x - document.getElementById('page-design-canvas').getBoundingClientRect().left
const lostY = e.y - document.getElementById('page-design-canvas').getBoundingClientRect().top
//
if (type === 'group') {
let parent = {}
item = await getComponentsData(item)
item.forEach((element) => {
if (element.type === 'w-group') {
parent.width = element.width
parent.height = element.height
}
})
const half = { x: parent.width ? (parent.width * this.$store.getters.dZoom) / 100 / 2 : 0, y: parent.height ? (parent.height * this.$store.getters.dZoom) / 100 / 2 : 0 }
item.forEach((element) => {
element.left += (lost ? lostX - half.x : e.layerX - half.x) * (100 / this.$store.getters.dZoom)
element.top += (lost ? lostY - half.y : e.layerY - half.y) * (100 / this.$store.getters.dZoom)
})
this.addGroup(item)
}
//
const half = { x: setting.width ? (setting.width * this.$store.getters.dZoom) / 100 / 2 : 0, y: setting.height ? (setting.height * this.$store.getters.dZoom) / 100 / 2 : 0 }
// const half = { x: (this.dDragInitData.offsetX * this.dZoom) / 100, y: (this.dDragInitData.offsetY * this.dZoom) / 100 }
setting.left = (lost ? lostX - half.x : e.layerX - half.x) * (100 / this.$store.getters.dZoom)
setting.top = (lost ? lostY - half.y : e.layerY - half.y) * (100 / this.$store.getters.dZoom)
if (lost && type === 'image') {
// svg
const target = await getTarget(e.target)
const targetType = target.getAttribute('data-type')
const uuid = target.getAttribute('data-uuid')
if (targetType === 'w-mask') {
//
this.$store.commit('setShowMoveable', true) //
const widget = this.dWidgets.find((item) => item.uuid === uuid)
widget.imgUrl = item.value.url
// if (e.target.className.baseVal) {
// !widget.imgs && (widget.imgs = {})
// widget.imgs[`${e.target.className.baseVal}`] = item.value.url
// }
} else {
if (dropIn) {
const widget = this.dWidgets.find((item) => item.uuid == dropIn)
widget.imgUrl = item.value.url
console.log('加入+', widget)
this.$store.commit('setShowMoveable', true) //
} else {
this.addWidget(setting) //
}
}
} else if (type === 'bg') {
console.log('背景图片放置')
} else if (type !== 'group') {
console.log(setting)
this.addWidget(setting) //
}
//
// this.$store.commit('selectItem', {})
},
getScreen() {
let screen = this.$refs['page-design']
this.updateScreen({
width: screen.offsetWidth,
height: screen.offsetHeight,
})
},
async handleMouseMove(e) {
const pImg = new PointImg(e.target)
const { rgba } = pImg.getColorXY(e.offsetX, e.offsetY)
if (rgba && rgba === 'rgba(0,0,0,0)') {
console.log('解析点位颜色: ', rgba)
let target = await getTarget(e.target)
target.style.pointerEvents = 'none'
setTimeout(() => {
target.style.pointerEvents = 'auto'
}, 300)
}
},
async handleSelection(e) {
if (e.which === 3) {
return
}
let target = await getTarget(e.target)
let type = target.getAttribute('data-type')
if (type) {
let uuid = target.getAttribute('data-uuid')
if (uuid !== '-1' && !this.dAltDown) {
let widget = this.dWidgets.find((item) => item.uuid === uuid)
if (widget.parent !== '-1' && widget.parent !== this.dActiveElement.uuid && widget.parent !== this.dActiveElement.parent) {
uuid = widget.parent
}
}
//
// this.$store.commit('setMoveable', false)
if (this.showRotatable !== false) {
this.selectWidget({
uuid: uuid,
})
}
if (uuid !== '-1') {
this.initmovement && this.initmovement(e) // mixins
}
} else {
//
this.selectWidget({
uuid: '-1',
})
}
},
getlayers() {
return this.dWidgets.filter((item) => item.parent === this.dPage.uuid)
},
getChilds(uuid) {
return this.dWidgets.filter((item) => item.parent === uuid)
},
// getIsActive(uuid) {
// if (this.dSelectWidgets.length > 0) {
// let widget = this.dSelectWidgets.find((item) => item.uuid === uuid)
// if (widget) {
// return true
// }
// return false
// } else {
// return uuid === this.dActiveElement.uuid
// }
// },
},
})
</script>
<style lang="less" scoped>
#page-design {
height: 100%;
// display: flex;
// align-items: center;
overflow: auto;
position: relative;
width: 100%;
.out-page {
margin: 0 auto;
padding: 60px;
position: relative;
.design-canvas {
// transition: all 0.3s;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: 1px 1px 10px 3px rgba(0, 0, 0, 0.1);
margin: 0 auto;
position: relative;
// z-index: -9999;
// overflow: hidden;
// overflow: auto;
}
// .design-canvas ::-webkit-scrollbar {
// display: none; /* Chrome Safari */
// }
}
}
</style>

View File

@ -0,0 +1,136 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-04-08 10:31:34
* @Description:
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-06-29 18:07:40
-->
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent, watch } from 'vue'
import { useStore } from 'vuex'
import Guides from '@scena/guides'
export default defineComponent({
props: ['show'],
setup(props) {
const store = useStore()
const container = 'page-design' // page-design out-page
let guidesTop: any = null
let guidesLeft: any = null
watch(
() => props.show,
(open) => {
open ? render() : destroy()
},
)
watch(
() => store.getters.dZoom,
() => {
changeScroll()
},
)
// onMounted(() => {
// // let scrollX = 0
// // let scrollY = 0
// // window.addEventListener('resize', () => {
// // guides.resize()
// // })
// // window.addEventListener('wheel', (e) => {
// // scrollX += e.deltaX
// // scrollY += e.deltaY
// // guides.scrollGuides(scrollY)
// // guides.scroll(scrollX)
// // })
// })
function destroy() {
guidesTop.destroy()
guidesLeft.destroy()
guidesTop = null
guidesLeft = null
}
function render() {
const sameParams: any = {
backgroundColor: '#f9f9fa',
lineColor: '#bec2c7',
textColor: '#999999',
// direction: 'start',
// height: 30,
displayDragPos: true,
dragPosFormat: (v: any) => v + 'px',
}
guidesTop = new Guides(document.getElementById(container), {
...sameParams,
type: 'horizontal',
className: 'my-horizontal',
}).on('changeGuides', (e) => {
console.log(e, e.guides)
// const el = document.getElementById('out-page')
// const top = 20 + (el?.offsetTop || 0)
// store.commit('updateGuidelines', { horizontalGuidelines: e.guides.map((x) => x + top) })
})
guidesLeft = new Guides(document.getElementById(container), {
...sameParams,
type: 'vertical',
className: 'my-vertical',
}).on('changeGuides', (e) => {
console.log(e, e.guides)
// store.commit('updateGuidelines', { verticalGuidelines: e.guides })
})
changeScroll()
}
function changeScroll() {
if (guidesTop && guidesLeft) {
const zoom = store.getters.dZoom / 100
guidesTop.zoom = zoom
guidesLeft.zoom = zoom
if (zoom < 0.9) {
guidesTop.unit = Math.floor(1 / zoom) * 50
guidesLeft.unit = Math.floor(1 / zoom) * 50
}
setTimeout(() => {
const el = document.getElementById('out-page')
const left = 60 + (el?.offsetLeft || 0)
const top = 30 + (el?.offsetTop || 0)
guidesTop.scroll(-left / zoom)
guidesTop.scrollGuides(-top / zoom)
guidesLeft.scroll(-top / zoom)
guidesLeft.scrollGuides(-(left - 30) / zoom)
}, 300)
}
}
},
})
</script>
<style lang="less">
// :deep(.shortLineSize) {
// height: 1px !important;
// }
.my-horizontal,
.my-vertical {
position: absolute !important;
z-index: 99;
}
.my-horizontal {
left: 0px;
top: 0;
width: calc(100% - 30px);
height: 30px !important;
}
.my-vertical {
top: 30px;
left: 0px;
height: calc(100% - 60px);
width: 30px !important;
}
</style>

View File

@ -0,0 +1,181 @@
<!--
* @Author: ShawnPhang
* @Date: 2021-08-03 17:50:21
* @Description: 旧大小控制组件已交由moveable控制
* @LastEditors: ShawnPhang
* @LastEditTime: 2021-08-09 11:13:09
-->
<template>
<div v-if="dActiveElement.record && dActiveElement.uuid !== '-1'" id="size-control">
<!-- 上左 -->
<div
v-if="dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + 'px',
top: top + 'px',
cursor: 'nw-resize',
}"
@mousedown="handlemousedown($event, 'left-top')"
></div>
<!-- 上中 -->
<div
v-if="dActiveElement.record.dir === 'vertical' || dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + width / 2 + 'px',
top: top + 'px',
cursor: 'n-resize',
}"
@mousedown="handlemousedown($event, 'top')"
></div>
<!-- 上右 -->
<div
v-if="dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + width + 'px',
top: top + 'px',
cursor: 'ne-resize',
}"
@mousedown="handlemousedown($event, 'right-top')"
></div>
<!-- 中左 -->
<div
v-if="dActiveElement.record.dir === 'horizontal' || dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + 'px',
top: top + height / 2 + 'px',
cursor: 'w-resize',
}"
@mousedown="handlemousedown($event, 'left')"
></div>
<!-- 中右 -->
<div
v-if="dActiveElement.record.dir === 'horizontal' || dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + width + 'px',
top: top + height / 2 + 'px',
cursor: 'e-resize',
}"
@mousedown="handlemousedown($event, 'right')"
></div>
<!-- 下左 -->
<div
v-if="dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + 'px',
top: top + height + 'px',
cursor: 'sw-resize',
}"
@mousedown="handlemousedown($event, 'left-bottom')"
></div>
<!-- 下中 -->
<div
v-if="dActiveElement.record.dir === 'vertical' || dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + width / 2 + 'px',
top: top + height + 'px',
cursor: 's-resize',
}"
@mousedown="handlemousedown($event, 'bottom')"
></div>
<!-- 下右 -->
<div
v-if="dActiveElement.record.dir === 'all'"
class="square"
:style="{
left: left + width + 'px',
top: top + height + 'px',
cursor: 'se-resize',
}"
@mousedown="handlemousedown($event, 'right-bottom')"
></div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
//
const NAME = 'size-control'
export default {
name: NAME,
data() {
return {
dirs: [],
}
},
computed: {
...mapGetters(['dActiveElement', 'dWidgets']),
left() {
return parseInt(this.dActiveElement.left)
},
top() {
return parseInt(this.dActiveElement.top)
},
width() {
return parseInt(this.dActiveElement.record.width)
},
height() {
return parseInt(this.dActiveElement.record.height)
},
},
watch: {},
methods: {
...mapActions(['dResize', 'initDResize', 'dResize', 'stopDResize']),
handlemousedown(e, dirs) {
e.stopPropagation()
this.dirs = dirs.split('-')
this.initDResize({
startX: e.pageX,
startY: e.pageY,
originX: this.dActiveElement.left,
originY: this.dActiveElement.top,
width: this.width,
height: this.height,
})
document.addEventListener('mousemove', this.handlemousemove, true)
document.addEventListener('mouseup', this.handlemouseup, true)
},
handlemousemove(e) {
e.stopPropagation()
e.preventDefault()
this.dResize({
x: e.pageX,
y: e.pageY,
dirs: this.dirs,
})
},
handlemouseup() {
document.removeEventListener('mousemove', this.handlemousemove, true)
document.removeEventListener('mouseup', this.handlemouseup, true)
this.stopDResize()
},
},
}
</script>
<style lang="less" scoped>
#size-control {
position: absolute;
.square {
background-color: #ffffff;
height: 10px;
outline: 1px solid #3b74f1;
position: absolute;
transform: translateX(-50%) translateY(-50%);
width: 10px;
z-index: 999;
}
}
</style>

View File

@ -0,0 +1,395 @@
<template>
<div id="zoom-control">
<ul v-show="show" class="zoom-selecter">
<li v-for="(item, index) in zoomList" :key="index" :class="['zoom-item', { 'zoom-item-active': activezoomIndex === index }]" @click.stop="selectItem(index)">
<!-- <i v-if="item.icon" :class="['iconfont', item.icon]"></i> -->
<span>{{ item.text }}</span>
<i v-if="activezoomIndex === index" class="iconfont icon-selected"></i>
</li>
</ul>
<div v-if="!hideControl" class="zoom-control-wrap">
<div :class="['zoom-icon radius-left', { disable: activezoomIndex === 0 }]" @click.stop="activezoomIndex > 0 ? sub() : ''">
<i class="iconfont icon-sub"></i>
</div>
<div :class="['zoom-text', { 'zoom-text-active': show }]" @click.stop="show = !show">{{ zoom.text }}</div>
<div :class="['zoom-icon radius-right', { disable: otherIndex === otherList.length - 1 }]" @click.stop="otherIndex < otherList.length - 1 ? add() : ''">
<i class="iconfont icon-add"></i>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import addMouseWheel from '@/common/methods/addMouseWheel'
//
const NAME = 'zoom-control'
let holder = null
export default {
name: NAME,
data() {
return {
hideControl: false,
activezoomIndex: 0,
zoomList: [
{
text: '25%',
value: 25,
},
{
text: '50%',
value: 50,
},
{
text: '75%',
value: 75,
},
{
text: '100%',
value: 100,
},
{
text: '125%',
value: 125,
},
{
text: '150%',
value: 150,
},
{
text: '200%',
value: 200,
},
{
text: '最佳尺寸',
value: -1,
// icon: 'icon-best-size',
},
],
show: false,
zoom: {
value: 0,
text: 0,
},
otherList: [
{
text: '250%',
value: 250,
},
{
text: '300%',
value: 300,
},
{
text: '350%',
value: 350,
},
{
text: '400%',
value: 400,
},
{
text: '450%',
value: 450,
},
{
text: '500%',
value: 500,
},
],
otherIndex: -1,
bestZoom: 0,
}
},
computed: {
...mapGetters(['dPage', 'dScreen', 'zoomScreenChange', 'dZoom']),
},
watch: {
activezoomIndex(value) {
if (value < 0 || value > this.zoomList.length - 1) {
return
}
this.zoom = JSON.parse(JSON.stringify(this.zoomList[value]))
},
otherIndex(value) {
if (value < 0 || value > this.otherList.length - 1) {
return
}
this.zoom = JSON.parse(JSON.stringify(this.otherList[value]))
},
zoom(value) {
let realValue = value.value
if (realValue === -1) {
realValue = this.calcZoom()
}
this.updateZoom(realValue)
this.autoFixTop()
},
dScreen: {
handler() {
this.screenChange()
},
deep: true,
},
zoomScreenChange() {
this.activezoomIndex = this.zoomList.length - 1
this.screenChange()
},
dPage: {
handler(val) {
this.screenChange()
},
deep: true,
},
},
async mounted() {
await this.$nextTick()
window.addEventListener('click', this.close)
if (this.$route.path === '/draw') {
this.activezoomIndex = 3
this.hideControl = true
} else {
this.activezoomIndex = this.zoomList.length - 1
}
//
addMouseWheel('page-design', (isDown) => {
this.mousewheelZoom(isDown)
})
//
window.addEventListener('resize', (event) => {
this.changeScreen()
})
},
beforeUnmount() {
window.removeEventListener('click', this.close)
},
methods: {
...mapActions(['updateZoom', 'updateScreen']),
changeScreen() {
clearTimeout(holder)
holder = setTimeout(() => {
const screen = document.getElementById('page-design')
this.updateScreen({
width: screen.offsetWidth,
height: screen.offsetHeight,
})
}, 300)
},
screenChange() {
//
if (this.activezoomIndex === this.zoomList.length - 1) {
this.updateZoom(this.calcZoom())
this.autoFixTop()
}
},
selectItem(index) {
this.activezoomIndex = index
this.otherIndex = -1
this.show = false
},
close(e) {
this.show = false
},
add() {
this.curAction = 'add'
this.show = false
if (this.activezoomIndex === this.zoomList.length - 2 || this.activezoomIndex === this.zoomList.length - 1) {
this.activezoomIndex = this.zoomList.length
// this.otherIndex += 1
if (this.bestZoom) {
this.nearZoom(true)
} else {
this.otherIndex += 1
}
return
}
if (this.activezoomIndex != this.zoomList.length) {
this.activezoomIndex++
return
}
if (this.otherIndex < this.otherList.length - 1) {
this.otherIndex++
}
},
sub() {
this.curAction = null
this.show = false
if (this.otherIndex === 0) {
this.otherIndex = -1
this.activezoomIndex = this.zoomList.length - 2
return
}
if (this.otherIndex != -1) {
this.otherIndex--
return
}
if (this.activezoomIndex === this.zoomList.length - 1) {
if (this.bestZoom) {
this.nearZoom()
} else {
this.activezoomIndex = this.zoomList.length - 2
}
return
}
if (this.activezoomIndex != 0) {
this.activezoomIndex--
}
},
mousewheelZoom(down) {
const value = Number(this.dZoom.toFixed(0))
this.updateZoom(down ? value - 1 : value + 1)
this.zoom.text = value + '%'
this.autoFixTop()
},
nearZoom(add) {
for (let i = 0; i < this.zoomList.length; i++) {
this.activezoomIndex = i
if (this.zoomList[i].value > this.bestZoom) {
if (add) break
} else if (this.zoomList[i].value < this.bestZoom) {
if (!add) break
}
}
this.bestZoom = 0
},
calcZoom() {
let widthZoom = ((this.dScreen.width - 142) * 100) / this.dPage.width
let heightZoom = ((this.dScreen.height - 122) * 100) / this.dPage.height
this.bestZoom = Math.min(widthZoom, heightZoom)
return this.bestZoom
},
async autoFixTop() {
await this.$nextTick()
const presetPadding = 60
const el = document.getElementById('out-page')
// const clientHeight = document.body.clientHeight - 54
const parentHeight = el.offsetParent.offsetHeight - 54
let padding = (parentHeight - el.offsetHeight) / 2
if (typeof this.curAction === 'undefined') {
padding += presetPadding / 2
}
this.curAction === 'add' && (padding -= presetPadding)
this.$store.commit('updatePaddingTop', padding > 0 ? padding : 0)
},
},
}
</script>
<style lang="less" scoped>
@color-select: #1b1634;
@color1: #ffffff; //
@color2: #ffffff; // Appears 3 times
@color3: #666666; //
@color4: #c2c2c2; //
@color5: rgba(0, 0, 0, 0.12); //
@z-border-color: #e6e6e6;
#zoom-control {
bottom: 20px;
position: absolute;
right: 302px;
z-index: 1000;
.zoom-control-wrap {
display: flex;
flex-direction: row;
font-size: 14px;
height: 40px;
.radius-left {
border-bottom-left-radius: 50%;
border-top-left-radius: 50%;
border-block-end: 1px solid @z-border-color;
border-block-start: 1px solid @z-border-color;
}
.radius-right {
border-bottom-right-radius: 50%;
border-top-right-radius: 50%;
border-block-end: 1px solid @z-border-color;
border-block-start: 1px solid @z-border-color;
}
.zoom-icon {
align-items: center;
background-color: @color2;
color: @color3;
cursor: pointer;
display: flex;
justify-content: center;
width: 40px;
&:hover {
background-color: @color1;
color: @color-select;
}
}
.disable {
color: @color4;
&:hover {
background-color: @color2;
color: @color4;
cursor: not-allowed;
}
}
.zoom-text {
user-select: none;
align-items: center;
background-color: @color2;
color: @color3;
cursor: pointer;
display: flex;
justify-content: center;
width: 60px;
border-block-end: 1px solid @z-border-color;
border-block-start: 1px solid @z-border-color;
&:hover {
background-color: @color1;
color: @color-select;
}
}
}
.zoom-selecter {
background-color: @color1;
color: @color3;
position: absolute;
top: -8px;
transform: translateY(-100%);
width: 100%;
z-index: 1000;
&:after {
bottom: -8px;
content: '';
left: 50%;
position: absolute;
transform: translateX(-50%);
}
.zoom-item {
align-items: center;
cursor: pointer;
display: flex;
font-size: 14px;
height: 34px;
padding: 10px;
width: 100%;
i {
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
span {
flex: 1;
}
&:hover {
background-color: @color5;
color: @color-select;
}
}
}
}
// #zoom-control-active {
// background-color: @color1;
// background-color: @color5;
// color: @color-select;
// color: @color-select;
// }
</style>

View File

@ -0,0 +1,265 @@
<!--
* @Author: ShawnPhang
* @Date: 2022-03-07 17:25:19
* @Description: 图层组件
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-10 15:38:30
-->
<template>
<ul class="widget-list">
<!-- <li v-for="widget in getWidgets" :key="widget.uuid" :class="['widget', { active: getIsActive(widget.uuid) }, 'item-one']" @click="selectLayer(widget)" @mouseover="hoverLayer(widget.uuid)" @mouseout="hoverLayer('-1')">
<span v-show="widget.parent !== '-1'" :class="['widget-type icon', `sd-xiaji`]"></span>
<img v-if="widget.imgUrl" class="widget-type widget-type__img" :src="widget.imgUrl" />
<span v-else :class="['widget-type icon', `sd-${widget.type}`, widget.type]"></span>
<span :class="['widget-name', 'line-clamp-1', `${widget.type}`]">{{ widget.text || widget.name }}</span>
<div class="widget-out" :data-type="widget.type" :data-uuid="widget.uuid"></div>
</li> -->
<draggable v-model="widgets" group="type" item-key="uuid" v-bind="dragOptions" :move="onMove" @start="drag = true" @end="onDone">
<template #item="{ element }">
<li :class="['widget', { active: getIsActive(element.uuid), disable: !showItem(element) }, 'item-one']" @click="selectLayer(element)" @mouseover="hoverLayer(element)" @mouseout="hoverLayer('-1')">
<!-- <span v-show="+element.parent !== -1" :class="['widget-type icon', `sd-xiaji`]"></span> -->
<span v-show="+element.parent !== -1" class="second-layer"></span>
<img v-if="element.imgUrl" class="widget-type widget-type__img" :src="element.imgUrl" />
<img v-else-if="element.svgUrl" class="widget-type widget-type__img" :src="element.svgUrl" />
<span v-else :class="['widget-type icon', `sd-${element.type}`, element.type]"></span>
<span :class="['widget-name', 'line-clamp-1', `${element.type}`]">{{ element.text || element.name }} {{ element.mask ? '(容器)' : '' }}</span>
<div class="widget-out" :data-type="element.type" :data-uuid="element.uuid">
<i :class="['icon', element.lock ? 'sd-suoding' : 'sd-jiesuo']" @click.stop="lockLayer(element)" />
</div>
</li>
</template>
</draggable>
<!-- <li :class="['widget', { active: dActiveElement.uuid === dPage.uuid && dSelectWidgets.length === 0 }]" @click="selectLayer(dPage)" @mouseover="hoverLayer('-1')" @mouseout="hoverLayer('-1')">
<span class="widget-type"></span>
<span class="widget-name">{{ dPage.name }}</span>
<div class="widget-out" :data-type="dPage.type" :data-uuid="dPage.uuid"></div>
</li> -->
</ul>
</template>
<script lang="ts">
import { defineComponent, computed, reactive, ref, toRefs } from 'vue'
import { useStore } from 'vuex'
import draggable from 'vuedraggable'
export default defineComponent({
components: { draggable },
props: ['data'],
emits: ['change'],
setup(props, context) {
let widgets: any = ref([])
const state: any = reactive({
drag: false,
})
const dragOptions = computed(() => {
return {
animation: 300,
// disabled: !state.editable,
ghostClass: 'ghost',
chosenClass: 'choose',
}
})
const store = useStore()
// const dPage = computed(() => {
// return store.getters.dPage
// })
// const dActiveElement = computed(() => {
// return store.getters.dActiveElement
// })
// const dSelectWidgets = computed(() => {
// return store.getters.dSelectWidgets
// })
const showItem = (item: any) => {
return state.drag === true && item.parent != '-1' ? false : true
}
// const cWidgets = computed(() => {
// return getWidgets()
// })
const getWidgets = () => {
let widgets = []
let len = props.data.length
const data = props.data.slice(0)
const childs = [] //
for (let i = len - 1; i >= 0; --i) {
let widget = JSON.parse(JSON.stringify(data[i]))
if (widget.parent != -1) {
childs.unshift(widget)
} else {
widgets.push(widget)
}
}
for (const item of childs) {
// widgets[widgets.findIndex((x) => x.uuid === item.parent)].childs.push(item)
const index = widgets.findIndex((x) => x.uuid === item.parent)
widgets.splice(index + 1, 0, item)
}
return widgets
}
const getIsActive = (uuid: number) => {
if (store.getters.dSelectWidgets.length > 0) {
let widget = store.getters.dSelectWidgets.find((item: any) => item.uuid === uuid)
if (widget) {
return true
}
return false
} else {
return uuid === store.getters.dActiveElement.uuid
}
}
const selectLayer = (widget: any) => {
// console.log(widget)
store.dispatch('selectWidget', { uuid: widget.uuid })
}
const hoverLayer = ({ uuid, parent }: any) => {
store.dispatch('updateHoverUuid', uuid)
}
const onMove = ({ relatedContext, draggedContext }: any) => {
const relatedElement = relatedContext.element
const draggedElement = draggedContext.element
// const index = props.data.findIndex((x: any) => x.uuid === draggedElement.uuid)
// const toIndex = props.data.findIndex((x: any) => x.uuid === relatedElement.uuid)
// console.log(index, toIndex)
return (!relatedElement || relatedElement.parent == -1) && draggedElement.parent == -1
}
const onDone = () => {
state.drag = false
context.emit('change', widgets.value)
}
//
const lockLayer = (item: any) => {
store.dispatch('updateWidgetData', {
uuid: item.uuid,
key: 'lock',
value: typeof item.lock === 'undefined' ? true : !item.lock,
pushHistory: false,
})
// item.lock = typeof item.lock === 'undefined' ? true : !item.lock
}
return { lockLayer, onDone, onMove, selectLayer, hoverLayer, widgets, getWidgets, getIsActive, ...toRefs(state), dragOptions, showItem }
},
watch: {
data: {
async handler(nval) {
this.widgets = this.getWidgets()
},
immediate: true,
deep: true,
},
},
})
</script>
<style lang="less" scoped>
@color0: #ffffff; // Appears 5 times
@color1: #999999; // Appears 3 times
@color2: rgba(0, 0, 0, 0.05); // Appears 2 times
.widget-list {
width: 100%;
.widget {
align-items: center;
background-color: #ffffff;
border-bottom: 1px solid @color2;
color: @color1;
// cursor: move;
cursor: grab;
display: flex;
padding: 8px;
position: relative;
width: 100%;
.widget-type {
// outline: 1px solid #dedede;
align-items: center;
color: @color1;
display: flex;
height: 30px;
width: 30px;
justify-content: center;
margin-right: 10px;
&__img {
object-fit: contain;
background-color: @color0;
background-image: -webkit-linear-gradient(45deg, #efefef 25%, transparent 25%, transparent 75%, #efefef 75%, #efefef), -webkit-linear-gradient(45deg, #efefef 25%, transparent 25%, transparent 75%, #efefef 75%, #efefef);
background-position: 0 0, 10px 10px;
background-size: 21px 21px;
outline: 1px solid #dedede;
}
}
.widget-name {
flex: 1;
font-size: 14px;
padding-right: 22px;
}
.widget-out {
height: 100%;
margin-left: -12px;
position: absolute;
width: 100%;
display: flex;
align-items: center;
}
.widget-out:hover > .sd-jiesuo {
opacity: 1;
}
}
.widget.active {
background-color: #888888;
color: @color0;
}
.item-one {
padding-left: 12px;
}
// .item-two {
// padding-left: 40px;
// }
}
.w-group {
font-weight: bold;
}
// icons
.sd-jiesuo,
.sd-suoding {
position: absolute;
right: 12px;
font-size: 18px;
cursor: default;
color: #444444;
}
.sd-jiesuo {
opacity: 0;
}
.sd-xiaji {
margin: 0 -4px 0 32px !important;
}
.second-layer {
margin-right: 40px;
}
// dragable
.choose {
border: 1px dashed #999999 !important;
}
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.disable {
opacity: 0.3;
}
.ghost {
opacity: 0.3;
background: @main-color;
}
</style>

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