(resolve => {
+ setTimeout(() => {
+ resolve();
+ }, time);
+ });
+}
+
+/**
+ * 获取事件对象中的坐标
+ * @param event 事件对象
+ */
+export function getEventPosition(event: MouseEvent | TouchEvent): {
+ clientX: number;
+ clientY: number;
+} {
+ if (event instanceof MouseEvent) {
+ return {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ };
+ }
+
+ const touch = event.touches[0] || event.changedTouches[0] || event.targetTouches[0];
+ const touchX = touch.pageX;
+ const touchY = touch.pageY;
+
+ return {
+ clientX: touchX,
+ clientY: touchY,
+ };
+}
diff --git a/src/assets/utils/validate.ts b/src/assets/utils/validate.ts
new file mode 100644
index 0000000000000000000000000000000000000000..289f9ec76b891bec7a4c4dbcbf5b8a263ecd3d03
--- /dev/null
+++ b/src/assets/utils/validate.ts
@@ -0,0 +1,47 @@
+/**
+ * @file 验证相关的工具函数
+ */
+
+/**
+ * 根据区号验证手机号是否正确
+ * @param phoneNumber 手机号
+ * @param areaCode 区号,默认:+86
+ * @returns 正确的手机号返回 true
+ */
+export function validatePhoneNumber(phoneNumber: string, areaCode = '+86'): boolean {
+ // 对于非国内的手机号,只需要 5~20 位
+ if (areaCode !== '+86') {
+ return /(^\d{5,20}$)/.test(phoneNumber);
+ }
+
+ return /^1[3-9]\d{9}$/.test(phoneNumber);
+}
+
+/**
+ * 验证图片验证码
+ * @param imageCaptcha 图片验证码
+ * @param imageId 图片验证码 id
+ * @returns 正确的验证码返回 true
+ */
+export function validateImageCaptcha(imageCaptcha: string, imageId: string): boolean {
+ return imageCaptcha.length >= 5 && imageId.length > 0;
+}
+
+/**
+ * 文本是否包含特殊字符
+ * @param text 目标文本
+ * @returns 如果存在特殊字符则返回 true
+ */
+export function validateSpecialString(text: string): boolean {
+ const containSpecial = /[~!@#$%^&*()+=[\]{}|\\;:",./<>?]+/;
+ return containSpecial.test(text);
+}
+
+/**
+ * 检测文本中是否仅包含中英文和空格
+ * @param text 目标文本
+ * @returns 如果存在其他字符则返回 false
+ */
+export function validateCnAndEn(text: string): boolean {
+ return /^[\u4e00-\u9fa5a-zA-Z ]+$/.test(text);
+}
diff --git a/src/assets/utils/vue-utils/emit-utils.ts b/src/assets/utils/vue-utils/emit-utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7fff0add5f5d0ec237b7e23168c014b228c3a44
--- /dev/null
+++ b/src/assets/utils/vue-utils/emit-utils.ts
@@ -0,0 +1,18 @@
+import { EmitFn, ObjectEmitsOptions } from 'vue/types/v3-setup-context';
+
+type EmitFuncType = (arg: P) => void;
+
+/**
+ * 创建 emit 配置的空函数
+ */
+export const emitFunc =
(): EmitFuncType
=> (() => true) as EmitFuncType
;
+
+export type VueEmit ObjectEmitsOptions> = EmitFn>;
+
+type UpdateEmitReturn = Record<`update:${K}`, EmitFuncType>;
+
+export function updateModelEmit(field: K) {
+ return {
+ [`update:${field}`]: () => true,
+ } as unknown as UpdateEmitReturn;
+}
diff --git a/src/assets/utils/vue-utils/index.ts b/src/assets/utils/vue-utils/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a7dc83b5eb3212fe4c62382b36a0275c8b4c8501
--- /dev/null
+++ b/src/assets/utils/vue-utils/index.ts
@@ -0,0 +1,23 @@
+import Vue, { Ref, unref } from 'vue';
+
+export type SimilarResponsive = T | Ref;
+
+/**
+ * 获取 ref 响应式对象中的 dom 节点,如果不是则返回 undefined
+ * @param targetRef 响应式对象
+ */
+export const getRefElem = (targetRef: Ref | HTMLElement): E | undefined => {
+ const refValue = unref(targetRef);
+
+ // 如果解构出来的是 dom 节点
+ if (refValue instanceof HTMLElement) {
+ return refValue as E;
+ }
+
+ // 如果解构出来的是 vue 对象,则返回 $el
+ if (refValue instanceof Vue) {
+ return refValue.$el;
+ }
+
+ return undefined;
+};
diff --git a/src/assets/utils/vue-utils/props-utils.ts b/src/assets/utils/vue-utils/props-utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..297e5a7f1b4d0f014730c3de47845ef0d77b5466
--- /dev/null
+++ b/src/assets/utils/vue-utils/props-utils.ts
@@ -0,0 +1,94 @@
+/**
+ * @file Vue 的 props 工具
+ */
+import { IconComponentOption } from '@polyv/icons-vue/icon-builder';
+import { computed, ComputedRef, ExtractPropTypes } from 'vue';
+import {
+ string as _string,
+ number as _number,
+ bool as _bool,
+ object as _object,
+ func as _func,
+ oneOf as _oneOf,
+ array as _array,
+ oneOfType as _oneOfType,
+ VueTypeValidableDef,
+} from 'vue-types';
+
+/** Vue prop 配置工具 */
+export class PropUtils {
+ /** String 类型,默认:'' */
+ static get string() {
+ return _string().def('');
+ }
+
+ /** String 类型,默认:undefined */
+ static get looseString() {
+ return _string();
+ }
+
+ /** Number 类型,默认:0 */
+ static get number() {
+ return _number().def(0);
+ }
+
+ /** Number 类型,默认:undefined */
+ static get looseNumber() {
+ return _number();
+ }
+
+ /** Boolean 类型,默认:true */
+ static get bool() {
+ return _bool().def(true);
+ }
+
+ static readonly objectType = _object;
+
+ /** Array 类型,默认:[] */
+ static readonly array = () => {
+ return _array().def([]);
+ };
+
+ /** Object 类型 */
+ static readonly object = _object;
+
+ /** Function 类型 */
+ static readonly func = _func;
+
+ /** 指定为数组中的值 */
+ static readonly oneOf = _oneOf;
+
+ /** 自定义类型 */
+ static readonly oneOfType = _oneOfType;
+
+ /** 枚举类型 */
+ static readonly enum = _string;
+
+ /** 图标组件类型 */
+ static readonly icon = (): VueTypeValidableDef => {
+ return {
+ type: undefined,
+ default: undefined,
+ required: false,
+ } as VueTypeValidableDef;
+ };
+}
+
+export type VueProps UniversalParams> = Readonly>>;
+
+export type FormatProps UniversalParams> = Partial<
+ ExtractPropTypes>
+>;
+
+export type PropComputedRefs = {
+ [K in keyof P]: ComputedRef
;
+};
+
+/** 结构 props,将 props 转成 computed */
+export const useProps =
(props: P): PropComputedRefs
=> {
+ const ret: PropComputedRefs
= {} as PropComputedRefs
;
+ for (const key in props) {
+ ret[key] = computed(() => props[key]);
+ }
+ return ret;
+};
diff --git a/src/components/README.md b/src/components/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b801d9a72c6dbdd2d86097a662668f2b0efeabdc
--- /dev/null
+++ b/src/components/README.md
@@ -0,0 +1,9 @@
+# 说明
+
+| 目录 | 说明 |
+| :----------------- | :------------------------------------------------------------- |
+| common-base | 存放一些基础组件,尽量不要使用 store |
+| common-business | 存放 `splash` 和 `watch` 都需要用到的业务组件 |
+| component-icons | 源文件在 `icon-svgs`,该文件夹是经过脚手架自动生成的的图标组件 |
+| page-splash-common | 引导页公共组件 |
+| page-watch-common | 直播观看页公共组件 |
diff --git a/src/components/common-base/action-sheet/mobile-action-sheet.vue b/src/components/common-base/action-sheet/mobile-action-sheet.vue
new file mode 100644
index 0000000000000000000000000000000000000000..386abdfbb91cdcbbabb7b22348ff04917ec3d558
--- /dev/null
+++ b/src/components/common-base/action-sheet/mobile-action-sheet.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+ {{ option.name }}
+
+
+
+ {{ $lang('base.cancel') }}
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/action-sheet/types.ts b/src/components/common-base/action-sheet/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f7b5ce977aaa77b5c616b25cb3eafde6be425004
--- /dev/null
+++ b/src/components/common-base/action-sheet/types.ts
@@ -0,0 +1,8 @@
+export type ActionSheetValue = string | number;
+
+export interface ActionSheetItem {
+ /** 名称 */
+ name: string;
+ /** 选项值 */
+ value: ActionSheetValue;
+}
diff --git a/src/components/common-base/action-sheet/use-action-sheet.ts b/src/components/common-base/action-sheet/use-action-sheet.ts
new file mode 100644
index 0000000000000000000000000000000000000000..968af6c7588e826a3a2187a9406034a57a00c355
--- /dev/null
+++ b/src/components/common-base/action-sheet/use-action-sheet.ts
@@ -0,0 +1,76 @@
+import { emitFunc, updateModelEmit, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { ref, watch } from 'vue';
+import { PopupInstance } from '@/components/common-base/popup/types';
+import { popupEmits, popupProps } from '@/components/common-base/popup/use-popup';
+import { ActionSheetItem, ActionSheetValue } from './types';
+
+export const actionSheetProps = () => ({
+ ...popupProps(),
+ /** 操作选项列表 */
+ actions: PropUtils.array(),
+ /** 当前选择的操作,支持.sync */
+ actionValue: PropUtils.oneOfType([String, Number]).def(''),
+ /** 是否显示取消,默认:true */
+ showCancel: PropUtils.bool.def(false),
+});
+
+export const actionSheetEmits = () => ({
+ ...popupEmits(),
+ ...updateModelEmit<'actionValue', ActionSheetValue>('actionValue'),
+ /** 点击选项 */
+ 'click-action': emitFunc(),
+});
+
+export const useActionSheet = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ const actionVisible = ref(false);
+
+ watch(
+ () => props.visible,
+ () => {
+ actionVisible.value = props.visible;
+ },
+ );
+
+ watch(
+ () => actionVisible.value,
+ () => {
+ if (actionVisible.value === props.visible) return;
+ emit('update:visible', actionVisible.value);
+ },
+ );
+
+ const popupRef = ref();
+
+ /** 处理点击选项 */
+ function onClickOption(option: ActionSheetItem) {
+ if (props.actionValue !== option.value) {
+ emit('update:actionValue', option.value);
+ }
+
+ if (popupRef.value) {
+ popupRef.value.closePopup();
+ }
+
+ emit('click-action', option.value);
+ }
+
+ /** 处理点击取消 */
+ function onClickCancel() {
+ if (popupRef.value) {
+ popupRef.value.closePopup();
+ }
+ }
+
+ return {
+ actionVisible,
+ popupRef,
+ onClickOption,
+ onClickCancel,
+ };
+};
diff --git a/src/components/common-base/count-down/count-down.vue b/src/components/common-base/count-down/count-down.vue
new file mode 100644
index 0000000000000000000000000000000000000000..27771089ff245aab504d5e0c6ae10ed9a051555c
--- /dev/null
+++ b/src/components/common-base/count-down/count-down.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+ {{ item.unit }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/count-down/types.ts b/src/components/common-base/count-down/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a1b261eac3ff1319f9315ad4fef0fbff02b49e5f
--- /dev/null
+++ b/src/components/common-base/count-down/types.ts
@@ -0,0 +1,7 @@
+import { tupleString } from '@/assets/utils/array';
+
+export const countDownSizes = tupleString('default', 'small');
+export type CountDownSize = typeof countDownSizes[number];
+
+export const countdownThemes = tupleString('square', 'text');
+export type CountDownTheme = typeof countdownThemes[number];
diff --git a/src/components/common-base/count-down/use-count-down.ts b/src/components/common-base/count-down/use-count-down.ts
new file mode 100644
index 0000000000000000000000000000000000000000..29060e10f1fec4f94934b0c9ea1915efe4dd5a15
--- /dev/null
+++ b/src/components/common-base/count-down/use-count-down.ts
@@ -0,0 +1,159 @@
+import { translate } from '@/assets/lang';
+import { SimilarResponsive } from '@/assets/utils/vue-utils';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { useSecondCountDown } from '@/hooks/tools/use-count-down';
+import { IRemaining as CountDownSurplusData } from '@utils-ts/countdown';
+import { computed, Ref, unref, watch } from 'vue';
+import { countDownSizes, countdownThemes } from './types';
+
+export const countDownProps = () => ({
+ /** 尺寸,small-小型尺寸,常用于移动端 */
+ size: PropUtils.oneOf(countDownSizes).def('default'),
+ /** 倒计时样式,square-方形,text-文本,默认:square */
+ theme: PropUtils.oneOf(countdownThemes).def('square'),
+ /** 计时时间,优先级高于 endTimestamp */
+ second: PropUtils.number.def(undefined),
+ /** 结束的时间戳 */
+ endTimestamp: PropUtils.number.def(undefined),
+ /** 只有 0 天时是否隐藏天数,默认:true */
+ hideZeroDays: PropUtils.bool.def(true),
+});
+
+/**
+ * 倒计时组件的 emit 配置
+ */
+export const countDownEmits = () => ({
+ /** 倒计时改变 */
+ 'count-down-change': emitFunc(),
+ /** 倒计时结束 */
+ 'count-down-finish': emitFunc(),
+});
+
+export type CountDownField = 'days' | 'hours' | 'minutes' | 'seconds';
+
+export interface CountDownFieldItem {
+ /** 字段名 */
+ field: CountDownField;
+ /** 标题 */
+ title: string;
+ /** 时间单位,多语言 */
+ unit: string;
+}
+
+export interface CountDownItem {
+ /** 数值 */
+ count: number;
+ /** 数值文案,自动补 0 */
+ text: string;
+ /** 标题 */
+ title: string;
+ /** 时间单位,含多语言 */
+ unit: string;
+}
+
+export interface UseCountDownOptions {
+ /** 总秒数,单位秒 */
+ second?: SimilarResponsive;
+ /** 结束的时间戳 */
+ endTimestamp?: SimilarResponsive;
+ /** 当天数为 0 时隐藏天数,默认:true */
+ hideZeroDays?: boolean | Ref;
+ emit?: VueEmit;
+}
+
+/**
+ * 倒计时 hook
+ * @param {Object} options 选项
+ * @param {Proxy} options.second 总秒数,单位:秒
+ * @param {Proxy} options.endTimestamp 结束的时间戳
+ * @param {Proxy} options.hideZeroDays 当天数为 0 时隐藏天数,默认:true
+ * @param {Function} options.emit 回调方法
+ */
+export const useCountDown = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+ const { hideZeroDays, second, endTimestamp } = useProps(props);
+
+ const { surplusTime, computedSecond, initCountDown, startCountDown, stopCountDown } =
+ useSecondCountDown({
+ second,
+ endTimestamp,
+ onCountDownChange: data => emit('count-down-change', data),
+ onCountDownFinish: () => emit('count-down-finish'),
+ });
+
+ /** 显示的时间内容列表 */
+ const timeContents = computed(() => {
+ const contents: CountDownItem[] = [];
+
+ const surplusVal = unref(surplusTime);
+
+ const fields: CountDownFieldItem[] = [
+ { field: 'days', title: 'DAY', unit: translate('unit.day') },
+ { field: 'hours', title: 'HOUR', unit: translate('unit.hour') },
+ { field: 'minutes', title: 'MIN', unit: translate('unit.minute') },
+ { field: 'seconds', title: 'SEC', unit: translate('unit.second') },
+ ];
+
+ fields.forEach(({ field, title, unit }) => {
+ const count = surplusVal[field];
+
+ // 对于 0 天则不在显示列表中
+ if (unref(hideZeroDays) && field === 'days' && count === 0) {
+ return;
+ }
+
+ contents.push({
+ // 数值
+ count,
+ // 数值文案,自动补 0
+ text: formatNumber(count),
+ // 标题
+ title,
+ // 单位,含多语言
+ unit: unref(unit),
+ });
+ });
+
+ return contents;
+ });
+
+ /**
+ * 显示的时间文案
+ * @example
+ * 01 天 10 时 22 分 30 秒
+ */
+ const timeText = computed(() => {
+ const texts: string[] = [];
+
+ unref(timeContents).forEach(data => {
+ texts.push(`${data.text} ${data.unit}`);
+ });
+
+ return texts.join(' ');
+ });
+
+ /** 格式化数值,自动补 0 */
+ function formatNumber(count: number): string {
+ return count < 10 ? `0${count}` : count.toString();
+ }
+
+ watch(
+ () => unref(computedSecond),
+ () => initCountDown(),
+ {
+ immediate: true,
+ },
+ );
+
+ return {
+ surplusTime,
+ timeContents,
+ timeText,
+ startCountDown,
+ stopCountDown,
+ };
+};
diff --git a/src/components/common-base/custom-teleport/custom-teleport.ts b/src/components/common-base/custom-teleport/custom-teleport.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9e60a5a9dfb45bcbe01bf1c0286ca7f0c1585494
--- /dev/null
+++ b/src/components/common-base/custom-teleport/custom-teleport.ts
@@ -0,0 +1,101 @@
+/**
+ * @file 自定义 vue3 的 teleport
+ */
+import { useVue } from '@/hooks/core/use-vue';
+import { PropUtils } from '@/assets/utils/vue-utils/props-utils';
+import { defineComponent, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
+import { $ } from '@just4/dom';
+
+const customTeleportProps = () => ({
+ /** 渲染到某个节点 */
+ to: PropUtils.oneOfType([String, HTMLElement]),
+});
+
+export const CustomTeleport = defineComponent({
+ props: {
+ ...customTeleportProps(),
+ },
+ setup(props, { slots }) {
+ const { getCurrentElem } = useVue();
+
+ const startComment = document.createComment('teleport start');
+ const $startComment = $(startComment);
+
+ const endComment = document.createComment('teleport end');
+ const $endComment = $(endComment);
+
+ /** 设置渲染位置的占位 */
+ function setTeleportPosition() {
+ const elem = getCurrentElem();
+ if (!elem) {
+ return;
+ }
+ const $elem = $(elem);
+ $startComment.insertBefore($elem);
+ $endComment.insertAfter($elem);
+ }
+
+ /** 重置元素到初时位置 */
+ function resetToDefaultPosition() {
+ const elem = getCurrentElem();
+ if (!elem) {
+ return;
+ }
+ const $elem = $(elem);
+ $elem.insertAfter($startComment);
+ }
+
+ /** 设置节点位置 */
+ async function setElemPosition() {
+ await nextTick();
+ // 没传则重置到初时位置
+ if (!props.to) {
+ resetToDefaultPosition();
+ return;
+ }
+
+ const elem = getCurrentElem();
+ if (!elem) {
+ return;
+ }
+
+ let parentElem: Element | undefined | null;
+ if (typeof props.to === 'string') {
+ parentElem = document.querySelector(props.to);
+ } else if (props.to instanceof Element) {
+ parentElem = props.to;
+ }
+
+ if (!parentElem) {
+ resetToDefaultPosition();
+ return;
+ }
+
+ const $parentElem = $(parentElem);
+ $parentElem.append(elem);
+ }
+
+ watch(
+ () => props.to,
+ () => {
+ setElemPosition();
+ },
+ );
+
+ onMounted(() => {
+ setTeleportPosition();
+ if (props.to) {
+ setElemPosition();
+ }
+ });
+
+ onBeforeUnmount(() => {
+ $startComment.remove();
+ $endComment.remove();
+ });
+
+ return () => slots.default && slots.default();
+ },
+});
+
+export default CustomTeleport;
diff --git a/src/components/common-base/dialog/mobile-dialog/imgs/icon-arrow-l.png b/src/components/common-base/dialog/mobile-dialog/imgs/icon-arrow-l.png
new file mode 100644
index 0000000000000000000000000000000000000000..0ec661d8ada78cac0027c4072aec4d9c08c498a6
Binary files /dev/null and b/src/components/common-base/dialog/mobile-dialog/imgs/icon-arrow-l.png differ
diff --git a/src/components/common-base/dialog/mobile-dialog/imgs/icon_close.png b/src/components/common-base/dialog/mobile-dialog/imgs/icon_close.png
new file mode 100644
index 0000000000000000000000000000000000000000..8c03227f8e3fb819459d37129e783d76deab34c9
Binary files /dev/null and b/src/components/common-base/dialog/mobile-dialog/imgs/icon_close.png differ
diff --git a/src/components/common-base/dialog/mobile-dialog/mobile-dialog.vue b/src/components/common-base/dialog/mobile-dialog/mobile-dialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0dec16f9aec1d642f42b3d59794cc9dee0bae7a2
--- /dev/null
+++ b/src/components/common-base/dialog/mobile-dialog/mobile-dialog.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/dialog/pc-dialog/pc-dialog.vue b/src/components/common-base/dialog/pc-dialog/pc-dialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..793fcf2f4ee7ce8ade05bd7c2c3b40beddce95c5
--- /dev/null
+++ b/src/components/common-base/dialog/pc-dialog/pc-dialog.vue
@@ -0,0 +1,498 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dialogTips }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/dialog/pc-dialog/types.ts b/src/components/common-base/dialog/pc-dialog/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bc56ced864d5cc6f56ef895b3078f1397dc90cd5
--- /dev/null
+++ b/src/components/common-base/dialog/pc-dialog/types.ts
@@ -0,0 +1,4 @@
+export interface DialogInstance {
+ /** 重置位置 */
+ resetPosition(): void;
+}
diff --git a/src/components/common-base/dialog/use-dialog-tips.ts b/src/components/common-base/dialog/use-dialog-tips.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b3366feefe79b4853f62080b963d7de23a48f2f9
--- /dev/null
+++ b/src/components/common-base/dialog/use-dialog-tips.ts
@@ -0,0 +1,74 @@
+import { inject, InjectionKey, onBeforeUnmount, provide, ref } from 'vue';
+
+export interface DialogTipsInstance {
+ /** 显示弹层的提示 */
+ showDialogTips: (tips: string) => void;
+}
+
+export const DIALOG_TIPS_PROVIDE_KEY: InjectionKey =
+ Symbol('DIALOG_TIPS_PROVIDE_KEY');
+
+/**
+ * 弹层提示 hooks(用于弹层中)
+ */
+export const useDialogTips = () => {
+ let timer: number | undefined;
+
+ const dialogTips = ref();
+
+ /** 显示弹层提示 */
+ function showDialogTips(tips: string) {
+ dialogTips.value = tips;
+ setHiddenTimer();
+ }
+
+ /** 设置隐藏定时器 */
+ function setHiddenTimer() {
+ clearHiddenTimer();
+
+ timer = window.setTimeout(() => {
+ dialogTips.value = undefined;
+ clearHiddenTimer();
+ }, 2000);
+ }
+
+ /** 清楚隐藏定时器 */
+ function clearHiddenTimer() {
+ if (timer) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ }
+
+ onBeforeUnmount(() => {
+ clearHiddenTimer();
+ });
+
+ const dialogTipsInstance: DialogTipsInstance = {
+ showDialogTips,
+ };
+
+ provide(DIALOG_TIPS_PROVIDE_KEY, dialogTipsInstance);
+
+ return {
+ dialogTips,
+ showDialogTips,
+ dialogTipsInstance,
+ };
+};
+
+/**
+ * 弹层提示 hook
+ */
+export const useDialogTipsUtils = () => {
+ const dialogTipsContext = inject(DIALOG_TIPS_PROVIDE_KEY);
+
+ /** 显示弹层提示 */
+ function showDialogTips(tips: string) {
+ dialogTipsContext && dialogTipsContext.showDialogTips(tips);
+ }
+
+ return {
+ showDialogTips,
+ };
+};
diff --git a/src/components/common-base/form/form-area-picker/mobile-form-area-picker.vue b/src/components/common-base/form/form-area-picker/mobile-form-area-picker.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6302e60dc1c5d98b5646fd58358f523a4935615f
--- /dev/null
+++ b/src/components/common-base/form/form-area-picker/mobile-form-area-picker.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-area-picker/pc-form-area-picker.vue b/src/components/common-base/form/form-area-picker/pc-form-area-picker.vue
new file mode 100644
index 0000000000000000000000000000000000000000..263a467300397efe914cd5c6772328b77b3cae66
--- /dev/null
+++ b/src/components/common-base/form/form-area-picker/pc-form-area-picker.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-area-picker/use-form-area-picker.ts b/src/components/common-base/form/form-area-picker/use-form-area-picker.ts
new file mode 100644
index 0000000000000000000000000000000000000000..09a14ed9cd98a9ff75efec38795be2db2c44afd2
--- /dev/null
+++ b/src/components/common-base/form/form-area-picker/use-form-area-picker.ts
@@ -0,0 +1,144 @@
+import { computed, ComputedRef, onBeforeMount, ref, unref, watch } from 'vue';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import type { AreaData, AreaPickerComponent } from '@/plugins/polyv-ui/types';
+import { formCommonProps, useFormCommonValidate } from '../hooks/use-form-common';
+import { isMobile } from '@/assets/utils/browser';
+import { getSelectData, isSelectFinish } from '@/plugins/polyv-ui/area-utils';
+import { useCommonStore } from '@/store/use-common-store';
+
+export const formAreaPickerProps = () => ({
+ ...formCommonProps(),
+ /** 绑定值 */
+ value: PropUtils.array(),
+ /** 占位文本 */
+ placeholder: PropUtils.string,
+});
+
+export const formAreaPickerEmit = () => ({
+ input: emitFunc(),
+});
+
+export interface FormAreaPickerInstance {
+ /** 是否已选择完成 */
+ isFinish: ComputedRef;
+}
+
+export const useFormAreaPicker = (options: {
+ AreaPicker: AreaPickerComponent;
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const commonStore = useCommonStore();
+ const { AreaPicker, props, emit } = options;
+
+ const { formItemIsError, blurToValidateItem, focusToRemoveError } = useFormCommonValidate({
+ props,
+ });
+
+ const innerValue = ref([]);
+
+ const inputPlaceholder = computed(() => props.placeholder);
+
+ /** 是否已选择完成 */
+ const isFinish = computed(() => {
+ return isSelectFinish(props.value, commonStore.areaData);
+ });
+
+ const innerIsFinish = computed(() => {
+ return isSelectFinish(innerValue.value, commonStore.areaData);
+ });
+
+ const inputValue = computed(() => {
+ let data: AreaData[] = [];
+
+ if (innerIsFinish.value) {
+ data = getSelectData(innerValue.value, commonStore.areaData);
+ } else if (isSelectFinish(props.value, commonStore.areaData)) {
+ data = getSelectData(props.value, commonStore.areaData);
+ }
+
+ return data.map(item => item.name).join('/');
+ });
+
+ watch(
+ () => unref(innerValue),
+ () => {
+ if (!isMobile) {
+ emit('input', unref(innerValue));
+ }
+ },
+ );
+
+ const instance: FormAreaPickerInstance = {
+ isFinish,
+ };
+
+ const pickerVisible = ref(false);
+
+ const refreshKey = ref(1);
+
+ function openPicker() {
+ if (!isFinish.value) {
+ refreshKey.value += 1;
+ }
+ pickerVisible.value = true;
+ onShowPicker();
+ }
+
+ function closePicker() {
+ if (!innerIsFinish.value) {
+ innerValue.value = props.value;
+ }
+ pickerVisible.value = false;
+ onHiddenPicker();
+ }
+
+ /** 处理隐藏弹层 */
+ function onHiddenPicker() {
+ blurToValidateItem();
+ }
+
+ /** 处理显示弹层 */
+ function onShowPicker() {
+ focusToRemoveError();
+ }
+
+ function onClickConfirm() {
+ if (!unref(innerIsFinish)) {
+ return;
+ }
+ emit('input', unref(innerValue));
+ closePicker();
+ }
+
+ onBeforeMount(async () => {
+ if (commonStore.areaData.length === 0) {
+ const areaData = await AreaPicker.loadAreaDataUrl();
+ commonStore.$patch({
+ areaData,
+ });
+ }
+ });
+
+ return {
+ innerValue,
+ isFinish,
+ innerIsFinish,
+ instance,
+ inputValue,
+ inputPlaceholder,
+ refreshKey,
+
+ formItemIsError,
+ blurToValidateItem,
+ focusToRemoveError,
+
+ pickerVisible,
+ openPicker,
+ closePicker,
+ onHiddenPicker,
+ onShowPicker,
+ onClickConfirm,
+ };
+};
diff --git a/src/components/common-base/form/form-checkbox/mobile-form-checkbox.vue b/src/components/common-base/form/form-checkbox/mobile-form-checkbox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..45265f592ffc1881fb8cba289ea870acff01938d
--- /dev/null
+++ b/src/components/common-base/form/form-checkbox/mobile-form-checkbox.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-checkbox/pc-form-checkbox.vue b/src/components/common-base/form/form-checkbox/pc-form-checkbox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..79dbb7b6e5df5445b6bab96bb09275beee0f43df
--- /dev/null
+++ b/src/components/common-base/form/form-checkbox/pc-form-checkbox.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-checkbox/use-form-checkbox.ts b/src/components/common-base/form/form-checkbox/use-form-checkbox.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f729d37f1716e563bcad64919e8214e636b23607
--- /dev/null
+++ b/src/components/common-base/form/form-checkbox/use-form-checkbox.ts
@@ -0,0 +1,34 @@
+import { emitFunc, updateModelEmit, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed, unref } from 'vue';
+
+export const formCheckboxProps = () => ({
+ /** 是否选中,支持.sync */
+ checked: PropUtils.bool.def(false),
+});
+
+export const formCheckboxEmits = () => ({
+ ...updateModelEmit<'checked', boolean>('checked'),
+ change: emitFunc(),
+});
+
+export const useFormCheckbox = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ /** 是否已选中 */
+ const isChecked = computed(() => props.checked);
+
+ /** 处理点击复选框 */
+ function onClickCheckbox(): void {
+ emit('update:checked', !unref(isChecked));
+ emit('change', !unref(isChecked));
+ }
+
+ return {
+ isChecked,
+ onClickCheckbox,
+ };
+};
diff --git a/src/components/common-base/form/form-image-verify-input/mobile-form-image-verify-input.vue b/src/components/common-base/form/form-image-verify-input/mobile-form-image-verify-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..be1481bb09f435434e6b0d459032e46e9aa919a1
--- /dev/null
+++ b/src/components/common-base/form/form-image-verify-input/mobile-form-image-verify-input.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-image-verify-input/pc-form-image-verify-input.vue b/src/components/common-base/form/form-image-verify-input/pc-form-image-verify-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f697c54feff1a37aa80c2ffe03e8898a86a14784
--- /dev/null
+++ b/src/components/common-base/form/form-image-verify-input/pc-form-image-verify-input.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-image-verify-input/type.ts b/src/components/common-base/form/form-image-verify-input/type.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6c64277e8b6abb2b9326d04734692a6030266022
--- /dev/null
+++ b/src/components/common-base/form/form-image-verify-input/type.ts
@@ -0,0 +1,4 @@
+export interface ImageVerifyInputInstance {
+ /** 刷新图片验证码 */
+ refreshVerifyImage(resetVal?: boolean): Promise;
+}
diff --git a/src/components/common-base/form/form-image-verify-input/use-image-verify-input.ts b/src/components/common-base/form/form-image-verify-input/use-image-verify-input.ts
new file mode 100644
index 0000000000000000000000000000000000000000..00f8edc68c40f1e5c4d0e0b19fc81d13de159e9f
--- /dev/null
+++ b/src/components/common-base/form/form-image-verify-input/use-image-verify-input.ts
@@ -0,0 +1,75 @@
+import { translate } from '@/assets/lang';
+import { emitFunc, updateModelEmit, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { getWatchCore } from '@/core/watch-sdk';
+import { computed, onMounted, ref, watch } from 'vue';
+import { InputValueType } from '../form-input/hooks/use-form-input';
+import { ImageVerifyInputInstance } from './type';
+
+export const formImageVerifyInputProps = () => ({
+ // 验证码绑定值
+ value: PropUtils.string.def(''),
+ // 图片 id,支持 .sync
+ imageId: PropUtils.string.def(''),
+});
+
+export const formImageVerifyInputEmits = () => ({
+ ...updateModelEmit<'imageId', string>('imageId'),
+ input: emitFunc(),
+});
+
+export const useImageVerifyInput = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ const inputPlaceholder = computed(() => {
+ return translate('form.placeholder.imageVerify');
+ });
+
+ /** 验证码图片地址 */
+ const imageUrl = ref('');
+
+ /** 刷新验证码图片 */
+ async function refreshVerifyImage(resetVal = false) {
+ const watchCore = getWatchCore();
+ const data = await watchCore.sms.generateImageVerifyCode();
+ imageUrl.value = data.url;
+ emit('update:imageId', data.imageId);
+
+ if (resetVal) {
+ emit('input', '');
+ }
+ }
+
+ onMounted(() => {
+ refreshVerifyImage();
+ });
+
+ watch(
+ () => props.imageId,
+ () => {
+ if (props.imageId === '') {
+ refreshVerifyImage();
+ }
+ },
+ );
+
+ /** 处理输入框输入 */
+ function onInputChange(val: InputValueType): void {
+ emit('input', `${val}`);
+ }
+
+ const instance: ImageVerifyInputInstance = {
+ refreshVerifyImage,
+ };
+
+ return {
+ inputPlaceholder,
+ imageUrl,
+ refreshVerifyImage,
+ onInputChange,
+ instance,
+ };
+};
diff --git a/src/components/common-base/form/form-input/hooks/use-form-input.ts b/src/components/common-base/form/form-input/hooks/use-form-input.ts
new file mode 100644
index 0000000000000000000000000000000000000000..252b2510c82f7842e17f3e7e83738a881aad1bef
--- /dev/null
+++ b/src/components/common-base/form/form-input/hooks/use-form-input.ts
@@ -0,0 +1,152 @@
+/**
+ * @file 表单组件输入框 hook
+ */
+
+import { useVue } from '@/hooks/core/use-vue';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import {
+ formCommonProps,
+ useFormCommon,
+ useFormCommonValidate,
+} from '@/components/common-base/form/hooks/use-form-common';
+import { computed, ref, unref } from 'vue';
+import { FormInputInstance } from '../types/form-input-types';
+
+export type InputValueType = string | number;
+
+export const formInputProps = () => ({
+ ...formCommonProps(),
+ /** 禁用输入 */
+ disabled: PropUtils.bool.def(false),
+ /** 绑定值 */
+ value: PropUtils.oneOfType([String, Number]).def(''),
+ /** 输入框原生类型 */
+ type: PropUtils.string.def('text'),
+ /** 输入框占位 */
+ placeholder: PropUtils.string.def(''),
+ /** 最大长度 */
+ maxlength: PropUtils.number.def(Infinity),
+ /** 显示字数限制,默认:false */
+ showWordLimit: PropUtils.bool.def(false),
+ /** 尾部文字 */
+ suffixText: PropUtils.string.def(''),
+});
+
+export const formInputEmits = () => ({
+ input: emitFunc(),
+ /** 失焦 */
+ blur: emitFunc(),
+ /** 聚焦 */
+ focus: emitFunc(),
+ /** 回车事件 */
+ enter: emitFunc(),
+});
+
+export const useFormInput = (options: {
+ props: VueProps;
+ emit: VueEmit;
+ classPrefix: string;
+}) => {
+ const { emit, props, classPrefix } = options;
+
+ const { blurToValidateItem, focusToRemoveError, formItemIsError } = useFormCommonValidate({
+ props,
+ });
+
+ const { commonClassNames } = useFormCommon({
+ props,
+ classPrefix,
+ });
+
+ /** 实例 */
+ const inputRef = ref();
+
+ /** 输入框 className */
+ const inputClassNames = computed(() => {
+ const classNames = [...unref(commonClassNames)];
+
+ if (unref(formItemIsError)) {
+ classNames.push(`${classPrefix}--error`);
+ }
+
+ return classNames;
+ });
+
+ /** 字数限制文案 */
+ const wordLimitText = computed(() => {
+ if (!props.showWordLimit) {
+ return '';
+ }
+
+ const length = `${props.value ?? ''}`.length;
+ if (props.maxlength === Infinity) {
+ return `${length}`;
+ }
+
+ return `${length}/${props.maxlength}`;
+ });
+
+ /** 处理输入框输入事件 */
+ function onInputChanged(event: Event) {
+ const target = event.target as HTMLInputElement;
+ let value = target.value;
+ if (props.type === 'number') {
+ value = value.slice(0, props.maxlength);
+ target.value = value;
+ }
+ emit('input', value);
+ }
+
+ /** 处理输入框失焦事件 */
+ function onInputBlur() {
+ blurToValidateItem();
+ emit('blur', props.value);
+ }
+
+ /** 处理输入框聚焦事件 */
+ function onInputFocus() {
+ focusToRemoveError();
+ emit('focus', props.value);
+ }
+
+ /** 处理输入框回车事件 */
+ function onInputEnter() {
+ emit('enter', props.value);
+ }
+
+ const { getInstance } = useVue();
+
+ /** 强制更新 */
+ function forceUpdate(): void {
+ const instance = getInstance();
+ instance?.$forceUpdate();
+ }
+
+ /** 聚焦输入框 */
+ function focusInput() {
+ inputRef.value?.focus();
+ }
+
+ /** 失焦输入框 */
+ function blurInput() {
+ inputRef.value?.blur();
+ }
+
+ const formInstance: FormInputInstance = {
+ forceUpdate,
+ focusInput,
+ blurInput,
+ };
+
+ return {
+ inputRef,
+ inputClassNames,
+ onInputChanged,
+ onInputBlur,
+ onInputFocus,
+ onInputEnter,
+ formInstance,
+ wordLimitText,
+ };
+};
diff --git a/src/components/common-base/form/form-input/mobile-form-input.vue b/src/components/common-base/form/form-input/mobile-form-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..527f3e996400919018d1f7cc548f6301c5d34cce
--- /dev/null
+++ b/src/components/common-base/form/form-input/mobile-form-input.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-input/pc-form-input.vue b/src/components/common-base/form/form-input/pc-form-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9729b1cd018375243e10912c82123d9b6611f9e2
--- /dev/null
+++ b/src/components/common-base/form/form-input/pc-form-input.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-input/types/form-input-types.ts b/src/components/common-base/form/form-input/types/form-input-types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b6fb46a0a98850880c67d76e2ca9754fd574c8cf
--- /dev/null
+++ b/src/components/common-base/form/form-input/types/form-input-types.ts
@@ -0,0 +1,11 @@
+/**
+ * 实例
+ */
+export interface FormInputInstance {
+ /** 强制更新 */
+ forceUpdate(): void;
+ /** 聚焦输入框 */
+ focusInput(): void;
+ /** 失焦输入框 */
+ blurInput(): void;
+}
diff --git a/src/components/common-base/form/form-item.vue b/src/components/common-base/form/form-item.vue
new file mode 100644
index 0000000000000000000000000000000000000000..181c61f4ba5c883de92e0b7fe3c1f75459d45ef3
--- /dev/null
+++ b/src/components/common-base/form/form-item.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-number-input/mobile-form-number-input.vue b/src/components/common-base/form/form-number-input/mobile-form-number-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c5e7662fa9399c7f76bdc5379d39d71bec638428
--- /dev/null
+++ b/src/components/common-base/form/form-number-input/mobile-form-number-input.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-number-input/pc-form-number-input.vue b/src/components/common-base/form/form-number-input/pc-form-number-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..774209ca137eade2d8ca0e8424e947d44412b098
--- /dev/null
+++ b/src/components/common-base/form/form-number-input/pc-form-number-input.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-number-input/use-form-number-input.ts b/src/components/common-base/form/form-number-input/use-form-number-input.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2e1b8b6832b34eba4b46b8d893c3daf9ecac664a
--- /dev/null
+++ b/src/components/common-base/form/form-number-input/use-form-number-input.ts
@@ -0,0 +1,76 @@
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { ref, unref } from 'vue';
+import { InputValueType } from '../form-input/hooks/use-form-input';
+import { FormInputInstance } from '../form-input/types/form-input-types';
+import { formCommonProps } from '../hooks/use-form-common';
+
+export type NumberInputValueType = string | number;
+
+export const formNumberInputProps = () => ({
+ ...formCommonProps(),
+ // 绑定值
+ value: PropUtils.oneOfType([String, Number]).def(''),
+ // 最大值
+ max: PropUtils.number.def(Infinity),
+ // 最小值
+ min: PropUtils.number.def(-Infinity),
+});
+
+export const formNumberInputEmits = () => ({
+ input: emitFunc(),
+});
+
+export const useFormNumberInput = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ const { value } = useProps(props);
+
+ const inputRef = ref();
+
+ /**
+ * 格式化值
+ */
+ function formatValue(val: string | number): NumberInputValueType {
+ if (val === '') {
+ return val;
+ }
+ return Number(val);
+ }
+
+ /**
+ * 检查最大值和最小值
+ */
+ function checkMaxMin(val: NumberInputValueType): NumberInputValueType {
+ if (val === '') {
+ return val;
+ }
+ if (val > props.max) {
+ return props.max;
+ }
+ if (val < props.min) {
+ return props.min;
+ }
+ return val;
+ }
+
+ function onInputChange(val: InputValueType): void {
+ let newVal = formatValue(val);
+ newVal = checkMaxMin(newVal);
+ emit('input', newVal);
+
+ const inputInstance = unref(inputRef);
+ if (inputInstance) {
+ inputInstance.forceUpdate();
+ }
+ }
+
+ return {
+ inputRef,
+ value,
+ onInputChange,
+ };
+};
diff --git a/src/components/common-base/form/form-phone-input/mobile-form-phone-input.vue b/src/components/common-base/form/form-phone-input/mobile-form-phone-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..38981ab11d66776e3196b28f90e59dc9a8f7f79b
--- /dev/null
+++ b/src/components/common-base/form/form-phone-input/mobile-form-phone-input.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-phone-input/pc-form-phone-input.vue b/src/components/common-base/form/form-phone-input/pc-form-phone-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ae4d530450e6ec2e28ba88628344d4f74ea763d9
--- /dev/null
+++ b/src/components/common-base/form/form-phone-input/pc-form-phone-input.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-phone-input/use-form-phone-input.ts b/src/components/common-base/form/form-phone-input/use-form-phone-input.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ee905255516fb64f7edd6d7d0812b5a2e2a5bd77
--- /dev/null
+++ b/src/components/common-base/form/form-phone-input/use-form-phone-input.ts
@@ -0,0 +1,69 @@
+/**
+ * @file 手机号码输入框 hook
+ */
+
+import { translate } from '@/assets/lang';
+import { formCommonProps, useFormCommonValidate } from '../hooks/use-form-common';
+import { computed } from 'vue';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { emitFunc, updateModelEmit, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { InputValueType } from '../form-input/hooks/use-form-input';
+
+export const formPhoneInputProps = () => ({
+ ...formCommonProps(),
+ // 绑定值
+ value: PropUtils.string,
+ // 区号,支持.sync
+ areaCode: PropUtils.string.def('+86'),
+ // 占位符
+ placeholder: PropUtils.string,
+});
+
+export const formPhoneInputEmits = () => ({
+ input: emitFunc(),
+ blur: emitFunc(),
+ ...updateModelEmit<'areaCode', string>('areaCode'),
+});
+
+export const useFormPhoneInput = (options: {
+ props: VueProps;
+ emit: VueEmit;
+ closePhoneCode?: () => void;
+}) => {
+ const { props, emit, closePhoneCode } = options;
+ const { validateCurrentFormItem, blurToValidateItem } = useFormCommonValidate({
+ props,
+ });
+
+ /** 输入框占位文本 */
+ const inputPlaceholder = computed(() => {
+ return props.placeholder || translate('form.placeholder.phoneInput');
+ });
+
+ /** 处理表单输入框修改 */
+ function onFormInputChange(val: InputValueType) {
+ emit('input', `${val}`);
+ }
+
+ /** 处理表单输入框失焦 */
+ async function onFormInputBlur() {
+ try {
+ await blurToValidateItem();
+ emit('blur', props.value);
+ } catch (error) {}
+ }
+
+ /** 处理区号选择 */
+ function onPhoneCodeInput(code: string) {
+ emit('update:areaCode', code);
+ validateCurrentFormItem();
+ closePhoneCode && closePhoneCode();
+ }
+
+ return {
+ inputPlaceholder,
+ onFormInputChange,
+ onFormInputBlur,
+ onPhoneCodeInput,
+ };
+};
diff --git a/src/components/common-base/form/form-protocol/mobile-form-protocol.vue b/src/components/common-base/form/form-protocol/mobile-form-protocol.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d20b0e474dbf25d924608e511584707c15c2ddde
--- /dev/null
+++ b/src/components/common-base/form/form-protocol/mobile-form-protocol.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-protocol/pc-form-protocol.vue b/src/components/common-base/form/form-protocol/pc-form-protocol.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b13bba3eb4a27bda18d65bf54cfe02bdacbebb74
--- /dev/null
+++ b/src/components/common-base/form/form-protocol/pc-form-protocol.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-protocol/use-form-protocol.ts b/src/components/common-base/form/form-protocol/use-form-protocol.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9cfa505fbf1df3e419f9852daa2116b9cc7a9265
--- /dev/null
+++ b/src/components/common-base/form/form-protocol/use-form-protocol.ts
@@ -0,0 +1,35 @@
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed } from 'vue';
+
+export const formProtocolProps = () => ({
+ /** 是否选中 */
+ value: PropUtils.bool.def(false),
+ /** 协议内容 */
+ content: PropUtils.string,
+});
+
+export const formProtocolEmits = () => ({
+ input: emitFunc(),
+});
+
+export const useFormProtocol = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ const checkboxChecked = computed(() => props.value);
+
+ const contentHtml = computed(() => props.content);
+
+ function onCheckboxChange(checked: boolean) {
+ emit('input', checked);
+ }
+
+ return {
+ checkboxChecked,
+ contentHtml,
+ onCheckboxChange,
+ };
+};
diff --git a/src/components/common-base/form/form-select/mobile-form-select.vue b/src/components/common-base/form/form-select/mobile-form-select.vue
new file mode 100644
index 0000000000000000000000000000000000000000..59cb73a1e0d04d7756f0d93d30f53e2c94119ee8
--- /dev/null
+++ b/src/components/common-base/form/form-select/mobile-form-select.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-select/pc-form-select.vue b/src/components/common-base/form/form-select/pc-form-select.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0e12fe7ebbbbff5a2f4adec498f5256f48e66ed3
--- /dev/null
+++ b/src/components/common-base/form/form-select/pc-form-select.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-select/types/form-select-types.ts b/src/components/common-base/form/form-select/types/form-select-types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d3eb3f764514d954c684f55beb95456779eaa3cf
--- /dev/null
+++ b/src/components/common-base/form/form-select/types/form-select-types.ts
@@ -0,0 +1,8 @@
+export type SelectValueType = string | number;
+
+export interface SelectOptionItem {
+ /** 显示的文案 */
+ label: string;
+ /** 绑定值 */
+ value: SelectValueType;
+}
diff --git a/src/components/common-base/form/form-select/use-form-select.ts b/src/components/common-base/form/form-select/use-form-select.ts
new file mode 100644
index 0000000000000000000000000000000000000000..741a90af308b9fbb91a17b1867527786f4133ea4
--- /dev/null
+++ b/src/components/common-base/form/form-select/use-form-select.ts
@@ -0,0 +1,201 @@
+import { translate } from '@/assets/lang';
+import { isArray } from '@/assets/utils/array';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed, ref } from 'vue';
+import { SelectOptionItem, SelectValueType } from './types/form-select-types';
+import { formCommonProps, useFormCommonValidate } from '../hooks/use-form-common';
+
+export const formSelectProps = () => ({
+ ...formCommonProps(),
+ // 绑定值
+ value: PropUtils.oneOfType([String, Number, Array]).def(''),
+ // 输入框标题
+ title: PropUtils.string,
+ // 占位符
+ placeholder: PropUtils.string,
+ // 选项列表
+ options: PropUtils.array(),
+ // 是否多选
+ multiple: PropUtils.bool.def(false),
+});
+
+export const formSelectEmits = () => ({
+ input: emitFunc(),
+});
+
+export const useFormSelect = (options: {
+ props: VueProps;
+ emit: VueEmit;
+ closeOptions?: () => void;
+}) => {
+ const { props, emit, closeOptions } = options;
+ const { formItemIsError, blurToValidateItem, focusToRemoveError } = useFormCommonValidate({
+ props,
+ });
+
+ const { title, multiple } = useProps(props);
+
+ /** 输入框的 value 值 */
+ const inputValue = computed(() => {
+ let valText = '';
+
+ if (props.multiple && isArray(props.value) && props.value.length) {
+ return `${translate('form.select.multiPrefix')}${props.value.length}${translate(
+ 'form.select.multiSuffix',
+ )}`;
+ }
+
+ if (!props.multiple) {
+ const index = props.options.findIndex(option => option.value === props.value);
+ if (index !== -1) {
+ valText = props.options[index].label;
+ }
+ }
+
+ return valText;
+ });
+
+ /** 输入框占位符 */
+ const inputPlaceholder = computed(() => props.placeholder);
+
+ /** 选项列表 */
+ const optionList = computed(() => {
+ return props.options;
+ });
+
+ function onClickOption(option: SelectOptionItem): void {
+ if (props.multiple && isArray(props.value)) {
+ if (props.value.includes(option.value)) {
+ emit(
+ 'input',
+ props.value.filter(item => item !== option.value),
+ );
+ } else {
+ emit('input', [...props.value, option.value]);
+ }
+ }
+
+ if (!props.multiple) {
+ if (option.value !== props.value) {
+ emit('input', option.value);
+ }
+
+ if (closeOptions) {
+ closeOptions();
+ }
+ }
+ }
+
+ /** 是否已选中 */
+ function isSelectedOption(value: SelectValueType): boolean {
+ if (props.multiple && isArray(props.value)) {
+ return props.value.includes(value);
+ }
+ if (!props.multiple) {
+ return value === props.value;
+ }
+ return false;
+ }
+
+ return {
+ multiple,
+ title,
+ inputValue,
+ inputPlaceholder,
+ optionList,
+ onClickOption,
+ isSelectedOption,
+
+ formItemIsError,
+ blurToValidateItem,
+ focusToRemoveError,
+ };
+};
+
+/**
+ * 移动端下的 select
+ */
+export const useFormSelectMobile = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ /** 选项是否显示 */
+ const optionVisible = ref(false);
+
+ /** 打开选项 */
+ function openOption() {
+ optionVisible.value = true;
+ }
+
+ /** 关闭选项 */
+ function closeOption() {
+ optionVisible.value = false;
+ }
+
+ /** 已选的 value */
+ const selectValue = ref('');
+
+ /** 处理点击选项 */
+ function onClickOption(option: SelectOptionItem): void {
+ if (!props.multiple) {
+ selectValue.value = option.value;
+ return;
+ }
+
+ if (!isArray(selectValue.value)) {
+ selectValue.value = [];
+ }
+
+ if (selectValue.value.includes(option.value)) {
+ selectValue.value = selectValue.value.filter(item => item !== option.value);
+ } else {
+ selectValue.value = [...selectValue.value, option.value];
+ }
+ }
+
+ /** 处理点击确认 */
+ function onClickConfirm() {
+ if (!props.multiple) {
+ if (!selectValue.value) {
+ return;
+ }
+
+ if (selectValue.value !== props.value) {
+ emit('input', selectValue.value);
+ closeOption();
+ }
+ }
+
+ if (props.multiple) {
+ if (!isArray(selectValue.value)) {
+ selectValue.value = [];
+ }
+
+ emit('input', selectValue.value);
+ closeOption();
+ }
+ }
+
+ /** 是否已选中 */
+ function isSelectedOption(value: SelectValueType): boolean {
+ if (props.multiple && isArray(selectValue.value)) {
+ return selectValue.value.includes(value);
+ }
+ if (!props.multiple) {
+ return value === selectValue.value;
+ }
+ return false;
+ }
+
+ return {
+ optionVisible,
+ openOption,
+ closeOption,
+ onClickOption,
+ onClickConfirm,
+ isSelectedOption,
+ };
+};
diff --git a/src/components/common-base/form/form-slide-verify/mobile-form-slide-verify.vue b/src/components/common-base/form/form-slide-verify/mobile-form-slide-verify.vue
new file mode 100644
index 0000000000000000000000000000000000000000..955b6f256443632f03b485692c06183877b07318
--- /dev/null
+++ b/src/components/common-base/form/form-slide-verify/mobile-form-slide-verify.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-slide-verify/pc-form-slide-verify.vue b/src/components/common-base/form/form-slide-verify/pc-form-slide-verify.vue
new file mode 100644
index 0000000000000000000000000000000000000000..be24ba51c0fdaa60a142e2c5b221a0163e9904fa
--- /dev/null
+++ b/src/components/common-base/form/form-slide-verify/pc-form-slide-verify.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-slide-verify/use-slide-verify.ts b/src/components/common-base/form/form-slide-verify/use-slide-verify.ts
new file mode 100644
index 0000000000000000000000000000000000000000..839089d53bd8c4b8e5993eef1c5cc42f32e62b8a
--- /dev/null
+++ b/src/components/common-base/form/form-slide-verify/use-slide-verify.ts
@@ -0,0 +1,100 @@
+/**
+ * @file 滑块验证码 hook
+ */
+
+import { loadAliAwsc } from '@/plugins/external-lib-loaders/load-ali-awsc';
+import { randomStr } from '@utils-ts/string';
+import { onMounted } from 'vue';
+import { useLangStore } from '@/store/use-lang-store';
+import { LangType } from '@/assets/lang/lang-enum';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { getWatchCore } from '@/core/watch-sdk';
+
+export interface SlideVerifyData {
+ sessionId: string;
+ sig: string;
+ token: string;
+ scene: string;
+}
+
+export const formSlideVerifyProps = () => ({
+ /** 绑定的验证值 */
+ value: PropUtils.objectType(),
+});
+
+export const formSlideVerifyEmits = () => ({
+ input: emitFunc(),
+ 'verify-success': emitFunc(),
+ 'verify-fail': emitFunc(),
+ 'verify-error': emitFunc(),
+});
+
+export const useSlideVerify = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { emit } = options;
+ const langStore = useLangStore();
+
+ const id = randomStr(8);
+ const scene = 'nc_register_h5';
+
+ // 语言配置
+ const langConfig = {
+ cn: {
+ SLIDE: '按住滑块,拖动到最右',
+ },
+ };
+
+ /** 初始化验证码 */
+ async function initSlideVerify() {
+ const AWSC = await loadAliAwsc();
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ AWSC.use('nc', (state: unknown, module: any) => {
+ const watchCore = getWatchCore();
+ module.init({
+ // 应用类型标识。它和使用场景标识(scene字段)一起决定了滑动验证的业务场景与后端对应使用的策略模型。您可以在阿里云验证码控制台的配置管理页签找到对应的appkey字段值,请务必正确填写。
+ appkey: watchCore.utils.getAliSliderAppKey(),
+ // 使用场景标识。它和应用类型标识(appkey字段)一起决定了滑动验证的业务场景与后端对应使用的策略模型。您可以在阿里云验证码控制台的配置管理页签找到对应的scene值,请务必正确填写。
+ scene,
+ // 声明滑动验证需要渲染的目标ID。
+ renderTo: `#${id}`,
+ // 配置语言
+ language: langStore.currentLang === LangType.Chinese ? 'cn' : 'en',
+ // 更新多语言配置
+ upLang: langConfig,
+ // 前端滑动验证通过时会触发该回调参数。您可以在该回调参数中将会话ID(sessionId)、签名串(sig)、请求唯一标识(token)字段记录下来,随业务请求一同发送至您的服务端调用验签。
+ success: (data: SlideVerifyData) => {
+ const slideData = {
+ sessionId: data.sessionId,
+ sig: data.sig,
+ token: data.token,
+ scene,
+ };
+
+ emit('input', slideData);
+ emit('verify-success', slideData);
+ },
+ // 滑动验证失败时触发该回调参数。
+ fail: (failCode: string) => {
+ emit('verify-fail', failCode);
+ },
+ // 验证码加载出现异常时触发该回调参数。
+ error: (errorCode: string) => {
+ emit('verify-error', errorCode);
+ },
+ });
+ });
+ }
+
+ onMounted(() => {
+ initSlideVerify();
+ });
+
+ return {
+ id,
+ initSlideVerify,
+ };
+};
diff --git a/src/components/common-base/form/form-sms-verify-input/mobile-form-sms-verify-input.vue b/src/components/common-base/form/form-sms-verify-input/mobile-form-sms-verify-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2bf875fac05296486fc8a316ff557f163a54ef17
--- /dev/null
+++ b/src/components/common-base/form/form-sms-verify-input/mobile-form-sms-verify-input.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-sms-verify-input/pc-form-sms-verify-input.vue b/src/components/common-base/form/form-sms-verify-input/pc-form-sms-verify-input.vue
new file mode 100644
index 0000000000000000000000000000000000000000..49174b43298265dae45dba24e11b654d7e7d76ab
--- /dev/null
+++ b/src/components/common-base/form/form-sms-verify-input/pc-form-sms-verify-input.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-sms-verify-input/use-sms-verify-input.ts b/src/components/common-base/form/form-sms-verify-input/use-sms-verify-input.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c11a6f28e5da64d8f7e362f888e736b616b0cc9f
--- /dev/null
+++ b/src/components/common-base/form/form-sms-verify-input/use-sms-verify-input.ts
@@ -0,0 +1,242 @@
+/**
+ * @file 短信验证码输入框 hook
+ */
+
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { translate } from '@/assets/lang';
+import { validateImageCaptcha, validatePhoneNumber } from '@/assets/utils/validate';
+import { computed, ref, unref, watchEffect } from 'vue';
+import { toast } from '@/hooks/components/use-toast';
+import { getWatchCore } from '@/core/watch-sdk';
+import { useSecondCountDown } from '@/hooks/tools/use-count-down';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import {
+ SmsScene,
+ SmsValidType,
+ SendSmsVerifyCodeFail,
+ SendSmsVerifyResult,
+} from '@polyv/live-watch-sdk';
+import { InputValueType } from '../form-input/hooks/use-form-input';
+import { ImageVerifyInputInstance } from '../form-image-verify-input/type';
+import { isArray } from '@/assets/utils/array';
+
+/** 验证类型,imageCode - 图片验证码,sliderCode - 滑块验证码 */
+export type VerifyType = 'imageCaptcha' | 'sliderCode';
+
+/**
+ * props 配置
+ */
+export const formSmsVerifyInputProps = () => ({
+ /** 绑定值 */
+ value: PropUtils.string.def(''),
+ /** 区号 */
+ areaCode: PropUtils.string.def('+86'),
+ /** 手机号 */
+ phoneNumber: PropUtils.string.def(''),
+ /** 短信场景 */
+ smsScene: PropUtils.enum().isRequired,
+ /** 验证类型 */
+ validType: PropUtils.enum().def(SmsValidType.Image),
+ /** 图片验证码 id */
+ imageId: PropUtils.string.def(''),
+ /** 图片验证码 */
+ imageCaptcha: PropUtils.string.def(''),
+ /** 图片验证码节点 */
+ imageVerifyInputRef: PropUtils.oneOfType([
+ Array,
+ Object,
+ ]),
+});
+
+/**
+ * emit 配置
+ */
+export const formSmsVerifyInputEmits = () => ({
+ input: emitFunc(),
+});
+
+export const useSmsVerifyInput = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+ const { areaCode, phoneNumber, smsScene, imageCaptcha, imageId, validType } = useProps(props);
+ const { surplusTime, initCountDown } = useSecondCountDown({
+ second: 60,
+ });
+
+ /** 输入框占位文本 */
+ const inputPlaceholder = computed(() => {
+ return translate('form.placeholder.smsVerify');
+ });
+
+ /** 发送按钮是否禁用 */
+ const sendDisabled = computed(() => {
+ let isDisabled = false;
+ const areaCodeVal = unref(areaCode);
+ const phoneNumberVal = unref(phoneNumber);
+
+ // 手机号不规范
+ if (!validatePhoneNumber(phoneNumberVal, areaCodeVal)) {
+ isDisabled = true;
+ }
+
+ // 存在倒计时
+ if (unref(surplusTime).seconds !== 0) {
+ isDisabled = true;
+ }
+
+ // 如果是图片验证码则验证长度
+ if (
+ unref(validType) === SmsValidType.Image &&
+ !validateImageCaptcha(unref(imageCaptcha), unref(imageId))
+ ) {
+ isDisabled = true;
+ }
+
+ return isDisabled;
+ });
+
+ /** 发送按钮文本 */
+ const sendText = computed(() => {
+ const seconds = unref(surplusTime).seconds;
+ if (seconds !== 0) {
+ return `${translate('form.resend')}${seconds}${translate('unit.second')}`;
+ }
+
+ return translate('form.getSmsVerify');
+ });
+
+ /** 处理输入框输入 */
+ function onInputChange(val: InputValueType): void {
+ emit('input', `${val}`);
+ }
+
+ /** 滑块验证码是否显示 */
+ const slideVerifyVisible = ref(false);
+
+ watchEffect(() => {
+ if (unref(sendDisabled)) {
+ slideVerifyVisible.value = false;
+ }
+ });
+
+ /** 处理点击发送 */
+ function onClickSend() {
+ if (unref(sendDisabled)) {
+ return;
+ }
+
+ if (unref(validType) === SmsValidType.Slider) {
+ slideVerifyVisible.value = true;
+ } else {
+ toSendImageSmsCode();
+ }
+ }
+
+ /**
+ * 发送短信验证码(滑块验证)
+ */
+ async function toSendSliderSmsCode(slideData: AliAwscSliceData) {
+ const watchCore = getWatchCore();
+ const validTypeVal = unref(validType);
+
+ let result: SendSmsVerifyResult | undefined;
+
+ if (validTypeVal === SmsValidType.Slider) {
+ result = await watchCore.sms.sendSmsVerifyCode({
+ phoneNumber: unref(phoneNumber),
+ areaCode: unref(areaCode),
+ validType: validTypeVal,
+ scene: unref(smsScene),
+ sessionId: slideData.sessionId,
+ sig: slideData.sig,
+ token: slideData.token,
+ });
+ }
+
+ if (!result) {
+ return;
+ }
+
+ if (result.success) {
+ onSendSuccess();
+ return;
+ }
+
+ toast.error(translate('base.frequentOperation'));
+ slideVerifyVisible.value = false;
+ }
+
+ /**
+ * 发送短信验证码(图片验证)
+ */
+ async function toSendImageSmsCode() {
+ const watchCore = getWatchCore();
+ const validTypeVal = unref(validType);
+
+ let result: SendSmsVerifyResult | undefined;
+
+ if (validTypeVal === SmsValidType.Image) {
+ result = await watchCore.sms.sendSmsVerifyCode({
+ phoneNumber: unref(phoneNumber),
+ areaCode: unref(areaCode),
+ validType: validTypeVal,
+ scene: unref(smsScene),
+ imageId: unref(imageId),
+ imageCaptcha: unref(imageCaptcha),
+ });
+ }
+
+ if (!result) {
+ return;
+ }
+
+ if (result.success) {
+ onSendSuccess();
+ return;
+ }
+
+ const failReason = result.failReason;
+ if (failReason === SendSmsVerifyCodeFail.ImageCaptchaError) {
+ toast.error(translate('form.error.imageCaptchaError'));
+ refreshImageVerify();
+ }
+ }
+
+ /** 刷新图片验证码的输入框 */
+ function refreshImageVerify() {
+ if (isArray(props.imageVerifyInputRef)) {
+ props.imageVerifyInputRef.forEach(instance => {
+ instance.refreshVerifyImage(true);
+ });
+ } else {
+ props.imageVerifyInputRef?.refreshVerifyImage(true);
+ }
+ }
+
+ /**
+ * 处理发送成功
+ */
+ function onSendSuccess() {
+ // 开始倒计时
+ initCountDown();
+
+ // 2秒后隐藏滑块验证码
+ setTimeout(() => {
+ slideVerifyVisible.value = false;
+ }, 2000);
+ }
+
+ return {
+ inputPlaceholder,
+ sendDisabled,
+ sendText,
+ SmsValidType,
+ onInputChange,
+ slideVerifyVisible,
+ onClickSend,
+ toSendSliderSmsCode,
+ toSendImageSmsCode,
+ };
+};
diff --git a/src/components/common-base/form/form-submit-button.vue b/src/components/common-base/form/form-submit-button.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c15b5961c549c9d57a81dd2991a616f25bfc8024
--- /dev/null
+++ b/src/components/common-base/form/form-submit-button.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-upload-image/form-upload-image.vue b/src/components/common-base/form/form-upload-image/form-upload-image.vue
new file mode 100644
index 0000000000000000000000000000000000000000..83b941efce20988101d0a04f8052903dd849e6ba
--- /dev/null
+++ b/src/components/common-base/form/form-upload-image/form-upload-image.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/form-upload-image/use-form-upload-image.ts b/src/components/common-base/form/form-upload-image/use-form-upload-image.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bea4a3bd74ae04ff6bdb8bf653e0c4054541733b
--- /dev/null
+++ b/src/components/common-base/form/form-upload-image/use-form-upload-image.ts
@@ -0,0 +1,71 @@
+import { translate } from '@/assets/lang';
+import { isMobile } from '@/assets/utils/browser';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { getWatchCore } from '@/core/watch-sdk';
+import { toast } from '@/hooks/components/use-toast';
+import { computed, unref } from 'vue';
+
+export const formUploadImageProps = () => ({
+ /** 绑定值 */
+ value: PropUtils.array(),
+ /** 最大上传数量 */
+ maxCount: PropUtils.number.def(Infinity),
+});
+
+export const formUploadImageEmit = () => ({
+ input: emitFunc(),
+});
+
+export const useFormUploadImage = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+ const { value, maxCount } = useProps(props);
+
+ /** 处理点击上传 */
+ async function onClickUpload() {
+ try {
+ if (unref(value).length >= unref(maxCount)) {
+ return;
+ }
+
+ const watchCore = getWatchCore();
+ const { imageUrl } = await watchCore.utils.uploadImage();
+ const newVal = [...unref(value), imageUrl];
+ emit('input', newVal);
+ } catch (error) {
+ toast.error(translate('watchCore.error.uploadImage'));
+ }
+ }
+
+ /** 处理点击删除 */
+ function onClickDelete(index: number) {
+ const newVal = unref(value).filter((url, _index) => _index !== index);
+ emit('input', newVal);
+ }
+
+ /** 数量提示 */
+ const countTips = computed(() => {
+ if (props.maxCount === 1 || props.maxCount === Infinity) {
+ return '';
+ }
+ const surplusCount = props.maxCount - props.value.length;
+ if (surplusCount === 0) {
+ return '';
+ }
+ return `(${translate('form.uploadImage.prefixTips')}${surplusCount}${translate(
+ 'form.uploadImage.suffixTips',
+ )})`;
+ });
+
+ return {
+ value,
+ maxCount,
+ onClickUpload,
+ onClickDelete,
+ countTips,
+ isMobile,
+ };
+};
diff --git a/src/components/common-base/form/form-wrap.vue b/src/components/common-base/form/form-wrap.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c602f7797158fa3a48856f9cb8d43d7c288e0f88
--- /dev/null
+++ b/src/components/common-base/form/form-wrap.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/form/hooks/use-form-common.ts b/src/components/common-base/form/hooks/use-form-common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..146b6e406ec334ea5b348951411c25415fff2b60
--- /dev/null
+++ b/src/components/common-base/form/hooks/use-form-common.ts
@@ -0,0 +1,90 @@
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed, unref } from 'vue';
+import { FormSize, FormTheme } from '../types/enums';
+import { useFormItemInject } from './use-form-item';
+import { useFormWrapPropsInject } from './use-form';
+
+export const formCommonProps = () => ({
+ /** 尺寸 */
+ size: PropUtils.enum(),
+ /** 主题 */
+ theme: PropUtils.enum(),
+ /** 是否进行表单验证 */
+ validateForm: PropUtils.bool.def(true),
+});
+
+export const useFormCommon = (options: {
+ props: VueProps;
+ classPrefix: string;
+}) => {
+ const { props, classPrefix } = options;
+ const formProps = useFormWrapPropsInject();
+
+ /** 表单组件公用 className */
+ const commonClassNames = computed(() => {
+ const list = [];
+
+ // 尺寸
+ const sizeVal = props.size;
+ if (sizeVal && sizeVal !== FormSize.Default) {
+ list.push(`${classPrefix}--size-${sizeVal}`);
+ }
+
+ // 主题
+ const themeVal = props.theme || formProps?.theme;
+ if (unref(themeVal)) {
+ list.push(`${classPrefix}--theme-${themeVal}`);
+ }
+
+ return list;
+ });
+
+ return {
+ commonClassNames,
+ };
+};
+
+/** 表单组件下的验证状态 hook */
+export const useFormCommonValidate = (options: { props: VueProps }) => {
+ const { props } = options;
+ const formItemContext = useFormItemInject();
+
+ /** 验证当前的表单节点 */
+ async function validateCurrentFormItem() {
+ await formItemContext?.validateFormItem();
+ }
+
+ /** 在失焦时进行表单节点验证 */
+ async function blurToValidateItem() {
+ if (!props.validateForm) {
+ return;
+ }
+
+ await validateCurrentFormItem();
+ }
+
+ /** 在聚焦时移除表单节点异常提示 */
+ function focusToRemoveError() {
+ if (!props.validateForm) {
+ return;
+ }
+
+ formItemContext?.removeErrorMessage();
+ }
+
+ /** 表单节点是否处于异常 */
+ const formItemIsError = computed(() => {
+ if (!props.validateForm || !formItemContext) {
+ return false;
+ }
+
+ return unref(formItemContext.formItemIsError);
+ });
+
+ return {
+ validateCurrentFormItem,
+ blurToValidateItem,
+ focusToRemoveError,
+ formItemIsError,
+ };
+};
diff --git a/src/components/common-base/form/hooks/use-form-item.ts b/src/components/common-base/form/hooks/use-form-item.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9ea7b1b954d8b434f051fd5d2eddb08cf006234b
--- /dev/null
+++ b/src/components/common-base/form/hooks/use-form-item.ts
@@ -0,0 +1,188 @@
+import { isMobile } from '@/assets/utils/browser';
+import { formatStyleSize } from '@/assets/utils/dom';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import Schema from '@/plugins/async-validator';
+import { computed, inject, InjectionKey, provide, ref, unref, watchEffect } from 'vue';
+import {
+ FormItemInstance,
+ formItemLabelModels,
+ FormItemLabelModel,
+ FormValidateOptions,
+} from '../types/form-types';
+import { useFormWrapPropsInject } from './use-form';
+
+export const formItemProps = () => ({
+ /** 该节点属于表单数据中的哪个字段 */
+ formField: PropUtils.looseString,
+ /** 节点标签 */
+ label: PropUtils.looseString,
+ /** 表单标题宽度 */
+ labelWidth: PropUtils.looseNumber,
+ /** 表单标题高度 */
+ labelHeight: PropUtils.looseNumber,
+ /** 表单输入框内容宽度 */
+ contentWidth: PropUtils.looseNumber,
+ /** 是否显示异常信息 */
+ showErrorMessage: PropUtils.bool.def(true),
+ /** 节点异常信息 */
+ errorMessage: PropUtils.looseString,
+ /** label 模式 */
+ labelModel: PropUtils.oneOf(formItemLabelModels),
+ /** 是否显示必填星号 */
+ required: PropUtils.bool.def(false),
+});
+
+/** 组件名 */
+export const FORM_ITEM_COMPONENT_NAME = 'form-item';
+
+/**
+ * 判断 vue 实例是否为
+ * @param instance 实例
+ */
+export const isFormItemInstance = (instance: unknown): instance is FormItemInstance => {
+ const _instance = instance as Partial | undefined;
+ return _instance?.isFormItem ?? false;
+};
+
+export const FORM_ITEM_PROVIDE_KEY: InjectionKey =
+ Symbol('FORM_ITEM_PROVIDE_KEY');
+
+export const useFormItemInject = (): FormItemInstance | undefined => {
+ return inject(FORM_ITEM_PROVIDE_KEY);
+};
+
+export const useFormItem = (options: { props: VueProps }) => {
+ const { props } = options;
+
+ const required = computed(() => props.required);
+
+ const formProps = useFormWrapPropsInject();
+
+ const labelText = computed(() => props.label);
+
+ const labelModel = computed(
+ () => props.labelModel ?? formProps?.labelModel ?? 'inline',
+ );
+
+ /** 节点标签样式 */
+ const labelStyle = computed(() => {
+ if (unref(labelModel) === 'header') {
+ return {};
+ }
+
+ const width = props.labelWidth ?? formProps?.labelWidth;
+ const height = props.labelHeight ?? formProps?.labelHeight;
+
+ return {
+ width: formatStyleSize(width),
+ height: formatStyleSize(height),
+ };
+ });
+
+ /** 节点内容宽度样式 */
+ const contentWidthStyle = computed(() => {
+ const width = props.contentWidth ?? formProps?.contentWidth;
+
+ return formatStyleSize(width);
+ });
+
+ /** 节点异常提示 */
+ const itemErrorMessage = ref();
+ /** 当前表单节点是否异常 */
+ const formItemIsError = computed(() => typeof unref(itemErrorMessage) !== 'undefined');
+
+ watchEffect(() => {
+ if (props.errorMessage) {
+ setErrorMessage(props.errorMessage);
+ } else {
+ itemErrorMessage.value = undefined;
+ }
+ });
+
+ /** 设置异常提示 */
+ function setErrorMessage(message: string | undefined): void {
+ if (message && !props.showErrorMessage) return;
+
+ itemErrorMessage.value = message;
+ }
+
+ /** 移除异常提示 */
+ function removeErrorMessage() {
+ itemErrorMessage.value = undefined;
+ }
+
+ /** 验证当前表单节点 */
+ function validateFormItem(validateOptions: FormValidateOptions = {}): Promise {
+ const showErrorMessage = validateOptions.showErrorMessage ?? true;
+
+ // 当前节点验证的字段名
+ const validateField = props.formField;
+ const formDataVal = formProps?.formData;
+ const formRulesVal = formProps?.formRules;
+
+ /**
+ * 没有传验证字段
+ * 中没有 formData
+ * 以上条件满足其一则不进行验证,默认通过验证
+ */
+ if (!validateField || !formDataVal || !formRulesVal || !formRulesVal[validateField]) {
+ return Promise.resolve();
+ }
+
+ // 创建验证对象
+ const descriptor = {
+ [validateField]: formRulesVal[validateField],
+ };
+ const validateVal = {
+ [validateField]: formDataVal[validateField],
+ };
+ const validator = new Schema(descriptor);
+
+ // 执行验证
+ let message: string | undefined;
+ validator.validate(validateVal, errors => {
+ if (errors && errors.length) {
+ message = errors[0].message;
+ }
+ });
+
+ // 验证失败则提示
+ if (showErrorMessage && props.showErrorMessage) {
+ setErrorMessage(message);
+ }
+
+ return new Promise((resolve, reject) => {
+ if (typeof message === 'undefined') {
+ resolve();
+ } else {
+ reject(new Error(message));
+ }
+ });
+ }
+
+ // expose 导出对象
+ const formItemInstance: FormItemInstance = {
+ isFormItem: true,
+ formItemIsError,
+ setErrorMessage,
+ removeErrorMessage,
+ validateFormItem,
+ };
+
+ // 向下注入
+ provide(FORM_ITEM_PROVIDE_KEY, formItemInstance);
+
+ return {
+ required,
+ labelText,
+ labelModel,
+ labelStyle,
+ contentWidthStyle,
+ itemErrorMessage,
+ formItemIsError,
+ removeErrorMessage,
+ validateFormItem,
+ formItemInstance,
+ isMobile,
+ };
+};
diff --git a/src/components/common-base/form/hooks/use-form-submit-button.ts b/src/components/common-base/form/hooks/use-form-submit-button.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae1e9a4ab7d0cfd9808aafb4298582014b2a4ef3
--- /dev/null
+++ b/src/components/common-base/form/hooks/use-form-submit-button.ts
@@ -0,0 +1,64 @@
+import { ref, unref, watch } from 'vue';
+import { useFormWrapInject, useFormWrapPropsInject } from './use-form';
+
+export const useFormSubmitButton = () => {
+ const formProps = useFormWrapPropsInject();
+ const formContext = useFormWrapInject();
+
+ /** 提交按钮是否禁用 */
+ const buttonIsDisabled = ref(false);
+
+ /** 检查按钮是否需要禁用 */
+ async function checkDisabled(): Promise {
+ let isDisabled = false;
+
+ if (!formContext) {
+ return;
+ }
+
+ // 表单正在提交中
+ if (unref(formContext.isSubmiting)) {
+ isDisabled = true;
+ buttonIsDisabled.value = isDisabled;
+ return;
+ }
+
+ // 进行表单验证
+ try {
+ if (formContext.validateCurrentForm) {
+ await formContext.validateCurrentForm({
+ showErrorMessage: false,
+ });
+ }
+ } catch (e) {
+ if (unref(formContext.debug)) {
+ console.error('form-submit-button-error', e);
+ }
+ isDisabled = true;
+ }
+
+ buttonIsDisabled.value = isDisabled;
+ }
+
+ watch(
+ () => {
+ return [formProps && formProps.formData, formContext && unref(formContext.isSubmiting)];
+ },
+ () => checkDisabled(),
+ {
+ immediate: true,
+ deep: true,
+ },
+ );
+
+ /** 处理按钮点击 */
+ function onButtonClick() {
+ formContext && formContext.submitForm();
+ }
+
+ return {
+ buttonIsDisabled,
+ checkDisabled,
+ onButtonClick,
+ };
+};
diff --git a/src/components/common-base/form/hooks/use-form.ts b/src/components/common-base/form/hooks/use-form.ts
new file mode 100644
index 0000000000000000000000000000000000000000..40d133781868d2e417764a66cc5b5a120a7beed6
--- /dev/null
+++ b/src/components/common-base/form/hooks/use-form.ts
@@ -0,0 +1,130 @@
+import { useVue } from '@/hooks/core/use-vue';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { ValidatorRules } from '@/plugins/async-validator';
+import { ComputedRef, inject, InjectionKey, provide, Ref, ref, unref } from 'vue';
+import { FormTheme } from '../types/enums';
+import { formItemLabelModels, FormValidateOptions } from '../types/form-types';
+import { isFormItemInstance } from './use-form-item';
+
+export const formWrapProps = () => ({
+ /** 绑定的表单对象 */
+ formData: PropUtils.object().def({}),
+ /** 验证规则 */
+ formRules: PropUtils.object().def({}),
+ /** 表单标题宽度 */
+ labelWidth: PropUtils.number.def(170),
+ /** 表单标题高度 */
+ labelHeight: PropUtils.number.def(40),
+ /** 表单输入框内容宽度 */
+ contentWidth: PropUtils.looseNumber,
+ /** 表单提交方法 */
+ submitAction: PropUtils.func(),
+ /** 表单主题,dark-黑暗 */
+ theme: PropUtils.enum(),
+ /** label 模式,默认:inline */
+ labelModel: PropUtils.oneOf(formItemLabelModels).def('inline'),
+ /** 是否开启 debug 打印,默认:false */
+ debug: PropUtils.bool.def(false),
+});
+
+export const formWrapEmits = () => ({
+ // 表单提交事件
+ 'submit-form': emitFunc(),
+});
+
+export type FormWrapProps = VueProps;
+
+/** 注入类型 */
+export interface FormWrapInjectData {
+ /** 是否正在提交 */
+ isSubmiting: Ref;
+ /** debug */
+ debug: ComputedRef;
+ /** 验证当前表单 */
+ validateCurrentForm(validateOptions?: FormValidateOptions): Promise;
+ /** 提交当前表单 */
+ submitForm(validateOptions?: FormValidateOptions): Promise;
+}
+
+export const FORM_WRAP_PROPS_PROVIDE_KEY: InjectionKey = Symbol(
+ 'FORM_WRAP_PROPS_PROVIDE_KEY',
+);
+
+export const useFormWrapPropsInject = (): FormWrapProps | undefined => {
+ return inject(FORM_WRAP_PROPS_PROVIDE_KEY);
+};
+
+export const FORM_WRAP_PROVIDE_KEY: InjectionKey =
+ Symbol('FORM_WRAP_PROVIDE_KEY');
+
+export const useFormWrapInject = (): FormWrapInjectData | undefined => {
+ return inject(FORM_WRAP_PROVIDE_KEY);
+};
+
+export const useFormWrap = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ const { formData, submitAction, debug } = useProps(props);
+ const { getCurrentChildrens } = useVue();
+
+ /** 表单是否正在提交 */
+ const isSubmiting = ref(false);
+
+ /** 验证当前表单 */
+ async function validateCurrentForm(validateOptions: FormValidateOptions = {}): Promise {
+ const childrens = getCurrentChildrens();
+ if (!childrens || childrens.length === 0) {
+ return;
+ }
+
+ for (let i = 0; i < childrens.length; i++) {
+ const instance = childrens[i];
+ if (isFormItemInstance(instance)) {
+ try {
+ await instance.validateFormItem(validateOptions);
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ }
+ }
+ }
+
+ /** 验证并触发表单提交 */
+ async function submitForm(validateOptions: FormValidateOptions): Promise {
+ await validateCurrentForm(validateOptions);
+
+ emit('submit-form', unref(formData));
+
+ const submitActionFn = unref(submitAction);
+ if (typeof submitActionFn === 'function') {
+ isSubmiting.value = true;
+ try {
+ await submitActionFn(unref(formData));
+ } catch (e) {
+ isSubmiting.value = false;
+ } finally {
+ isSubmiting.value = false;
+ }
+ }
+ }
+
+ // 向下注入 props
+ provide(FORM_WRAP_PROPS_PROVIDE_KEY, props);
+
+ provide(FORM_WRAP_PROVIDE_KEY, {
+ debug,
+ isSubmiting,
+ validateCurrentForm,
+ submitForm,
+ });
+
+ return {
+ isSubmiting,
+ validateCurrentForm,
+ submitForm,
+ };
+};
diff --git a/src/components/common-base/form/imgs/form-upload-add.png b/src/components/common-base/form/imgs/form-upload-add.png
new file mode 100644
index 0000000000000000000000000000000000000000..a3b1e6c2efa0347aeea44cd2074bd04a32df45d9
Binary files /dev/null and b/src/components/common-base/form/imgs/form-upload-add.png differ
diff --git a/src/components/common-base/form/imgs/form-upload-del.png b/src/components/common-base/form/imgs/form-upload-del.png
new file mode 100644
index 0000000000000000000000000000000000000000..03322421f786c2801d0237843d81d40266037bb2
Binary files /dev/null and b/src/components/common-base/form/imgs/form-upload-del.png differ
diff --git a/src/components/common-base/form/imgs/form-upload-failed.png b/src/components/common-base/form/imgs/form-upload-failed.png
new file mode 100644
index 0000000000000000000000000000000000000000..5c0565654b5d841433a8de81f8fc641d50759f9d
Binary files /dev/null and b/src/components/common-base/form/imgs/form-upload-failed.png differ
diff --git a/src/components/common-base/form/imgs/form-upload-loading.png b/src/components/common-base/form/imgs/form-upload-loading.png
new file mode 100644
index 0000000000000000000000000000000000000000..600b64c8778149f02223c5220324cbd9619e56f1
Binary files /dev/null and b/src/components/common-base/form/imgs/form-upload-loading.png differ
diff --git a/src/components/common-base/form/types/enums.ts b/src/components/common-base/form/types/enums.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4822b06c575942661340ddebec13be3479ffbb4e
--- /dev/null
+++ b/src/components/common-base/form/types/enums.ts
@@ -0,0 +1,19 @@
+/** 表单尺寸 */
+export enum FormSize {
+ /** 默认 - 48px */
+ Default = 'default',
+ /** 中 - 40px */
+ Medium = 'medium',
+ /** 小 - 40px */
+ Small = 'small',
+}
+
+/** 表单主题 */
+export enum FormTheme {
+ /** 默认 */
+ Default = 'default',
+ /** 黑暗 */
+ Dark = 'dark',
+ /** 简约 */
+ Simplicity = 'simplicity',
+}
diff --git a/src/components/common-base/form/types/form-types.ts b/src/components/common-base/form/types/form-types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..825a04622e016f562a2026f76c3b603594f15695
--- /dev/null
+++ b/src/components/common-base/form/types/form-types.ts
@@ -0,0 +1,29 @@
+import { tupleString } from '@/assets/utils/array';
+import { ComputedRef } from 'vue';
+
+/**
+ * 表单验证选项
+ */
+export interface FormValidateOptions {
+ /** 是否显示异常消息 */
+ showErrorMessage?: boolean;
+}
+
+/**
+ * 表单节点对象
+ */
+export interface FormItemInstance {
+ isFormItem: true;
+ /** 表单节点是否验证异常 */
+ formItemIsError: ComputedRef;
+ /** 设置异常提示 */
+ setErrorMessage(message: string | undefined): void;
+ /** 移除异常提示 */
+ removeErrorMessage(): void;
+ /** 验证表单节点 */
+ validateFormItem(validateOptions?: FormValidateOptions): Promise;
+}
+
+export const formItemLabelModels = tupleString('inline', 'header');
+/** 表单节点 label 样式,inline-行内,header-头部 */
+export type FormItemLabelModel = typeof formItemLabelModels[number];
diff --git a/src/components/common-base/form/utils/utils.ts b/src/components/common-base/form/utils/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..715978353134582fa65bb2a1a4cf4ac80a3e23d8
--- /dev/null
+++ b/src/components/common-base/form/utils/utils.ts
@@ -0,0 +1,14 @@
+import { SelectOptionItem } from '../form-select/types/form-select-types';
+
+/**
+ * 格式化字符串数组下拉选项
+ */
+export function formatSimpleSelectOptions(options: string[]): SelectOptionItem[] {
+ const strList = options.filter(str => !!str);
+ return strList.map(str => {
+ return {
+ label: str,
+ value: str,
+ };
+ });
+}
diff --git a/src/components/common-base/iframe-render/mobile-iframe-render.vue b/src/components/common-base/iframe-render/mobile-iframe-render.vue
new file mode 100644
index 0000000000000000000000000000000000000000..935a7127cae732b4f3cf0b13c1a82a3da136783e
--- /dev/null
+++ b/src/components/common-base/iframe-render/mobile-iframe-render.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/iframe-render/pc-iframe-render.vue b/src/components/common-base/iframe-render/pc-iframe-render.vue
new file mode 100644
index 0000000000000000000000000000000000000000..10cc00a1e52fdff31da6e21a68dc5b820eb1e4dd
--- /dev/null
+++ b/src/components/common-base/iframe-render/pc-iframe-render.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/iframe-render/use-iframe-render.ts b/src/components/common-base/iframe-render/use-iframe-render.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4eae674699f34756ed0348cce76a00f070ea4133
--- /dev/null
+++ b/src/components/common-base/iframe-render/use-iframe-render.ts
@@ -0,0 +1,32 @@
+import { formatStyleSize } from '@/assets/utils/dom';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed } from 'vue';
+import { CSSProperties } from 'vue/types/jsx';
+
+export const iframeRenderProps = () => ({
+ /** 地址 */
+ src: PropUtils.looseString,
+ /** 高度,默认:600 */
+ height: PropUtils.oneOfType([String, Number]).def(600),
+});
+
+export const useIframeRender = (options: { props: VueProps }) => {
+ const { props } = options;
+
+ const iframeSrc = computed(() => props.src);
+
+ const wrapStyle = computed(() => {
+ const styles: CSSProperties = {};
+
+ if (props.height) {
+ styles.height = formatStyleSize(props.height);
+ }
+
+ return styles;
+ });
+
+ return {
+ iframeSrc,
+ wrapStyle,
+ };
+};
diff --git a/src/components/common-base/list-loading/imgs/loading.png b/src/components/common-base/list-loading/imgs/loading.png
new file mode 100644
index 0000000000000000000000000000000000000000..8a2d5b705698d63ce54f18b469b30eab095949b8
Binary files /dev/null and b/src/components/common-base/list-loading/imgs/loading.png differ
diff --git a/src/components/common-base/list-loading/list-loading.vue b/src/components/common-base/list-loading/list-loading.vue
new file mode 100644
index 0000000000000000000000000000000000000000..08f686097b72b0da1bee91f631495905f6637f37
--- /dev/null
+++ b/src/components/common-base/list-loading/list-loading.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/components/common-base/normal-button/normal-button.vue b/src/components/common-base/normal-button/normal-button.vue
new file mode 100644
index 0000000000000000000000000000000000000000..33616e3b8d7e936bb43a6dda7d1ec590fa15a08e
--- /dev/null
+++ b/src/components/common-base/normal-button/normal-button.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/normal-button/types.ts b/src/components/common-base/normal-button/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2e81bbbf713bf4cb746dee14eac19e96f3a93898
--- /dev/null
+++ b/src/components/common-base/normal-button/types.ts
@@ -0,0 +1,44 @@
+/**
+ * 按钮类型
+ */
+export enum ButtonType {
+ /** 主题按钮 */
+ Primary = 'primary',
+ /** 信息按钮 */
+ Info = 'info',
+ /** 取消按钮 */
+ Cancel = 'cancel',
+ /** 竖屏按钮 */
+ Portrait = 'portrait',
+ /** 加重按钮 */
+ Aggravate = 'aggravate',
+ /** 侧边菜单栏 */
+ AsideMenu = 'aside-menu',
+}
+
+/**
+ * 按钮尺寸
+ */
+export enum ButtonSize {
+ /** 大 - 56px */
+ Large = 'large',
+ /** 中 - 48px */
+ Medium = 'medium',
+ /** 默认 - 40px */
+ Default = 'default',
+ /** 小 - 32px */
+ Small = 'small',
+ /** 超小 - 28px */
+ Mini = 'mini',
+ /** 超级小 - 24px */
+ XMini = 'x-mini',
+}
+
+/**
+ * 按钮原生类型
+ */
+export enum ButtonNativeType {
+ Button = 'button',
+ Submit = 'submit',
+ Reset = 'reset',
+}
diff --git a/src/components/common-base/normal-button/use-normal-button.ts b/src/components/common-base/normal-button/use-normal-button.ts
new file mode 100644
index 0000000000000000000000000000000000000000..565202e8023997b1c9f08fdc431039d6e6e78aa4
--- /dev/null
+++ b/src/components/common-base/normal-button/use-normal-button.ts
@@ -0,0 +1,27 @@
+import { emitFunc } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils } from '@/assets/utils/vue-utils/props-utils';
+import { ButtonNativeType, ButtonSize, ButtonType } from './types';
+
+export const normalButtonProps = () => ({
+ /** 按钮文本,默认:按钮 */
+ text: PropUtils.string.def('按钮'),
+ /** 原生类型,默认:button */
+ nativeType: PropUtils.enum().def(ButtonNativeType.Button),
+ /** 按钮类型,默认:ButtonType.Primary */
+ type: PropUtils.enum().def(ButtonType.Primary),
+ /** 按钮尺寸,默认:ButtonSize.Default */
+ size: PropUtils.enum().def(ButtonSize.Default),
+ /** 按钮是否禁用,默认:false */
+ disabled: PropUtils.bool.def(false),
+ /** 是否块状级按钮,默认:false */
+ block: PropUtils.bool.def(false),
+ /** 按钮图标 */
+ icon: PropUtils.icon(),
+ /** 按钮图标 class */
+ iconClass: PropUtils.string,
+});
+
+export const normalButtonEmits = () => ({
+ /** 点击事件 */
+ click: emitFunc(),
+});
diff --git a/src/components/common-base/phone-code/mobile-phone-code.vue b/src/components/common-base/phone-code/mobile-phone-code.vue
new file mode 100644
index 0000000000000000000000000000000000000000..00a1bf02915d89b05535648ea404816e0368c11f
--- /dev/null
+++ b/src/components/common-base/phone-code/mobile-phone-code.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.letter }}
+
+
+ {{ item.country }}
+ {{ item.code }}
+
+
+
+
+
+
+
+ {{ item.letter }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/phone-code/pc-phone-code.vue b/src/components/common-base/phone-code/pc-phone-code.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6b6a8309cbeeeb857117a5a09b7cea7a55a5d001
--- /dev/null
+++ b/src/components/common-base/phone-code/pc-phone-code.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+ {{ item.letter }}
+
+
+ {{ item.country }}
+ {{ item.code }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/phone-code/phone-code-data.ts b/src/components/common-base/phone-code/phone-code-data.ts
new file mode 100644
index 0000000000000000000000000000000000000000..52b57d1ed968d0bdc7fc912acd9a908d680ff531
--- /dev/null
+++ b/src/components/common-base/phone-code/phone-code-data.ts
@@ -0,0 +1,931 @@
+const phoneCodeData = [
+ {
+ letter: 'A',
+ },
+ {
+ country: '阿富汗',
+ code: '+93',
+ },
+ {
+ country: '阿尔巴尼亚',
+ code: '+355',
+ },
+ {
+ country: '阿尔及利亚',
+ code: '+213',
+ },
+ {
+ country: '安道尔',
+ code: '+376',
+ },
+ {
+ country: '安哥拉',
+ code: '+244',
+ },
+ {
+ country: '安圭拉',
+ code: '+1264',
+ },
+ {
+ country: '安提瓜和巴布达',
+ code: '+1268',
+ },
+ {
+ country: '阿根廷',
+ code: '+54',
+ },
+ {
+ country: '阿鲁巴',
+ code: '+297',
+ },
+ {
+ country: '澳大利亚',
+ code: '+61',
+ },
+ {
+ country: '奥地利',
+ code: '+43',
+ },
+ {
+ country: '阿塞拜疆',
+ code: '+994',
+ },
+ {
+ country: '埃及',
+ code: '+20',
+ },
+ {
+ country: '爱沙尼亚',
+ code: '+372',
+ },
+ {
+ country: '埃塞俄比亚',
+ code: '+251',
+ },
+ {
+ country: '爱尔兰',
+ code: '+353',
+ },
+ {
+ country: '阿曼',
+ code: '+968',
+ },
+ {
+ country: '阿拉伯联合酋长国',
+ code: '+971',
+ },
+ {
+ letter: 'B',
+ },
+ {
+ country: '巴哈马',
+ code: '+1242',
+ },
+ {
+ country: '巴林',
+ code: '+973',
+ },
+ {
+ country: '巴巴多斯',
+ code: '+1246',
+ },
+ {
+ country: '白俄罗斯',
+ code: '+375',
+ },
+ {
+ country: '比利时',
+ code: '+32',
+ },
+ {
+ country: '伯利兹',
+ code: '+501',
+ },
+ {
+ country: '贝宁',
+ code: '+229',
+ },
+ {
+ country: '百慕大群岛',
+ code: '+1441',
+ },
+ {
+ country: '不丹',
+ code: '+975',
+ },
+ {
+ country: '玻利维亚',
+ code: '+591',
+ },
+ {
+ country: '波斯尼亚和黑塞哥维那',
+ code: '+387',
+ },
+ {
+ country: '博茨瓦纳',
+ code: '+267',
+ },
+ {
+ country: '巴西',
+ code: '+55',
+ },
+ {
+ country: '保加利亚',
+ code: '+359',
+ },
+ {
+ country: '布基纳法索',
+ code: '+226',
+ },
+ {
+ country: '布隆迪',
+ code: '+257',
+ },
+ {
+ country: '冰岛',
+ code: '+354',
+ },
+ {
+ country: '巴基斯坦',
+ code: '+92',
+ },
+ {
+ country: '巴勒斯坦',
+ code: '+970',
+ },
+ {
+ country: '巴拿马',
+ code: '+507',
+ },
+ {
+ country: '巴布亚新几内亚',
+ code: '+675',
+ },
+ {
+ country: '巴拉圭',
+ code: '+595',
+ },
+ {
+ country: '波兰',
+ code: '+48',
+ },
+ {
+ country: '波多黎各',
+ code: '+1787',
+ },
+ {
+ letter: 'C',
+ },
+ {
+ country: '赤道几内亚',
+ code: '+240',
+ },
+ {
+ letter: 'D',
+ },
+ {
+ country: '丹麦',
+ code: '+45',
+ },
+ {
+ country: '多米尼加',
+ code: '+1767',
+ },
+ {
+ country: '多米尼加共和国',
+ code: '+1809',
+ },
+ {
+ country: '东帝汶',
+ code: '+670',
+ },
+ {
+ country: '德国',
+ code: '+49',
+ },
+ {
+ country: '多哥',
+ code: '+228',
+ },
+ {
+ letter: 'E',
+ },
+ {
+ country: '厄瓜多尔',
+ code: '+593',
+ },
+ {
+ country: '厄立特里亚',
+ code: '+291',
+ },
+ {
+ country: '俄罗斯',
+ code: '+7',
+ },
+ {
+ letter: 'F',
+ },
+ {
+ country: '法罗群岛',
+ code: '+298',
+ },
+ {
+ country: '斐济',
+ code: '+679',
+ },
+ {
+ country: '芬兰',
+ code: '+358',
+ },
+ {
+ country: '法国',
+ code: '+33',
+ },
+ {
+ country: '法属圭亚那',
+ code: '+594',
+ },
+ {
+ country: '法属波利尼西亚',
+ code: '+689',
+ },
+ {
+ country: '菲律宾',
+ code: '+63',
+ },
+ {
+ letter: 'G',
+ },
+ {
+ country: '哥伦比亚',
+ code: '+57',
+ },
+ {
+ country: '哥斯达黎加',
+ code: '+506',
+ },
+ {
+ country: '古巴',
+ code: '+53',
+ },
+ {
+ country: '刚果民主共和国',
+ code: '+243',
+ },
+ {
+ country: '冈比亚',
+ code: '+220',
+ },
+ {
+ country: '格鲁吉亚',
+ code: '+995',
+ },
+ {
+ country: '格陵兰岛',
+ code: '+299',
+ },
+ {
+ country: '格林纳达',
+ code: '+1473',
+ },
+ {
+ country: '瓜德罗普岛',
+ code: '+590',
+ },
+ {
+ country: '关岛',
+ code: '+1671',
+ },
+ {
+ country: '瓜地马拉',
+ code: '+502',
+ },
+ {
+ country: '圭亚那',
+ code: '+592',
+ },
+ {
+ country: '刚果共和国',
+ code: '+242',
+ },
+ {
+ letter: 'H',
+ },
+ {
+ country: '海地',
+ code: '+509',
+ },
+ {
+ country: '洪都拉斯',
+ code: '+504',
+ },
+ {
+ country: '哈萨克斯坦',
+ code: '+7',
+ },
+ {
+ country: '黑山',
+ code: '+382',
+ },
+ {
+ country: '荷兰',
+ code: '+31',
+ },
+ {
+ country: '韩国',
+ code: '+82',
+ },
+ {
+ letter: 'J',
+ },
+ {
+ country: '柬埔寨',
+ code: '+855',
+ },
+ {
+ country: '加拿大',
+ code: '+1',
+ },
+ {
+ country: '捷克',
+ code: '+420',
+ },
+ {
+ country: '吉布提',
+ code: '+253',
+ },
+ {
+ country: '加蓬',
+ code: '+241',
+ },
+ {
+ country: '加纳',
+ code: '+233',
+ },
+ {
+ country: '几内亚',
+ code: '+224',
+ },
+ {
+ country: '几内亚比绍共和国',
+ code: '+245',
+ },
+ {
+ country: '基里巴斯',
+ code: '+686',
+ },
+ {
+ country: '吉尔吉斯斯坦',
+ code: '+996',
+ },
+ {
+ country: '津巴布韦',
+ code: '+263',
+ },
+ {
+ letter: 'K',
+ },
+ {
+ country: '喀麦隆',
+ code: '+237',
+ },
+ {
+ country: '开普',
+ code: '+238',
+ },
+ {
+ country: '开曼群岛',
+ code: '+1345',
+ },
+ {
+ country: '科摩罗',
+ code: '+269',
+ },
+ {
+ country: '库克群岛',
+ code: '+682',
+ },
+ {
+ country: '克罗地亚',
+ code: '+385',
+ },
+ {
+ country: '库拉索',
+ code: '+599',
+ },
+ {
+ country: '肯尼亚',
+ code: '+254',
+ },
+ {
+ country: '科威特',
+ code: '+965',
+ },
+ {
+ country: '卡塔尔',
+ code: '+974',
+ },
+ {
+ letter: 'L',
+ },
+ {
+ country: '老挝',
+ code: '+856',
+ },
+ {
+ country: '拉脱维亚',
+ code: '+371',
+ },
+ {
+ country: '黎巴嫩',
+ code: '+961',
+ },
+ {
+ country: '莱索托',
+ code: '+266',
+ },
+ {
+ country: '利比里亚',
+ code: '+231',
+ },
+ {
+ country: '利比亚',
+ code: '+218',
+ },
+ {
+ country: '列支敦士登',
+ code: '+423',
+ },
+ {
+ country: '立陶宛',
+ code: '+370',
+ },
+ {
+ country: '卢森堡',
+ code: '+352',
+ },
+ {
+ country: '留尼汪',
+ code: '+262',
+ },
+ {
+ country: '罗马尼亚',
+ code: '+40',
+ },
+ {
+ country: '卢旺达',
+ code: '+250',
+ },
+ {
+ letter: 'M',
+ },
+ {
+ country: '美属萨摩亚',
+ code: '+1684',
+ },
+ {
+ country: '孟加拉国',
+ code: '+880',
+ },
+ {
+ country: '马其顿',
+ code: '+389',
+ },
+ {
+ country: '马达加斯加',
+ code: '+261',
+ },
+ {
+ country: '马拉维',
+ code: '+265',
+ },
+ {
+ country: '马来西亚',
+ code: '+60',
+ },
+ {
+ country: '马尔代夫',
+ code: '+960',
+ },
+ {
+ country: '马里',
+ code: '+223',
+ },
+ {
+ country: '马耳他',
+ code: '+356',
+ },
+ {
+ country: '马提尼克',
+ code: '+596',
+ },
+ {
+ country: '毛里塔尼亚',
+ code: '+222',
+ },
+ {
+ country: '毛里求斯',
+ code: '+230',
+ },
+ {
+ country: '马约特',
+ code: '+262',
+ },
+ {
+ country: '墨西哥',
+ code: '+52',
+ },
+ {
+ country: '摩尔多瓦',
+ code: '+373',
+ },
+ {
+ country: '摩纳哥',
+ code: '+377',
+ },
+ {
+ country: '蒙古',
+ code: '+976',
+ },
+ {
+ country: '蒙特塞拉特岛',
+ code: '+1664',
+ },
+ {
+ country: '摩洛哥',
+ code: '+212',
+ },
+ {
+ country: '莫桑比克',
+ code: '+258',
+ },
+ {
+ country: '缅甸',
+ code: '+95',
+ },
+ {
+ country: '秘鲁',
+ code: '+51',
+ },
+ {
+ country: '美国',
+ code: '+1',
+ },
+ {
+ country: '美属维尔京群岛',
+ code: '+1284',
+ },
+ {
+ letter: 'N',
+ },
+ {
+ country: '纳米比亚',
+ code: '+264',
+ },
+ {
+ country: '尼泊尔',
+ code: '+977',
+ },
+ {
+ country: '尼加拉瓜',
+ code: '+505',
+ },
+ {
+ country: '尼日尔',
+ code: '+227',
+ },
+ {
+ country: '尼日利亚',
+ code: '+234',
+ },
+ {
+ country: '挪威',
+ code: '+47',
+ },
+ {
+ country: '南非',
+ code: '+27',
+ },
+ {
+ letter: 'P',
+ },
+ {
+ country: '帕劳',
+ code: '+680',
+ },
+ {
+ country: '葡萄牙',
+ code: '+351',
+ },
+ {
+ letter: 'R',
+ },
+ {
+ country: '日本',
+ code: '+81',
+ },
+ {
+ country: '瑞典',
+ code: '+46',
+ },
+ {
+ country: '瑞士',
+ code: '+41',
+ },
+ {
+ letter: 'S',
+ },
+ {
+ country: '塞浦路斯',
+ code: '+357',
+ },
+ {
+ country: '萨尔瓦多',
+ code: '+503',
+ },
+ {
+ country: '圣基茨和尼维斯',
+ code: '+1869',
+ },
+ {
+ country: '圣露西亚',
+ code: '+1758',
+ },
+ {
+ country: '圣彼埃尔和密克隆岛',
+ code: '+508',
+ },
+ {
+ country: '圣文森特和格林纳丁斯',
+ code: '+1784',
+ },
+ {
+ country: '萨摩亚',
+ code: '+685',
+ },
+ {
+ country: '圣马力诺',
+ code: '+378',
+ },
+ {
+ country: '圣多美和普林西比',
+ code: '+239',
+ },
+ {
+ country: '沙特阿拉伯',
+ code: '+966',
+ },
+ {
+ country: '塞内加尔',
+ code: '+221',
+ },
+ {
+ country: '塞尔维亚',
+ code: '+381',
+ },
+ {
+ country: '塞舌尔',
+ code: '+248',
+ },
+ {
+ country: '塞拉利昂',
+ code: '+232',
+ },
+ {
+ country: '圣马丁岛(荷兰部分)',
+ code: '+1721',
+ },
+ {
+ country: '斯洛伐克',
+ code: '+421',
+ },
+ {
+ country: '斯洛文尼亚',
+ code: '+386',
+ },
+ {
+ country: '所罗门群岛',
+ code: '+677',
+ },
+ {
+ country: '索马里',
+ code: '+252',
+ },
+ {
+ country: '斯里兰卡',
+ code: '+94',
+ },
+ {
+ country: '苏丹',
+ code: '+249',
+ },
+ {
+ country: '苏里南',
+ code: '+597',
+ },
+ {
+ country: '斯威士兰',
+ code: '+268',
+ },
+ {
+ letter: 'T',
+ },
+ {
+ country: '塔吉克斯坦',
+ code: '+992',
+ },
+ {
+ country: '坦桑尼亚',
+ code: '+255',
+ },
+ {
+ country: '泰国',
+ code: '+66',
+ },
+ {
+ country: '汤加',
+ code: '+676',
+ },
+ {
+ country: '特立尼达和多巴哥',
+ code: '+1868',
+ },
+ {
+ country: '突尼斯',
+ code: '+216',
+ },
+ {
+ country: '土耳其',
+ code: '+90',
+ },
+ {
+ country: '土库曼斯坦',
+ code: '+993',
+ },
+ {
+ country: '特克斯和凯科斯群岛',
+ code: '+1649',
+ },
+ {
+ letter: 'W',
+ },
+ {
+ country: '文莱',
+ code: '+673',
+ },
+ {
+ country: '乌干达',
+ code: '+256',
+ },
+ {
+ country: '乌克兰',
+ code: '+380',
+ },
+ {
+ country: '乌拉圭',
+ code: '+598',
+ },
+ {
+ country: '乌兹别克斯坦',
+ code: '+998',
+ },
+ {
+ country: '瓦努阿图',
+ code: '+678',
+ },
+ {
+ country: '委内瑞拉',
+ code: '+58',
+ },
+ {
+ letter: 'X',
+ },
+ {
+ country: '希腊',
+ code: '+30',
+ },
+ {
+ country: '匈牙利',
+ code: '+36',
+ },
+ {
+ country: '象牙海岸',
+ code: '+225',
+ },
+ {
+ country: '新喀里多尼亚',
+ code: '+687',
+ },
+ {
+ country: '新西兰',
+ code: '+64',
+ },
+ {
+ country: '新加坡',
+ code: '+65',
+ },
+ {
+ country: '西班牙',
+ code: '+34',
+ },
+ {
+ country: '叙利亚',
+ code: '+963',
+ },
+ {
+ letter: 'Y',
+ },
+ {
+ country: '亚美尼亚',
+ code: '+374',
+ },
+ {
+ country: '印度',
+ code: '+91',
+ },
+ {
+ country: '印度尼西亚',
+ code: '+62',
+ },
+ {
+ country: '伊朗',
+ code: '+98',
+ },
+ {
+ country: '伊拉克',
+ code: '+964',
+ },
+ {
+ country: '以色列',
+ code: '+972',
+ },
+ {
+ country: '意大利',
+ code: '+39',
+ },
+ {
+ country: '牙买加',
+ code: '+1876',
+ },
+ {
+ country: '约旦',
+ code: '+962',
+ },
+ {
+ country: '英国',
+ code: '+44',
+ },
+ {
+ country: '越南',
+ code: '+84',
+ },
+ {
+ country: '英属处女群岛',
+ code: '+1340',
+ },
+ {
+ country: '也门',
+ code: '+967',
+ },
+ {
+ letter: 'Z',
+ },
+ {
+ country: '中非共和国',
+ code: '+236',
+ },
+ {
+ country: '乍得',
+ code: '+235',
+ },
+ {
+ country: '智利',
+ code: '+56',
+ },
+ {
+ country: '直布罗陀',
+ code: '+350',
+ },
+ {
+ country: '中国大陆',
+ code: '+86',
+ },
+ {
+ country: '中国香港',
+ code: '+852',
+ },
+ {
+ country: '中国澳门',
+ code: '+853',
+ },
+ {
+ country: '中国台湾',
+ code: '+886',
+ },
+ {
+ country: '赞比亚',
+ code: '+260',
+ },
+];
+
+export default phoneCodeData;
diff --git a/src/components/common-base/phone-code/use-phone-code.ts b/src/components/common-base/phone-code/use-phone-code.ts
new file mode 100644
index 0000000000000000000000000000000000000000..205b37c679670fd78e76d9590e03f1e12b055644
--- /dev/null
+++ b/src/components/common-base/phone-code/use-phone-code.ts
@@ -0,0 +1,90 @@
+/**
+ * @file 手机区号选择 hook
+ */
+
+import { isUndefined } from '@/assets/utils/types';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed, ref, unref } from 'vue';
+import codeList from './phone-code-data';
+
+export const PhoneCodeEmits = ['input'];
+
+export type PhoneCodeDataItem = typeof codeList[number];
+
+export const phoneCodeProps = () => ({
+ value: PropUtils.string.def(''),
+});
+
+export const phoneCodeEmits = () => ({
+ input: emitFunc(),
+});
+
+export const usePhoneCode = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ /** 搜索的关键词 */
+ const searchKey = ref('');
+
+ /** 所有首字母列表 */
+ const letterList = computed(() => {
+ return codeList.filter(item => item.letter);
+ });
+
+ const phoneCodeList = computed(() => {
+ if (!unref(searchKey)) {
+ return codeList;
+ }
+
+ return codeList.filter(item => {
+ if (item.letter) {
+ return false;
+ }
+
+ if (item.code && item.code.indexOf(unref(searchKey)) !== -1) {
+ return true;
+ }
+
+ if (item.country && item.country.indexOf(unref(searchKey)) !== -1) {
+ return true;
+ }
+
+ return false;
+ });
+ });
+
+ /** 区号选择列表 ref */
+ const codeListRef = ref();
+
+ /** 滚动到某个字母中 */
+ function scrollToLetter(letter: string | undefined) {
+ const codeListElem = unref(codeListRef);
+ if (!codeListElem || !letter) {
+ return;
+ }
+
+ const targetLetterEl = codeListElem.querySelector(`[date-letter=${letter}]`);
+
+ targetLetterEl && targetLetterEl.scrollIntoView();
+ }
+
+ /** 处理点击区号 */
+ function onClickCountry(item: PhoneCodeDataItem) {
+ if (isUndefined(item.code)) return;
+ if (props.value === item.code) return;
+
+ emit('input', item.code);
+ }
+
+ return {
+ searchKey,
+ phoneCodeList,
+ letterList,
+ codeListRef,
+ scrollToLetter,
+ onClickCountry,
+ };
+};
diff --git a/src/components/common-base/popup/mobile-popup.vue b/src/components/common-base/popup/mobile-popup.vue
new file mode 100644
index 0000000000000000000000000000000000000000..354e14bb7e6b072658b346dfb907ca740f5d0bcb
--- /dev/null
+++ b/src/components/common-base/popup/mobile-popup.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/popup/types.ts b/src/components/common-base/popup/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4d6d1d9265a20b54c26c93807ea747c6afd19ec6
--- /dev/null
+++ b/src/components/common-base/popup/types.ts
@@ -0,0 +1,25 @@
+/**
+ * Popup 弹层主题
+ */
+export enum PopupTheme {
+ /** 默认主题 */
+ Default = 'default',
+ /** 竖屏主题-黑色主题 */
+ Portrait = 'portrait',
+ /** 竖屏主题-白色主题 */
+ PortraitLight = 'portrait-light',
+}
+
+export interface PopupInstance {
+ /** 打开 */
+ openPopup(): void;
+ /** 关闭 */
+ closePopup(): void;
+}
+
+/** 屏幕横屏下的弹窗位置 */
+export enum PopupHorizontalScreenPosition {
+ Left = 'left',
+ Center = 'center',
+ Right = 'right',
+}
diff --git a/src/components/common-base/popup/use-popup.ts b/src/components/common-base/popup/use-popup.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d795f8146b701f81d6b37e05f94bb8f610287971
--- /dev/null
+++ b/src/components/common-base/popup/use-popup.ts
@@ -0,0 +1,416 @@
+/* eslint-disable sonarjs/cognitive-complexity */
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, unref, watch } from 'vue';
+import AlloyFinger from 'alloyfinger';
+
+import { PlvPopperManager } from '@/plugins/polyv-ui/admin-import';
+import { Transform } from '@/plugins/alloy-finger/transform';
+
+import { useAppendTo } from '@/hooks/behaviors/use-append-to';
+
+import { emitFunc, updateModelEmit, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { isUndefined } from '@/assets/utils/types';
+
+import { CSSProperties } from 'vue/types/jsx';
+import { PopupInstance, PopupHorizontalScreenPosition, PopupTheme } from './types';
+import { useScreenOrientHook } from '@/hooks/core/use-screen-orient';
+
+export const popupProps = () => ({
+ /** 显示状态,默认:false,支持 .sync */
+ visible: PropUtils.bool.def(false),
+ /** 弹层标题 */
+ title: PropUtils.string.def(''),
+ /** 是否占满全屏,默认:false */
+ fullScreen: PropUtils.bool.def(false),
+ /** 占满除播放器以外的主体区域,默认:false */
+ fillBodySection: PropUtils.bool.def(false),
+ /** 是否显示头部标题区,默认:true */
+ showHeader: PropUtils.bool.def(true),
+ /** 是否插入到 body 中,默认:true */
+ appendToBody: PropUtils.bool.def(true),
+ /** 是否显示关闭,默认:true */
+ closable: PropUtils.bool.def(true),
+ /** 是否显示返回,默认:false */
+ backable: PropUtils.bool.def(false),
+ /** 在点击返回时自动关闭,默认:false */
+ backAutoClose: PropUtils.bool.def(false),
+ /** 点击蒙层关闭弹窗,默认:true */
+ closeOnClickMask: PropUtils.bool.def(true),
+ /** 蒙层透明度,默认:0.6 */
+ maskOpacity: PropUtils.number.def(0.6),
+ /** 弹层主体样式 */
+ contentStyle: PropUtils.objectType().def({}),
+ /** 是否使用弹出层管理器,集中控制弹窗层级,默认:true */
+ usePopperManager: PropUtils.bool.def(true),
+ /** 窗口尺寸变化时是否重设样式,默认:false */
+ updateOnResize: PropUtils.bool.def(false),
+ /** 弹层主题,默认:default */
+ theme: PropUtils.enum().def(PopupTheme.Default),
+ /** 弹层主体高度 */
+ contentHeight: PropUtils.number,
+ /** 弹层主体使用页面高度百分比,0~1,默认:false */
+ contentPercentHeight: PropUtils.oneOfType([Boolean, Number]).def(false),
+ /** 隐藏 header 的底部边框,默认:false */
+ hideHeaderBorder: PropUtils.bool.def(false),
+ /** 弹层主体背景色 */
+ contentBackground: PropUtils.string,
+ /** 是否使用动画显隐,默认:true */
+ useTransition: PropUtils.bool.def(true),
+ /**
+ * 是否使用纵向拖拽,默认:false
+ * 注意,使用拖拽功能,弹窗主体高度为 100%,具体内容高度需要外部组件搭配 drag-vertical 钩子自行计算
+ * */
+ draggable: PropUtils.bool.def(false),
+ /** 弹窗主体距离顶部的百分比距离,0~1,默认 0 */
+ contentPercentTop: PropUtils.number.def(0),
+ /** 强制横屏展示 */
+ forceHorizontalScreen: PropUtils.bool.def(false),
+ /** 屏幕横屏下的弹窗位置,默认为 right */
+ horizontalScreenPosition: PropUtils.enum().def(
+ PopupHorizontalScreenPosition.Right,
+ ),
+});
+
+export const popupEmits = () => ({
+ ...updateModelEmit<'visible', boolean>('visible'),
+ ...updateModelEmit<'useTransition', boolean>('useTransition'),
+ /** 进入之后 */
+ 'after-enter': emitFunc(),
+ /** 离开之后 */
+ 'after-leave': emitFunc(),
+ /** 点击返回 */
+ 'click-back': emitFunc(),
+ /** 上下推拽 */
+ 'drag-vertical': emitFunc<{ translateY: number }>(),
+});
+
+export const usePopup = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ const {
+ isVerticalScreenOrientation,
+ isHorizontalScreenOrientation,
+ updateScreenOrientationModeManually,
+ } = useScreenOrientHook({
+ autoListen: props.updateOnResize,
+ });
+
+ /** 弹层 ref */
+ const popupRef = ref();
+ /** 弹层主体 ref */
+ const popupContentRef = ref();
+ /** 弹层主体头部 Ref */
+ const popupContentHeaderRef = ref();
+
+ /** 弹层外层显示状态 */
+ const wrapVisible = ref(false);
+
+ /** 主体显示状态 */
+ const contentVisible = ref(false);
+
+ /** 主体计算的高度 */
+ const contentComputedHeight = ref();
+
+ /** 主体最大高度 */
+ const contentMaxHeight = ref();
+
+ /** 窗口原高度 */
+ const pageOriginHeight = ref(document.documentElement.clientHeight || document.body.clientHeight);
+
+ /** 标识位-初次渲染 */
+ const firstPaint = ref(true);
+
+ /** 是否需要"可拖拽"提示框 */
+ const needDraggableTip = ref(true);
+
+ /**
+ * 是否展示"可拖拽"提示框
+ * @desc 业务逻辑,配置可拖拽的情况下,第一次打开弹窗时必定展示
+ * */
+ const draggableTipVisible = computed(() => {
+ return props.draggable && firstPaint.value && needDraggableTip.value;
+ });
+
+ /** 蒙层样式 */
+ const popupMaskStyle = computed(() => {
+ return {
+ background: `rgba(0, 0, 0, ${props.maskOpacity})`,
+ };
+ });
+
+ /** 弹层主体渲染高度 */
+ const contentRenderHeight = computed(() => {
+ if (
+ props.draggable ||
+ props.fullScreen ||
+ props.forceHorizontalScreen ||
+ isHorizontalScreenOrientation.value
+ ) {
+ return '100%';
+ }
+
+ if (props.contentHeight) {
+ return props.contentHeight;
+ }
+
+ let contentPercentHeight = 0;
+ if (props.contentPercentHeight === true) {
+ contentPercentHeight = 0.6;
+ } else if (typeof props.contentPercentHeight === 'number') {
+ contentPercentHeight = props.contentPercentHeight;
+ }
+
+ if (contentPercentHeight) {
+ return document.documentElement.clientHeight * contentPercentHeight;
+ }
+
+ return contentComputedHeight.value;
+ });
+
+ /** 弹层主体样式 */
+ const popupContentStyle = computed(() => {
+ let style: CSSProperties = {};
+
+ if (props.contentPercentTop) {
+ style.top = `${props.contentPercentTop * 100}%`;
+ }
+
+ if (!isUndefined(contentRenderHeight.value)) {
+ style.height =
+ typeof contentRenderHeight.value === 'string'
+ ? contentRenderHeight.value
+ : `${contentRenderHeight.value}px`;
+ }
+
+ if (contentMaxHeight.value) {
+ style.maxHeight = `${contentMaxHeight.value}px`;
+ }
+
+ // 如果存在自定义header,则不设置圆角
+ if (!props.showHeader) {
+ style.borderRadius = 'unset';
+ }
+
+ if (props.contentBackground) {
+ style.background = props.contentBackground;
+ }
+
+ style = Object.assign({}, style, props.contentStyle);
+
+ return style;
+ });
+
+ /** 关闭"可拖拽"提示框 */
+ function closeDraggableTip() {
+ needDraggableTip.value = false;
+ }
+
+ /** 点击蒙层 */
+ function onClickMask() {
+ if (props.closeOnClickMask) {
+ closePopup();
+ }
+ }
+
+ /** 打开弹层 */
+ function openPopup() {
+ wrapVisible.value = true;
+ contentVisible.value = true;
+ updateHeight();
+ nextTick(() => {
+ if (props.usePopperManager && popupRef.value) {
+ PlvPopperManager.openPopper(popupRef.value);
+ }
+ });
+ }
+
+ /** 关闭弹层 */
+ function closePopup() {
+ contentVisible.value = false;
+ emit('update:visible', false);
+ }
+
+ /** 处理进入动画结束之后 */
+ function onAfterEnter() {
+ emit('update:useTransition', true);
+ emit('after-enter');
+ }
+
+ /** 处理离开动画结束之后 */
+ function onAfterLeave() {
+ wrapVisible.value = false;
+ emit('update:useTransition', true);
+ emit('after-leave');
+ if (props.draggable) {
+ const $popupContent = popupRef.value as AlloyFingerTransformHTMLElement;
+ $popupContent.translateY = 0;
+ }
+ }
+
+ /** 处理点击返回 */
+ function onClickBack() {
+ if (props.backAutoClose) {
+ closePopup();
+ }
+ emit('click-back');
+ }
+
+ /** 获取弹窗主体头部的高度 */
+ function getPopupContentHeaderHeight() {
+ return (popupContentHeaderRef.value && popupContentHeaderRef.value.clientHeight) || 50;
+ }
+
+ /** 绑定 touch 行为 */
+ function bindTouchBehavior() {
+ const $popup = popupRef.value;
+ const $popupContentHeader = popupContentHeaderRef.value;
+
+ if (!$popup || !$popupContentHeader) return;
+
+ const contentPercentTop = props.contentPercentTop;
+ const documentHeight = document.documentElement.clientHeight;
+ const minY = documentHeight * contentPercentTop;
+ const maxY = documentHeight - minY - getPopupContentHeaderHeight();
+
+ // 在 $popup 上挂载 css-transform 钩子
+ const $transformPanel = Transform($popup);
+
+ new AlloyFinger($popupContentHeader, {
+ pressMove: evt => {
+ const translateY = $transformPanel.translateY + evt.deltaY;
+ if (translateY <= -minY || $transformPanel.translateY > maxY) {
+ evt.preventDefault();
+ return;
+ }
+ $transformPanel.translateY = translateY > maxY ? maxY : translateY;
+ emit('drag-vertical', { translateY });
+ evt.preventDefault();
+ },
+ });
+ }
+
+ watch(
+ () => props.visible,
+ () => {
+ if (props.visible) {
+ openPopup();
+ if (props.draggable && firstPaint.value) {
+ nextTick(() => {
+ bindTouchBehavior();
+ });
+ }
+ } else {
+ closePopup();
+ closeDraggableTip();
+ firstPaint.value = false;
+ }
+ },
+ );
+
+ // 更新高度信息
+ async function updateHeight() {
+ contentComputedHeight.value = undefined;
+ contentMaxHeight.value = undefined;
+ // 手动更新屏幕旋转模式,减少性能开销
+ updateScreenOrientationModeManually();
+ await nextTick();
+ setContentHeight();
+ }
+
+ /**
+ * 设置主题高度
+ * 限高,最高不遮盖播放器区域
+ * 在安卓手机上,软键盘弹起会导致页面高度变化,如果当前页面高度小于初始高度,popper高度按初始高度计算
+ */
+ function setContentHeight() {
+ const popupEl = unref(popupRef);
+ if (!popupEl) return;
+
+ if (
+ props.draggable ||
+ props.fullScreen ||
+ props.forceHorizontalScreen ||
+ isHorizontalScreenOrientation.value
+ ) {
+ return;
+ }
+
+ const ratio = 16 / 9;
+ const playerHeight = window.innerWidth / ratio;
+ const currentPageHeight = document.documentElement.clientHeight || document.body.clientHeight;
+ const isCompressed = currentPageHeight < pageOriginHeight.value;
+ const bodyClientHeight = isCompressed ? pageOriginHeight.value : popupEl.offsetHeight;
+ // 高度遮盖播放器,或始终需要占满除播放器以外高度(横屏状态除外,横屏状态没有最大高度,可以遮挡播放器)
+ const maxHeight = bodyClientHeight - playerHeight;
+
+ contentMaxHeight.value = maxHeight;
+ if (isVerticalScreenOrientation.value && props.fillBodySection) {
+ contentComputedHeight.value = maxHeight;
+ }
+ }
+
+ /** 若浏览器窗口尺寸变化(多窗口、webview 小窗恢复、软键盘弹出),在弹窗可见情况下,重新调整窗口尺寸 */
+ function onPageResize() {
+ if (!props.visible) {
+ return;
+ }
+ const isFocusInput =
+ document.activeElement instanceof HTMLTextAreaElement ||
+ document.activeElement instanceof HTMLInputElement;
+ // 软键盘弹出导致窗口变化
+ if (isFocusInput && window.innerHeight < screen.availHeight) {
+ return;
+ }
+ setTimeout(() => {
+ updateHeight();
+ }, 200);
+ }
+
+ if (props.appendToBody) {
+ useAppendTo(popupRef);
+ }
+
+ onMounted(() => {
+ if (props.updateOnResize) {
+ window.addEventListener('resize', onPageResize);
+ }
+ });
+
+ onBeforeUnmount(() => {
+ if (props.updateOnResize) {
+ window.removeEventListener('resize', onPageResize);
+ }
+ });
+
+ const popupInstance: PopupInstance = {
+ openPopup,
+ closePopup,
+ };
+
+ return {
+ wrapVisible,
+ contentVisible,
+ popupRef,
+ popupContentHeaderRef,
+ popupContentRef,
+ popupMaskStyle,
+ popupContentStyle,
+ popupInstance,
+ onAfterEnter,
+ onAfterLeave,
+ onClickMask,
+ openPopup,
+ closePopup,
+ onClickBack,
+
+ firstPaint,
+ draggableTipVisible,
+ closeDraggableTip,
+
+ isVerticalScreen: isVerticalScreenOrientation,
+ isHorizontalScreen: isHorizontalScreenOrientation,
+ };
+};
diff --git a/src/components/common-base/rich-text-render/mobile-rich-text-render.vue b/src/components/common-base/rich-text-render/mobile-rich-text-render.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1ee6e238a16211dcf98c528c8afed7eee8920a04
--- /dev/null
+++ b/src/components/common-base/rich-text-render/mobile-rich-text-render.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/rich-text-render/pc-rich-text-render.vue b/src/components/common-base/rich-text-render/pc-rich-text-render.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3c6a13c05ccf2c090af158a96b486c83df24ef67
--- /dev/null
+++ b/src/components/common-base/rich-text-render/pc-rich-text-render.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/rich-text-render/use-rich-text-render.ts b/src/components/common-base/rich-text-render/use-rich-text-render.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3dd0679198af0a4813479d7231889cd66b994c47
--- /dev/null
+++ b/src/components/common-base/rich-text-render/use-rich-text-render.ts
@@ -0,0 +1,24 @@
+import { resizeHtmlContentImg } from '@/assets/utils/string';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed } from 'vue';
+
+/**
+ * props 配置
+ */
+export const richTextRenderProps = () => ({
+ /** html 富文本内容 */
+ htmlContent: PropUtils.string.def(''),
+});
+
+export const useRichTextRender = (options: { props: VueProps }) => {
+ const { props } = options;
+
+ /** 用于渲染的富文本内容 */
+ const renderHtmlContent = computed(() => {
+ return resizeHtmlContentImg(props.htmlContent);
+ });
+
+ return {
+ renderHtmlContent,
+ };
+};
diff --git a/src/components/common-base/slider-bar/slider-bar.vue b/src/components/common-base/slider-bar/slider-bar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5f3d0dee12ac1f8a8fef99e18ed6886517a7795f
--- /dev/null
+++ b/src/components/common-base/slider-bar/slider-bar.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+ {{ trackHoverValue }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/slider-bar/use-slider-bar.ts b/src/components/common-base/slider-bar/use-slider-bar.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5f82aa4ec7ff73456a13cb29b2309a50c033227f
--- /dev/null
+++ b/src/components/common-base/slider-bar/use-slider-bar.ts
@@ -0,0 +1,280 @@
+import { tupleString } from '@/assets/utils/array';
+import { isMobile } from '@/assets/utils/browser';
+import { numberToFixed } from '@/assets/utils/number';
+import { getEventPosition } from '@/assets/utils/utils';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed, onBeforeUnmount, ref, unref, watch } from 'vue';
+import { CSSProperties } from 'vue/types/jsx';
+
+export const sliderBarDirections = tupleString('horizontal', 'vertical');
+export type SliderBarDirection = typeof sliderBarDirections[number];
+
+export const sliderBarProps = () => ({
+ /** 绑定值 */
+ value: PropUtils.number.def(0),
+ /** hover 时放大滑块 */
+ hoverToZoom: PropUtils.bool.def(false),
+ /** 热区范围 */
+ hotAreaSize: PropUtils.number.def(28),
+ /** 滑块轨道粗细 */
+ sliderTrackSize: PropUtils.number.def(6),
+ /** 隐藏轨道圆角 */
+ sliderTrackRadiusHide: PropUtils.bool.def(false),
+ /** 外层轨道颜色 */
+ wrapSliderTrackColor: PropUtils.string.def('rgba(255, 255, 255, .8)'),
+ /** 内层轨道颜色 */
+ innerSliderTrackColor: PropUtils.string.def('#3082FE'),
+ /** 滑块原点大小 */
+ sliderDotSize: PropUtils.number.def(14),
+ /** 滑块方向 */
+ direction: PropUtils.oneOf(sliderBarDirections).def('horizontal'),
+ /** 最小值 */
+ min: PropUtils.number.def(0),
+ /** 最大值 */
+ max: PropUtils.number.def(100),
+ /** 显示提示 */
+ tooltips: PropUtils.bool.def(false),
+});
+
+export const sliderBarEmits = () => ({
+ input: emitFunc(),
+ change: emitFunc(),
+ 'drag-change': emitFunc(),
+});
+
+export const useSliderBar = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ /** 当前的进度值 */
+ const currentValue = ref(0);
+ /** 当前是否正在拖拽中 */
+ const isDragging = ref(false);
+ const { hoverToZoom, tooltips } = useProps(props);
+
+ watch(
+ () => props.value,
+ () => (currentValue.value = props.value),
+ {
+ immediate: true,
+ },
+ );
+ watch(
+ () => unref(currentValue),
+ () => {
+ if (unref(currentValue) !== props.value) {
+ emit('input', unref(currentValue));
+ emit('change', unref(currentValue));
+ }
+ },
+ );
+
+ /** 热区样式 */
+ const containerStyle = computed(() => {
+ const styles: CSSProperties = {};
+
+ switch (props.direction) {
+ case 'horizontal':
+ styles.height = `${props.hotAreaSize}px`;
+ break;
+ case 'vertical':
+ styles.width = `${props.hotAreaSize}px`;
+ break;
+ }
+
+ return styles;
+ });
+
+ /** 外层轨道样式 */
+ const wrapTrackStyle = computed(() => {
+ const styles: CSSProperties = {
+ background: props.wrapSliderTrackColor,
+ borderRadius: `${props.sliderTrackSize}px`,
+ };
+
+ if (props.sliderTrackRadiusHide) {
+ styles.borderRadius = 0;
+ }
+
+ switch (props.direction) {
+ case 'horizontal':
+ styles.height = `${props.sliderTrackSize}px`;
+ break;
+ case 'vertical':
+ styles.width = `${props.sliderTrackSize}px`;
+ break;
+ }
+
+ return styles;
+ });
+
+ /** 内部轨道的长度百分比 */
+ const innerTrackLengthPercent = computed(() => {
+ const percentNum = (100 * (unref(currentValue) - props.min)) / (props.max - props.min);
+ return `${percentNum > 100 ? 100 : percentNum}%`;
+ });
+
+ /** 内部轨道样式 */
+ const innerTrackStyle = computed(() => {
+ const styles: CSSProperties = {
+ background: props.innerSliderTrackColor,
+ };
+
+ switch (props.direction) {
+ case 'horizontal':
+ styles.width = unref(innerTrackLengthPercent);
+ break;
+ case 'vertical':
+ styles.height = unref(innerTrackLengthPercent);
+ break;
+ }
+
+ return styles;
+ });
+
+ /** 点样式 */
+ const sliderDotStyle = computed(() => {
+ const styles: CSSProperties = {
+ width: `${props.sliderDotSize}px`,
+ height: `${props.sliderDotSize}px`,
+ };
+
+ switch (props.direction) {
+ case 'horizontal':
+ styles.left = unref(innerTrackLengthPercent);
+ break;
+ case 'vertical':
+ styles.bottom = unref(innerTrackLengthPercent);
+ break;
+ }
+
+ return styles;
+ });
+
+ const containerRef = ref();
+
+ function listenWindowEvent(): void {
+ removeListenWindowEvent();
+ if (isMobile) {
+ window.addEventListener('touchmove', onMouseMove);
+ window.addEventListener('touchend', onMouseUp);
+ window.addEventListener('touchcancel', onMouseUp);
+ } else {
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ }
+ }
+
+ function removeListenWindowEvent(): void {
+ if (isMobile) {
+ window.removeEventListener('touchmove', onMouseMove);
+ window.removeEventListener('touchend', onMouseUp);
+ window.removeEventListener('touchcancel', onMouseUp);
+ } else {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ }
+ }
+
+ function onMouseDown(event: MouseEvent | TouchEvent): void {
+ const eventPosition = getEventPosition(event);
+ let clientNumber = eventPosition.clientX;
+ if (props.direction === 'vertical') {
+ clientNumber = eventPosition.clientY;
+ }
+ computedValue(clientNumber);
+ isDragging.value = true;
+ listenWindowEvent();
+ }
+
+ function onMouseMove(event: MouseEvent | TouchEvent): void {
+ const eventPosition = getEventPosition(event);
+ let clientNumber = eventPosition.clientX;
+ if (props.direction === 'vertical') {
+ clientNumber = eventPosition.clientY;
+ }
+ computedValue(clientNumber);
+
+ onTrackMouseMove(event);
+ }
+
+ function onMouseUp(): void {
+ isDragging.value = false;
+ removeListenWindowEvent();
+ }
+
+ const trackHoverX = ref(0);
+ const trackHoverValue = ref(0);
+
+ function onTrackMouseMove(event: MouseEvent | TouchEvent): void {
+ const containerElem = unref(containerRef);
+ if (!containerElem) {
+ return;
+ }
+ const bound = containerElem.getBoundingClientRect();
+ const eventPosition = getEventPosition(event);
+ const hoverX = eventPosition.clientX - bound.left;
+ trackHoverX.value = hoverX >= 0 ? hoverX : 0;
+ const hoverValue = computedValue(eventPosition.clientX, false);
+ if (typeof hoverValue !== 'undefined' && hoverValue >= 0) {
+ trackHoverValue.value = hoverValue;
+ }
+ }
+
+ function computedValue(clientNumber: number, setValue = true): number | undefined {
+ const containerElem = unref(containerRef);
+ if (!containerElem) {
+ return;
+ }
+ const bound = containerElem.getBoundingClientRect();
+ const totalLength = props.direction === 'vertical' ? bound.height : bound.width;
+ const startPoint = props.direction === 'vertical' ? bound.bottom : bound.left;
+ let pointLength = clientNumber - startPoint;
+ if (props.direction === 'vertical' && pointLength > 0) {
+ return;
+ }
+ if (props.direction === 'horizontal' && pointLength < 0) {
+ return;
+ }
+
+ pointLength = Math.abs(pointLength);
+
+ let percent = pointLength / totalLength;
+ if (percent > 1) {
+ percent = 1;
+ } else if (percent < 0) {
+ percent = 0;
+ }
+ const newValue = numberToFixed(props.min + (props.max - props.min) * percent);
+ if (setValue && newValue !== unref(currentValue)) {
+ currentValue.value = newValue;
+ emit('drag-change', newValue);
+ }
+
+ return newValue;
+ }
+
+ onBeforeUnmount(() => {
+ removeListenWindowEvent();
+ });
+
+ return {
+ currentValue,
+ hoverToZoom,
+ tooltips,
+ isDragging,
+ containerStyle,
+ wrapTrackStyle,
+ innerTrackStyle,
+ sliderDotStyle,
+ containerRef,
+ onMouseDown,
+ onMouseUp,
+ trackHoverX,
+ trackHoverValue,
+ onTrackMouseMove,
+ };
+};
diff --git a/src/components/common-base/tabs/hooks/types.ts b/src/components/common-base/tabs/hooks/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c4c0523dcc3adb1ba37efd339f409e5e77f8c562
--- /dev/null
+++ b/src/components/common-base/tabs/hooks/types.ts
@@ -0,0 +1,50 @@
+import { Ref } from 'vue';
+
+export type TabNameType = string | number;
+
+export interface TabPaneDataItem {
+ /** tab 的标签 */
+ label: string;
+ /** tab 的名称 */
+ name: TabNameType;
+ /** tab 的附加标签 */
+ subLabel: string;
+ /** 是否显示红点 */
+ showReminder: boolean;
+}
+
+/** tab 附加数据 */
+export type TabPaneAdditionalData = Pick;
+
+export interface TabsInstance {
+ currentTabName: Ref;
+ /** 设置当前激活的面板 */
+ setCurrentTab(name: TabNameType): void;
+}
+
+export interface TabsInjectContext {
+ /** 上一次的 tab 激活 name */
+ prevTabName: Ref;
+ /** 当前激活的 tab name */
+ currentTabName: Ref;
+ /** 上一次的 tab 激活 index */
+ prevTabIndex: Ref;
+ /** 当前激活的 tab index */
+ currentTabIndex: Ref;
+ /** tab 面板数据列表 */
+ tabPaneData: Ref;
+ /** 设置当前激活面板 */
+ setCurrentTab: (name: TabNameType) => void;
+ /** 切到下一个 tab */
+ toNextTab: () => void;
+ /** 切到上一个 tab */
+ toPrevTab: () => void;
+}
+
+/**
+ * tab 面板对象
+ */
+export interface TabPaneInstance {
+ /** 获取面板数据 */
+ getTabPaneData: () => TabPaneDataItem;
+}
diff --git a/src/components/common-base/tabs/hooks/use-simple-tabs.ts b/src/components/common-base/tabs/hooks/use-simple-tabs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0640222ce964a4dfb842851bbac7eda9828ca20f
--- /dev/null
+++ b/src/components/common-base/tabs/hooks/use-simple-tabs.ts
@@ -0,0 +1,41 @@
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+
+export interface SimpleTabItem {
+ /** 文本 */
+ label: string;
+ /** 绑定值 */
+ value: string;
+}
+
+export const simpleTabsProps = () => ({
+ /** 是否显示边框 */
+ border: PropUtils.bool.def(true),
+ /** 绑定值,支持 v-model */
+ value: PropUtils.string.def(''),
+ /** tab 列表 */
+ tabs: PropUtils.array().def([]),
+});
+
+export const simpleTabsEmits = () => ({
+ input: emitFunc(),
+ 'tab-click': emitFunc(),
+});
+
+export const useSimpleTabs = (options: {
+ props: VueProps;
+ emit: VueEmit;
+}) => {
+ const { props, emit } = options;
+
+ function onClickTab(value: string) {
+ if (value !== props.value) {
+ emit('input', value);
+ }
+ emit('tab-click', value);
+ }
+
+ return {
+ onClickTab,
+ };
+};
diff --git a/src/components/common-base/tabs/hooks/use-tab-header.ts b/src/components/common-base/tabs/hooks/use-tab-header.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3ba3d53f1b9eadde56a851808b40fed16c119a6f
--- /dev/null
+++ b/src/components/common-base/tabs/hooks/use-tab-header.ts
@@ -0,0 +1,183 @@
+import { nextTick, onBeforeUnmount, onMounted, Ref, ref, unref, watch } from 'vue';
+import { useTabsInject } from './use-tabs';
+import { useLangStore } from '@/store/use-lang-store';
+import { TabPaneDataItem } from './types';
+import { useWindowResizeListener } from '@/hooks/core/use-window-resize-listener';
+
+export const useTabHeader = (
+ options: {
+ /** 点击后滚动到当前 tab,默认:false */
+ scrollCurrent?: boolean;
+ } = {},
+) => {
+ const { scrollCurrent = false } = options;
+ const langStore = useLangStore();
+
+ const { tabPaneData, currentTabName, currentTabIndex, setCurrentTab, toNextTab, toPrevTab } =
+ useTabsInject();
+
+ const scrollRef = ref();
+ const itemRef = ref([]);
+
+ /** 处理点击节点 */
+ function onClickHeaderItem(paneData: TabPaneDataItem) {
+ if (paneData.name === unref(currentTabName)) {
+ return;
+ }
+ setCurrentTab(paneData.name);
+ if (scrollCurrent) {
+ scrollToCurrent();
+ }
+ }
+
+ /** 处理点击 next 箭头 */
+ async function onClickNext() {
+ toNextTab();
+ await nextTick();
+ if (scrollCurrent) {
+ scrollToCurrent();
+ }
+ }
+
+ /** 处理点击 prev 箭头 */
+ async function onClickPrev() {
+ toPrevTab();
+ await nextTick();
+ if (scrollCurrent) {
+ scrollToCurrent();
+ }
+ }
+
+ /** 当前节点的下横线 left 定位 */
+ const currentLineLeft = ref();
+ const currentLineRef = ref();
+
+ /** 滚动到当前 tab */
+ function scrollToCurrent() {
+ const currentItemElem = itemRef.value[currentTabIndex.value];
+ const scrollElem = scrollRef.value;
+ if (currentItemElem && scrollElem) {
+ const scrollLeft = Math.max(
+ 0,
+ currentItemElem.offsetLeft - (scrollElem.offsetWidth - currentItemElem.offsetWidth) / 2,
+ );
+ scrollElem.scrollTo({
+ top: 0,
+ left: scrollLeft,
+ behavior: 'smooth',
+ });
+ }
+ }
+
+ /** 计算下横线 left 定位 */
+ async function computeCurrentLineLeft() {
+ await nextTick();
+ const currentLineElem = unref(currentLineRef);
+ const itemElements = unref(itemRef);
+ const currentIndex = unref(currentTabIndex);
+
+ if (!Array.isArray(itemElements) || !currentLineElem || currentIndex === -1) {
+ return;
+ }
+
+ const currentItemElem = itemElements[currentIndex];
+ if (!currentItemElem) {
+ return;
+ }
+
+ const left =
+ currentItemElem.offsetLeft + (currentItemElem.offsetWidth - currentLineElem.offsetWidth) / 2;
+
+ if (left !== unref(currentLineLeft)) {
+ currentLineLeft.value = left;
+ }
+ }
+
+ useWindowResizeListener(() => {
+ setTimeout(() => {
+ computeCurrentLineLeft();
+ }, 5);
+ }, true);
+
+ watch(
+ () => [unref(currentTabIndex), unref(tabPaneData), langStore.currentLang],
+ () => computeCurrentLineLeft(),
+ {
+ immediate: true,
+ },
+ );
+
+ return {
+ scrollRef,
+ itemRef,
+ tabPaneData,
+ currentTabName,
+ scrollToCurrent,
+ onClickHeaderItem,
+ onClickNext,
+ onClickPrev,
+ currentLineLeft,
+ currentLineRef,
+ };
+};
+
+/**
+ * Tab 左右箭头 hook
+ */
+export const useTabHeaderArrow = (options: {
+ scrollRef: Ref;
+ tabPaneData: Ref;
+}) => {
+ const { scrollRef, tabPaneData } = options;
+
+ const leftArrowVisible = ref(false);
+ const rightArrowVisible = ref(false);
+
+ /** 计算箭头显示 */
+ function computedArrowVisible() {
+ const scrollElem = unref(scrollRef);
+
+ if (!scrollElem) return;
+
+ const scrollLeft = Math.ceil(scrollElem.scrollLeft);
+ if (scrollLeft > 0) {
+ leftArrowVisible.value = true;
+ } else {
+ leftArrowVisible.value = false;
+ }
+
+ if (scrollLeft < Math.floor(scrollElem.scrollWidth - scrollElem.clientWidth)) {
+ rightArrowVisible.value = true;
+ } else {
+ rightArrowVisible.value = false;
+ }
+ }
+
+ watch(
+ () => unref(tabPaneData),
+ async () => {
+ await nextTick();
+ computedArrowVisible();
+ },
+ );
+
+ onMounted(() => {
+ computedArrowVisible();
+ if (scrollRef.value) {
+ scrollRef.value.addEventListener('scroll', computedArrowVisible);
+ }
+ });
+
+ onBeforeUnmount(() => {
+ if (scrollRef.value) {
+ scrollRef.value.removeEventListener('scroll', computedArrowVisible);
+ }
+ });
+
+ return {
+ scrollRef,
+ leftArrowVisible,
+ rightArrowVisible,
+ computedArrowVisible,
+ };
+};
diff --git a/src/components/common-base/tabs/hooks/use-tab-pane.ts b/src/components/common-base/tabs/hooks/use-tab-pane.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6cde49c21d6383f4eb2bedfb6ec7f03eca06f72e
--- /dev/null
+++ b/src/components/common-base/tabs/hooks/use-tab-pane.ts
@@ -0,0 +1,102 @@
+/**
+ * @file 标签页 TabPane hook
+ */
+
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { computed, onBeforeMount, ref, unref, watch } from 'vue';
+import { TabNameType, TabPaneDataItem, TabPaneInstance } from './types';
+import { useTabsInject } from './use-tabs';
+
+export const tabPaneProps = () => ({
+ /** 面板标签名 */
+ label: PropUtils.string.def(''),
+ /** 面板附加标签名 */
+ subLabel: PropUtils.string.def(''),
+ /** 面板名称 */
+ name: PropUtils.oneOfType([String, Number]).isRequired,
+ /** 是否懒加载,默认:false */
+ lazyLoad: PropUtils.bool.def(false),
+ /** 切换到当前面板时重新渲染,优先级高于 lazyLoad,默认:false */
+ switchToRefresh: PropUtils.bool.def(false),
+ /** 显示红点 */
+ showReminder: PropUtils.bool.def(false),
+});
+
+/**
+ * 判断 vue 实例是否为
+ * @param instance 实例
+ */
+export const isTabPaneInstance = (instance: unknown): instance is TabPaneInstance => {
+ const _instance = instance as Partial | undefined;
+ return typeof _instance?.getTabPaneData === 'function';
+};
+
+export const useTabPane = (options: { props: VueProps }) => {
+ const { props } = options;
+ const { label, subLabel, name, lazyLoad, switchToRefresh, showReminder } = useProps(props);
+ const { currentTabName, currentTabIndex, prevTabIndex } = useTabsInject();
+
+ /** 当前面板是否显示 */
+ const paneVisible = computed(() => unref(currentTabName) === unref(name));
+
+ /** 面板进出动画 */
+ const transitionName = computed(() => {
+ const prevIndexVal = prevTabIndex.value;
+ const currentIndexVal = currentTabIndex.value;
+ let name = '';
+
+ if (prevIndexVal !== -1 && currentIndexVal !== -1) {
+ name = currentIndexVal > prevIndexVal ? 'g-transition-menu-right' : 'g-transition-menu-left';
+ }
+
+ return name;
+ });
+
+ /** 获取当前标签页面板的数据 */
+ function getTabPaneData(): TabPaneDataItem {
+ return {
+ label: unref(label),
+ subLabel: unref(subLabel),
+ name: unref(name),
+ showReminder: unref(showReminder),
+ };
+ }
+
+ /** 是否渲染 slot */
+ const renderSlot = ref(false);
+
+ /** 检查渲染状态 */
+ function checkRenderStatus() {
+ if (unref(switchToRefresh)) {
+ renderSlot.value = unref(currentTabName) === unref(name);
+ return;
+ }
+
+ if (!unref(lazyLoad) || unref(currentTabName) === unref(name)) {
+ renderSlot.value = true;
+ }
+ }
+
+ onBeforeMount(() => {
+ checkRenderStatus();
+ });
+
+ watch(
+ () => unref(currentTabName),
+ () => checkRenderStatus(),
+ );
+
+ const tabPaneInstance: TabPaneInstance = {
+ getTabPaneData,
+ };
+
+ return {
+ label,
+ name,
+ paneVisible,
+ transitionName,
+ getTabPaneData,
+ renderSlot,
+ tabPaneInstance,
+ };
+};
diff --git a/src/components/common-base/tabs/hooks/use-tabs.ts b/src/components/common-base/tabs/hooks/use-tabs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ce15a913bc7fa9a69868f9dc03fccfec0a678abc
--- /dev/null
+++ b/src/components/common-base/tabs/hooks/use-tabs.ts
@@ -0,0 +1,227 @@
+/**
+ * @file 标签页 Tabs hook
+ */
+
+import { eventBus } from '@/app/app-events';
+import { useVue } from '@/hooks/core/use-vue';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { PropUtils, useProps, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import {
+ inject,
+ InjectionKey,
+ nextTick,
+ onMounted,
+ onUpdated,
+ provide,
+ ref,
+ unref,
+ watch,
+} from 'vue';
+import {
+ TabNameType,
+ TabPaneDataItem,
+ TabPaneInstance,
+ TabsInjectContext,
+ TabsInstance,
+} from './types';
+import { isTabPaneInstance } from './use-tab-pane';
+
+export const tabsProps = () => ({
+ /** 激活的面板 name */
+ value: PropUtils.oneOfType([String, Number]).def(''),
+});
+
+export const tabsEmits = () => ({
+ input: emitFunc(),
+});
+
+export const TABS_PROVIDE_KEY: InjectionKey = Symbol('tabsProvideKey');
+
+export const useTabsProvide = (context: TabsInjectContext) => {
+ provide(TABS_PROVIDE_KEY, context);
+};
+
+export const useTabsInject = (): TabsInjectContext => {
+ return inject(TABS_PROVIDE_KEY) as TabsInjectContext;
+};
+
+/** 标签页 tabs hook */
+export const useTabs = (options: {
+ props: VueProps;
+ emit: VueEmit;
+ switchEvent?: string;
+}) => {
+ const { props, emit } = options;
+ const { value } = useProps(props);
+ const { getSlot } = useVue();
+
+ /** 上一次激活的面板 name */
+ const prevTabName = ref('');
+ /** 当前激活的面板 name */
+ const currentTabName = ref('');
+ /** 上一刻激活的面板索引 */
+ const prevTabIndex = ref(-1);
+ /** 当前激活的面板索引 */
+ const currentTabIndex = ref(-1);
+ /** 面板数据列表 */
+ const tabPaneData = ref([]);
+
+ /** 设置当前激活的面板 */
+ function setCurrentTab(name: TabNameType) {
+ if (name === unref(currentTabName)) {
+ return;
+ }
+ const newIndex = unref(tabPaneData).findIndex(paneData => paneData.name === name);
+
+ if (newIndex === -1) {
+ return;
+ }
+
+ prevTabName.value = unref(currentTabName);
+ prevTabIndex.value = unref(currentTabIndex);
+
+ currentTabName.value = name;
+ currentTabIndex.value = newIndex;
+
+ emit('input', name);
+
+ if (options.switchEvent) {
+ eventBus.$emit(options.switchEvent, name);
+ }
+ }
+
+ /** 切到下一个 tab */
+ function toNextTab() {
+ // 已是最后一个
+ if (currentTabIndex.value >= tabPaneData.value.length - 1) {
+ return;
+ }
+ const nextTabData = tabPaneData.value[currentTabIndex.value + 1];
+ nextTabData && setCurrentTab(nextTabData.name);
+ }
+
+ /** 切到上一个 tab */
+ function toPrevTab() {
+ // 已是最后一个
+ if (currentTabIndex.value <= 0) {
+ return;
+ }
+ const prevTabData = tabPaneData.value[currentTabIndex.value - 1];
+ prevTabData && setCurrentTab(prevTabData.name);
+ }
+
+ watch(
+ () => unref(value),
+ () => setCurrentTab(unref(value)),
+ );
+
+ /** 获取面板实例对象 */
+ function getTabPaneInstances() {
+ const defaultSlot = getSlot();
+
+ const instances: TabPaneInstance[] = [];
+
+ defaultSlot.forEach(vNode => {
+ const instance = vNode.componentInstance;
+ if (isTabPaneInstance(instance)) {
+ instances.push(instance);
+ }
+ });
+
+ return instances;
+ }
+
+ /** 获取标签面板数据 */
+ function getTabPaneData() {
+ const panes: TabPaneDataItem[] = [];
+ const instances = getTabPaneInstances();
+
+ instances.forEach(instance => {
+ const panelData = instance.getTabPaneData();
+ panes.push(panelData);
+ });
+
+ return panes;
+ }
+
+ /** 更新标签面板数据 */
+ async function updateTabPaneData() {
+ await nextTick();
+ const panes = getTabPaneData();
+ const paneDataVal = unref(tabPaneData);
+
+ // 是否需要更新 tab 面板数据
+ let shouldUpdateTabPaneData = false;
+ // 是否需要重置到第一个 tab
+ let shouldSetFirstTab = false;
+
+ if (panes.length !== paneDataVal.length) {
+ shouldUpdateTabPaneData = true;
+ } else {
+ panes.forEach((paneData, index) => {
+ const oldItem = paneDataVal[index];
+ for (const key in paneData) {
+ const field = key as keyof TabPaneDataItem;
+ if (paneData[field] !== oldItem[field]) {
+ shouldUpdateTabPaneData = true;
+ }
+ }
+ });
+ }
+
+ if (shouldUpdateTabPaneData) {
+ if (panes.length !== tabPaneData.value.length) {
+ shouldSetFirstTab = true;
+ }
+ tabPaneData.value = panes;
+ }
+
+ const currentIndex = panes.findIndex(paneData => paneData.name === unref(currentTabName));
+ if (currentIndex === -1 && panes.length) {
+ shouldSetFirstTab = true;
+ }
+
+ if (shouldSetFirstTab) {
+ setCurrentTab(panes[0].name);
+ }
+ }
+
+ onMounted(() => {
+ updateTabPaneData();
+ });
+
+ onUpdated(() => {
+ updateTabPaneData();
+ });
+
+ useTabsProvide({
+ prevTabName,
+ currentTabName,
+ prevTabIndex,
+ currentTabIndex,
+ tabPaneData,
+ setCurrentTab,
+ toNextTab,
+ toPrevTab,
+ });
+
+ const tabsInstance: TabsInstance = {
+ currentTabName,
+ setCurrentTab,
+ };
+
+ return {
+ prevTabName,
+ currentTabName,
+ prevTabIndex,
+ currentTabIndex,
+ setCurrentTab,
+ toNextTab,
+ toPrevTab,
+
+ tabPaneData,
+ getTabPaneData,
+ updateTabPaneData,
+ tabsInstance,
+ };
+};
diff --git a/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tab-header.vue b/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tab-header.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cc6f36ae20f05ef92bb35ca4db927094a8c16072
--- /dev/null
+++ b/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tab-header.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tab-pane.vue b/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tab-pane.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5ade0a8d1b32d469ea27dc6f21f02214b9687930
--- /dev/null
+++ b/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tab-pane.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tabs.vue b/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tabs.vue
new file mode 100644
index 0000000000000000000000000000000000000000..54d787bf7bd8612566845e197325962e24ff8d0b
--- /dev/null
+++ b/src/components/common-base/tabs/mobile-menu-tabs/mobile-menu-tabs.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tab-header.vue b/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tab-header.vue
new file mode 100644
index 0000000000000000000000000000000000000000..15276785b4a25462bac603eab6f5a1e818f6a5ce
--- /dev/null
+++ b/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tab-header.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tab-pane.vue b/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tab-pane.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5c4dbeb627d965e2748798bb49ad1eec6026b5ff
--- /dev/null
+++ b/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tab-pane.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tabs.vue b/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tabs.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bc8f173cb8736291ae99d43fe600fc5847a131d3
--- /dev/null
+++ b/src/components/common-base/tabs/pc-aside-tabs/pc-aside-tabs.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tab-header.vue b/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tab-header.vue
new file mode 100644
index 0000000000000000000000000000000000000000..95513ae23c3eeebac2637c1f38c645189145bd59
--- /dev/null
+++ b/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tab-header.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tab-pane.vue b/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tab-pane.vue
new file mode 100644
index 0000000000000000000000000000000000000000..119b12892f1081b6fc24ac14b666307289263b05
--- /dev/null
+++ b/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tab-pane.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tabs.vue b/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tabs.vue
new file mode 100644
index 0000000000000000000000000000000000000000..dab4e2db2a9bdb2cc8b3651a328af6323c0da8f5
--- /dev/null
+++ b/src/components/common-base/tabs/pc-normal-tabs/pc-normal-tabs.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
diff --git a/src/components/common-base/tabs/portrait-normal-tabs/portrait-normal-tabs.vue b/src/components/common-base/tabs/portrait-normal-tabs/portrait-normal-tabs.vue
new file mode 100644
index 0000000000000000000000000000000000000000..549fa9120ea0a17f89cd518d27b817324c47f558
--- /dev/null
+++ b/src/components/common-base/tabs/portrait-normal-tabs/portrait-normal-tabs.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
diff --git a/src/components/common-business/basic-info/imgs/low-latency-bg-mob.svg b/src/components/common-business/basic-info/imgs/low-latency-bg-mob.svg
new file mode 100644
index 0000000000000000000000000000000000000000..93c92a5e3c9c25d7cd4488e9b3640aac12323950
--- /dev/null
+++ b/src/components/common-business/basic-info/imgs/low-latency-bg-mob.svg
@@ -0,0 +1,119 @@
+
+
\ No newline at end of file
diff --git a/src/components/common-business/basic-info/imgs/low-latency-bg.svg b/src/components/common-business/basic-info/imgs/low-latency-bg.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d8d202f00a73bedf760ba5de17070514fd3a4553
--- /dev/null
+++ b/src/components/common-business/basic-info/imgs/low-latency-bg.svg
@@ -0,0 +1,117 @@
+
+
\ No newline at end of file
diff --git a/src/components/common-business/basic-info/mobile-basic-info.vue b/src/components/common-business/basic-info/mobile-basic-info.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c56b6589342c7d0aa80f0bd490629d41ce4cecc8
--- /dev/null
+++ b/src/components/common-business/basic-info/mobile-basic-info.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ channelTitle }}
+
+
+
+
+
+
+
+
+ {{ pageViewVisible ? '' : `${$lang('watch.liveTime')}:` }}
+ {{ liveStartTimeText }}
+
+
+ {{ shortNumber(pageViewCount) }} {{ $lang('watch.pvSuffix') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ channelPublisher }}
+
+
+
+
+
+ {{ likeCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/basic-info/pc-basic-info.vue b/src/components/common-business/basic-info/pc-basic-info.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7d686e81453b90f19f3d2d129f31331562d688e0
--- /dev/null
+++ b/src/components/common-business/basic-info/pc-basic-info.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ channelTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ channelPublisher }}
+
+
+
+ {{ channelPublisher }}
+
+
+
+
+
+
+ {{ shortNumber(pageViewCount) }} {{ $lang('watch.pvSuffix') }}
+
+
+
+
+
+
+
+
+
{{ $lang('share.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/hooks/use-enroll-dialog.ts b/src/components/common-business/enroll/hooks/use-enroll-dialog.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c1619311704fa03853f4b7254211467573abe1d3
--- /dev/null
+++ b/src/components/common-business/enroll/hooks/use-enroll-dialog.ts
@@ -0,0 +1,70 @@
+import { translate } from '@/assets/lang';
+import { useEnrollStore } from '@/store/use-enroll-store';
+import { computed, ref, unref } from 'vue';
+
+/**
+ * 报名表单实例
+ */
+export interface EnrollDialogInstance {
+ /** 显示报名观看表单弹层 */
+ openEnrollForm(): void;
+}
+
+export type EnrollDialogModel = 'form' | 'login';
+
+/**
+ * 报名观看弹窗 hook
+ */
+export const useEnrollDialog = () => {
+ const enrollStore = useEnrollStore();
+
+ /** 弹层是否显示 */
+ const dialogVisible = ref(false);
+
+ /** 弹层模式 */
+ const dialogModel = ref('form');
+
+ /** 弹层标题 */
+ const dialogTitle = computed(() => {
+ if (unref(dialogModel) === 'login') {
+ return translate('enroll.loginTitle');
+ }
+
+ return enrollStore.enrollTitle;
+ });
+
+ /** 显示报名观看表单弹层 */
+ function openEnrollForm() {
+ dialogModel.value = 'form';
+ dialogVisible.value = true;
+ }
+
+ /** 关闭报名观看表单弹层 */
+ function closeEnrollDialog() {
+ dialogVisible.value = false;
+ }
+
+ /** 处理点击已报名入口 */
+ function onClickEnrolled() {
+ dialogModel.value = 'login';
+ }
+
+ function onClickBack() {
+ dialogModel.value = 'form';
+ }
+
+ const enrollDialogInstance: EnrollDialogInstance = {
+ openEnrollForm,
+ };
+
+ return {
+ dialogVisible,
+ dialogTitle,
+ enrollDialogInstance,
+ dialogModel,
+ openEnrollForm,
+ closeEnrollDialog,
+ onClickEnrolled,
+ onClickBack,
+ };
+};
diff --git a/src/components/common-business/enroll/hooks/use-enroll-enter.ts b/src/components/common-business/enroll/hooks/use-enroll-enter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0b80cd1276cf42bd9112e23e2645dfacd3f4a171
--- /dev/null
+++ b/src/components/common-business/enroll/hooks/use-enroll-enter.ts
@@ -0,0 +1,22 @@
+import { ref, unref } from 'vue';
+import { EnrollDialogInstance } from './use-enroll-dialog';
+
+/**
+ * 报名观看入口 hook
+ */
+export const useEnrollEnter = () => {
+ const dialogRef = ref();
+
+ /** 处理点击入口 */
+ function onClickEnter() {
+ const dialogInstrance = unref(dialogRef);
+ if (dialogInstrance) {
+ dialogInstrance.openEnrollForm();
+ }
+ }
+
+ return {
+ dialogRef,
+ onClickEnter,
+ };
+};
diff --git a/src/components/common-business/enroll/hooks/use-enroll-form.ts b/src/components/common-business/enroll/hooks/use-enroll-form.ts
new file mode 100644
index 0000000000000000000000000000000000000000..85cfaf571b06912f2f7c59131575d2e5c07a14c3
--- /dev/null
+++ b/src/components/common-business/enroll/hooks/use-enroll-form.ts
@@ -0,0 +1,347 @@
+/* eslint-disable sonarjs/cognitive-complexity */
+import { translate } from '@/assets/lang';
+import { validatePhoneNumber } from '@/assets/utils/validate';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { ImageVerifyInputInstance } from '@/components/common-base/form/form-image-verify-input/type';
+import { useDialogTipsUtils } from '@/components/common-base/dialog/use-dialog-tips';
+import { toast } from '@/hooks/components/use-toast';
+import { getWatchCore } from '@/core/watch-sdk';
+import { ValidatorRules } from '@/plugins/async-validator';
+import { getAreaSelectNames, isSelectFinish } from '@/plugins/polyv-ui/area-utils';
+import { useEnrollStore } from '@/store/use-enroll-store';
+import {
+ EnrollAuthType,
+ EnrollFieldItem,
+ EnrollFieldType,
+ EnrollFormContent,
+ SubmitEnrollError,
+} from '@polyv/live-watch-sdk';
+import { computed, reactive, ref } from 'vue';
+import { useCommonStore } from '@/store/use-common-store';
+import { useWatchAppStore } from '@/store/use-watch-app-store';
+
+interface EnrollBasicFormData {
+ /** 验证的手机号 */
+ phoneNumber: string;
+ /** 手机区号 */
+ areaCode: string;
+ /** 图片验证码 */
+ imageId: string;
+ imageCaptcha: string;
+ /** 短信验证码 */
+ smsCode: string;
+ /** 验证的邮箱地址 */
+ email: string;
+}
+
+type EnrollValFormData = Record;
+
+export const enrollFormEmits = () => ({
+ /** 关闭弹层 */
+ 'close-dialog': emitFunc(),
+ /** 点击取消 */
+ 'click-cancel': emitFunc(),
+ /** 点击我已报名 */
+ 'click-enrolled': emitFunc(),
+});
+
+/**
+ * 报名观看表单填写 hook
+ */
+export const useEnrollForm = (options: { emit: VueEmit }) => {
+ const { emit } = options;
+
+ const watchAppStore = useWatchAppStore();
+ const enrollStore = useEnrollStore();
+ const commonStore = useCommonStore();
+
+ /**
+ * 获取字段的表单初始值
+ */
+ function getInitFormData() {
+ const data: EnrollValFormData = {};
+
+ enrollStore.enrollFields.forEach(item => {
+ if (
+ [EnrollFieldType.Checkbox, EnrollFieldType.Upload, EnrollFieldType.Area].includes(item.type)
+ ) {
+ data[item.fieldId] = [];
+ } else if (item.type === EnrollFieldType.Privacy) {
+ data[item.fieldId] = false;
+ } else {
+ data[item.fieldId] = '';
+ }
+ });
+
+ return data;
+ }
+
+ const imageVerifyInputRef = ref();
+
+ /** 填写表单对象 */
+ const formData = reactive({
+ ...getInitFormData(),
+ // 手机号
+ phoneNumber: '',
+ // 手机区号
+ areaCode: '+86',
+ // 图片验证码 id
+ imageId: '',
+ // 图片验证码
+ imageCaptcha: '',
+ // 短信验证码
+ smsCode: '',
+ // 验证的邮箱地址
+ email: '',
+ });
+
+ /** 处理手机号码输入框改变 */
+ function onPhoneNumberChange(phoneNumber: string) {
+ formData.phoneNumber = phoneNumber;
+ }
+
+ /** 处理邮箱输入框改变 */
+ function onEmailChange(email: string) {
+ formData.email = email;
+ }
+
+ /** 表单验证规则 */
+ const formRules = computed(() => {
+ const rules: ValidatorRules = {};
+
+ enrollStore.enrollFields.forEach(fieldItem => {
+ const fieldId = fieldItem.fieldId;
+ const isRequired = fieldItem.isRequired;
+
+ switch (fieldItem.type) {
+ case EnrollFieldType.Name:
+ case EnrollFieldType.Text:
+ case EnrollFieldType.Radio:
+ rules[fieldId] = [
+ { type: 'string', message: translate('auth.error.improveInfo'), required: isRequired },
+ ];
+ break;
+ case EnrollFieldType.Email:
+ rules[fieldId] = [
+ { type: 'string', message: translate('auth.error.improveInfo'), required: isRequired },
+ { type: 'email', message: translate('form.error.emailError') },
+ ];
+ break;
+ case EnrollFieldType.Checkbox:
+ case EnrollFieldType.Upload:
+ rules[fieldId] = [
+ { type: 'array', message: translate('auth.error.improveInfo'), required: isRequired },
+ ];
+ break;
+ case EnrollFieldType.Area:
+ rules[fieldId] = [
+ { type: 'array', message: translate('auth.error.improveInfo'), required: isRequired },
+ {
+ validator: (rule, val: string[]) => {
+ if (val.length && !isSelectFinish(val, commonStore.areaData)) {
+ return [translate('form.error.areaError')];
+ }
+ return [];
+ },
+ },
+ ];
+ break;
+ case EnrollFieldType.Privacy:
+ rules[fieldId] = {
+ validator: (rule, val: boolean) => {
+ if (!val) {
+ return [translate('auth.error.checkProtocol')];
+ }
+ return [];
+ },
+ };
+ break;
+ case EnrollFieldType.Mobile:
+ // 使用手机号验证
+ if (fieldItem.authType === EnrollAuthType.Mobile) {
+ rules[fieldId] = [
+ {
+ type: 'string',
+ message: translate('form.error.phoneNumberRequired'),
+ required: true,
+ },
+ {
+ validator: () => {
+ const phoneNumber = formData.phoneNumber;
+ const areaCode = formData.areaCode;
+
+ if (validatePhoneNumber(phoneNumber, areaCode)) {
+ return [];
+ }
+
+ return [translate('form.error.phoneNumberError')];
+ },
+ },
+ ];
+ if (enrollStore.smsVerifyEnabled) {
+ rules.smsCode = [
+ {
+ type: 'string',
+ message: translate('form.error.smsVerifyRequired'),
+ required: true,
+ },
+ ];
+ }
+ }
+ // 使用邮箱验证
+ if (fieldItem.authType === EnrollAuthType.Email) {
+ rules[fieldId] = [
+ {
+ type: 'string',
+ message: translate('form.error.emailRequired'),
+ required: isRequired,
+ },
+ { type: 'email', message: translate('form.error.emailError') },
+ ];
+ }
+ break;
+ }
+ });
+
+ return rules;
+ });
+
+ /** 格式化特殊的字段名称 */
+ function formatSpecialName(fieldItem: EnrollFieldItem): string {
+ let name = fieldItem.name;
+ if (fieldItem.type === EnrollFieldType.Radio) {
+ name = `[${translate('form.radio')}] ${name}`;
+ }
+ if (fieldItem.type === EnrollFieldType.Checkbox) {
+ name = `[${translate('form.checkbox')}] ${name}`;
+ }
+ return name;
+ }
+
+ /**
+ * 处理表单提交
+ */
+ async function submitForm() {
+ const watchCore = getWatchCore();
+ const content: EnrollFormContent = {};
+ const formDataVal = formData as EnrollValFormData;
+
+ const phoneNumber = formData.phoneNumber;
+ const areaCode = formData.areaCode;
+ const smsCode = formData.smsCode;
+ const email = formData.email;
+
+ enrollStore.enrollFields.forEach(fieldItem => {
+ const fieldId = fieldItem.fieldId;
+
+ switch (fieldItem.type) {
+ case EnrollFieldType.Name:
+ case EnrollFieldType.Text:
+ case EnrollFieldType.Radio:
+ case EnrollFieldType.Mobile:
+ case EnrollFieldType.Email:
+ content[fieldId] = formDataVal[fieldId] as string;
+ break;
+ case EnrollFieldType.Checkbox:
+ case EnrollFieldType.Upload:
+ content[fieldId] = formDataVal[fieldId] as string[];
+ break;
+ case EnrollFieldType.Area: {
+ const areaValue = formDataVal[fieldId] as string[];
+ const areaPickData = getAreaSelectNames(areaValue, commonStore.areaData).join('/');
+ content[fieldId] = areaPickData;
+ break;
+ }
+ case EnrollFieldType.Privacy:
+ content[fieldId] = '√';
+ break;
+ }
+ });
+
+ const result = await watchCore.enroll.submitEnrollForm({
+ phoneNumber,
+ areaCode,
+ smsCode,
+ email,
+ content,
+ });
+
+ if (!result.success) {
+ switch (result.failReason) {
+ case SubmitEnrollError.SmsCodeVerifyError:
+ toast.error(translate('enroll.error.smsCodeError'));
+ break;
+ case SubmitEnrollError.LackRequired:
+ default:
+ toast.error(result.failMessage || '未知错误!');
+ break;
+ }
+ return;
+ }
+
+ // 优先判断是否需要审核和审核是否通过
+ if (result.auditEnabled && !result.hasAudited) {
+ toast.success(translate('enroll.auditing'));
+ emit('close-dialog');
+ return;
+ }
+
+ // 入口报名且报名成功的情况下需要重新安装 watchCore
+ if (enrollStore.needEnrollByEnter && result.hasEnrolled) {
+ await watchAppStore.resetUpWatchCore();
+ }
+
+ emit('close-dialog');
+ }
+
+ function onClickCancel() {
+ emit('click-cancel');
+ }
+
+ function onClickEnrolled() {
+ emit('click-enrolled');
+ }
+
+ return {
+ EnrollFieldType,
+ EnrollAuthType,
+ imageVerifyInputRef,
+ formData,
+ formRules,
+ onPhoneNumberChange,
+ onEmailChange,
+ formatSpecialName,
+ submitForm,
+ onClickCancel,
+ onClickEnrolled,
+ };
+};
+
+export const useEnrollCheckMobile = (options: {
+ /** 获取手机区号 */
+ getAreaCode: () => string;
+}) => {
+ const { showDialogTips } = useDialogTipsUtils();
+
+ /** 失焦检查报名状态 */
+ async function blurToCheckStatus(mobileOrEmail: string) {
+ if (!mobileOrEmail) {
+ return;
+ }
+
+ const watchCore = getWatchCore();
+ const result = await watchCore.enroll.getEnrollStatus({
+ phoneNumber: mobileOrEmail,
+ email: mobileOrEmail,
+ areaCode: options.getAreaCode(),
+ });
+
+ if (result.hasEnrolled) {
+ showDialogTips(translate('enroll.enrolledTips'));
+ }
+ }
+
+ return {
+ blurToCheckStatus,
+ };
+};
diff --git a/src/components/common-business/enroll/hooks/use-enroll-login.ts b/src/components/common-business/enroll/hooks/use-enroll-login.ts
new file mode 100644
index 0000000000000000000000000000000000000000..45c58f9fa6754acaea583583e94f54103ce36b78
--- /dev/null
+++ b/src/components/common-business/enroll/hooks/use-enroll-login.ts
@@ -0,0 +1,141 @@
+import { DEFAULT_PHONE_NUMBER_AREA_CODE } from '@/assets/constants/defaults';
+import { translate } from '@/assets/lang';
+import { validatePhoneNumber } from '@/assets/utils/validate';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { ImageVerifyInputInstance } from '@/components/common-base/form/form-image-verify-input/type';
+import { toast } from '@/hooks/components/use-toast';
+import { getWatchCore } from '@/core/watch-sdk';
+import { ValidatorRules } from '@/plugins/async-validator';
+import { useEnrollStore } from '@/store/use-enroll-store';
+import { EnrollAuthType } from '@polyv/live-watch-sdk';
+import { computed, reactive, ref } from 'vue';
+import { useWatchAppStore } from '@/store/use-watch-app-store';
+
+export const enrollLoginEmits = () => ({
+ /** 点击取消 */
+ 'click-cancel': emitFunc(),
+ /** 关闭弹层 */
+ 'close-dialog': emitFunc(),
+});
+
+/**
+ * 报名观看登录 hook
+ */
+export const useEnrollLogin = (options: { emit: VueEmit }) => {
+ const { emit } = options;
+
+ const watchAppStore = useWatchAppStore();
+ const enrollStore = useEnrollStore();
+
+ const imageVerifyInputRef = ref();
+
+ /** 表单对象 */
+ const formData = reactive({
+ // 手机号
+ phoneNumber: '',
+ // 区号
+ areaCode: DEFAULT_PHONE_NUMBER_AREA_CODE,
+ // 图片验证码
+ imageId: '',
+ imageCaptcha: '',
+ // 短信验证码
+ smsCode: '',
+ // 邮箱
+ email: '',
+ });
+
+ /** 表单验证规则 */
+ const formRules = computed(() => {
+ const rules: ValidatorRules = {};
+
+ // 手机号验证
+ if (enrollStore.enrollAuthType === EnrollAuthType.Mobile) {
+ rules.phoneNumber = [
+ { type: 'string', message: translate('form.error.phoneNumberRequired'), required: true },
+ {
+ validator: () => {
+ const phoneNumber = formData.phoneNumber;
+ const areaCode = formData.areaCode;
+
+ if (validatePhoneNumber(phoneNumber, areaCode)) {
+ return [];
+ }
+
+ return [translate('form.error.phoneNumberError')];
+ },
+ },
+ ];
+
+ if (enrollStore.smsVerifyEnabled) {
+ rules.smsCode = [
+ { type: 'string', message: translate('form.error.smsVerifyRequired'), required: true },
+ ];
+ }
+ }
+
+ // 邮箱验证
+ if (enrollStore.enrollAuthType === EnrollAuthType.Email) {
+ rules.email = [
+ { type: 'string', message: translate('form.error.emailRequired'), required: true },
+ { type: 'email', message: translate('form.error.emailError') },
+ ];
+ }
+
+ return rules;
+ });
+
+ /**
+ * 提交登录表单
+ */
+ async function submitLoginForm() {
+ const watchCore = getWatchCore();
+
+ const result = await watchCore.enroll.loginEnroll({
+ phoneNumber: formData.phoneNumber,
+ areaCode: formData.areaCode,
+ smsCode: formData.smsCode,
+ email: formData.email,
+ });
+
+ if (!result.success) {
+ toast.error(result.failMessage || '未知错误!');
+ return;
+ }
+
+ // 未填写报名信息
+ if (!result.hasEnrolled) {
+ let toastMsg = translate('enroll.error.phoneUnenroll');
+ if (enrollStore.enrollAuthType === EnrollAuthType.Email) {
+ toastMsg = translate('enroll.error.emailUnenroll');
+ }
+ toast.error(toastMsg);
+ return;
+ }
+
+ // 已填写但报名审核中
+ if (result.auditEnabled && !result.hasAudited) {
+ toast.success(translate('enroll.auditing'));
+ emit('close-dialog');
+ return;
+ }
+
+ // 入口报名且报名成功的情况下需要重新安装 watchCore
+ if (enrollStore.needEnrollByEnter && result.hasEnrolled) {
+ await watchAppStore.resetUpWatchCore();
+ }
+
+ emit('close-dialog');
+ }
+
+ function onClickCancel() {
+ emit('click-cancel');
+ }
+
+ return {
+ imageVerifyInputRef,
+ formData,
+ formRules,
+ submitLoginForm,
+ onClickCancel,
+ };
+};
diff --git a/src/components/common-business/enroll/mobile-enroll-form.vue b/src/components/common-business/enroll/mobile-enroll-form.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8edb3e5d0356cab53fb4c68180c32202b7e5dea3
--- /dev/null
+++ b/src/components/common-business/enroll/mobile-enroll-form.vue
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/mobile-enroll-login.vue b/src/components/common-business/enroll/mobile-enroll-login.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1e3b8996340bdf1d3eb7e43848359ff8c0532610
--- /dev/null
+++ b/src/components/common-business/enroll/mobile-enroll-login.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/mobile-enroll-popup.vue b/src/components/common-business/enroll/mobile-enroll-popup.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3ca6a24708be0cee3c29d97e527eb603bee4bab5
--- /dev/null
+++ b/src/components/common-business/enroll/mobile-enroll-popup.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/mobile-enroll.vue b/src/components/common-business/enroll/mobile-enroll.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8429af7fdc21aded893642a7a61881e20eedc4d0
--- /dev/null
+++ b/src/components/common-business/enroll/mobile-enroll.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/pc-enroll-dialog.vue b/src/components/common-business/enroll/pc-enroll-dialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7dafffa672826b6a95457f68d41437e3d3a0fd42
--- /dev/null
+++ b/src/components/common-business/enroll/pc-enroll-dialog.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/pc-enroll-form.vue b/src/components/common-business/enroll/pc-enroll-form.vue
new file mode 100644
index 0000000000000000000000000000000000000000..af87d09b5a7ace9e150fe1132868e2ba5145d9a1
--- /dev/null
+++ b/src/components/common-business/enroll/pc-enroll-form.vue
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/pc-enroll-login.vue b/src/components/common-business/enroll/pc-enroll-login.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8187494a445f723afa67c147f2ff34b9b75276be
--- /dev/null
+++ b/src/components/common-business/enroll/pc-enroll-login.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/enroll/pc-enroll.vue b/src/components/common-business/enroll/pc-enroll.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3ee8d7d398b4ebf4adc4a8df436519472d4c01de
--- /dev/null
+++ b/src/components/common-business/enroll/pc-enroll.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/lang-switch/mobile-lang-switch.vue b/src/components/common-business/lang-switch/mobile-lang-switch.vue
new file mode 100644
index 0000000000000000000000000000000000000000..367e9dcad18b57556ce0d814d62a423c59e248ea
--- /dev/null
+++ b/src/components/common-business/lang-switch/mobile-lang-switch.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
diff --git a/src/components/common-business/lang-switch/pc-lang-switch.vue b/src/components/common-business/lang-switch/pc-lang-switch.vue
new file mode 100644
index 0000000000000000000000000000000000000000..23c34b0939368bf5c4ae2ac14a11bfa82a2159e9
--- /dev/null
+++ b/src/components/common-business/lang-switch/pc-lang-switch.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
{{ currentLangText }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-booking/hooks/use-live-booking-fail.ts b/src/components/common-business/live-booking/hooks/use-live-booking-fail.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0b83e5bfd00ed6017a7b9138e6841bc004484be4
--- /dev/null
+++ b/src/components/common-business/live-booking/hooks/use-live-booking-fail.ts
@@ -0,0 +1,18 @@
+import { translate } from '@/assets/lang';
+import { toast } from '@/hooks/components/use-toast';
+import { LiveBookingFailReason } from '@polyv/live-watch-sdk';
+
+/**
+ * 提示预约错误原因
+ * @param failReason 错误原因
+ */
+export function toastBookingFail(failReason: LiveBookingFailReason) {
+ switch (failReason) {
+ case LiveBookingFailReason.Unknown:
+ toast.error(translate('liveBooking.fail'));
+ break;
+ case LiveBookingFailReason.VerifyCodeError:
+ toast.error(translate('liveBooking.smsWrong'));
+ break;
+ }
+}
diff --git a/src/components/common-business/live-booking/hooks/use-live-booking-form.ts b/src/components/common-business/live-booking/hooks/use-live-booking-form.ts
new file mode 100644
index 0000000000000000000000000000000000000000..170f805baac62276124f652ef5183409f322a46f
--- /dev/null
+++ b/src/components/common-business/live-booking/hooks/use-live-booking-form.ts
@@ -0,0 +1,122 @@
+/**
+ * @file (短信)直播预约表单 hook
+ */
+import { translate } from '@/assets/lang';
+import { getStorageKey } from '@/assets/utils/storage';
+import { validatePhoneNumber } from '@/assets/utils/validate';
+import { toast } from '@/hooks/components/use-toast';
+import { getWatchCore } from '@/core/watch-sdk';
+import { ValidatorRules } from '@/plugins/async-validator';
+import { useLiveBookingStore } from '@/store/use-live-booking-store';
+import { local } from '@just4/storage';
+import { computed, reactive, ref } from 'vue';
+import { LiveBookingFormInstance } from '../types';
+import { toastBookingFail } from './use-live-booking-fail';
+
+// local 中的手机号码缓存 key
+const LOCAL_PHONE_KEY = getStorageKey('live-booking-phone');
+// local 中的手机区号缓存 key
+const LOCAL_AREA_CODE_KEY = getStorageKey('live-booking-area-code');
+
+export const useLiveBookingForm = () => {
+ const liveBookingStore = useLiveBookingStore();
+
+ /** 直播预约表单数据 */
+ const bookingFormData = reactive({
+ // 手机号
+ phoneNumber: local.get(LOCAL_PHONE_KEY) || '',
+ // 手机区号
+ areaCode: local.get(LOCAL_AREA_CODE_KEY) || '+86',
+ // 短信验证码
+ verifyCode: '',
+ });
+
+ /** 直播预约表单验证规则 */
+ const bookingFormRules = computed(() => {
+ return {
+ phoneNumber: [
+ { type: 'string', message: translate('form.error.phoneNumberRequired'), required: true },
+ {
+ validator: () => {
+ const { phoneNumber, areaCode } = bookingFormData;
+
+ if (validatePhoneNumber(phoneNumber, areaCode)) {
+ return [];
+ }
+
+ return [translate('form.error.phoneNumberError')];
+ },
+ },
+ ],
+ verifyCode: [
+ { type: 'string', message: translate('form.error.smsVerifyRequired'), required: true },
+ ],
+ };
+ });
+
+ /** 预约表单是否显示 */
+ const bookingFormVisible = ref(false);
+
+ /** 打开弹层 */
+ function openBookingForm() {
+ bookingFormVisible.value = true;
+ bookingFormData.verifyCode = '';
+ }
+
+ /** 提交直播预约表单 */
+ async function submitLiveBookingForm() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.liveBooking.submitSmsLiveBooking({
+ phoneNumber: bookingFormData.phoneNumber,
+ areaCode: bookingFormData.areaCode,
+ verifyCode: bookingFormData.verifyCode,
+ });
+
+ if (result.success) {
+ local.set(LOCAL_PHONE_KEY, bookingFormData.phoneNumber);
+ local.set(LOCAL_AREA_CODE_KEY, bookingFormData.areaCode);
+
+ toast.success(translate('liveBooking.bookingSuccess'));
+ liveBookingStore.isSmsLiveBooking = true;
+
+ bookingFormVisible.value = false;
+ } else {
+ toastBookingFail(result.failReason);
+ }
+ }
+
+ /** 取消预约 */
+ async function cancelLiveBooking() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.liveBooking.cancelSmsLiveBooking({
+ phoneNumber: bookingFormData.phoneNumber,
+ areaCode: bookingFormData.areaCode,
+ });
+
+ if (result.success) {
+ local.remove(LOCAL_PHONE_KEY);
+ local.remove(LOCAL_AREA_CODE_KEY);
+
+ toast.success(translate('liveBooking.cancelSuccess'));
+ liveBookingStore.isSmsLiveBooking = false;
+ } else {
+ toastBookingFail(result.failReason);
+ }
+ }
+
+ const liveBookingFormInstance: LiveBookingFormInstance = {
+ openBookingForm,
+ submitLiveBookingForm,
+ cancelLiveBooking,
+ };
+
+ return {
+ bookingFormData,
+ bookingFormRules,
+ bookingFormVisible,
+ openBookingForm,
+ submitLiveBookingForm,
+ cancelLiveBooking,
+ liveBookingFormInstance,
+ };
+};
diff --git a/src/components/common-business/live-booking/hooks/use-live-booking-wx.ts b/src/components/common-business/live-booking/hooks/use-live-booking-wx.ts
new file mode 100644
index 0000000000000000000000000000000000000000..baeba4af483d6c6a9c5f5b4a86b38985b0af5451
--- /dev/null
+++ b/src/components/common-business/live-booking/hooks/use-live-booking-wx.ts
@@ -0,0 +1,48 @@
+import { getWatchCore } from '@/core/watch-sdk';
+import { useLiveBookingStore } from '@/store/use-live-booking-store';
+import { ref } from 'vue';
+import { SubscribeInstance } from '../types';
+import { toastBookingFail } from './use-live-booking-fail';
+
+export const useLiveBookingWx = () => {
+ const liveBookingStore = useLiveBookingStore();
+
+ const subscribeRef = ref();
+
+ /** 提交微信预约 */
+ async function wxSubmitLiveBooking() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.liveBooking.submitWxLiveBooking();
+
+ if (result.success) {
+ liveBookingStore.wxBookingCount += 1;
+ liveBookingStore.isWxLiveBooking = true;
+
+ // 未关注,弹出二维码弹层
+ if (!liveBookingStore.isWxSubscribed) {
+ subscribeRef.value?.openPopup();
+ }
+ } else {
+ toastBookingFail(result.failReason);
+ }
+ }
+
+ /** 取消微信预约 */
+ async function wxCancelLiveBooking() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.liveBooking.cancelWxLiveBooking();
+
+ if (result.success) {
+ liveBookingStore.wxBookingCount -= 1;
+ liveBookingStore.isWxLiveBooking = false;
+ } else {
+ toastBookingFail(result.failReason);
+ }
+ }
+
+ return {
+ subscribeRef,
+ wxSubmitLiveBooking,
+ wxCancelLiveBooking,
+ };
+};
diff --git a/src/components/common-business/live-booking/imgs/apollo-live-qrcode.jpg b/src/components/common-business/live-booking/imgs/apollo-live-qrcode.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..b8985815a28ff08f27de214b50016cb23165da5d
Binary files /dev/null and b/src/components/common-business/live-booking/imgs/apollo-live-qrcode.jpg differ
diff --git a/src/components/common-business/live-booking/mobile-live-booking-button.vue b/src/components/common-business/live-booking/mobile-live-booking-button.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a4a23965ff685a078ec9fffe5d885edfd27bb6e5
--- /dev/null
+++ b/src/components/common-business/live-booking/mobile-live-booking-button.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-booking/mobile-live-booking-form-popup.vue b/src/components/common-business/live-booking/mobile-live-booking-form-popup.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ecb5b0c2a8a975422f84eccbf46058f4f8d9f0f7
--- /dev/null
+++ b/src/components/common-business/live-booking/mobile-live-booking-form-popup.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-booking/mobile-live-booking-subscribe-popup.vue b/src/components/common-business/live-booking/mobile-live-booking-subscribe-popup.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d565ec4416efcf05984360edc10c841e48f8ebea
--- /dev/null
+++ b/src/components/common-business/live-booking/mobile-live-booking-subscribe-popup.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-booking/pc-live-booking-button.vue b/src/components/common-business/live-booking/pc-live-booking-button.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5216600500f4865b26086792e3408d3ba2f20707
--- /dev/null
+++ b/src/components/common-business/live-booking/pc-live-booking-button.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-booking/pc-live-booking-form-dialog.vue b/src/components/common-business/live-booking/pc-live-booking-form-dialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..954da7a6061777524b98c01dcc655e856c2b6233
--- /dev/null
+++ b/src/components/common-business/live-booking/pc-live-booking-form-dialog.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-booking/types/index.ts b/src/components/common-business/live-booking/types/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1331ad52682bc96040cfeae3507503f5e605b396
--- /dev/null
+++ b/src/components/common-business/live-booking/types/index.ts
@@ -0,0 +1,21 @@
+/**
+ * 短信预约表单实例
+ */
+export interface LiveBookingFormInstance {
+ /** 打开表单弹层 */
+ openBookingForm: () => void;
+ /** 提交预约 */
+ submitLiveBookingForm: () => Promise;
+ /** 取消预约 */
+ cancelLiveBooking: () => Promise;
+}
+
+/**
+ * 公众号二维码实例
+ */
+export interface SubscribeInstance {
+ /** 打开公众号二维码弹层 */
+ openPopup: (qrcodeUrl?: string) => void;
+ /** 关闭弹层 */
+ closePopup: () => void;
+}
diff --git a/src/components/common-business/live-count-down/mobile-live-count-down.vue b/src/components/common-business/live-count-down/mobile-live-count-down.vue
new file mode 100644
index 0000000000000000000000000000000000000000..15859bee6cf87629f51a11d3143a0be6dddc6e67
--- /dev/null
+++ b/src/components/common-business/live-count-down/mobile-live-count-down.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-count-down/pc-live-count-down.vue b/src/components/common-business/live-count-down/pc-live-count-down.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2a1d5666a50d1116bb3b6904f2cb1a78b864fe46
--- /dev/null
+++ b/src/components/common-business/live-count-down/pc-live-count-down.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/live-count-down/use-live-count-down.ts b/src/components/common-business/live-count-down/use-live-count-down.ts
new file mode 100644
index 0000000000000000000000000000000000000000..100fd4f0b6314aee87db61308edb3459d13e94c4
--- /dev/null
+++ b/src/components/common-business/live-count-down/use-live-count-down.ts
@@ -0,0 +1,13 @@
+import { useChannelInfoStore } from '@/store/use-channel-info-store';
+
+export const useLiveCountDown = () => {
+ const channelInfoStore = useChannelInfoStore();
+
+ function onCountDownFinish() {
+ channelInfoStore.isLiveStartCountDownEnd = true;
+ }
+
+ return {
+ onCountDownFinish,
+ };
+};
diff --git a/src/components/common-business/page-fixed-widgets/fixed-back-top.vue b/src/components/common-business/page-fixed-widgets/fixed-back-top.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ed39d1eea8b4bc13e59b2d23724f42f7638abcdb
--- /dev/null
+++ b/src/components/common-business/page-fixed-widgets/fixed-back-top.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/page-fixed-widgets/fixed-web-share.vue b/src/components/common-business/page-fixed-widgets/fixed-web-share.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a6b0cc96b26a3b09f4f041620181547dd824b7f3
--- /dev/null
+++ b/src/components/common-business/page-fixed-widgets/fixed-web-share.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/page-fixed-widgets/page-fixed-widgets.vue b/src/components/common-business/page-fixed-widgets/page-fixed-widgets.vue
new file mode 100644
index 0000000000000000000000000000000000000000..65d8aed2e79527d4ab280e667e2a1bd94e75ebcd
--- /dev/null
+++ b/src/components/common-business/page-fixed-widgets/page-fixed-widgets.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/page-footer/mobile-page-footer.vue b/src/components/common-business/page-footer/mobile-page-footer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..28c23e326580269f9c03aac42d09107908a6a59a
--- /dev/null
+++ b/src/components/common-business/page-footer/mobile-page-footer.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/page-footer/pc-page-footer.vue b/src/components/common-business/page-footer/pc-page-footer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7263b05c5e455c7f6c7a1d70344affcff0a2e4e1
--- /dev/null
+++ b/src/components/common-business/page-footer/pc-page-footer.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/page-footer/use-page-footer.ts b/src/components/common-business/page-footer/use-page-footer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..83284e202a2ffc1cbe4b3778a269aac5b940b610
--- /dev/null
+++ b/src/components/common-business/page-footer/use-page-footer.ts
@@ -0,0 +1,41 @@
+/**
+ * @file 页脚 hook
+ */
+import { useChannelStore } from '@/store/use-channel-store';
+import { storeDefinitionToRefs } from '@/plugins/pinia/util';
+import { ynToBool } from '@utils-ts/boolean';
+import { changeProtocol } from '@utils-ts/net';
+import { computed, unref } from 'vue';
+
+/** 页脚 hook */
+export const usePageFooter = () => {
+ const { channelDetail } = storeDefinitionToRefs(useChannelStore);
+
+ /** 页脚设置信息 */
+ const footerSetting = computed(() => unref(channelDetail)?.footerSetting);
+
+ /** 页脚是否显示 */
+ const footerVisible = computed(() => ynToBool(unref(footerSetting)?.showFooterEnabled || 'N'));
+
+ /** 页脚跳转链接 */
+ const footerLink = computed(() => {
+ const setting = unref(footerSetting);
+ if (!setting) {
+ return '';
+ }
+ const { footTextLinkProtocol, footTextLinkUrl } = setting;
+ return changeProtocol(footTextLinkUrl, footTextLinkProtocol);
+ });
+
+ /** 页脚文案 */
+ const footerText = computed(() => {
+ return unref(footerSetting)?.footerText || '';
+ });
+
+ return {
+ footerSetting,
+ footerVisible,
+ footerLink,
+ footerText,
+ };
+};
diff --git a/src/components/common-business/player/player-audio-live-placeholder.vue b/src/components/common-business/player/player-audio-live-placeholder.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b8896ea08ea6758e4608e07b635a31f05066534f
--- /dev/null
+++ b/src/components/common-business/player/player-audio-live-placeholder.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+

+
{{ $lang('live.audioLive') }}
+
+
+
+
+
+
+
diff --git a/src/components/common-business/player/player-video-placeholder.vue b/src/components/common-business/player/player-video-placeholder.vue
new file mode 100644
index 0000000000000000000000000000000000000000..55cb7b410fb796dc550f9d2fcb1e31374cd1f730
--- /dev/null
+++ b/src/components/common-business/player/player-video-placeholder.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/risk-confirm-letter/img/no-agree.png b/src/components/common-business/risk-confirm-letter/img/no-agree.png
new file mode 100644
index 0000000000000000000000000000000000000000..740c4f696e0535d3fcde068923781f83d5a7d4d6
Binary files /dev/null and b/src/components/common-business/risk-confirm-letter/img/no-agree.png differ
diff --git a/src/components/common-business/risk-confirm-letter/mobile-risk-confirm-letter.vue b/src/components/common-business/risk-confirm-letter/mobile-risk-confirm-letter.vue
new file mode 100644
index 0000000000000000000000000000000000000000..489d8a5509fd65735c40a199d46ed58a1593e8a9
--- /dev/null
+++ b/src/components/common-business/risk-confirm-letter/mobile-risk-confirm-letter.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 为了您有更好的观看体验
+
+
+ 请同意协议后进入直播间
+
+
+
+
+
+
+
+
+
+ 欢迎您进入
+
+ {{ viewerRiskConfirmInfo.channelName }}
+
+ 直播间,为更好地保护您的权益,根据国家相关法律法规规定,请您观看直播前,务必认真阅读并充分理解
+
+ 我们的
+
+ 《{{ agreement.agreementTitle }}》
+ 、
+
+ 以及
+
+ 直播平台保利威的
+
+ 《服务协议》
+
+ 和
+
《隐私协议》
+
+ 。
+
您点击同意,即表示您已阅读、理解且接受全部的协议条款,确认同意后即可进入直播间。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/risk-confirm-letter/pc-risk-confirm-letter.vue b/src/components/common-business/risk-confirm-letter/pc-risk-confirm-letter.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9f9d7a49665349a0edf5ae0474bbdb3318578652
--- /dev/null
+++ b/src/components/common-business/risk-confirm-letter/pc-risk-confirm-letter.vue
@@ -0,0 +1,269 @@
+
+
+
+
+
+
+ {{ curPreviewAgreement.title }}
+
+
+
+
+
+
+
+
+
+
+ 为了您有更好的观看体验
+
+
+ 请同意协议后进入直播间
+
+
+
+
+
+
+
+
+
+ 欢迎您进入
+
+ {{ viewerRiskConfirmInfo.channelName }}
+
+ 直播间,为更好地保护您的权益,根据国家相关法律法规规定,请您观看直播前,务必认真阅读并充分理解
+
+ 我们的
+
+ 《{{ agreement.agreementTitle }}》
+ 、
+
+ 以及
+
+ 直播平台保利威的
+
《服务协议》和
《隐私协议》。
+
您点击同意,即表示您已阅读、理解且接受全部的协议条款,确认同意后即可进入直播间。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common-business/risk-confirm-letter/use-risk-confirm-letter.ts b/src/components/common-business/risk-confirm-letter/use-risk-confirm-letter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..92624ef3cd6209d6a355ea1e0172d559483d9389
--- /dev/null
+++ b/src/components/common-business/risk-confirm-letter/use-risk-confirm-letter.ts
@@ -0,0 +1,170 @@
+import { computed, ref, reactive, watchEffect } from 'vue';
+import { getWatchCore } from '@/core/watch-sdk';
+import { FinanceRiskConfirmAgreement, FinanceRiskConfirmInfo } from '@polyv/live-watch-sdk';
+import { isMobile } from '@/assets/utils/browser';
+import { toast } from '@/hooks/components/use-toast';
+import { useFinanceStore } from '@/store/use-finance-store';
+import { useWatchAppStore } from '@/store/use-watch-app-store';
+
+export enum RiskConfirmLetterRenderMode {
+ /** 默认展示 */
+ DEFAULT = 'default',
+ /** 预览协议 */
+ PREVIEW_AGREEMENT = 'previewAgreement',
+ /** "不同意"的提示 */
+ DISAGREE_TIPS = 'disagreeTips',
+}
+
+enum RiskConfirmAgreeStatus {
+ DEFAULT = 0,
+ AGREE = 1,
+ DISAGREE = 2,
+}
+
+export const useRiskConfirmLetterHook = () => {
+ const watchAppStore = useWatchAppStore();
+ const financeStore = useFinanceStore();
+
+ const dialogVisible = ref(false);
+
+ /** 风险确认同意状态 */
+ const riskConfirmAgreeStatus = ref(RiskConfirmAgreeStatus.DEFAULT);
+
+ /** 观众风险确认信息 */
+ const viewerRiskConfirmInfo = ref(null);
+
+ /** 当前预览的协议 */
+ const curPreviewAgreement = reactive({
+ title: '',
+ src: '',
+ });
+
+ /** 风险确认函-渲染模式 */
+ const riskConfirmLetterRenderMode = computed(() => {
+ if (curPreviewAgreement.src) {
+ return RiskConfirmLetterRenderMode.PREVIEW_AGREEMENT;
+ }
+
+ if (riskConfirmAgreeStatus.value === RiskConfirmAgreeStatus.DISAGREE) {
+ return RiskConfirmLetterRenderMode.DISAGREE_TIPS;
+ }
+
+ return RiskConfirmLetterRenderMode.DEFAULT;
+ });
+
+ const contentWrapperStyle = computed(() => {
+ if (riskConfirmLetterRenderMode.value === RiskConfirmLetterRenderMode.PREVIEW_AGREEMENT) {
+ return {
+ minHeight: '400px',
+ };
+ }
+ return {
+ minHeight: '212px',
+ maxHeight: '212px',
+ };
+ });
+
+ /** 预览协议 */
+ function handlePreviewAgreement(agreement: FinanceRiskConfirmAgreement) {
+ if (!viewerRiskConfirmInfo.value) return;
+
+ const watchCore = getWatchCore();
+ const previewAgreementUrl = watchCore.financeRiskConfirm.getRiskConfirmAgreementContentUrl({
+ agreement,
+ isMobile: isMobile,
+ });
+
+ curPreviewAgreement.title = agreement.agreementTitle;
+ curPreviewAgreement.src = previewAgreementUrl;
+ }
+
+ /** 预览默认协议 */
+ function handlePreviewDefaultAgreement(title: '服务协议' | '隐私协议') {
+ if (!viewerRiskConfirmInfo.value) return;
+
+ const defaultAgreement = viewerRiskConfirmInfo.value.defaultAgreements.find(
+ agreement => agreement.agreementTitle === title,
+ );
+
+ if (!defaultAgreement) {
+ console.error(`找不到《${title}》对应的协议内容`);
+ return;
+ }
+
+ handlePreviewAgreement(defaultAgreement);
+ }
+
+ /** 不同意 */
+ function handleDisagreeRiskConfirm() {
+ riskConfirmAgreeStatus.value = RiskConfirmAgreeStatus.DISAGREE;
+ }
+
+ /** 同意确认 */
+ async function handleAgreeRiskConfirm() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.financeRiskConfirm.agreeRiskConfirm();
+
+ if (result.success) {
+ dialogVisible.value = false;
+ riskConfirmAgreeStatus.value = RiskConfirmAgreeStatus.AGREE;
+
+ financeStore.syncRiskConfirm({
+ riskConfirmStatus: true,
+ });
+ watchAppStore.recoverConnectLiveWatch();
+ } else {
+ console.error(result.failMessage);
+ toast.error('同意失败');
+ }
+ }
+
+ /** 重置风险确认函渲染模式 */
+ function resetRiskConfirmLetterRenderMode() {
+ curPreviewAgreement.title = '';
+ curPreviewAgreement.src = '';
+
+ riskConfirmAgreeStatus.value = RiskConfirmAgreeStatus.DEFAULT;
+ }
+
+ /**
+ * 初始化观众风险确认信息
+ * @desc 需要在获取到观众信息后才能正常使用
+ * */
+ async function initViewerRiskConfirmInfo() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.financeRiskConfirm.getViewerRiskConfirmInfo();
+ if (!result.success) {
+ console.error(result.failMessage);
+ toast.error('获取观众风险确认函信息失败');
+ return;
+ }
+
+ viewerRiskConfirmInfo.value = result;
+ }
+
+ // 副作用监听
+ const stopWatchEffect = watchEffect(async () => {
+ if (!financeStore.riskConfirmEnabled) return;
+ if (watchAppStore.shouldShowSplash) return;
+ if (!watchAppStore.liveWatchInited) return;
+
+ await initViewerRiskConfirmInfo();
+ dialogVisible.value = true;
+ stopWatchEffect();
+ });
+
+ return {
+ dialogVisible,
+ viewerRiskConfirmInfo,
+ curPreviewAgreement,
+
+ riskConfirmLetterRenderMode,
+ contentWrapperStyle,
+
+ resetRiskConfirmLetterRenderMode,
+ handlePreviewAgreement,
+ handlePreviewDefaultAgreement,
+ handleAgreeRiskConfirm,
+ handleDisagreeRiskConfirm,
+ };
+};
diff --git a/src/components/common-business/status-tag/mini-status-tag.vue b/src/components/common-business/status-tag/mini-status-tag.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b4721a3db8723d16867ba83af2e055edec4e3340
--- /dev/null
+++ b/src/components/common-business/status-tag/mini-status-tag.vue
@@ -0,0 +1,35 @@
+
+
+
+ {{ statusTagText }}
+
+
+
+
+
+
diff --git a/src/components/common-business/status-tag/mobile-status-tag.vue b/src/components/common-business/status-tag/mobile-status-tag.vue
new file mode 100644
index 0000000000000000000000000000000000000000..43d0ee26fc81bbc06e9a114f97033ad5d261d9a6
--- /dev/null
+++ b/src/components/common-business/status-tag/mobile-status-tag.vue
@@ -0,0 +1,31 @@
+
+
+ {{ statusTagText }}
+
+
+
+
+
+
diff --git a/src/components/common-business/status-tag/pc-status-tag.vue b/src/components/common-business/status-tag/pc-status-tag.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8f1605198b8924bc1e93be00ea7e305dd57562e3
--- /dev/null
+++ b/src/components/common-business/status-tag/pc-status-tag.vue
@@ -0,0 +1,32 @@
+
+
+ {{ statusTagText }}
+
+
+
+
+
+
diff --git a/src/components/common-business/status-tag/use-status-tag.ts b/src/components/common-business/status-tag/use-status-tag.ts
new file mode 100644
index 0000000000000000000000000000000000000000..92bc6c6a01abf3c6baa7f7d445f2ecf6fad9f800
--- /dev/null
+++ b/src/components/common-business/status-tag/use-status-tag.ts
@@ -0,0 +1,85 @@
+import { translate } from '@/assets/lang';
+import { PropUtils, VueProps } from '@/assets/utils/vue-utils/props-utils';
+import { useChannelStore } from '@/store/use-channel-store';
+import { LiveStatus } from '@polyv/live-watch-sdk';
+import { computed, unref } from 'vue';
+
+export const statusTagProps = () => ({
+ liveStatus: PropUtils.enum(),
+});
+
+/**
+ * 直播状态标签 hook
+ */
+export const useStatusTag = (options: { props: VueProps }) => {
+ const { props } = options;
+
+ const channelStore = useChannelStore();
+
+ const liveStatus = computed(() => {
+ return props.liveStatus ?? channelStore.liveStatus;
+ });
+
+ /**
+ * 获取直播状态文本
+ * @param status 直播状态
+ */
+ function getLiveStatusText(status: LiveStatus): string {
+ const tagTexts: Record = {
+ [LiveStatus.Live]: translate('liveStatus.live'),
+ [LiveStatus.Waiting]: translate('liveStatus.waiting'),
+ [LiveStatus.End]: translate('liveStatus.end'),
+ [LiveStatus.Stop]: translate('liveStatus.stop'),
+ [LiveStatus.Playback]: translate('liveStatus.playback'),
+ [LiveStatus.UnStart]: translate('liveStatus.unStart'),
+ };
+
+ return tagTexts[status];
+ }
+
+ /**
+ * 直播状态标签文本
+ */
+ const statusTagText = computed(() => {
+ return getLiveStatusText(unref(liveStatus));
+ });
+
+ return {
+ liveStatus,
+ statusTagText,
+ };
+};
+
+export const useMiniStatusTagStyle = (options: { props: VueProps }) => {
+ const { props } = options;
+
+ const channelStore = useChannelStore();
+
+ const liveStatus = computed(() => {
+ return props.liveStatus ?? channelStore.liveStatus;
+ });
+
+ const tagColors: Record = {
+ [LiveStatus.Live]: 'linear-gradient(150deg, #F06E6E 0%, #E63A3A 100%)',
+ [LiveStatus.Playback]: 'linear-gradient(152deg, #5ba3ff, #3082fe)',
+ [LiveStatus.Waiting]: 'linear-gradient(152deg, #abafc0, #73778c)',
+ [LiveStatus.End]: 'linear-gradient(152deg, #abafc0, #73778c)',
+ [LiveStatus.Stop]: 'linear-gradient(152deg, #abafc0, #73778c)',
+ [LiveStatus.UnStart]: 'linear-gradient(152deg, #abafc0, #73778c)',
+ };
+
+ /**
+ * 直播状态标签样式
+ */
+ const minStatusTagStyle = computed(() => {
+ const color = tagColors[unref(liveStatus)];
+
+ return {
+ background: color,
+ };
+ });
+
+ return {
+ minStatusTagStyle,
+ };
+};
diff --git a/src/components/common-business/web-share-panel/imgs/icon-share-wechat.png b/src/components/common-business/web-share-panel/imgs/icon-share-wechat.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e6a3c53b7e77c03a72910cbec5ba502e4eea565
Binary files /dev/null and b/src/components/common-business/web-share-panel/imgs/icon-share-wechat.png differ
diff --git a/src/components/common-business/web-share-panel/web-share-panel.vue b/src/components/common-business/web-share-panel/web-share-panel.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c2c25b098137c0e3903fefe893d8bf28e177ffc6
--- /dev/null
+++ b/src/components/common-business/web-share-panel/web-share-panel.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+ {{ $lang('copy.link') }}
+
+
+
+
+
+
+
![]()
+
+
+
+ {{ $lang('share.wxScan') }}
+
+
+
+
+
+
+
+
diff --git a/src/components/component-icons/mobile/icons/arrow-down/index.ts b/src/components/component-icons/mobile/icons/arrow-down/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fe00365bba2add6700cef843d0ac17afcee5ce2c
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/arrow-down/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowDown Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-down',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/arrow-left/index.ts b/src/components/component-icons/mobile/icons/arrow-left/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f90e0b768ba5dd534c238ff33c3dd64fced619d4
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/arrow-left/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowLeft Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-left',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/arrow-right/index.ts b/src/components/component-icons/mobile/icons/arrow-right/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9528ebce06bc2357cc5331a1847950ea6baab8a7
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/arrow-right/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowRight Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-right',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/arrow-up/index.ts b/src/components/component-icons/mobile/icons/arrow-up/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2217e91af9ba28676161be04ffd36677d2690d3
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/arrow-up/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowUp Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-up',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/booking/index.ts b/src/components/component-icons/mobile/icons/booking/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c7b154e2f3639d0e1f6bfe3a7c8f24f696671efd
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/booking/index.ts
@@ -0,0 +1,23 @@
+/**
+ * @file Booking Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'booking',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/check-round-fill/index.ts b/src/components/component-icons/mobile/icons/check-round-fill/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e87b601700a4c7754a212a9662310ea5682fee55
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/check-round-fill/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file CheckRoundFill Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'check-round-fill',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/check/index.ts b/src/components/component-icons/mobile/icons/check/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a3220467be73f85d008953f59a5c7cf1f996f390
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/check/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Check Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'check',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/close-round/index.ts b/src/components/component-icons/mobile/icons/close-round/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..77aecca95e47b4069f31739cf0a1cf996f1338d2
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/close-round/index.ts
@@ -0,0 +1,27 @@
+/**
+ * @file CloseRound Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'close-round',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/close/index.ts b/src/components/component-icons/mobile/icons/close/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5816d4c087c17e5cdc62d4b99b06cd33a86d60a3
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/close/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Close Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'close',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/danmu-switch/index.ts b/src/components/component-icons/mobile/icons/danmu-switch/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a228e295a7d058124749d67bb4cc524ab725d05
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/danmu-switch/index.ts
@@ -0,0 +1,23 @@
+/**
+ * @file DanmuSwitch Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'danmu-switch',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/delete/index.ts b/src/components/component-icons/mobile/icons/delete/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..087ea0dcbf37779e393bb7a64285443283bab044
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/delete/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Delete Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'delete',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/emotion/index.ts b/src/components/component-icons/mobile/icons/emotion/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9a9169b925d4899db377e147d3f6b10abe8e119b
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/emotion/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Emotion Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'emotion',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/forbid/index.ts b/src/components/component-icons/mobile/icons/forbid/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0ae523b984b2aecd8b7da5926cfea3adc7f59252
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/forbid/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Forbid Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'forbid',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/heart/index.ts b/src/components/component-icons/mobile/icons/heart/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bea63f087670b4461408caeebe85dfd0cb7981ac
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/heart/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Heart Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'heart',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/keyboard/index.ts b/src/components/component-icons/mobile/icons/keyboard/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cd2f86dfaba8ff01ea772c8101e898d20396c6bc
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/keyboard/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Keyboard Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'keyboard',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/like/index.ts b/src/components/component-icons/mobile/icons/like/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fe50be7b594dcc11333170af9404e111367c8958
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/like/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Like Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'like',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/media/index.ts b/src/components/component-icons/mobile/icons/media/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..977c0eba8fce744cb304362f369acb0a3f61fe85
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/media/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Media Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'media',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/message/index.ts b/src/components/component-icons/mobile/icons/message/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d47217395cb514de64061e48d3b725c2a0c0b7b9
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/message/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Message Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'message',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/people/index.ts b/src/components/component-icons/mobile/icons/people/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..88fe585e7f43eb7652173f36529a2e4adce4b2cc
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/people/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file People Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'people',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/play-round/index.ts b/src/components/component-icons/mobile/icons/play-round/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c66f1b993b236d81f56485aa94d4393432caa5f0
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/play-round/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file PlayRound Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'play-round',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/product/index.ts b/src/components/component-icons/mobile/icons/product/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0f45c9e61af53d807b7207209fb6868767de1478
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/product/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Product Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'product',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/smiling-face/index.ts b/src/components/component-icons/mobile/icons/smiling-face/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..550542558ba26ccc5a07273055a84e546786b7b0
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/smiling-face/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file SmilingFace Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'smiling-face',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/translate/index.ts b/src/components/component-icons/mobile/icons/translate/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..17767596edc19e6231de45661c2cdc3ae2362a50
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/translate/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Translate Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'translate',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/video-call/index.ts b/src/components/component-icons/mobile/icons/video-call/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..066397adc618e7d1a27c4dc9677cffdfb40de283
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/video-call/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file VideoCall Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'video-call',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/icons/voice-call/index.ts b/src/components/component-icons/mobile/icons/voice-call/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b40bd63e3c946e45a38d188b429c92d0e550c380
--- /dev/null
+++ b/src/components/component-icons/mobile/icons/voice-call/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file VoiceCall Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'voice-call',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/mobile/map.ts b/src/components/component-icons/mobile/map.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5686c42403f09a14f6a7ceb66f0335057422bd58
--- /dev/null
+++ b/src/components/component-icons/mobile/map.ts
@@ -0,0 +1,30 @@
+/**
+ * @file All Icon Exporter
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+export { default as MobileIconArrowDown } from './icons/arrow-down';
+export { default as MobileIconArrowLeft } from './icons/arrow-left';
+export { default as MobileIconArrowRight } from './icons/arrow-right';
+export { default as MobileIconArrowUp } from './icons/arrow-up';
+export { default as MobileIconBooking } from './icons/booking';
+export { default as MobileIconCheck } from './icons/check';
+export { default as MobileIconCheckRoundFill } from './icons/check-round-fill';
+export { default as MobileIconClose } from './icons/close';
+export { default as MobileIconCloseRound } from './icons/close-round';
+export { default as MobileIconDanmuSwitch } from './icons/danmu-switch';
+export { default as MobileIconDelete } from './icons/delete';
+export { default as MobileIconEmotion } from './icons/emotion';
+export { default as MobileIconForbid } from './icons/forbid';
+export { default as MobileIconHeart } from './icons/heart';
+export { default as MobileIconKeyboard } from './icons/keyboard';
+export { default as MobileIconLike } from './icons/like';
+export { default as MobileIconMedia } from './icons/media';
+export { default as MobileIconMessage } from './icons/message';
+export { default as MobileIconPeople } from './icons/people';
+export { default as MobileIconPlayRound } from './icons/play-round';
+export { default as MobileIconProduct } from './icons/product';
+export { default as MobileIconSmilingFace } from './icons/smiling-face';
+export { default as MobileIconTranslate } from './icons/translate';
+export { default as MobileIconVideoCall } from './icons/video-call';
+export { default as MobileIconVoiceCall } from './icons/voice-call';
diff --git a/src/components/component-icons/pc/icons/apply-video-call/index.ts b/src/components/component-icons/pc/icons/apply-video-call/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b8cf5928e34fef61d3ddf787c6e0b90ee9c1c85d
--- /dev/null
+++ b/src/components/component-icons/pc/icons/apply-video-call/index.ts
@@ -0,0 +1,27 @@
+/**
+ * @file ApplyVideoCall Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'apply-video-call',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/arrow-down/index.ts b/src/components/component-icons/pc/icons/arrow-down/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fe00365bba2add6700cef843d0ac17afcee5ce2c
--- /dev/null
+++ b/src/components/component-icons/pc/icons/arrow-down/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowDown Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-down',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/arrow-left/index.ts b/src/components/component-icons/pc/icons/arrow-left/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f90e0b768ba5dd534c238ff33c3dd64fced619d4
--- /dev/null
+++ b/src/components/component-icons/pc/icons/arrow-left/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowLeft Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-left',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/arrow-right/index.ts b/src/components/component-icons/pc/icons/arrow-right/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9528ebce06bc2357cc5331a1847950ea6baab8a7
--- /dev/null
+++ b/src/components/component-icons/pc/icons/arrow-right/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowRight Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-right',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/arrow-up/index.ts b/src/components/component-icons/pc/icons/arrow-up/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2217e91af9ba28676161be04ffd36677d2690d3
--- /dev/null
+++ b/src/components/component-icons/pc/icons/arrow-up/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file ArrowUp Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'arrow-up',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/booking/index.ts b/src/components/component-icons/pc/icons/booking/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c7b154e2f3639d0e1f6bfe3a7c8f24f696671efd
--- /dev/null
+++ b/src/components/component-icons/pc/icons/booking/index.ts
@@ -0,0 +1,23 @@
+/**
+ * @file Booking Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'booking',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/bulletin/index.ts b/src/components/component-icons/pc/icons/bulletin/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..80aba63c9495b010a8a0cf9ff8545201d5381262
--- /dev/null
+++ b/src/components/component-icons/pc/icons/bulletin/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Bulletin Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'bulletin',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/caret-down/index.ts b/src/components/component-icons/pc/icons/caret-down/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..58962db405272b23c85f61aec4c2ec44d5e8cfe6
--- /dev/null
+++ b/src/components/component-icons/pc/icons/caret-down/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file CaretDown Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'caret-down',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/caret-left/index.ts b/src/components/component-icons/pc/icons/caret-left/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0b50c8f6947c3cbc35beaab03a5b75553506f5ad
--- /dev/null
+++ b/src/components/component-icons/pc/icons/caret-left/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file CaretLeft Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'caret-left',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/caret-right/index.ts b/src/components/component-icons/pc/icons/caret-right/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae81b889bf1e48c9f027f4fb31c89545685e66d3
--- /dev/null
+++ b/src/components/component-icons/pc/icons/caret-right/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file CaretRight Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'caret-right',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/caret-up/index.ts b/src/components/component-icons/pc/icons/caret-up/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7c135d63bcfda34731abb1cfc6d1891e40ed8c68
--- /dev/null
+++ b/src/components/component-icons/pc/icons/caret-up/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file CaretUp Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'caret-up',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/check-round-fill/index.ts b/src/components/component-icons/pc/icons/check-round-fill/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e87b601700a4c7754a212a9662310ea5682fee55
--- /dev/null
+++ b/src/components/component-icons/pc/icons/check-round-fill/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file CheckRoundFill Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'check-round-fill',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/check/index.ts b/src/components/component-icons/pc/icons/check/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a3220467be73f85d008953f59a5c7cf1f996f390
--- /dev/null
+++ b/src/components/component-icons/pc/icons/check/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Check Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'check',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/close-round/index.ts b/src/components/component-icons/pc/icons/close-round/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..77aecca95e47b4069f31739cf0a1cf996f1338d2
--- /dev/null
+++ b/src/components/component-icons/pc/icons/close-round/index.ts
@@ -0,0 +1,27 @@
+/**
+ * @file CloseRound Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'close-round',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/close/index.ts b/src/components/component-icons/pc/icons/close/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..be2561ad1caea96323b05c7e3d8d0a5b88faa150
--- /dev/null
+++ b/src/components/component-icons/pc/icons/close/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Close Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'close',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/device-setting/index.ts b/src/components/component-icons/pc/icons/device-setting/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1605e914e4f4cc56174c28d823ac0b636f15f9a6
--- /dev/null
+++ b/src/components/component-icons/pc/icons/device-setting/index.ts
@@ -0,0 +1,30 @@
+/**
+ * @file DeviceSetting Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'device-setting',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/emotion/index.ts b/src/components/component-icons/pc/icons/emotion/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a2156e28cbf31f70f59aed6b1e23cebcc2cf515
--- /dev/null
+++ b/src/components/component-icons/pc/icons/emotion/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Emotion Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'emotion',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/feedback/index.ts b/src/components/component-icons/pc/icons/feedback/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5e5f1a4ded9a4cd40a5187dc3baf826efd6414ff
--- /dev/null
+++ b/src/components/component-icons/pc/icons/feedback/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Feedback Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'feedback',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/forbid/index.ts b/src/components/component-icons/pc/icons/forbid/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0ae523b984b2aecd8b7da5926cfea3adc7f59252
--- /dev/null
+++ b/src/components/component-icons/pc/icons/forbid/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Forbid Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'forbid',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/hang-up/index.ts b/src/components/component-icons/pc/icons/hang-up/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1c913291d8e8b943ba3157ea384ddcb48a13247d
--- /dev/null
+++ b/src/components/component-icons/pc/icons/hang-up/index.ts
@@ -0,0 +1,27 @@
+/**
+ * @file HangUp Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'hang-up',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/lang/index.ts b/src/components/component-icons/pc/icons/lang/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..152c8a5492f9f55ba0f718397dc34273aa2823a1
--- /dev/null
+++ b/src/components/component-icons/pc/icons/lang/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Lang Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'lang',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/media/index.ts b/src/components/component-icons/pc/icons/media/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c19cb38c80c3a9baf9695733323200c39ac14fd6
--- /dev/null
+++ b/src/components/component-icons/pc/icons/media/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Media Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'media',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/people/index.ts b/src/components/component-icons/pc/icons/people/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38353b5638f816d53e3c5a1c65ad6bc77a8145fe
--- /dev/null
+++ b/src/components/component-icons/pc/icons/people/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file People Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'people',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/pic/index.ts b/src/components/component-icons/pc/icons/pic/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1a90839cd3e62061ddb203a3e98258adbec31d2b
--- /dev/null
+++ b/src/components/component-icons/pc/icons/pic/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Pic Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'pic',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/picture/index.ts b/src/components/component-icons/pc/icons/picture/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9790c0f436346af51d60b7164e66fe8f2e8bfee0
--- /dev/null
+++ b/src/components/component-icons/pc/icons/picture/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Picture Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'picture',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/play-round/index.ts b/src/components/component-icons/pc/icons/play-round/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4ea18d460b5435c695e86e96ec1123ea98c15094
--- /dev/null
+++ b/src/components/component-icons/pc/icons/play-round/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file PlayRound Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'play-round',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/set-nick/index.ts b/src/components/component-icons/pc/icons/set-nick/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6cd5e52435f55071e1b048833935aa935ea3ea12
--- /dev/null
+++ b/src/components/component-icons/pc/icons/set-nick/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file SetNick Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'set-nick',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/share/index.ts b/src/components/component-icons/pc/icons/share/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fcf9415277eb1214df505b1f3dc2973cd1785e4e
--- /dev/null
+++ b/src/components/component-icons/pc/icons/share/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Share Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'share',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/tips/index.ts b/src/components/component-icons/pc/icons/tips/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4c9b2f082c92f5bb5a7f95c8af311d14cbfc0b61
--- /dev/null
+++ b/src/components/component-icons/pc/icons/tips/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Tips Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'tips',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/translate/index.ts b/src/components/component-icons/pc/icons/translate/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..17767596edc19e6231de45661c2cdc3ae2362a50
--- /dev/null
+++ b/src/components/component-icons/pc/icons/translate/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file Translate Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'translate',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/video-call/index.ts b/src/components/component-icons/pc/icons/video-call/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..066397adc618e7d1a27c4dc9677cffdfb40de283
--- /dev/null
+++ b/src/components/component-icons/pc/icons/video-call/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file VideoCall Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'video-call',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/voice-call/index.ts b/src/components/component-icons/pc/icons/voice-call/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b40bd63e3c946e45a38d188b429c92d0e550c380
--- /dev/null
+++ b/src/components/component-icons/pc/icons/voice-call/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file VoiceCall Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'voice-call',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/icons/warning-round-fill/index.ts b/src/components/component-icons/pc/icons/warning-round-fill/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..105675bb9f931a33e86c8773a3db9dd95be7a48b
--- /dev/null
+++ b/src/components/component-icons/pc/icons/warning-round-fill/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @file WarningRoundFill Icon
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+import { IconBuilder } from '@polyv/icons-vue/icon-builder';
+
+export default IconBuilder(
+ 'warning-round-fill',
+ (data) => `
+
+`
+);
diff --git a/src/components/component-icons/pc/map.ts b/src/components/component-icons/pc/map.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b96272336565998921b778a576249207038d119d
--- /dev/null
+++ b/src/components/component-icons/pc/map.ts
@@ -0,0 +1,38 @@
+/**
+ * @file All Icon Exporter
+ * @author Auto Generated by @polyv/icons-cli
+ */
+
+export { default as PcIconApplyVideoCall } from './icons/apply-video-call';
+export { default as PcIconArrowDown } from './icons/arrow-down';
+export { default as PcIconArrowLeft } from './icons/arrow-left';
+export { default as PcIconArrowRight } from './icons/arrow-right';
+export { default as PcIconArrowUp } from './icons/arrow-up';
+export { default as PcIconBooking } from './icons/booking';
+export { default as PcIconBulletin } from './icons/bulletin';
+export { default as PcIconCaretDown } from './icons/caret-down';
+export { default as PcIconCaretLeft } from './icons/caret-left';
+export { default as PcIconCaretRight } from './icons/caret-right';
+export { default as PcIconCaretUp } from './icons/caret-up';
+export { default as PcIconCheck } from './icons/check';
+export { default as PcIconCheckRoundFill } from './icons/check-round-fill';
+export { default as PcIconClose } from './icons/close';
+export { default as PcIconCloseRound } from './icons/close-round';
+export { default as PcIconDeviceSetting } from './icons/device-setting';
+export { default as PcIconEmotion } from './icons/emotion';
+export { default as PcIconFeedback } from './icons/feedback';
+export { default as PcIconForbid } from './icons/forbid';
+export { default as PcIconHangUp } from './icons/hang-up';
+export { default as PcIconLang } from './icons/lang';
+export { default as PcIconMedia } from './icons/media';
+export { default as PcIconPeople } from './icons/people';
+export { default as PcIconPic } from './icons/pic';
+export { default as PcIconPicture } from './icons/picture';
+export { default as PcIconPlayRound } from './icons/play-round';
+export { default as PcIconSetNick } from './icons/set-nick';
+export { default as PcIconShare } from './icons/share';
+export { default as PcIconTips } from './icons/tips';
+export { default as PcIconTranslate } from './icons/translate';
+export { default as PcIconVideoCall } from './icons/video-call';
+export { default as PcIconVoiceCall } from './icons/voice-call';
+export { default as PcIconWarningRoundFill } from './icons/warning-round-fill';
diff --git a/src/components/page-splash-common/auth/auth-code/mobile-auth-code.vue b/src/components/page-splash-common/auth/auth-code/mobile-auth-code.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c2327fd038ae598ffec4736635bc8f605b02427a
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-code/mobile-auth-code.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
![]()
+
+
+ {{ qrcodeTips }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/page-splash-common/auth/auth-code/pc-auth-code.vue b/src/components/page-splash-common/auth/auth-code/pc-auth-code.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b597d2990caebebd310e6b67a11c0dfd61d66156
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-code/pc-auth-code.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
![]()
+
+
+ {{ qrcodeTips }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/page-splash-common/auth/auth-code/use-auth-code.ts b/src/components/page-splash-common/auth/auth-code/use-auth-code.ts
new file mode 100644
index 0000000000000000000000000000000000000000..02961e6cf2d8a1ff8c453f5850840c4fb4628e15
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-code/use-auth-code.ts
@@ -0,0 +1,93 @@
+import { translate } from '@/assets/lang';
+import { isFormItemInstance } from '@/components/common-base/form/hooks/use-form-item';
+import { getWatchCore } from '@/core/watch-sdk';
+import { ValidatorRules } from '@/plugins/async-validator';
+import { AuthSettingItemCode } from '@polyv/live-watch-sdk';
+import { computed, reactive, ref, unref } from 'vue';
+import { useAuthButtonInject } from '../hooks/use-auth-button';
+import { useAuthCommon, useAuthProtocol } from '../hooks/use-auth-common';
+
+/** 观看条件:验证码 hook */
+export const useAuthCode = () => {
+ const { authItem } = useAuthButtonInject(onClickAuthButton);
+
+ /** 弹层是否显示 */
+ const dialogVisible = ref(false);
+
+ /** 弹层标题 */
+ const dialogTitle = computed(() => authItem.codeAuthTips);
+
+ /** 表单对象 */
+ const formData = reactive({
+ code: '',
+ checkProtocol: false,
+ });
+
+ const { protocolContent, protocolFormRules } = useAuthProtocol({
+ authItem,
+ formData,
+ });
+
+ /** 处理点击授权入口按钮 */
+ function onClickAuthButton(): void {
+ dialogVisible.value = true;
+ formData.code = '';
+ formData.checkProtocol = false;
+ }
+
+ /** 处理点击取消 */
+ function onClickCancel(): void {
+ dialogVisible.value = false;
+ }
+
+ /** 二维码图片地址 */
+ const qrcodeImg = computed(() => authItem.qcodeImg);
+
+ /** 二维码提示文案 */
+ const qrcodeTips = computed(() => authItem.qcodeTips);
+
+ const formRules = computed(() => {
+ return {
+ code: { type: 'string', message: translate('auth.error.codeEmpty'), required: true },
+ ...unref(protocolFormRules),
+ };
+ });
+
+ const { failMessage, handleAuthVerifySuccess, handleAuthVerifyFail } = useAuthCommon();
+ const formItemRef = ref();
+
+ /** 提交授权表单 */
+ async function submitAuth() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.auth.verifyCodeAuth({
+ code: formData.code,
+ });
+
+ if (result.success) {
+ dialogVisible.value = false;
+ handleAuthVerifySuccess(result);
+ } else {
+ handleAuthVerifyFail(result);
+
+ // 设置表单的异常提示
+ const formItemInstance = unref(formItemRef);
+ if (failMessage.value && isFormItemInstance(formItemInstance)) {
+ formItemInstance.setErrorMessage(failMessage.value);
+ }
+ }
+ }
+
+ return {
+ dialogVisible,
+ dialogTitle,
+ qrcodeImg,
+ qrcodeTips,
+ protocolContent,
+ formData,
+ formRules,
+ onClickCancel,
+ submitAuth,
+ failMessage,
+ formItemRef,
+ };
+};
diff --git a/src/components/page-splash-common/auth/auth-custom/auth-custom.vue b/src/components/page-splash-common/auth/auth-custom/auth-custom.vue
new file mode 100644
index 0000000000000000000000000000000000000000..94d0e9447a3fd5a2c923018d7d338e998fe3c0d4
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-custom/auth-custom.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/src/components/page-splash-common/auth/auth-custom/use-auth-custom.ts b/src/components/page-splash-common/auth/auth-custom/use-auth-custom.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0ff0f723f74b5f2f58d3ce5513695191d28eedeb
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-custom/use-auth-custom.ts
@@ -0,0 +1,51 @@
+import { getWatchCore } from '@/core/watch-sdk';
+import { AuthSettingItemCustom } from '@polyv/live-watch-sdk';
+import { useAuthButtonInject } from '../hooks/use-auth-button';
+import { useAuthCommon } from '../hooks/use-auth-common';
+
+/**
+ * 自定义授权操作 hook
+ */
+export const useAuthCustomAction = () => {
+ const { handleAuthVerifySuccess, handleAuthVerifyFail } = useAuthCommon();
+
+ /** 跳转到自定义授权地址 */
+ async function redirectCustomAuthUrl() {
+ const watchCore = getWatchCore();
+ await watchCore.auth.redirectCustomAuthUrl();
+ }
+
+ /** 允许验证自定义授权 */
+ async function allowToVerifyCustomAuth(): Promise {
+ const watchCore = getWatchCore();
+ return watchCore.auth.allowToVerifyCustomAuth();
+ }
+
+ /** 验证自定义授权 */
+ async function verifyAuthCustom() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.auth.verifyCustomAuth();
+ if (result.success) {
+ await handleAuthVerifySuccess(result);
+ } else {
+ await handleAuthVerifyFail(result);
+ }
+ }
+
+ return {
+ redirectCustomAuthUrl,
+ allowToVerifyCustomAuth,
+ verifyAuthCustom,
+ };
+};
+
+export const useAuthCustom = () => {
+ useAuthButtonInject(onClickAuthButton);
+
+ const { redirectCustomAuthUrl } = useAuthCustomAction();
+
+ /** 处理点击授权入口按钮 */
+ function onClickAuthButton() {
+ redirectCustomAuthUrl();
+ }
+};
diff --git a/src/components/page-splash-common/auth/auth-direct/use-auth-direct.ts b/src/components/page-splash-common/auth/auth-direct/use-auth-direct.ts
new file mode 100644
index 0000000000000000000000000000000000000000..035cdcd35b0eebfe0c9d422bb1b23490dd359c50
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-direct/use-auth-direct.ts
@@ -0,0 +1,62 @@
+import { translate } from '@/assets/lang';
+import { getWatchCore } from '@/core/watch-sdk';
+import { PageErrorType } from '@/app/layout/page-error/page-error-type';
+import { useWatchAppStore } from '@/store/use-watch-app-store';
+import { useAuthCommon } from '../hooks/use-auth-common';
+import { DirectAuthQueryParams } from '@polyv/live-watch-sdk';
+import { paramGetter } from '@/hooks/core/use-query-params';
+
+export const useAuthDirectAction = () => {
+ const watchAppStore = useWatchAppStore();
+
+ const { handleAuthVerifySuccess, handleAuthVerifyFail } = useAuthCommon();
+
+ /**
+ * 获取独立授权需要校验的参数
+ */
+ function getDirectAuthQueryParams(): DirectAuthQueryParams {
+ return {
+ userid: paramGetter.userid() || '',
+ ts: paramGetter.ts() || '',
+ sign: paramGetter.sign() || '',
+ nickname: paramGetter.nickname() || '',
+ };
+ }
+
+ /**
+ * 允许验证独立授权
+ */
+ async function allowToVerifyDirectAuth(): Promise {
+ const watchCore = getWatchCore();
+ return watchCore.auth.allowToVerifyDirectAuth(getDirectAuthQueryParams());
+ }
+
+ /**
+ * 重定向到独立授权失败页
+ */
+ async function redirectDirectFailUrl(): Promise {
+ watchAppStore.setPageError({
+ type: PageErrorType.DirectAuthFail,
+ title: translate('auth.error.directFail'),
+ });
+ }
+
+ /**
+ * 验证独立授权
+ */
+ async function verifyAuthDirect() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.auth.verifyDirectAuth(getDirectAuthQueryParams());
+ if (result.success) {
+ await handleAuthVerifySuccess(result);
+ } else {
+ await handleAuthVerifyFail(result);
+ }
+ }
+
+ return {
+ allowToVerifyDirectAuth,
+ redirectDirectFailUrl,
+ verifyAuthDirect,
+ };
+};
diff --git a/src/components/page-splash-common/auth/auth-external/auth-external.vue b/src/components/page-splash-common/auth/auth-external/auth-external.vue
new file mode 100644
index 0000000000000000000000000000000000000000..45922ac4d65db1da7db57068979cc12050508045
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-external/auth-external.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/src/components/page-splash-common/auth/auth-external/use-auth-external.ts b/src/components/page-splash-common/auth/auth-external/use-auth-external.ts
new file mode 100644
index 0000000000000000000000000000000000000000..504231899c1762d92ec1de3a4e09b3af48e30f96
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-external/use-auth-external.ts
@@ -0,0 +1,81 @@
+import { translate } from '@/assets/lang';
+import { getWatchCore } from '@/core/watch-sdk';
+import { PageErrorType } from '@/app/layout/page-error/page-error-type';
+import { useWatchAppStore } from '@/store/use-watch-app-store';
+import { AuthSettingItemExternal, ExternalAuthQueryParams } from '@polyv/live-watch-sdk';
+import { useAuthButtonInject } from '../hooks/use-auth-button';
+import { useAuthCommon } from '../hooks/use-auth-common';
+import { paramGetter } from '@/hooks/core/use-query-params';
+
+/**
+ * 外部授权操作 hook
+ */
+export const useAuthExternalAction = () => {
+ const watchAppStore = useWatchAppStore();
+ const { handleAuthVerifySuccess, handleAuthVerifyFail } = useAuthCommon();
+
+ /**
+ * 获取外部授权需要校验的参数
+ */
+ function getExternalAuthQueryParams(): ExternalAuthQueryParams {
+ return {
+ userid: paramGetter.userid() || '',
+ ts: paramGetter.ts() || '',
+ sign: paramGetter.sign() || '',
+ };
+ }
+
+ /**
+ * 允许验证外部授权
+ */
+ async function allowToVerifyExternalAuth(): Promise {
+ const watchCore = getWatchCore();
+ return watchCore.auth.allowToVerifyExternalAuth(getExternalAuthQueryParams());
+ }
+
+ /**
+ * 重定向到外部授权地址
+ */
+ async function redirectExternalFailUrl() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.auth.redirectExternalFailUrl();
+
+ // 跳转不成功,没有配置错误地址,显示本地的错误页
+ if (!result) {
+ watchAppStore.setPageError({
+ type: PageErrorType.ExternalAuthFail,
+ title: translate('auth.error.externalFail'),
+ });
+ }
+ }
+
+ /**
+ * 验证外部授权
+ */
+ async function verifyAuthExternal() {
+ const watchCore = getWatchCore();
+ const result = await watchCore.auth.verifyExternalAuth(getExternalAuthQueryParams());
+ if (result.success) {
+ await handleAuthVerifySuccess(result);
+ } else {
+ await handleAuthVerifyFail(result);
+ }
+ }
+
+ return {
+ allowToVerifyExternalAuth,
+ redirectExternalFailUrl,
+ verifyAuthExternal,
+ };
+};
+
+export const useAuthExternal = () => {
+ useAuthButtonInject(onClickAuthButton);
+
+ const { redirectExternalFailUrl } = useAuthExternalAction();
+
+ /** 处理点击授权入口按钮 */
+ function onClickAuthButton() {
+ redirectExternalFailUrl();
+ }
+};
diff --git a/src/components/page-splash-common/auth/auth-info/hooks/use-auth-info-form.ts b/src/components/page-splash-common/auth/auth-info/hooks/use-auth-info-form.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7a5c2a279cd40591bf2b68fcd4980d450103f60
--- /dev/null
+++ b/src/components/page-splash-common/auth/auth-info/hooks/use-auth-info-form.ts
@@ -0,0 +1,250 @@
+import { translate } from '@/assets/lang';
+import { validateCnAndEn, validatePhoneNumber } from '@/assets/utils/validate';
+import { emitFunc, VueEmit } from '@/assets/utils/vue-utils/emit-utils';
+import { ImageVerifyInputInstance } from '@/components/common-base/form/form-image-verify-input/type';
+import { SelectOptionItem } from '@/components/common-base/form/form-select/types/form-select-types';
+import { toast } from '@/hooks/components/use-toast';
+import { getWatchCore } from '@/core/watch-sdk';
+import { ValidatorRules } from '@/plugins/async-validator';
+import { useAuthStore } from '@/store/use-auth-store';
+import {
+ AuthInfoPropValKey,
+ AuthInfoPropValType,
+ AuthInfoType,
+ AuthSettingItemInfo,
+} from '@polyv/live-watch-sdk';
+import { storeToRefs } from 'pinia';
+import { computed, reactive, ref, unref } from 'vue';
+import { useAuthButtonInject } from '../../hooks/use-auth-button';
+import { useAuthCommon, useAuthProtocol } from '../../hooks/use-auth-common';
+import { useAuthInfoSetting } from './use-auth-info-setting';
+
+export const authInfoFormEmits = () => ({
+ /** 关闭窗口 */
+ 'close-dialog': emitFunc(),
+ /** 点击了已登录 */
+ 'click-logined': emitFunc(),
+});
+
+interface BasicFormData {
+ /** 手机号 */
+ phoneNumber: string;
+ /** 手机区号 */
+ areaCode: string;
+ /** 图片验证码 */
+ imageId: string;
+ imageCaptcha: string;
+ /** 短信验证码 */
+ smsCode: string;
+ /** 隐私协议勾选 */
+ checkProtocol: boolean;
+}
+
+export interface AuthInfoFormInstance {
+ /** 重置表单 */
+ resetFormData(): void;
+}
+
+export const useAuthInfoForm = (options: { emit: VueEmit }) => {
+ const { emit } = options;
+
+ const authStore = useAuthStore();
+ const { authItem } = useAuthButtonInject();
+ const { smsVerifyEnabled, hasMobileField } = useAuthInfoSetting();
+
+ const { authInfoFields } = storeToRefs(authStore);
+
+ /** 提示信息 */
+ const infoDesc = computed(() => authItem.infoDesc);
+
+ const imageVerifyInputRef = ref();
+
+ /** 格式化下拉选项 */
+ function formatSelectOptions(options?: string): SelectOptionItem[] {
+ if (!options) {
+ return [];
+ }
+
+ const strs = options.split(',').filter(str => !!str);
+ return strs.map(str => {
+ return {
+ label: str,
+ value: str,
+ };
+ });
+ }
+
+ /** 表单字段前缀 */
+ const fieldPrefix = 'propValue' as const;
+
+ function generateFromData(): AuthInfoPropValType & BasicFormData {
+ function getInitFormData() {
+ const data: AuthInfoPropValType = {};
+
+ unref(authInfoFields).forEach((option, index) => {
+ data[`${fieldPrefix}${index + 1}`] = '';
+ });
+
+ return data;
+ }
+
+ return {
+ ...getInitFormData(),
+ // 手机号
+ phoneNumber: '',
+ // 手机区号
+ areaCode: '+86',
+ // 图片验证码 id
+ imageId: '',
+ // 图片验证码
+ imageCaptcha: '',
+ // 短信验证码
+ smsCode: '',
+ // 隐私协议勾选
+ checkProtocol: false,
+ };
+ }
+
+ const formData = reactive(generateFromData());
+
+ function resetFormData() {
+ const generatedData = generateFromData();
+ for (const i in formData) {
+ const key = i as keyof AuthInfoPropValType;
+ formData[key] = generatedData[key];
+ }
+ }
+
+ function onPhoneNumberChange(phoneNumber: string) {
+ formData.phoneNumber = phoneNumber;
+ }
+
+ const { protocolContent, protocolFormRules } = useAuthProtocol({
+ authItem,
+ formData,
+ });
+
+ const formRules = computed