diff --git a/packages/page-spy-plugin-whole-bundle/index.html b/packages/page-spy-plugin-whole-bundle/index.html index 8e2bbdcb..20e137ca 100644 --- a/packages/page-spy-plugin-whole-bundle/index.html +++ b/packages/page-spy-plugin-whole-bundle/index.html @@ -6,7 +6,10 @@ Document - - + + diff --git a/packages/page-spy-plugin-whole-bundle/package.json b/packages/page-spy-plugin-whole-bundle/package.json index 8a70c580..7221d600 100644 --- a/packages/page-spy-plugin-whole-bundle/package.json +++ b/packages/page-spy-plugin-whole-bundle/package.json @@ -29,9 +29,11 @@ "build": "vite build" }, "dependencies": { + "@huolala-tech/page-spy-base": "^2.0.0", "@huolala-tech/page-spy-browser": "^2.0.2", "@huolala-tech/page-spy-plugin-data-harbor": "^2.0.2", - "@huolala-tech/page-spy-plugin-rrweb": "^2.0.0" + "@huolala-tech/page-spy-plugin-rrweb": "^2.0.0", + "@huolala-tech/page-spy-types": "^2.0.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/packages/page-spy-plugin-whole-bundle/src/assets/close.svg b/packages/page-spy-plugin-whole-bundle/src/assets/close.svg index a08f49af..906bc80f 100644 --- a/packages/page-spy-plugin-whole-bundle/src/assets/close.svg +++ b/packages/page-spy-plugin-whole-bundle/src/assets/close.svg @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/packages/page-spy-plugin-whole-bundle/src/assets/error.svg b/packages/page-spy-plugin-whole-bundle/src/assets/error.svg new file mode 100644 index 00000000..da3d60e6 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/assets/error.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/page-spy-plugin-whole-bundle/src/assets/logo.svg b/packages/page-spy-plugin-whole-bundle/src/assets/logo.svg index ded0cbd0..f03754c1 100644 --- a/packages/page-spy-plugin-whole-bundle/src/assets/logo.svg +++ b/packages/page-spy-plugin-whole-bundle/src/assets/logo.svg @@ -2,5 +2,5 @@ class="page-spy-logo"> + fill="#fff" /> \ No newline at end of file diff --git a/packages/page-spy-plugin-whole-bundle/src/assets/refresh.svg b/packages/page-spy-plugin-whole-bundle/src/assets/refresh.svg new file mode 100644 index 00000000..d4b44d92 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/assets/refresh.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/page-spy-plugin-whole-bundle/src/assets/success.svg b/packages/page-spy-plugin-whole-bundle/src/assets/success.svg new file mode 100644 index 00000000..2845bab7 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/assets/success.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/page-spy-plugin-whole-bundle/src/index.ts b/packages/page-spy-plugin-whole-bundle/src/index.ts index b4355173..8896cfff 100644 --- a/packages/page-spy-plugin-whole-bundle/src/index.ts +++ b/packages/page-spy-plugin-whole-bundle/src/index.ts @@ -3,28 +3,36 @@ import PageSpy from '@huolala-tech/page-spy-browser'; import DataHarborPlugin from '@huolala-tech/page-spy-plugin-data-harbor'; import RRWebPlugin from '@huolala-tech/page-spy-plugin-rrweb'; -import { dot, formatTime, pageSpyExist } from './utils'; +import { dot, pageSpyExist } from './utils'; import classes from './styles/index.module.less'; import './styles/normalize.less'; -import pageSpyLogo from './assets/logo.svg?raw'; -import closeSvg from './assets/close.svg?raw'; -import exportSvg from './assets/export.svg?raw'; -import replaySvg from './assets/replay.svg?raw'; +import pageSpyLogo from './assets/logo.svg'; import { moveable, UElement } from './utils/moveable'; +import { name } from '../package.json'; +import { buildForm } from './utils/build-form'; +import { modal } from './utils/modal'; interface Config { - title?: string; + title: string; /** * Online source: 'https://example.com/xxx.jpg' * Data url: 'data:image/png;base64,xxxx...' * Relative source: '../xxx.jpg' - * Plain SVG content: 'xxx' */ - logo?: string; - statement?: string; - replayLabUrl?: string; + logo: string; + primaryColor: string; + statement: string; } +const defaultConfig: Config = { + title: '问题反馈', + logo: pageSpyLogo, + primaryColor: '#8434E9', + statement: + '声明:「问题反馈」组件处理的所有数据都是保存在您本地,不会主动将数据传输到任何服务器,可放心使用。', + // replayLabUrl: 'https://pagespy.org/#/replay-lab', +}; + class WholeBundle { $pageSpy: PageSpy | null = null; @@ -32,24 +40,14 @@ class WholeBundle { $rrweb: RRWebPlugin | null = null; - config: Config = { - title: '问题反馈', - logo: pageSpyLogo, - statement: - '声明:「问题反馈」组件处理的所有数据都是保存在您本地,不会主动将数据传输到任何服务器,可放心使用。', - replayLabUrl: 'https://pagespy.org/#/replay-lab', - }; - - startTime = 0; - - timer: ReturnType | null = null; + config: Config = defaultConfig; static instance: WholeBundle | null = null; - constructor(userCfg?: Config) { + constructor(userCfg: Partial = {}) { if (pageSpyExist()) { console.info( - "[PageSpy] [WholeBundle] Detected that PageSpy already exists in the current context, so I won't be inited.", + `PageSpy is already exist, please remove it before using ${name}.`, ); return; } @@ -59,7 +57,7 @@ class WholeBundle { } WholeBundle.instance = this; this.config = { - ...this.config, + ...defaultConfig, ...userCfg, }; this.init(); @@ -95,7 +93,6 @@ class WholeBundle { offline: true, autoRender: false, }); - this.startTime = Date.now(); } render() { @@ -107,135 +104,44 @@ class WholeBundle { } startRender() { - const logo = this.getLogo(); - const { statement, title, replayLabUrl } = this.config; + const { statement, title, logo } = this.config; const doc = new DOMParser().parseFromString( ` -
+
-
-
-
-
- ${logo ?? ''} -
- ${title} -
- 操作录制基于 PageSpy 技术实现,查看详情 -
-
-
- ${closeSvg} -
-
-
-
-
- ${exportSvg} - 1. 导出文件到本地 -
-
- ${replaySvg} - 2. 前往 回放实验室 查看 -
-
-
- ${statement} -
-
-
-
- REC - -- -
-
- -
-
-
-
`, 'text/html', ); - const $c = (name: string) => { - return doc.querySelector.bind(doc)(dot(name)) as HTMLElement; + const $c = (className: string) => { + return doc.querySelector.bind(doc)(dot(className)) as HTMLElement; }; const root = doc.querySelector('#__pageSpyWholeBundle') as HTMLDivElement; const float = $c(classes.float) as UElement; - const modal = $c(classes.modal); - const close = $c(classes.h_right); - const content = $c(classes.content); - const duration = $c(classes.f_duration); - const exportBtn = $c(classes.f_export) as HTMLButtonElement; - - const openModal = () => { - if (float.isMoveEvent) return; - modal.classList.add(classes.show); - }; - float.addEventListener('click', openModal); moveable(float); - const closeModal = () => { - modal.classList.remove(classes.show); - modal.classList.add(classes.leaving); - setTimeout(() => { - modal.classList.remove(classes.leaving); - }, 300); - }; - close.addEventListener('click', closeModal); - modal.addEventListener('click', closeModal); - content.addEventListener('click', (e) => { - e.stopPropagation(); - }); - if (this.startTime && duration) { - this.timer = setInterval(() => { - const seconds = parseInt( - String((Date.now() - this.startTime) / 1000), - 10, - ); - duration.textContent = formatTime(seconds); - }, 1000); - } - exportBtn.addEventListener('click', async () => { - exportBtn.disabled = true; - exportBtn.textContent = '处理中'; - await this.$harbor?.onOfflineLog('download'); - exportBtn.textContent = '导出成功'; - setTimeout(() => { - exportBtn.disabled = false; - exportBtn.textContent = '导出'; - }, 1500); + float.addEventListener('click', () => { + modal.show(); }); + const form = buildForm({ harborPlugin: this.$harbor! }); - document.body.insertAdjacentElement('beforeend', root); - } - - getLogo() { - const { logo } = this.config; - if (!logo) return null; - - const isSvgContent = /]*>([\s\S]*?)<\/svg>/.test(logo); - if (isSvgContent) return logo; + modal.build({ + logo, + title, + content: form, + mounted: root, + }); - try { - const url = new URL(logo, window.location.href); - return `logo`; - } catch (e) { - return null; - } + document.documentElement.insertAdjacentElement('beforeend', root); } abort() { document.querySelector('#__pageSpyWholeBundle')?.remove(); this.$pageSpy?.abort(); - if (this.timer) { - clearInterval(this.timer); - } WholeBundle.instance = null; } } diff --git a/packages/page-spy-plugin-whole-bundle/src/styles/index.module.less b/packages/page-spy-plugin-whole-bundle/src/styles/index.module.less index de2eef79..7e569e33 100644 --- a/packages/page-spy-plugin-whole-bundle/src/styles/index.module.less +++ b/packages/page-spy-plugin-whole-bundle/src/styles/index.module.less @@ -1,12 +1,10 @@ -@primary-color: #9a62e4; -@font-size: 14px; -@font-color: #444; +@import url(./variable.less); :global { #__pageSpyWholeBundle { font-size: @font-size; - a { - text-decoration: underline; + * { + box-sizing: border-box; } } } @@ -26,276 +24,261 @@ letter-spacing: 1.5px; color: #fff; font-weight: 700; - background-color: @primary-color; + background-color: var(--primary-color, @primary-color); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); cursor: pointer; border: none; transition: color 0.3s ease; - &:hover { - color: @primary-color; - } &:active { transform: translateY(2px); box-shadow: 0 0px 4px rgba(0, 0, 0, 0.1); } - &:after { - position: absolute; - content: ''; - width: 0; - height: 100%; - top: 0; - right: 0; - z-index: -1; - background-color: #fff; - border-radius: 5px; - box-shadow: - inset 2px 2px 2px 0px rgba(255, 255, 255, 0.5), - 7px 7px 20px 0px rgba(0, 0, 0, 0.1), - 4px 4px 5px 0px rgba(0, 0, 0, 0.1); - transition: all 0.3s ease; + img { + max-height: 20px; } - &:hover:after { - left: 0; - width: 100%; +} + +.form { + font-family: @font-family; + font-size: (14em / @font-size); + color: rgba(0, 0, 0, 0.88); + * { + box-sizing: border-box; } - svg { - width: 20px; - height: 20px; + b, + strong { + font-weight: 700; } - img { - max-width: 40px; + button { + outline: none; } -} -.modal { - position: fixed; - left: 0; - right: 0; - top: 0; - bottom: 0; - display: none; - z-index: 99999; - &.show { + svg { display: block; - animation: fadeIn 0.3s ease-in-out forwards; - @keyframes fadeIn { - 0% { - background-color: rgba(0, 0, 0, 0); - } - 100% { - background-color: rgba(0, 0, 0, 0.3); - } + } + input:not([type='range']), + select, + textarea { + width: 100%; + border: 1px solid #f1f1f1; + border-radius: (4em / @font-size); + background-color: #f1f1f1; + outline: none; + font-size: (14em / @font-size); + &::placeholder { + color: #999; } - .content { - animation: scaleIn 0.3s ease-in forwards; - @keyframes scaleIn { - 0% { - opacity: 0; - transform: translate3d(-50%, -50%, 0) scale(0.8); - } - 70% { - opacity: 1; - transform: translate3d(-50%, -50%, 0) scale(1.1); - } - 100% { - transform: translate3d(-50%, -50%, 0) scale(1); - } - } + &:focus { + border-color: lighten(@primary-color, 20%); } } - &.leaving { - display: block; - animation: fadeOut 0.3s ease-in-out forwards; - @keyframes fadeOut { - 0% { - background-color: rgba(0, 0, 0, 0.3); - } - 100% { - background-color: rgba(0, 0, 0, 0); + select { + padding: (8em / @font-size) (4em / @font-size); + } + input:not([type='range']), + textarea { + padding: (8em / @font-size); + } + button { + .common-button(); + } + .formContent { + padding: @padding; + } + .formItem { + label { + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + margin-block: (8em / @font-size) (4em / @font-size); + font-weight: 500; + &[required] span::before { + content: '*'; + color: red; + margin-right: (2em / @font-size); } } - .content { - animation: scaleOut 0.3s ease-out forwards; - @keyframes scaleOut { - 0% { - opacity: 1; - transform: translate3d(-50%, -50%, 0) scale(1); - } - 30% { - transform: translate3d(-50%, -50%, 0) scale(1.1); - } - 60% { - opacity: 0; - transform: translate3d(-50%, -50%, 0) scale(0.9); - } - 100% { - opacity: 0; - transform: translate3d(-50%, -50%, 0) scale(0.9); - } - } + &:first-child label { + margin-top: 0; } } - .content { - position: absolute; - left: 50%; - top: 45%; - transform: translate3d(-50%, -50%, 0); - width: 90%; - background-color: #fff; - border-radius: 4px; - overflow: hidden; - @media screen and (min-width: 768px) { - max-width: 50%; - } - @media screen and (min-width: 1024px) { - max-width: 40%; + .duration { + font-family: monospace; + } + .refreshButton { + min-width: auto; + padding: 0; + background: none; + border: none; + outline: none; + cursor: pointer; + &:disabled { + cursor: not-allowed; + svg { + color: #888; + } } - @media screen and (min-width: 1920px) { - max-width: 35%; + svg { + width: (24em / @font-size); + height: (24em / @font-size); + color: var(--primary-color); } } -} + .selectPeriod { + @size: (24em / @font-size); + position: relative; + width: 93%; + margin: @size auto; -.header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 24px; - color: #fff; - background-color: #a474e1; - .h_left { - display: flex; - align-items: flex-start; - gap: 8px; - svg { - width: 24px; - height: 24px; - } - img { - max-width: 40px; - } - .h_title { - b { - font-size: 20px; - line-height: 1; + --thumb-size: @size; + --thumb-shadow: 0 0 0 5px var(--primary-color) inset, 0 0 0 99px white inset; + --thumb-shadow-hover: 0 0 0 7px var(--primary-color) inset, + 0 0 0 99px white inset; + --thumb-shadow-disabled: 0 0 0 5px #888 inset, 0 0 0 99px white inset; + --track-size: calc(var(--thumb-size) / 2); + &.disabled { + cursor: not-allowed; + .track .range { + background-color: #d7d7d7; } - span { - font-size: 12px; - transform: scale(0.8); - transform-origin: left center; - a { - color: white; - text-underline-offset: 2px; + input { + &:disabled { + --thumb-shadow: var(--thumb-shadow-disabled); + &::-webkit-slider-thumb { + cursor: not-allowed; + } + &::-moz-range-thumb { + cursor: not-allowed; + } + &::-ms-thumb { + cursor: not-allowed; + } } } } - } - .h_right { - cursor: pointer; - svg { - width: 24px; - height: 24px; + .track { + width: 100%; + height: var(--track-size); + background-color: #ddd; + border-radius: var(--track-size); + + .range { + --left: 0; + --right: 0; + --min-text: 'From'; + --max-text: 'To'; + position: absolute; + left: var(--left); + right: var(--right); + height: 100%; + background-color: var(--primary-color); + border-radius: var(--track-size); + &[data-min-text-position='bottom'] { + &::before { + top: calc(var(--thumb-size)); + } + } + &[data-max-text-position='bottom'] { + &::after { + top: calc(var(--thumb-size)); + } + } + &::before, + &::after { + position: absolute; + top: calc(var(--thumb-size) * -1); + transition: top 0.15s ease-in-out; + font-size: 1em; + font-weight: 500; + font-family: var(--mono-font); + color: #999; + } + &::before { + content: var(--min-text); + left: 0; + transform: translateX(-50%) scale(0.7); + } + &::after { + content: var(--max-text); + right: 0; + transform: translateX(50%) scale(0.7); + } + } } - } -} + input { + appearance: none; + pointer-events: none; + position: absolute; + left: calc(var(--track-size) * -1); + top: 0; + bottom: 0; -.main { - padding: 24px; - .m_process { - display: flex; - justify-content: space-around; - align-items: flex-end; - color: @font-color; - .m_p_item { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - &:nth-child(1) svg { - width: 52px; - height: 52px; + width: calc(100% + var(--thumb-size)); + background-color: transparent; + font-size: @font-size; + + z-index: 2; + &:hover { + --thumb-shadow: var(--thumb-shadow-hover); } - &:nth-child(2) svg { - width: 40px; - height: 40px; + .thumb-mixin() { + appearance: none; + pointer-events: auto; + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: var(--thumb-size); + background-color: #fff; + box-shadow: var(--thumb-shadow); + cursor: grab; + transition: 0.15s; + border: (2em / @font-size) solid white; } - p { - font-size: 20px; + &::-webkit-slider-thumb { + .thumb-mixin(); } - a { - color: @font-color; - text-underline-offset: 4px; + &::-moz-range-thumb { + .thumb-mixin(); + } + &::-ms-thumb { + .thumb-mixin(); } } } - .m_statement { - margin-top: 12px; - color: #aaa; - font-size: 12px; - &:before { - content: '*'; - color: red; - } - } -} - -.footer { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 24px; - border-top: 1px solid #efefef; - .f_left { + .footer { display: flex; + justify-content: space-between; align-items: center; - position: relative; - &::before { - @size: 9px; - content: ''; - width: @size; - height: @size; - border-radius: @size; - background-color: rgb(255, 15, 15); - animation: fade 0.8s ease-in-out infinite alternate; - @keyframes fade { - from { - opacity: 1; - } - to { - opacity: 0.05; + padding: @padding; + border-top: 1px solid #efefef; + background-color: #fff; + .recorder { + display: flex; + align-items: center; + position: relative; + &::before { + @size: (8em / @font-size); + content: ''; + width: @size; + height: @size; + border-radius: @size; + background-color: rgb(255, 15, 15); + animation: fade 0.8s ease-in-out infinite alternate; + @keyframes fade { + from { + opacity: 1; + } + to { + opacity: 0.05; + } } } - } - b { - margin-inline: 3px 6px; - font-size: 14px; - color: @font-color; - } - span { - color: #666; - font-family: monospace; - } - } - .f_right { - .f_export { - min-width: 80px; - padding: 8px; - letter-spacing: 1.4px; - border: none; - border-radius: 4px; - font-weight: 700; - background-color: #a474e1; - color: #fff; - cursor: pointer; - &:hover { - background-color: #a87ce2; - } - &:active { - transform: translateY(2px); + b { + margin-inline: (5em / @font-size) (8em / @font-size); + font-size: (16em / @font-size); + color: #333; } - &[disabled] { - background-color: #c8c8c8; - color: white; - cursor: not-allowed; + span { + color: #666; } } } diff --git a/packages/page-spy-plugin-whole-bundle/src/styles/modal.module.less b/packages/page-spy-plugin-whole-bundle/src/styles/modal.module.less new file mode 100644 index 00000000..e1b419db --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/styles/modal.module.less @@ -0,0 +1,141 @@ +@import url(./variable.less); + +.modal { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: none; + z-index: 99999; + &.show { + display: block; + animation: fadeIn 0.3s ease-in-out forwards; + @keyframes fadeIn { + 0% { + background-color: rgba(0, 0, 0, 0); + } + 100% { + background-color: rgba(0, 0, 0, 0.3); + } + } + .content { + animation: scaleIn 0.3s ease-in forwards; + @keyframes scaleIn { + 0% { + opacity: 0; + transform: translate3d(-50%, -50%, 0) scale(0.8); + } + 70% { + opacity: 1; + transform: translate3d(-50%, -50%, 0) scale(1.1); + } + 100% { + transform: translate3d(-50%, -50%, 0) scale(1); + } + } + } + } + &.leaving { + display: block; + animation: fadeOut 0.3s ease-in-out forwards; + @keyframes fadeOut { + 0% { + background-color: rgba(0, 0, 0, 0.3); + } + 100% { + background-color: rgba(0, 0, 0, 0); + } + } + .content { + animation: scaleOut 0.3s ease-out forwards; + @keyframes scaleOut { + 0% { + opacity: 1; + transform: translate3d(-50%, -50%, 0) scale(1); + } + 30% { + transform: translate3d(-50%, -50%, 0) scale(1.1); + } + 60% { + opacity: 0; + transform: translate3d(-50%, -50%, 0) scale(0.9); + } + 100% { + opacity: 0; + transform: translate3d(-50%, -50%, 0) scale(0.9); + } + } + } + } + .content { + position: absolute; + left: 50%; + top: 45%; + transform: translate3d(-50%, -50%, 0); + width: 95%; + background-color: #fff; + border-radius: 4px; + overflow: hidden; + @media screen and (min-width: 768px) { + max-width: 50%; + } + @media screen and (min-width: 1024px) { + max-width: 40%; + } + @media screen and (min-width: 1920px) { + max-width: 35%; + } + .header { + display: flex; + justify-content: space-between; + align-items: center; + padding: @padding; + color: white; + background-color: var(--primary-color); + .headerLeft { + display: flex; + align-items: flex-start; + gap: (8em / @font-size); + .logo { + width: (20em / @font-size); + } + .title { + b { + font-size: (20em / @font-size); + line-height: 1; + } + p { + margin: 4px 0; + font-size: (12em / @font-size); + a { + color: white; + text-decoration: underline; + text-underline-offset: 4px; + } + } + } + } + .headerRight { + padding: 6px; + cursor: pointer; + .close { + width: (24em / @font-size); + height: (24em / @font-size); + } + } + } + .main { + padding: 0; + .statement { + margin-top: 12px; + color: #aaa; + font-size: 12px; + &:before { + content: '*'; + color: red; + } + } + } + } +} diff --git a/packages/page-spy-plugin-whole-bundle/src/styles/toast.module.less b/packages/page-spy-plugin-whole-bundle/src/styles/toast.module.less new file mode 100644 index 00000000..d30d5254 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/styles/toast.module.less @@ -0,0 +1,44 @@ +@import url(./variable.less); + +.toast { + position: fixed; + left: 50%; + top: 10%; + transform: translate(-50%, 0); + + max-width: 50vw; + padding: 10px 14px; + font-size: @font-size; + line-height: 1; + color: lighten(#fff, 20%); + font-family: @font-family; + background-color: @primary-color; + border-radius: (4em / @font-size); + box-shadow: 0px 0px 4px 1px rgba(255, 255, 255, 0.15); + z-index: 99999; + opacity: 0; + &.show { + opacity: 1; + :local { + animation: slideIn 0.3s ease-in-out forwards; + } + @keyframes slideIn { + 0% { + transform: translate3d(-50%, (25em / @font-size), 0); + opacity: 0; + } + 80% { + transform: translate3d(-50%, (-10em / @font-size), 0); + } + 100% { + transform: translate3d(-50%, 0, 0); + opacity: 1; + } + } + } + &.withIcon { + display: flex; + gap: (4em / @font-size); + align-items: center; + } +} diff --git a/packages/page-spy-plugin-whole-bundle/src/styles/variable.less b/packages/page-spy-plugin-whole-bundle/src/styles/variable.less new file mode 100644 index 00000000..883cdd59 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/styles/variable.less @@ -0,0 +1,52 @@ +@primary-color: #8434e9; +@padding: 14px; +@font-size: 14px; +@font-color: #444; +@font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', + 'Microsoft YaHei', 'Source Han Sans SC', 'Noto Sans CJK SC', sans-serif; + +.common-button() { + display: flex; + justify-content: center; + align-items: center; + min-width: (60em / @font-size); + padding: (6em / @font-size) (6em / @font-size); + gap: (4em / @font-size); + letter-spacing: 0.4px; + border: 1px solid #333; + border-radius: (4em / @font-size); + background-color: white; + color: #333; + cursor: pointer; + white-space: nowrap; + transition: all ease-in-out 100ms; + font-size: (13em / @font-size); + &[data-primary] { + border-color: var(--primary-color, @primary-color); + background-color: var(--primary-color, @primary-color); + color: white; + } + &[data-dashed] { + border-style: dashed; + } + &:disabled { + border-color: #c8c8c8; + background-color: #c8c8c8; + color: white; + cursor: not-allowed; + } + &:active { + opacity: 0.7; + } + img, + svg { + width: (20em / @font-size); + height: (20em / @font-size); + } +} + +.text-ellipse() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/packages/page-spy-plugin-whole-bundle/src/utils/build-form.ts b/packages/page-spy-plugin-whole-bundle/src/utils/build-form.ts new file mode 100644 index 00000000..3a43ac21 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/utils/build-form.ts @@ -0,0 +1,226 @@ +import type DataHarborPlugin from '@huolala-tech/page-spy-plugin-data-harbor'; +import type { PeriodItem } from '@huolala-tech/page-spy-plugin-data-harbor/dist/types/harbor/base'; +import classes from '../styles/index.module.less'; +import refreshSvg from '../assets/refresh.svg?raw'; +import { t } from './locale'; +import { formatTime } from '.'; +import { Toast } from './toast'; +import { modal } from './modal'; + +function gapBetweenTextOnThumb(max: number) { + if (max < 10) return 0.15; + if (max < 30) return 0.13; + return 0.1; +} + +interface Params { + harborPlugin: DataHarborPlugin; +} + +export const buildForm = ({ harborPlugin }: Params) => { + const { harbor } = harborPlugin; + const doc = new DOMParser().parseFromString( + ` +
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ REC + -- +
+ +
+
`, + 'text/html', + ); + const $ = (selector: string) => { + return doc.querySelector.call(doc, selector); + }; + const $c = (c: string) => $(`.${c}`); + const form = $c(classes.form) as HTMLFormElement; + const refreshButton = $c(classes.refreshButton) as HTMLButtonElement; + const range = $c(classes.range) as HTMLDivElement; + const minThumb = $('#period-min') as HTMLInputElement; + const maxThumb = $('#period-max') as HTMLInputElement; + const duration = $c(classes.duration) as HTMLParagraphElement; + + setInterval(() => { + if (harborPlugin.startTimestamp && duration) { + const seconds = parseInt( + String((Date.now() - harborPlugin.startTimestamp) / 1000), + 10, + ); + duration.textContent = formatTime(seconds); + } + }, 1000); + + const periodInfoRef: { + max: number; + periods: PeriodItem[]; + firstTime: number; + lastTime: number; + } = { + max: 0, + periods: [], + firstTime: 0, + lastTime: 0, + }; + const updateRangeInTrack = () => { + const { max } = periodInfoRef; + const minValue = +minThumb.value; + const maxValue = +maxThumb.value; + + const left = minValue / max; + const right = 1 - maxValue / max; + range.style.setProperty('--left', `${(left * 100).toFixed(3)}%`); + range.style.setProperty('--right', `${(right * 100).toFixed(3)}%`); + + range.style.setProperty('--min-text', `"${formatTime(minValue)}"`); + range.style.setProperty('--max-text', `"${formatTime(maxValue)}"`); + }; + + const refreshPeriods = () => { + const periods = harbor.getPeriodList(); + const firstTime = periods[0].time.getTime(); + const lastTime = periods[periods.length - 1].time.getTime(); + + const seconds = Math.floor((lastTime - firstTime) / 1000); + const max = seconds.toString(); + + minThumb.max = max; + minThumb.value = '0'; + + maxThumb.max = max; + maxThumb.value = max; + + periodInfoRef.max = seconds; + periodInfoRef.periods = periods; + periodInfoRef.firstTime = firstTime; + periodInfoRef.lastTime = lastTime; + + updateRangeInTrack(); + }; + const getSelectedPeriod = () => { + const { firstTime } = periodInfoRef; + const minValue = +minThumb.value; + const maxValue = +maxThumb.value; + + const startTime = firstTime + minValue * 1000; + const endTime = firstTime + maxValue * 1000; + + return { + startTime, + endTime, + }; + }; + + refreshButton.addEventListener('click', () => { + refreshButton.disabled = true; + refreshPeriods(); + Toast.message(t.refreshed); + refreshButton.disabled = false; + }); + minThumb.addEventListener('input', () => { + const max = +maxThumb.value; + const current = +minThumb.value; + if (current > max - 1) { + minThumb.value = String(max - 1); + return; + } + const percent = (max - current) / periodInfoRef.max; + if (percent <= gapBetweenTextOnThumb(periodInfoRef.max)) { + range.dataset.maxTextPosition = 'bottom'; + } else { + range.dataset.maxTextPosition = 'top'; + } + range.dataset.minTextPosition = 'top'; + updateRangeInTrack(); + }); + maxThumb.addEventListener('input', () => { + const min = +minThumb.value; + const current = +maxThumb.value; + if (current < min + 1) { + maxThumb.value = String(min + 1); + return; + } + const percent = (current - min) / periodInfoRef.max; + if (percent <= gapBetweenTextOnThumb(periodInfoRef.max)) { + range.dataset.minTextPosition = 'bottom'; + } else { + range.dataset.minTextPosition = 'top'; + } + range.dataset.maxTextPosition = 'top'; + updateRangeInTrack(); + }); + + modal.addEventListener('open', () => { + refreshPeriods(); + }); + + const description = form.querySelector('textarea') as HTMLTextAreaElement; + // prettier-ignore + const submit = form.querySelector('button[type="submit"]') as HTMLButtonElement; + const updateSubmitStatus = () => { + const desc = description.value?.trim(); + if (desc) { + submit.disabled = false; + } else { + submit.disabled = true; + } + }; + updateSubmitStatus(); + description.addEventListener('input', updateSubmitStatus); + + form.addEventListener('submit', async (evt) => { + evt.preventDefault(); + + try { + submit.disabled = true; + submit.textContent = t.readying; + await harborPlugin!.onOfflineLog('download-periods', { + ...getSelectedPeriod(), + remark: `${description.value}`, + }); + + Toast.show('success', t.success); + modal.close(); + } catch (e: any) { + submit.textContent = t.fail; + Toast.show('error', e.message); + } finally { + submit.textContent = t.export; + submit.disabled = false; + } + }); + + return form; +}; diff --git a/packages/page-spy-plugin-whole-bundle/src/utils/index.ts b/packages/page-spy-plugin-whole-bundle/src/utils/index.ts index c33d73d0..c1839925 100644 --- a/packages/page-spy-plugin-whole-bundle/src/utils/index.ts +++ b/packages/page-spy-plugin-whole-bundle/src/utils/index.ts @@ -1,7 +1,7 @@ export const pageSpyExist = () => { - return ['PageSpy', 'DataHarborPlugin', 'RRWebPlugin'].every((prop) => - Object.prototype.hasOwnProperty.call(window, prop), - ); + return ['PageSpy', 'DataHarborPlugin', 'RRWebPlugin'].every((prop) => { + return Object.prototype.hasOwnProperty.call(window, prop); + }); }; export const dot = (className: string) => { @@ -13,9 +13,15 @@ export const fillTimeText = (v: number) => { return `0${v}`; }; -export const formatTime = (seconds: number) => { +export function formatTime(seconds: number) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds - 3600 * h) / 60); const s = Math.floor(seconds - 3600 * h - 60 * m); - return `${fillTimeText(h)}:${fillTimeText(m)}:${fillTimeText(s)}`; -}; + + const hh = fillTimeText(h); + const mm = fillTimeText(m); + const ss = fillTimeText(s); + if (h === 0) return `${mm}:${ss}`; + + return `${hh}:${mm}:${ss}`; +} diff --git a/packages/page-spy-plugin-whole-bundle/src/utils/locale.ts b/packages/page-spy-plugin-whole-bundle/src/utils/locale.ts new file mode 100644 index 00000000..2fff4dbe --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/utils/locale.ts @@ -0,0 +1,41 @@ +import { isCN } from '@huolala-tech/page-spy-base'; + +const source = { + zh: { + selectPeriod: '选择时间段', + title: '离线日志', + readying: '处理中...', + ready: '数据已就绪', + success: '处理成功', + fail: '处理失败', + copied: '已复制调试连接', + remark: '备注', + remarkPlaceholder: '回放时可以看到备注信息', + from: '从', + to: '到', + refreshed: '已刷新', + minutes: '分钟', + eventCountNotEnough: '时间段内的数据量不足以回放', + export: '导出日志', + }, + en: { + selectPeriod: 'Select period', + title: 'Offline log', + readying: 'Handling...', + ready: 'Ready', + success: 'Succeed', + fail: 'Failed', + copied: 'Replay url copied', + remark: 'Remark', + remarkPlaceholder: 'The remark will be displayed during replay', + from: 'From', + to: 'To', + refreshed: 'Refreshed', + minutes: 'minutes', + eventCountNotEnough: + 'The data within the time period is insufficient for playback', + export: 'Export log', + }, +}; + +export const t = isCN() ? source.zh : source.en; diff --git a/packages/page-spy-plugin-whole-bundle/src/utils/modal.ts b/packages/page-spy-plugin-whole-bundle/src/utils/modal.ts new file mode 100644 index 00000000..d596eca8 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/utils/modal.ts @@ -0,0 +1,126 @@ +import { isString } from '@huolala-tech/page-spy-base'; +import type { ModalConfig, ShowParams } from '@huolala-tech/page-spy-types'; +import classes from '../styles/modal.module.less'; +import closeSvg from '../assets/close.svg'; + +const defaultConfig: Omit = { + logo: '', + title: '', + content: document.createElement('div'), + mounted: document.body, +}; + +class Modal extends EventTarget { + private config = defaultConfig; + + private root: HTMLDivElement | null = null; + + private template = ` +
+
+ +
+
+ +
+ +

+ 操作录制基于 PageSpy 技术实现, + 查看文档 +

+
+
+
+ +
+
+ + +
+
+
+ `; + + private get rendered() { + return this.config.mounted.contains(this.root); + } + + private query(className: string) { + return this.root?.querySelector(`.${className}`) as HTMLElement; + } + + public build(cfg: Partial) { + this.config = { ...this.config, ...cfg }; + if (!this.root) { + this.root = new DOMParser() + .parseFromString(this.template, 'text/html') + .querySelector(`.${classes.modal}`) as HTMLDivElement; + + // mask + this.root.addEventListener('click', (e) => { + e.stopPropagation(); + this.close(); + }); + + // content + this.query(classes.content).addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // close + this.query(classes.headerRight).addEventListener('click', () => { + this.close(); + }); + + // logo + this.query(classes.logo).setAttribute('src', this.config.logo); + + // title + this.query(classes.title).querySelector('b')!.textContent = + this.config.title; + } + } + + public show(args?: Partial) { + if (!this.root) { + console.warn('modal has not been ready.'); + return; + } + + const { content, mounted } = this.config; + const main = args?.content ?? content; + + const mainEl = this.query(classes.main); + + mainEl.innerHTML = ''; + if (isString(main)) { + mainEl.insertAdjacentHTML('afterbegin', main); + } else { + mainEl.appendChild(main); + } + + if (!this.rendered) { + mounted.appendChild(this.root); + } + this.root.classList.add(classes.show); + this.dispatchEvent(new Event('open')); + } + + public close() { + if (!this.root || !this.rendered) return; + + this.root.classList.remove(classes.show); + this.root.classList.add(classes.leaving); + setTimeout(() => { + this.root?.classList.remove(classes.leaving); + this.dispatchEvent(new Event('close')); + }, 300); + } + + public reset() { + this.config = defaultConfig; + this.root = null; + } +} + +export const modal = new Modal(); diff --git a/packages/page-spy-plugin-whole-bundle/src/utils/toast.ts b/packages/page-spy-plugin-whole-bundle/src/utils/toast.ts new file mode 100644 index 00000000..418aeef7 --- /dev/null +++ b/packages/page-spy-plugin-whole-bundle/src/utils/toast.ts @@ -0,0 +1,69 @@ +import classes from '../styles/toast.module.less'; +import successSvg from '../assets/success.svg?raw'; +import errorSvg from '../assets/error.svg?raw'; + +const toastIconMap = { + success: successSvg, + error: errorSvg, +}; + +type ToastType = 'success' | 'error'; +/** + * Show notification use `Toast.message('Copied')` + * Clear all notifications use `Toast.destroy()` + */ +export class Toast { + public static timer: ReturnType | null = null; + + static message(text: string | Element) { + let node = text; + if (typeof node === 'string') { + node = document.createElement('div'); + node.textContent = String(text); + } + node.classList.add(classes.toast); + document.documentElement.appendChild(node); + setTimeout(() => { + node.classList.add(classes.show); + }, 0); + const timer = setTimeout(() => { + if (document.contains(node)) { + document.documentElement.removeChild(node); + } + if (Toast.timer === timer) { + Toast.timer = null; + } + }, 3000); + Toast.timer = timer; + } + + static show(type: ToastType, text: string) { + const icon = toastIconMap[type]; + const doc = new DOMParser().parseFromString( + ` +
+ ${icon} +
${text}
+
`, + 'text/html', + ); + const node = doc.querySelector(`.${classes.withIcon}`)!; + Toast.message(node); + } + + static destroy() { + const nodes = document.querySelectorAll('.page-spy-toast'); + if (nodes.length) { + [...nodes].forEach((n) => { + if (document.contains(n)) { + document.documentElement.removeChild(n); + } + }); + + if (Toast.timer) { + clearTimeout(Toast.timer); + } + } + Toast.timer = null; + } +} diff --git a/yarn.lock b/yarn.lock index 015ce6bc..00d34eff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5103,7 +5103,7 @@ copy-anything@^2.0.1: dependencies: is-what "^3.14.1" -copy-to-clipboard@^3.3.1: +copy-to-clipboard@^3.3.1, copy-to-clipboard@^3.3.3: version "3.3.3" resolved "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==