diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..14d44d182c7326226da21a64a810170cf9f4032d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+/output
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/settings.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/README.en.md b/README.en.md
index 2a12cdab03fc2f99a762eadc1d0ac59344d3c789..c0d5e45edc8c7dd4f03a5995a7920f444404df6d 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,36 +1,5 @@
-# opendesign-build
+# @opensig/open-analytics
#### Description
-The repository of OpenDesign build
-#### Software Architecture
-Software architecture description
-
-#### Installation
-
-1. xxxx
-2. xxxx
-3. xxxx
-
-#### Instructions
-
-1. xxxx
-2. xxxx
-3. xxxx
-
-#### Contribution
-
-1. Fork the repository
-2. Create Feat_xxx branch
-3. Commit your code
-4. Create Pull Request
-
-
-#### Gitee Feature
-
-1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
-2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
-3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
-4. The most valuable open source project [GVP](https://gitee.com/gvp)
-5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
-6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
+data collection sdk
diff --git a/README.md b/README.md
index e4ff2bab7ad079cfec40fee717dbb26604e917ae..1489a755bca16f33e0c9c0ac567e6a8b31684bea 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,5 @@
-# opendesign-build
+# @opensig/open-analytics
#### 介绍
-The repository of OpenDesign build
-#### 软件架构
-软件架构说明
-
-
-#### 安装教程
-
-1. xxxx
-2. xxxx
-3. xxxx
-
-#### 使用说明
-
-1. xxxx
-2. xxxx
-3. xxxx
-
-#### 参与贡献
-
-1. Fork 本仓库
-2. 新建 Feat_xxx 分支
-3. 提交代码
-4. 新建 Pull Request
-
-
-#### 特技
-
-1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
-2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
-3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
-4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
-5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
-6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
+数据采集 sdk
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..224f1999fc7dffd94cbbdad0d7fb8729b1ba6186
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "opendesign-analytics",
+ "version": "1.0.0",
+ "description": "",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "dev": "pnpm --filter demo dev"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {},
+ "devDependencies": {
+ "@rushstack/eslint-patch": "^1.7.2",
+ "@types/node": "^20.11.22",
+ "@vue/eslint-config-typescript": "^12.0.0",
+ "eslint": "^8.57.0",
+ "eslint-plugin-vue": "^9.22.0",
+ "typescript": "~5.3.3"
+ }
+}
diff --git a/packages/analytics/README.md b/packages/analytics/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..3573acc86c8c6a1537ef401ee10667f6e0754688
--- /dev/null
+++ b/packages/analytics/README.md
@@ -0,0 +1,13 @@
+# analytics for open-design
+
+# 0.0.1
+
+1. 内部采集事件支持 PV 数据,事件名:OpenEventKeys.PV
+
+# 0.0.2
+
+1. 内部采集事件支持性能数据。事件名:OpenEventKeys.PageBasePerformance\OpenEventKeys.LCP\OpenEventKeys.INP
+
+# 0.0.3
+
+1. 基础性能数据增加网络信息
diff --git a/packages/analytics/env.d.ts b/packages/analytics/env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3b77212d693aa72c198eabfd90e6dbeca3c8d38a
--- /dev/null
+++ b/packages/analytics/env.d.ts
@@ -0,0 +1,17 @@
+declare type Navigator = NavigatorNetworkInformation;
+declare interface NavigatorNetworkInformation {
+ readonly connection?: NetworkInformation;
+}
+type Megabit = number;
+type Millisecond = number;
+declare type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g' | 'unknown';
+declare type ConnectionType = 'bluetooth' | 'cellular' | 'ethernet' | 'mixed' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax';
+declare interface NetworkInformation extends EventTarget {
+ readonly type?: ConnectionType;
+ readonly effectiveType?: EffectiveConnectionType;
+ readonly downlinkMax?: Megabit;
+ readonly downlink?: Megabit;
+ readonly rtt?: Millisecond;
+ readonly saveData?: boolean;
+ onchange?: EventListener;
+}
diff --git a/packages/analytics/index.html b/packages/analytics/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..dd76078936216124c3ae1d6fb5d1cc8bfd5f682f
--- /dev/null
+++ b/packages/analytics/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ Open Analytics
+
+
+
+
+
+
+
open link
+
+
123
+

+
+
+
+
+
+
+
+
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..840cfc6fb2530ef051c599a04cef5d5a212754ca
--- /dev/null
+++ b/packages/analytics/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@opensig/open-analytics",
+ "version": "0.0.3",
+ "description": "opendesign analytics",
+ "main": "dist/open-analytics.mjs",
+ "types": "dist/open-analytics.d.ts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "dev": "vite",
+ "build": " vite build"
+ },
+ "devDependencies": {
+ "@types/ua-parser-js": "^0.7.39",
+ "typescript": "~5.3.3",
+ "vite": "^5.3.4",
+ "vite-plugin-dts": "^3.7.3"
+ },
+ "dependencies": {
+ "ua-parser-js": "^1.0.38",
+ "web-vitals": "^4.2.2"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/analytics/src/constant.ts b/packages/analytics/src/constant.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ba2632bf98c0c5cde95c1ce24e8541d633a1ddcd
--- /dev/null
+++ b/packages/analytics/src/constant.ts
@@ -0,0 +1,10 @@
+export const Constant = {
+ OA_PREFIX: 'oa', // 本地存储key的前缀
+ ID_LENGTH: 36, // 标识id长度
+ OA_ENABLED: '1', // 开启采集标识
+ OA_DISABLED: '0', // 关闭采集标识
+ SESSION_EXPIRE_TIME: 30 * 60 * 1000, // 会话标识有效期
+ CLIENT_EXPIRE_TIME: 180 * 24 * 60 * 60 * 1000, // 设备标识有效期
+ DEFAULT_REQUEST_INTERVAL: 5 * 1000, // 默认上报间隔
+ MAX_EVENTS: 500, // 本地最大存储事件数 150k~
+};
diff --git a/packages/analytics/src/events/index.ts b/packages/analytics/src/events/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e18cc8f6ed29ce49f8051e4772624d05c4b96837
--- /dev/null
+++ b/packages/analytics/src/events/index.ts
@@ -0,0 +1,34 @@
+import { OpenEventKeys } from './keys';
+
+import { isFunction } from '../utils';
+
+type EventCollector = (...params: any[]) => Promise> | Record;
+interface Event {
+ event: string;
+ collector: EventCollector;
+}
+const modules: Record> = import.meta.glob(['./*.ts', '!./keys.ts'], {
+ eager: true,
+});
+
+const Events = new Map();
+
+for (const path in modules) {
+ const m = modules[path].default;
+ if (m) {
+ Events.set(m.event, m.collector);
+ }
+}
+
+export { OpenEventKeys, Events };
+
+export function isInnerEvent(event: string) {
+ return Events.has(event);
+}
+
+export function getInnerEventData(event: string, params: any[] = []) {
+ const colloctor = Events.get(event);
+ if (isFunction(colloctor)) {
+ return colloctor(...params);
+ }
+}
diff --git a/packages/analytics/src/events/inp.ts b/packages/analytics/src/events/inp.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ed7fd19d33855363e2158391622a0d519a0cc471
--- /dev/null
+++ b/packages/analytics/src/events/inp.ts
@@ -0,0 +1,15 @@
+import { OpenEventKeys } from './keys';
+import { onINP } from 'web-vitals';
+
+export default {
+ event: OpenEventKeys.INP,
+ collector: () => {
+ return new Promise((resolve) => {
+ onINP((m) => {
+ resolve({
+ inp: m.value,
+ });
+ });
+ });
+ },
+};
diff --git a/packages/analytics/src/events/keys.ts b/packages/analytics/src/events/keys.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3c02cad703c5b26118d123f7997a6d0f9d9d40b2
--- /dev/null
+++ b/packages/analytics/src/events/keys.ts
@@ -0,0 +1,6 @@
+export const OpenEventKeys = {
+ PV: '$PageView',
+ PageBasePerformance: '$PageBasePerformance',
+ LCP: '$LCP',
+ INP: '$INP',
+};
diff --git a/packages/analytics/src/events/lcp.ts b/packages/analytics/src/events/lcp.ts
new file mode 100644
index 0000000000000000000000000000000000000000..32b7a835a979542ddccdcc65ec508610979a10ae
--- /dev/null
+++ b/packages/analytics/src/events/lcp.ts
@@ -0,0 +1,15 @@
+import { OpenEventKeys } from './keys';
+import { onLCP } from 'web-vitals';
+
+export default {
+ event: OpenEventKeys.LCP,
+ collector: () => {
+ return new Promise((resolve) => {
+ onLCP((m) => {
+ resolve({
+ lcp: m.value,
+ });
+ });
+ });
+ },
+};
diff --git a/packages/analytics/src/events/performance.ts b/packages/analytics/src/events/performance.ts
new file mode 100644
index 0000000000000000000000000000000000000000..96e0e58c5c11d2995a091e4e81183db115eabcdc
--- /dev/null
+++ b/packages/analytics/src/events/performance.ts
@@ -0,0 +1,60 @@
+import { OpenEventKeys } from './keys';
+import { onFCP, onTTFB } from 'web-vitals';
+
+interface PerformanceData {
+ fcp: number;
+ ttfb: number;
+ load: number;
+ navigationEntry?: PerformanceNavigationTiming;
+ connection: {
+ downlink: Megabit; // 有效带宽估算(单位:Mbps/s)
+ effectiveType: EffectiveConnectionType; // 连接的有效类型
+ rtt: Millisecond; // 当前连接的往返时延评估
+ type: ConnectionType;
+ };
+}
+function getConnection() {
+ const { connection } = window.navigator as NavigatorNetworkInformation;
+
+ return {
+ downlink: connection?.downlink || 0,
+ effectiveType: connection?.effectiveType || 'unknown',
+ rtt: connection?.rtt || 0,
+ type: connection?.type || 'unknown',
+ };
+}
+export default {
+ event: OpenEventKeys.PageBasePerformance,
+ collector: () => {
+ return new Promise((resolve) => {
+ const data: PerformanceData = {
+ fcp: -1,
+ ttfb: -1,
+ load: -1,
+ connection: getConnection(),
+ };
+ let doneFcp = false;
+ let doneTtfb = false;
+
+ const doResolve = () => {
+ if (doneFcp && doneTtfb) {
+ resolve(data);
+ }
+ };
+
+ onFCP((m) => {
+ data.fcp = m.value;
+ doneFcp = true;
+ doResolve();
+ });
+ onTTFB((m) => {
+ doneTtfb = true;
+ const entry = m.entries[0];
+ data.ttfb = m.value;
+ data.navigationEntry = entry;
+ data.load = entry.domComplete - entry.startTime;
+ doResolve();
+ });
+ });
+ },
+};
diff --git a/packages/analytics/src/events/pv.ts b/packages/analytics/src/events/pv.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ab0e451750e11ccd5f0c5783d6436999bc898379
--- /dev/null
+++ b/packages/analytics/src/events/pv.ts
@@ -0,0 +1,15 @@
+import { OpenEventKeys } from './keys';
+
+export default {
+ event: OpenEventKeys.PV,
+ collector: () => {
+ return {
+ url: window.location.href,
+ path: window.location.pathname,
+ hash: window.location.hash,
+ search: window.location.search,
+ title: document.title,
+ referrer: document.referrer,
+ };
+ },
+};
diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6cc8e36d7f62c74aefb3d78878b37cd98473528d
--- /dev/null
+++ b/packages/analytics/src/index.ts
@@ -0,0 +1,301 @@
+import { Storage } from './storage';
+import { whenDocumentReady, getClientByUA, isFunction, isPromise, uniqueId } from './utils';
+import { Constant } from './constant';
+import { getInnerEventData } from './events';
+import packageJson from '../package.json';
+
+export { OpenEventKeys } from './events/keys';
+
+class StoreKey {
+ appPrefix: string;
+ client: string;
+ session: string;
+ enabled: string;
+ events: string;
+ constructor(appKey: string = '') {
+ this.appPrefix = appKey ? `${appKey}-` : '';
+
+ this.client = this.getKey('client');
+ this.session = this.getKey('session');
+ this.enabled = this.getKey('enabled');
+ this.events = this.getKey('events');
+ }
+ getKey(key: string) {
+ return `${Constant.OA_PREFIX}-${this.appPrefix}${key}`;
+ }
+}
+type StoreKeyIns = InstanceType;
+
+export interface EventData {
+ event: string; // 事件名
+ time: number; // 事件采集时间
+ data: Record; // 上报的事件数据
+ sId: string; // 会话id
+}
+
+interface ReportOptions {
+ immediate: boolean; // 是否立即上报
+}
+interface EventHeader {
+ cId?: string; // 客户端匿名标识,清除浏览器缓存销毁
+ aId?: string; // 应用id
+ oa_version?: string; // OA版本
+ screen_width?: number; // 屏幕宽度
+ screen_height?: number; // 屏幕高度
+ view_width?: number; // 视口宽度
+ view_height?: number; // 视口高度
+ os?: string; // 客户端操作系统
+ os_version?: string; // 客户端操作系统版本
+ browser?: string; // 客户端浏览器
+ browser_version?: string; // 客户端浏览器版本
+ device?: string; // 设备信息
+ device_type?: string; // 设备类型
+ device_vendor?: string; // 设备品牌
+}
+interface ReportData {
+ header: EventHeader;
+ body: EventData[];
+}
+type RequestFn = (data: ReportData) => Promise | void;
+
+export interface OpenAnalyticsParams {
+ request: (data: ReportData) => Promise | void; // 上报数据的接口
+ appKey?: string; // 采集app的key,用于区分多app上报
+ immediate?: boolean; // 全局设置是否立即上报
+ requestInterval?: number; //上报间隔
+ maxEvents?: number;
+ requestPlan?: (requestFn: () => void) => void;
+}
+
+const store = new Storage(localStorage);
+
+/**
+ * 初始化通用数据
+ * @param keys {string}
+ * @param appId {string}
+ */
+function initHeader(keys: StoreKeyIns, appId: string): EventHeader {
+ const aKey = keys.client;
+ const client = store.getAlways(aKey, {
+ defaultValue: () => ({
+ id: uniqueId('', Constant.ID_LENGTH),
+ }),
+ setOption: {
+ expire: Date.now() + Constant.CLIENT_EXPIRE_TIME,
+ },
+ onValid() {
+ store.setExpire(aKey, Date.now() + Constant.CLIENT_EXPIRE_TIME);
+ },
+ }).value;
+
+ const { browser, os, device } = getClientByUA();
+ return {
+ cId: client.id,
+ aId: appId,
+ oa_version: packageJson.version,
+ view_width: window.innerWidth,
+ view_height: window.innerHeight,
+ screen_width: window.screen.width || window.innerWidth,
+ screen_height: window.screen.height || window.innerHeight,
+ os: os.name ?? '',
+ os_version: os.version ?? '',
+ browser: browser.name ?? '',
+ browser_version: browser.version ?? '',
+ device: device.model ?? '',
+ device_type: device.type ?? '',
+ device_vendor: device.vendor ?? '',
+ };
+}
+/**
+ * 获取会话id,每次获取,延长有效期
+ * @param sKey session key
+ */
+function getSessionId(sKey: string) {
+ const session = store.getAlways(sKey, {
+ defaultValue: () => ({
+ id: uniqueId('', Constant.ID_LENGTH),
+ }),
+ setOption: {
+ expire: Date.now() + Constant.SESSION_EXPIRE_TIME,
+ },
+ onValid() {
+ store.setExpire(sKey, Date.now() + Constant.SESSION_EXPIRE_TIME);
+ },
+ }).value;
+
+ return session.id;
+}
+
+export class OpenAnalytics {
+ request: RequestFn;
+ eventData: EventData[];
+ immediate: boolean;
+ sessionId: string = '';
+ appKey: string = '';
+ header: EventHeader;
+ enabled: boolean;
+ StoreKey: StoreKeyIns;
+ // 自定义上报策略
+ requestPlan?: (requestFn: () => void) => void;
+ // 上报间隔,默认3s
+ requestInterval: number;
+ maxEvents: number;
+
+ #timer: number | null;
+ #firstReport: boolean;
+ /**
+ * 构造函数
+ * @param params {OpenAnalyticsParams}
+ */
+ constructor(params: OpenAnalyticsParams) {
+ this.request = params.request;
+ this.immediate = params.immediate ?? false;
+ this.appKey = params.appKey ?? '';
+ this.StoreKey = new StoreKey(params.appKey);
+ this.requestInterval = params.requestInterval ?? Constant.DEFAULT_REQUEST_INTERVAL;
+ this.#timer = null;
+ this.maxEvents = params.maxEvents ?? Constant.MAX_EVENTS;
+
+ this.#firstReport = true;
+
+ this.enabled = store.get(this.StoreKey.enabled).value === Constant.OA_ENABLED;
+
+ if (this.enabled) {
+ store.set(this.StoreKey.enabled, Constant.OA_ENABLED);
+ this.eventData = store.getAlways(this.StoreKey.events, {
+ defaultValue: () => [],
+ }).value;
+ this.header = initHeader(this.StoreKey, this.appKey);
+ } else {
+ this.header = {};
+ this.eventData = [];
+ store.remove(this.StoreKey.events);
+ }
+ }
+ /**
+ * 设置header
+ */
+ setHeader(header: Record) {
+ Object.assign(this.header, header);
+ }
+ /**
+ * 控制是否发送数据上报
+ * @param enabled
+ */
+ enableReporting(enabled: boolean = true) {
+ if (this.enabled !== enabled) {
+ this.enabled = enabled;
+ }
+
+ if (this.enabled) {
+ store.set(this.StoreKey.enabled, Constant.OA_ENABLED);
+ this.header = Object.assign(initHeader(this.StoreKey, this.appKey), this.header);
+ // 初始化sessionId
+ this.sessionId = getSessionId(this.StoreKey.session);
+ // 给内存中事件添加sessionId
+ this.eventData.forEach((event) => {
+ if (event.sId === '') {
+ event.sId = this.sessionId;
+ }
+ });
+ // 将数据存储到本地
+ store.set(this.StoreKey.events, this.eventData);
+ // 执行上报策略
+ this.runRequestPlan();
+ } else if (this.#timer) {
+ clearTimeout(this.#timer);
+ this.#timer = 0;
+ this.eventData = [];
+ store.remove(this.StoreKey.enabled);
+ store.remove(this.StoreKey.events);
+ store.remove(this.StoreKey.client);
+ store.remove(this.StoreKey.session);
+ }
+ }
+ /**
+ * 搜集数据
+ */
+ collect(data: EventData, immediate?: boolean) {
+ this.eventData.push(data);
+
+ // 如果事件数超过最大数量,丢弃之前的事件
+ if (this.eventData.length > this.maxEvents) {
+ this.eventData.shift();
+ }
+ if (this.enabled) {
+ store.set(this.StoreKey.events, this.eventData);
+
+ this.runRequestPlan(immediate);
+ }
+ }
+ /**
+ * 执行上报策略
+ * @param immediate
+ */
+ runRequestPlan(immediate?: boolean) {
+ if (immediate || this.immediate) {
+ this.doSendEventData();
+ } else if (this.#firstReport) {
+ this.#firstReport = false;
+ whenDocumentReady(() => this.doSendEventData());
+ } else {
+ if (isFunction(this.requestPlan)) {
+ this.requestPlan(this.doSendEventData);
+ } else {
+ const run = () => {
+ this.#timer = window.setTimeout(() => {
+ this.doSendEventData();
+ run();
+ }, this.requestInterval);
+ };
+ if (!this.#timer) {
+ run();
+ }
+ }
+ }
+ }
+ /**
+ * 发起数据上报
+ */
+ doSendEventData() {
+ if (!this.request || !this.enabled || this.eventData.length === 0) {
+ return;
+ }
+ const reportData = {
+ header: this.header,
+ body: this.eventData,
+ };
+ const rlt = this.request(reportData);
+ if (isPromise(rlt)) {
+ rlt.then((isSuccess) => {
+ if (isSuccess) {
+ this.eventData = [];
+ store.set(this.StoreKey.events, []);
+ }
+ });
+ } else {
+ this.eventData = [];
+ store.set(this.StoreKey.events, []);
+ }
+ }
+ /**
+ * 采集数据
+ * @param event 事件名
+ * @param data 事件数据
+ * @param options 配置
+ */
+ async report(event: string, data?: Record | (() => Record), options?: ReportOptions) {
+ const innerData: Record = (await getInnerEventData(event)) || {};
+
+ const outerData = isFunction(data) ? data() : data;
+
+ const eventData: EventData = {
+ event: event,
+ time: Date.now(),
+ data: Object.assign(innerData, outerData),
+ sId: this.enabled ? getSessionId(this.StoreKey.session) : '',
+ };
+
+ this.collect(eventData, options?.immediate);
+ }
+}
diff --git a/packages/analytics/src/storage.ts b/packages/analytics/src/storage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f549e44195a325d01e944af987181b53b2d2d53b
--- /dev/null
+++ b/packages/analytics/src/storage.ts
@@ -0,0 +1,105 @@
+import { isFunction } from './utils';
+
+type StorageInstance = typeof localStorage | typeof sessionStorage;
+interface StorageSetOptions {
+ expire?: number;
+ once?: boolean;
+}
+
+interface StorageOptions {
+ checkExpiration?: (time: number) => boolean;
+}
+export class Storage {
+ store: StorageInstance = localStorage;
+ checkExpiration: (expire: number) => boolean;
+ constructor(storage: StorageInstance, options?: StorageOptions) {
+ this.store = storage;
+ this.checkExpiration = isFunction(options?.checkExpiration)
+ ? options?.checkExpiration
+ : (time: number) => {
+ return Date.now() > time;
+ };
+ }
+ set(key: string, value: any, options?: StorageSetOptions) {
+ const { once, expire } = options || {};
+
+ const data = {
+ expire,
+ value,
+ once,
+ };
+
+ this.store.setItem(key, JSON.stringify(data));
+ }
+ remove(key: string) {
+ this.store.removeItem(key);
+ }
+ setExpire(key: string, expire: number) {
+ const { value } = this.get(key);
+ this.set(key, value, {
+ expire,
+ });
+ }
+ get(
+ key: string,
+ {
+ checkExpiration,
+ onValid,
+ }: {
+ checkExpiration?: (expire: number) => boolean;
+ onValid?: (value: any, expire: number) => void;
+ } = {}
+ ) {
+ const dataStr = this.store.getItem(key);
+ if (!dataStr) {
+ return {
+ value: undefined,
+ };
+ }
+ try {
+ const { once, expire, value } = JSON.parse(dataStr);
+ const check = isFunction(checkExpiration) ? checkExpiration : this.checkExpiration;
+ if (check(expire)) {
+ return {
+ value: undefined,
+ };
+ }
+ if (once) {
+ this.remove(key);
+ }
+ if (isFunction(onValid)) {
+ onValid(value, expire);
+ }
+
+ return { expire, value };
+ } catch {
+ return {
+ value: undefined,
+ };
+ }
+ }
+ getAlways(
+ key: string,
+ {
+ defaultValue,
+ setOption,
+ onValid,
+ checkExpiration,
+ }: {
+ defaultValue: () => any;
+ setOption?: StorageSetOptions;
+ onValid?: (value: any, expire: number) => void;
+ checkExpiration?: (expire: number) => boolean;
+ }
+ ) {
+ let { value } = this.get(key, { checkExpiration, onValid });
+ if (value === undefined) {
+ value = defaultValue();
+ this.set(key, value, setOption);
+ }
+ return {
+ value,
+ expire: setOption?.expire,
+ };
+ }
+}
diff --git a/packages/analytics/src/utils.ts b/packages/analytics/src/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0b7338cbabb68fc1562d935b41c8530b530d869e
--- /dev/null
+++ b/packages/analytics/src/utils.ts
@@ -0,0 +1,68 @@
+import { UAParser } from 'ua-parser-js';
+/**
+ * 生成随机字符串
+ * @param prefix 前缀
+ * @param length 字符串长度
+ */
+export function uniqueId(prefix: string = '', length: number = 8, Separator: string = '-'): string {
+ const gen = (len: number): string => {
+ if (len <= 11) {
+ return Math.random()
+ .toString(36)
+ .slice(2, 2 + len)
+ .padEnd(len, '0');
+ } else {
+ return gen(11) + gen(len - 11);
+ }
+ };
+ return prefix ? `${prefix}${Separator}${gen(length)}` : gen(length);
+}
+/**
+ * 判断是否是函数
+ */
+export function isFunction(val: unknown): val is Function {
+ return typeof val === 'function';
+}
+
+// 是否是对象或者数组等(key:value 形式)
+export function isObject(val: unknown): val is Record {
+ return val !== null && typeof val === 'object';
+}
+/**
+ * 判断是否是promise
+ * @param val
+ * @returns
+ */
+export const isPromise = (val: unknown): val is Promise => {
+ return isObject(val) && isFunction(val.then) && isFunction(val.catch);
+};
+/**
+ * 根据userAgent信息获取系统及浏览器信息
+ */
+export function getClientByUA(userAgent: string = window.navigator.userAgent) {
+ const { browser, os, device } = UAParser(userAgent);
+ return { browser, os, device };
+}
+/**
+ * 在文档准备完成
+ * @param callback
+ */
+export function whenDocumentReady(callback: () => any): void {
+ if (document.readyState !== 'loading') {
+ callback();
+ } else {
+ document.addEventListener('DOMContentLoaded', () => callback());
+ }
+}
+
+/**
+ * 在文档准备完成
+ * @param callback
+ */
+export function whenWindowLoad(callback: () => any): void {
+ if (document.readyState !== 'complete') {
+ window.addEventListener('load', () => callback());
+ } else {
+ callback();
+ }
+}
diff --git a/packages/analytics/test/main.ts b/packages/analytics/test/main.ts
new file mode 100644
index 0000000000000000000000000000000000000000..863e62d53ec7b041a26f91c5f965a4c8a17daa9c
--- /dev/null
+++ b/packages/analytics/test/main.ts
@@ -0,0 +1,50 @@
+import { OpenAnalytics, OpenEventKeys } from '../src/index';
+
+const btn1 = document.querySelector('#btn1');
+const btnOpen = document.querySelector('#btn-open');
+const btnClose = document.querySelector('#btn-close');
+
+const oa = new OpenAnalytics({
+ appKey: 'test',
+ request: (data) => {
+ console.log('request to send content', data);
+ return fetch('report', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }).then((response) => response.ok);
+ },
+ // immediate: true,
+});
+oa.setHeader({
+ appCode: '123',
+});
+function enabledOA(enabled) {
+ oa.enableReporting(enabled);
+ localStorage.setItem('enabled', enabled ? '1' : '0');
+}
+if (localStorage.getItem('enabled') !== '1') {
+ enabledOA(false);
+}
+
+oa.report(OpenEventKeys.PV, () => ({
+ id: 'home',
+}));
+oa.report(OpenEventKeys.PageBasePerformance);
+oa.report(OpenEventKeys.LCP);
+oa.report(OpenEventKeys.INP);
+
+btn1?.addEventListener('click', () => {
+ // window.open('/');
+ console.log('btn1 clicked');
+
+ oa.report('btn-click', {
+ date: Date.now(),
+ });
+});
+
+btnOpen?.addEventListener('click', () => {
+ enabledOA(true);
+});
+btnClose?.addEventListener('click', () => {
+ enabledOA(false);
+});
diff --git a/packages/analytics/test/style.css b/packages/analytics/test/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..1a8619a4bc503015381c6ec29e78d054c73bee37
--- /dev/null
+++ b/packages/analytics/test/style.css
@@ -0,0 +1,14 @@
+#app {
+ width: 800px;
+ margin: auto;
+}
+
+.box {
+ width: 300px;
+ height: 200px;
+ background: linear-gradient(0, red, blue 80%, green);
+}
+
+.img {
+ width: 50vw;
+}
\ No newline at end of file
diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..d1325f6ee7471999f64261cdae9b730aa4b6d3fe
--- /dev/null
+++ b/packages/analytics/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "sourceMap": true,
+ "outDir": "dist",
+ "module": "esnext",
+ "target": "ES2015",
+ "allowJs": true,
+ "moduleResolution": "node",
+ "baseUrl": ".",
+ "strict": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "types": [
+ "vite/client",
+ ],
+ "lib": [
+ "DOM",
+ "ESNext",
+ ]
+ },
+ "include": [
+ "src",
+ "env.d.ts",
+ ],
+}
\ No newline at end of file
diff --git a/packages/analytics/vite.config.mts b/packages/analytics/vite.config.mts
new file mode 100644
index 0000000000000000000000000000000000000000..4c4bd573e1c24935890ff9fc4a98683a6db9e8d4
--- /dev/null
+++ b/packages/analytics/vite.config.mts
@@ -0,0 +1,29 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+export default defineConfig({
+ base: './',
+ build: {
+ target: ['chrome74'],
+ lib: {
+ entry: resolve(__dirname, './src/index.ts'),
+ name: 'OpenAnalytics',
+ fileName: 'open-analytics',
+ formats: ['es'],
+ },
+ },
+ plugins: [
+ dts({
+ rollupTypes: true,
+ }),
+ ],
+ resolve: {
+ alias: {
+ '@/': `${resolve(__dirname, './src')}/`,
+ },
+ },
+ server: {
+ port: 3300,
+ },
+});
diff --git a/packages/demo/.gitignore b/packages/demo/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..da20fbc80ad491a5a6a7b7a1ff89a0a3fc400797
--- /dev/null
+++ b/packages/demo/.gitignore
@@ -0,0 +1,27 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+es
+lib
\ No newline at end of file
diff --git a/packages/demo/README.md b/packages/demo/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..30b15e215a24b78043970d820dd5dd5d2e6a3863
--- /dev/null
+++ b/packages/demo/README.md
@@ -0,0 +1,16 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `
+