commit c0e46d1ae71197a358c5b64c4ee5ef223747f431
Author: Lishundong <577732344@qq.com>
Date: Fri Oct 24 09:34:42 2025 +0800
init
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a503fa2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/unpackage/
diff --git a/.hbuilderx/launch.json b/.hbuilderx/launch.json
new file mode 100644
index 0000000..81f13f4
--- /dev/null
+++ b/.hbuilderx/launch.json
@@ -0,0 +1,16 @@
+{ // launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
+ // launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数
+ "version": "0.0",
+ "configurations": [{
+ "default" :
+ {
+ "launchtype" : "local"
+ },
+ "mp-weixin" :
+ {
+ "launchtype" : "local"
+ },
+ "type" : "uniCloud"
+ }
+ ]
+}
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..b58b603
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..6cef1ff
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/zhz-front-app.iml b/.idea/zhz-front-app.iml
new file mode 100644
index 0000000..24643cc
--- /dev/null
+++ b/.idea/zhz-front-app.iml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/App.vue b/App.vue
new file mode 100644
index 0000000..87487b0
--- /dev/null
+++ b/App.vue
@@ -0,0 +1,100 @@
+
+
+
diff --git a/common/IndexedDBHelper.js b/common/IndexedDBHelper.js
new file mode 100644
index 0000000..04192e4
--- /dev/null
+++ b/common/IndexedDBHelper.js
@@ -0,0 +1,364 @@
+class IndexedDBHelper {
+ constructor(dbName, version = 1) {
+ this.dbName = dbName;
+ this.version = version;
+ this.db = null;
+ }
+
+ /**
+ * 初始化数据库(打开/创建)
+ * @param {Array} stores [{ name: "storeName", keyPath: "id", indexes: [{ name: "indexName", key: "keyPath", unique: false }] }]
+ * @returns {Promise}
+ */
+ openDB(stores = []) {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version);
+
+ request.onupgradeneeded = (event) => {
+ this.db = event.target.result;
+ stores.forEach(store => {
+ if (!this.db.objectStoreNames.contains(store.name)) {
+ const objectStore = this.db.createObjectStore(store.name, {
+ keyPath: store.keyPath,
+ autoIncrement: store.autoIncrement || false
+ });
+ if (store.indexes) {
+ store.indexes.forEach(index => {
+ objectStore.createIndex(index.name, index.key, {
+ unique: index.unique
+ });
+ });
+ }
+ }
+
+ });
+ };
+
+ request.onsuccess = (event) => {
+ this.db = event.target.result;
+ console.log("✅ IndexedDB 连接成功");
+ resolve(this.db);
+ };
+
+ request.onerror = (event) => {
+ reject(`IndexedDB Error: ${event.target.error}`);
+ };
+ });
+ }
+
+ // 通用查询方法,按指定字段查询
+ async queryByField(storeName, fieldName, value) {
+ return new Promise(async (resolve, reject) => {
+ try {
+ if (!this.db) {
+ await this.openDB();
+ }
+ const transaction = this.db.transaction(storeName, 'readonly');
+ const store = transaction.objectStore(storeName);
+
+ if (!store.indexNames.contains(fieldName)) {
+ return reject(`索引 ${fieldName} 不存在`);
+ }
+
+ const index = store.index(fieldName);
+ const request = index.getAll(value);
+
+ request.onsuccess = (event) => {
+ resolve(event.target.result);
+ };
+
+ request.onerror = (event) => {
+ reject('查询失败: ' + event.target.error);
+ };
+ } catch (error) {
+ reject('查询错误: ' + error);
+ }
+ });
+ }
+
+ /**
+ * 添加数据(支持单条或批量)
+ * @param {string} storeName - 存储空间名称
+ * @param {Object|Array} data - 要添加的数据(单条对象或数组)
+ * @returns {Promise>} - 返回添加数据的ID(单条返回数字,批量返回数组)
+ */
+ add(storeName, data) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readwrite");
+ const store = transaction.objectStore(storeName);
+
+ // 统一处理为数组格式
+ const items = Array.isArray(data) ? data : [data];
+ const results = [];
+
+ // 监听每个添加操作的成功事件
+ items.forEach((item, index) => {
+ const request = store.add(item);
+ request.onsuccess = (event) => {
+ results[index] = event.target.result; // 保存生成的ID
+ };
+ request.onerror = (event) => {
+ transaction.abort(); // 遇到错误时中止事务
+ reject(`第 ${index + 1} 条数据添加失败: ${event.target.error}`);
+ };
+ });
+
+ // 监听事务完成事件
+ transaction.oncomplete = () => {
+ // 单条数据返回单个ID,批量返回数组
+ resolve(items.length === 1 ? results[0] : results);
+ };
+
+ // 统一错误处理
+ transaction.onerror = (event) => {
+ reject(`添加失败: ${event.target.error}`);
+ };
+ });
+ }
+
+ /**
+ * 读取数据(根据主键)
+ * @param {string} storeName
+ * @param {any} key
+ * @returns {Promise}
+ */
+ get(storeName, key) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readonly");
+ const store = transaction.objectStore(storeName);
+ const request = store.get(key);
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = (event) => reject(`Get Error: ${event.target.error}`);
+ });
+ }
+
+ /**
+ * 读取所有数据(兼容X5内核方案)
+ * @param {string} storeName
+ * @returns {Promise}
+ */
+ getAll(storeName) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readonly");
+ const store = transaction.objectStore(storeName);
+
+ // 兼容性检测:优先尝试原生getAll方法
+ if (typeof store.getAll === 'function') {
+ const request = store.getAll();
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = (e) => reject(`GetAll Error: ${e.target.error}`);
+ }
+ // 降级方案:使用游标手动遍历
+ else {
+ const results = [];
+ const request = store.openCursor();
+
+ request.onsuccess = (e) => {
+ const cursor = e.target.result;
+ if (cursor) {
+ results.push(cursor.value);
+ cursor.continue();
+ } else {
+ resolve(results);
+ }
+ };
+
+ request.onerror = (e) => reject(`Cursor Error: ${e.target.error}`);
+ }
+ });
+ }
+
+ /**
+ * 获取表的总记录数
+ * @param {string} storeName - 表名(Object Store 名称)
+ * @returns {Promise} - 记录总数
+ */
+ async getRecordCount(storeName) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readonly");
+ const store = transaction.objectStore(storeName);
+ const request = store.count();
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = (event) => reject(`❌ Count Error: ${event.target.error}`);
+ });
+ }
+
+ /**
+ * 更新数据(支持指定 key 或自动使用 keyPath)
+ * @param {string} storeName 存储对象名
+ * @param {Object} data 待更新数据
+ * @param {IDBValidKey|IDBKeyRange} [key] 可选参数,指定更新的 key
+ * @returns {Promise} 更新结果
+ */
+ update(storeName, data, key) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readwrite");
+ const store = transaction.objectStore(storeName);
+ const keyPath = store.keyPath;
+
+ // 有传入 key:直接使用 key 更新
+ if (key !== undefined) {
+ const request = store.put(data, key);
+ request.onsuccess = () => resolve("数据更新成功(指定 key)");
+ request.onerror = (event) => reject(`更新失败: ${event.target.error}`);
+ return;
+ }
+
+ // 无传入 key:依赖 keyPath 更新
+ if (!keyPath) {
+ reject("当前 store 未设置 keyPath,必须传入 key 参数");
+ return;
+ }
+
+ // 检查数据是否包含 keyPath 属性
+ let missingKeys = [];
+ if (Array.isArray(keyPath)) {
+ missingKeys = keyPath.filter(k => !data.hasOwnProperty(k));
+ } else if (typeof keyPath === 'string') {
+ if (!data.hasOwnProperty(keyPath)) {
+ missingKeys.push(keyPath);
+ }
+ }
+
+ if (missingKeys.length > 0) {
+ reject(`数据缺少必要的 keyPath 属性: ${missingKeys.join(', ')}`);
+ return;
+ }
+
+ // 默认使用 keyPath 更新
+ const request = store.put(data);
+ request.onsuccess = () => resolve("数据更新成功(默认 keyPath)");
+ request.onerror = (event) => reject(`更新失败: ${event.target.error}`);
+ });
+ }
+
+ /**
+ * 删除数据(根据主键)
+ * @param {string} storeName
+ * @param {any} key
+ * @returns {Promise}
+ */
+ delete(storeName, key) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readwrite");
+ const store = transaction.objectStore(storeName);
+ const request = store.delete(key);
+
+ request.onsuccess = () => resolve("Data deleted successfully");
+ request.onerror = (event) => reject(`Delete Error: ${event.target.error}`);
+ });
+ }
+ /**
+ * 根据条件删除所有匹配的数据
+ * @param {string} storeName - 数据仓库名
+ * @param {function} conditionFn - 判断是否删除 (record) => boolean
+ * @returns {Promise}
+ */
+ deleteByCondition(storeName, conditionFn) {
+ return new Promise((resolve, reject) => {
+ if (!this.db) {
+ reject('Database not initialized');
+ return;
+ }
+
+ const transaction = this.db.transaction([storeName], 'readwrite');
+ const store = transaction.objectStore(storeName);
+ const request = store.openCursor();
+
+ request.onsuccess = (event) => {
+ const cursor = event.target.result;
+ if (cursor && cursor.value) {
+ try {
+ // console.log(cursor.value)
+ const shouldDelete = conditionFn(cursor.value);
+ if (shouldDelete) {
+ cursor.delete();
+ }
+ } catch (err) {
+ console.error('Condition function error:', err);
+ }
+ cursor.continue();
+ } else {
+ resolve('All matching records deleted successfully');
+ }
+ };
+
+ request.onerror = (event) => {
+ reject(`Delete by condition failed: ${event.target.error}`);
+ };
+ });
+ }
+
+ /**
+ * 通过索引查询数据
+ * @param {string} storeName
+ * @param {string} indexName
+ * @param {any} value
+ * @returns {Promise}
+ */
+ getByIndex(storeName, indexName, value) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readonly");
+ const store = transaction.objectStore(storeName);
+ const index = store.index(indexName);
+ const request = index.get(value);
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = (event) => reject(`Get By Index Error: ${event.target.error}`);
+ });
+ }
+
+ /**
+ * 清空表
+ * @param {string} storeName
+ * @returns {Promise}
+ */
+ clearStore(storeName) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readwrite");
+ const store = transaction.objectStore(storeName);
+ const request = store.clear();
+
+ request.onsuccess = () => resolve("Store cleared successfully");
+ request.onerror = (event) => reject(`Clear Store Error: ${event.target.error}`);
+ });
+ }
+
+ /**
+ * 删除数据库
+ * @returns {Promise}
+ */
+ deleteDB(dbNamed = null) {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(dbNamed || this.dbName);
+
+ request.onsuccess = () => resolve("Database deleted successfully");
+ request.onerror = (event) => reject(`Delete DB Error: ${event.target.error}`);
+ });
+ }
+
+ async deleteOldestRecord(storeName) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([storeName], "readwrite");
+ const store = transaction.objectStore(storeName);
+ const request = store.openCursor(); // 🔹 获取最早的记录(按主键 ID 排序)
+
+ request.onsuccess = function(event) {
+ const cursor = event.target.result;
+ if (cursor) {
+ console.log(`🗑️ 删除最早的记录 ID: ${cursor.key}`);
+ store.delete(cursor.key); // 🔥 删除最小 ID(最早记录)
+ resolve();
+ } else {
+ resolve(); // 没有记录时跳过
+ }
+ };
+
+ request.onerror = (event) => reject(`❌ Cursor Error: ${event.target.error}`);
+ });
+ }
+}
+
+export default IndexedDBHelper
\ No newline at end of file
diff --git a/common/UniStorageHelper.js b/common/UniStorageHelper.js
new file mode 100644
index 0000000..324455c
--- /dev/null
+++ b/common/UniStorageHelper.js
@@ -0,0 +1,256 @@
+// uni-storage-helper.js
+class UniStorageHelper {
+ constructor(dbName, options = {}) {
+ this.dbName = dbName;
+ this.storesMeta = {};
+ this.options = {
+ maxEntries: 500, // 单个存储空间最大条目数
+ maxSizeMB: 1, // 单条数据最大限制(微信小程序限制)
+ autoPurge: true, // 是否自动清理旧数据
+ purgeBatch: 10, // 自动清理批次数量
+ debug: false, // 调试模式
+ ...options
+ };
+ }
+
+ /*==================
+ 核心方法
+ ==================*/
+
+ /**
+ * 初始化存储空间
+ * @param {Array} stores - 存储空间配置
+ */
+ async openDB(stores = []) {
+ stores.forEach(store => {
+ const storeKey = this._getStoreKey(store.name);
+ if (!this._storageHas(storeKey)) {
+ this._storageSet(storeKey, []);
+ }
+
+ this.storesMeta[store.name] = {
+ keyPath: store.keyPath,
+ autoIncrement: !!store.autoIncrement,
+ indexes: store.indexes || []
+ };
+
+ if (store.autoIncrement) {
+ const counterKey = this._getCounterKey(store.name);
+ if (!this._storageHas(counterKey)) {
+ this._storageSet(counterKey, 1);
+ }
+ }
+ });
+
+ this._log('数据库初始化完成');
+ return Promise.resolve();
+ }
+
+ /**
+ * 添加数据(自动处理容量限制)
+ */
+ async add(storeName, data) {
+ try {
+ const storeKey = this._getStoreKey(storeName);
+ let storeData = this._storageGet(storeKey) || [];
+ const meta = this.storesMeta[storeName];
+ const items = Array.isArray(data) ? data : [data];
+
+ // 容量预检
+ await this._checkCapacity(storeName, items);
+
+ // 处理自增ID
+ if (meta?.autoIncrement) {
+ const counterKey = this._getCounterKey(storeName);
+ let nextId = this._storageGet(counterKey) || 1;
+ items.forEach(item => {
+ item[meta.keyPath] = nextId++;
+ this._createIndexes(meta.indexes, item);
+ });
+ this._storageSet(counterKey, nextId);
+ }
+
+ // 保存数据
+ storeData = [...storeData, ...items];
+ this._storageSet(storeKey, storeData);
+
+ this._log(`成功添加${items.length}条数据到${storeName}`);
+
+ return meta?.autoIncrement ?
+ Array.isArray(data) ?
+ items.map(i => i[meta.keyPath]) :
+ items[0][meta.keyPath] :
+ undefined;
+
+ } catch (error) {
+ if (error.message.includes('exceed')) {
+ this._log('触发自动清理...');
+ await this._purgeData(storeName, this.options.purgeBatch);
+ return this.add(storeName, data);
+ }
+ throw error;
+ }
+ }
+
+ /*==================
+ 查询方法
+ ==================*/
+
+ async get(storeName, key) {
+ const storeData = this._storageGet(this._getStoreKey(storeName)) || [];
+ const keyPath = this.storesMeta[storeName]?.keyPath;
+ return storeData.find(item => item[keyPath] === key);
+ }
+
+ async getAll(storeName) {
+ return this._storageGet(this._getStoreKey(storeName)) || [];
+ }
+
+ async queryByField(storeName, fieldName, value) {
+ const storeData = this._storageGet(this._getStoreKey(storeName)) || [];
+ return storeData.filter(item => item[fieldName] === value);
+ }
+
+ /*==================
+ 更新/删除方法
+ ==================*/
+
+ async update(storeName, data, key) {
+ const storeKey = this._getStoreKey(storeName);
+ const storeData = this._storageGet(storeKey) || [];
+ const meta = this.storesMeta[storeName];
+ const keyPath = meta?.keyPath;
+ const targetKey = key ?? data[keyPath];
+
+ const index = storeData.findIndex(item => item[keyPath] === targetKey);
+ if (index === -1) throw new Error('未找到对应记录');
+
+ // 合并数据并重建索引
+ const newItem = {
+ ...storeData[index],
+ ...data
+ };
+ this._createIndexes(meta.indexes, newItem);
+
+ storeData[index] = newItem;
+ this._storageSet(storeKey, storeData);
+
+ return "更新成功";
+ }
+
+ async delete(storeName, key) {
+ const storeKey = this._getStoreKey(storeName);
+ const storeData = this._storageGet(storeKey) || [];
+ const keyPath = this.storesMeta[storeName]?.keyPath;
+ const newData = storeData.filter(item => item[keyPath] !== key);
+ this._storageSet(storeKey, newData);
+ return `删除${storeData.length - newData.length}条记录`;
+ }
+
+ /*==================
+ 存储管理
+ ==================*/
+
+ async clearStore(storeName) {
+ this._storageSet(this._getStoreKey(storeName), []);
+ return "存储空间已清空";
+ }
+
+ async deleteDB() {
+ Object.keys(this.storesMeta).forEach(storeName => {
+ uni.removeStorageSync(this._getStoreKey(storeName));
+ uni.removeStorageSync(this._getCounterKey(storeName));
+ });
+ return "数据库已删除";
+ }
+
+ /*==================
+ 私有方法
+ ==================*/
+
+ _getStoreKey(storeName) {
+ return `${this.dbName}_${storeName}`;
+ }
+
+ _getCounterKey(storeName) {
+ return `${this.dbName}_${storeName}_counter`;
+ }
+
+ _createIndexes(indexes, item) {
+ indexes.forEach(index => {
+ item[index.name] = item[index.key];
+ });
+ }
+
+ async _checkCapacity(storeName, newItems) {
+ const storeKey = this._getStoreKey(storeName);
+ const currentData = this._storageGet(storeKey) || [];
+
+ // 检查条目数限制
+ if (currentData.length + newItems.length > this.options.maxEntries) {
+ await this._purgeData(storeName, newItems.length);
+ }
+
+ // 检查单条数据大小
+ newItems.forEach(item => {
+ const sizeMB = this._getItemSizeMB(item);
+ if (sizeMB > this.options.maxSizeMB) {
+ throw new Error(`单条数据大小超出${this.options.maxSizeMB}MB限制`);
+ }
+ });
+ }
+
+ _getItemSizeMB(item) {
+ try {
+ // 精确计算(支持Blob的环境)
+ return new Blob([JSON.stringify(item)]).size / 1024 / 1024;
+ } catch {
+ // 兼容方案
+ return encodeURIComponent(JSON.stringify(item)).length * 2 / 1024 / 1024;
+ }
+ }
+
+ async _purgeData(storeName, count) {
+ const storeKey = this._getStoreKey(storeName);
+ const currentData = this._storageGet(storeKey) || [];
+ const newData = currentData.slice(count);
+ this._storageSet(storeKey, newData);
+ this._log(`自动清理${count}条旧数据`);
+ }
+
+ /*==================
+ 存储适配器
+ ==================*/
+
+ _storageHas(key) {
+ return !!uni.getStorageSync(key);
+ }
+
+ _storageGet(key) {
+ try {
+ return uni.getStorageSync(key);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ _storageSet(key, value) {
+ try {
+ uni.setStorageSync(key, value);
+ return true;
+ } catch (error) {
+ if (error.errMsg?.includes('exceed')) {
+ throw new Error('STORAGE_QUOTA_EXCEEDED');
+ }
+ throw error;
+ }
+ }
+
+ _log(...args) {
+ if (this.options.debug) {
+ console.log(`[StorageHelper]`, ...args);
+ }
+ }
+}
+
+export default UniStorageHelper;
\ No newline at end of file
diff --git a/common/animation.css b/common/animation.css
new file mode 100644
index 0000000..455eb4c
--- /dev/null
+++ b/common/animation.css
@@ -0,0 +1,193 @@
+/*base code*/
+.animated {
+ -webkit-animation-duration: 1s;
+ animation-duration: 1s;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+}
+
+.animated.infinite {
+ -webkit-animation-iteration-count: infinite;
+ animation-iteration-count: infinite;
+}
+
+.animated.hinge {
+ -webkit-animation-duration: 2s;
+ animation-duration: 2s;
+}
+
+/*the animation definition*/
+@-webkit-keyframes tada {
+ 0% {
+ -webkit-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+
+ 10%,
+ 20% {
+ -webkit-transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg);
+ transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg)
+ }
+
+ 30%,
+ 50%,
+ 70%,
+ 90% {
+ -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg);
+ transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)
+ }
+
+ 40%,
+ 60%,
+ 80% {
+ -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
+ transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg)
+ }
+
+ 100% {
+ -webkit-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+}
+
+@keyframes tada {
+ 0% {
+ -webkit-transform: scale3d(1, 1, 1);
+ -ms-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+
+ 10%,
+ 20% {
+ -webkit-transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg);
+ -ms-transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg);
+ transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg)
+ }
+
+ 30%,
+ 50%,
+ 70%,
+ 90% {
+ -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg);
+ -ms-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg);
+ transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)
+ }
+
+ 40%,
+ 60%,
+ 80% {
+ -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
+ -ms-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
+ transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg)
+ }
+
+ 100% {
+ -webkit-transform: scale3d(1, 1, 1);
+ -ms-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+}
+
+.tada {
+ -webkit-animation-name: tada;
+ animation-name: tada
+}
+
+
+.btn-tada:active {
+ -webkit-animation-name: tada;
+ animation-name: tada
+}
+
+/*the animation definition*/
+@-webkit-keyframes rubberBand {
+ 0% {
+ -webkit-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+
+ 30% {
+ -webkit-transform: scale3d(1.25, .75, 1);
+ transform: scale3d(1.25, .75, 1)
+ }
+
+ 40% {
+ -webkit-transform: scale3d(0.75, 1.25, 1);
+ transform: scale3d(0.75, 1.25, 1)
+ }
+
+ 50% {
+ -webkit-transform: scale3d(1.15, .85, 1);
+ transform: scale3d(1.15, .85, 1)
+ }
+
+ 65% {
+ -webkit-transform: scale3d(.95, 1.05, 1);
+ transform: scale3d(.95, 1.05, 1)
+ }
+
+ 75% {
+ -webkit-transform: scale3d(1.05, .95, 1);
+ transform: scale3d(1.05, .95, 1)
+ }
+
+ 100% {
+ -webkit-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+}
+
+@keyframes rubberBand {
+ 0% {
+ -webkit-transform: scale3d(1, 1, 1);
+ -ms-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+
+ 30% {
+ -webkit-transform: scale3d(1.25, .75, 1);
+ -ms-transform: scale3d(1.25, .75, 1);
+ transform: scale3d(1.25, .75, 1)
+ }
+
+ 40% {
+ -webkit-transform: scale3d(0.75, 1.25, 1);
+ -ms-transform: scale3d(0.75, 1.25, 1);
+ transform: scale3d(0.75, 1.25, 1)
+ }
+
+ 50% {
+ -webkit-transform: scale3d(1.15, .85, 1);
+ -ms-transform: scale3d(1.15, .85, 1);
+ transform: scale3d(1.15, .85, 1)
+ }
+
+ 65% {
+ -webkit-transform: scale3d(.95, 1.05, 1);
+ -ms-transform: scale3d(.95, 1.05, 1);
+ transform: scale3d(.95, 1.05, 1)
+ }
+
+ 75% {
+ -webkit-transform: scale3d(1.05, .95, 1);
+ -ms-transform: scale3d(1.05, .95, 1);
+ transform: scale3d(1.05, .95, 1)
+ }
+
+ 100% {
+ -webkit-transform: scale3d(1, 1, 1);
+ -ms-transform: scale3d(1, 1, 1);
+ transform: scale3d(1, 1, 1)
+ }
+}
+
+.rubberBand {
+ -webkit-animation-name: rubberBand;
+ animation-name: rubberBand
+}
+
+
+.btn-rubberBand:active {
+ -webkit-animation-name: tada;
+ animation-name: tada
+}
\ No newline at end of file
diff --git a/common/common.css b/common/common.css
new file mode 100644
index 0000000..54ac785
--- /dev/null
+++ b/common/common.css
@@ -0,0 +1,467 @@
+/* 公共样式表 */
+page {
+ min-height: calc(100vh - var(--window-top) - var(--status-bar-height) - var(--window-bottom));
+ font-size: 28rpx;
+ background-color: #FFFFFF;
+ color: #333333;
+ overflow: hidden;
+}
+
+/* 禁止页面回弹 */
+/* html,
+body,
+page {
+ overscroll-behavior: none;
+ overflow: hidden;
+} */
+
+image {
+ width: 100%;
+ height: 100%;
+}
+
+.page-body {
+ height: calc(100vh - var(--window-top) - var(--status-bar-height) - var(--window-bottom));
+ /* width: 100%; */
+ /* height: 100%; */
+}
+
+body,
+html {
+ height: 100%;
+ width: 100%;
+ overflow-x: hidden;
+}
+
+/* 布局调整 */
+
+/* 点击动效 */
+/* 缩小 */
+.button-click {
+ transition: transform 0.1s ease;
+}
+
+.button-click:active {
+ transform: scale(0.95);
+}
+
+/* 背景变色 */
+.btn-light {
+ color: white;
+ border-radius: 16rpx;
+ transition: background-color 0.2s;
+}
+
+.btn-light:active {
+ background-color: rgba(189, 197, 254, 0.15);
+}
+
+
+.btn-incline {
+ transition: transform 0.2s ease;
+ transform-style: preserve-3d;
+}
+
+.btn-incline:active {
+ transform: perspective(600px) rotateY(6deg) rotateX(3deg);
+}
+
+.btn-feel {
+ transition: transform 0.2s ease;
+ transform-style: preserve-3d;
+}
+
+.btn-feel:active {
+ transform: perspective(600px) rotateX(6deg) scale(0.98);
+}
+
+.press-button {
+ padding: 10px 20px;
+ background: #3A4750;
+ /* 深灰蓝 */
+ color: #ffffff;
+ font-size: 16px;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
+ /* box-shadow: 0 4px 0 #2C3E50; */
+}
+
+.press-button:active {
+ transform: scale(0.95) translateY(2px);
+ /* box-shadow: 0 2px 0 #1C2833; */
+}
+
+/* 动画效果 */
+.btn-shaky:active {
+ animation: shakeScale 0.6s;
+}
+
+@keyframes shakeScale {
+ 0% {
+ transform: scale(1);
+ }
+
+ 10% {
+ transform: scale(0.9) rotate(-3deg);
+ }
+
+ 20% {
+ transform: scale(1.05) rotate(3deg);
+ }
+
+ 30% {
+ transform: scale(0.95) rotate(-3deg);
+ }
+
+ 40% {
+ transform: scale(1.02) rotate(3deg);
+ }
+
+ 50% {
+ transform: scale(0.98) rotate(-2deg);
+ }
+
+ 60% {
+ transform: scale(1.01) rotate(2deg);
+ }
+
+ 70% {
+ transform: scale(0.99) rotate(-1deg);
+ }
+
+ 80% {
+ transform: scale(1.005) rotate(1deg);
+ }
+
+ 90% {
+ transform: scale(1) rotate(0deg);
+ }
+
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+}
+
+/* 控制hover */
+.opctiy_8 {
+ opacity: 0.8 !important;
+}
+
+.opctiy_7 {
+ opacity: 0.7 !important;
+}
+
+.opctiy_6 {
+ opacity: 0.6 !important;
+}
+
+.opctiy_5 {
+ opacity: 0.5 !important;
+}
+
+.opctiy_4 {
+ opacity: 0.4 !important;
+}
+
+.opctiy_3 {
+ opacity: 0.3 !important;
+}
+
+.opctiy_2 {
+ opacity: 0.2 !important;
+}
+
+.opctiy_1 {
+ opacity: 0.1 !important;
+}
+
+/* 控制文字大小 */
+.fs_10 {
+ font-size: 20rpx !important;
+}
+
+.fs_12 {
+ font-size: 24rpx !important;
+}
+
+.fs_14 {
+ font-size: 28rpx !important;
+}
+
+.fs_16 {
+ font-size: 32rpx !important;
+}
+
+.fs_18 {
+ font-size: 36rpx !important;
+}
+
+.fs_20 {
+ font-size: 40rpx !important;
+}
+
+.fs_22 {
+ font-size: 44rpx !important;
+}
+
+.fs_24 {
+ font-size: 48rpx !important;
+}
+
+.fs_26 {
+ font-size: 52rpx !important;
+}
+
+.fs_28 {
+ font-size: 56rpx !important;
+}
+
+.fs_30 {
+ font-size: 60rpx !important;
+}
+
+.fs_32 {
+ font-size: 64rpx !important;
+}
+
+/* 控制字体粗细 */
+.fw_blod {
+ font-weight: bold;
+}
+
+/* 控制字体颜色 */
+.color_D16B3F {
+ color: #D16B3F !important;
+}
+
+.color_C7331D {
+ color: #C7331D !important;
+}
+
+.color_666666 {
+ color: #666666 !important;
+}
+
+.color_F8A52F {
+ color: #F8A52F !important;
+}
+
+.color_999999 {
+ color: #999999 !important;
+}
+
+.color_C7331D {
+ color: #C7331D !important;
+}
+
+.color_333333 {
+ color: #333333 !important;
+}
+
+.color_FFFFFF {
+ color: #FFFFFF !important;
+}
+
+.color_E7612E {
+ color: #E7612E !important;
+}
+
+.color_EF4B37 {
+ color: #EF4B37 !important;
+}
+
+.color_5F5F5F {
+ color: #5F5F5F !important;
+}
+
+.color_FB7307 {
+ color: #FB7307 !important;
+}
+
+.color_256BFA {
+ color: #256BFA !important;
+}
+
+.color_4E8ADE {
+ color: #4E8ADE !important;
+}
+
+.color_D9D9D9 {
+ color: #D9D9D9 !important;
+}
+
+/* 控制左右距离 */
+.mar_le30 {
+ margin-left: 60rpx !important;
+}
+
+.mar_le25 {
+ margin-left: 50rpx !important;
+}
+
+.mar_le20 {
+ margin-left: 40rpx !important;
+}
+
+.mar_le15 {
+ margin-left: 30rpx !important;
+}
+
+.mar_le10 {
+ margin-left: 20rpx !important;
+}
+
+.mar_le5 {
+ margin-left: 10rpx !important;
+}
+
+.mar_ri5 {
+ margin-right: 10rpx !important;
+}
+
+.mar_ri10 {
+ margin-right: 20rpx !important;
+}
+
+.mar_ri15 {
+ margin-right: 30rpx !important;
+}
+
+.mar_ri20 {
+ margin-right: 40rpx !important;
+}
+
+.mar_ri25 {
+ margin-right: 50rpx !important;
+}
+
+.mar_top0 {
+ margin-top: 0 !important;
+}
+
+.mar_top5 {
+ margin-top: 10rpx !important;
+}
+
+.mar_top10 {
+ margin-top: 20rpx !important;
+}
+
+.mar_top15 {
+ margin-top: 30rpx !important;
+}
+
+.mar_top20 {
+ margin-top: 40rpx !important;
+}
+
+.mar_top25 {
+ margin-top: 50rpx !important;
+}
+
+/* 控制字体粗细 */
+.fw_blod {
+ font-weight: bold !important;
+}
+
+/* 控制背景色 */
+.bg_e8 {
+ background-color: #e8e8e8 !important;
+}
+
+/* 控制背景色 */
+.bg_cc {
+ background-color: #CCCCCC !important;
+}
+
+/* 控制背景色 */
+.bg_ff {
+ background-color: #ffffff !important;
+}
+
+
+
+/* 弹性布局 */
+.fl_box {
+ display: flex;
+}
+
+.fl_deri {
+ flex-direction: column;
+}
+
+.fl_row {
+ flex-direction: row;
+}
+
+.fl_justmiddle {
+ justify-content: center;
+}
+
+.fl_juststart {
+ justify-content: flex-start;
+}
+
+.fl_justbet {
+ justify-content: space-between;
+}
+
+.fl_justround {
+ justify-content: space-around;
+}
+
+.fl_justend {
+ justify-content: flex-end;
+}
+
+.fl_almiddle {
+ align-items: center;
+}
+
+.fl_alstart {
+ align-items: flex-start;
+}
+
+.fl_alend {
+ align-items: flex-end;
+}
+
+.fl_1 {
+ flex: 1;
+}
+
+.fl_warp {
+ flex-wrap: wrap
+}
+
+.fl_nowarp {
+ flex-wrap: nowrap
+}
+
+.line_2 {
+ display: -webkit-box;
+ /* 让文本内容成为弹性盒 */
+ -webkit-box-orient: vertical;
+ /* 设置盒子的方向为垂直 */
+ -webkit-line-clamp: 2;
+ /* 限制最多显示两行 */
+ overflow: hidden;
+ /* 隐藏超出的文本 */
+ text-overflow: ellipsis;
+ /* 使用省略号 */
+}
+
+.line_1 {
+ display: -webkit-box;
+ /* 让文本内容成为弹性盒 */
+ -webkit-box-orient: vertical;
+ /* 设置盒子的方向为垂直 */
+ -webkit-line-clamp: 1;
+ /* 限制最多显示两行 */
+ overflow: hidden;
+ /* 隐藏超出的文本 */
+ text-overflow: ellipsis;
+ /* 使用省略号 */
+}
\ No newline at end of file
diff --git a/common/globalFunction.js b/common/globalFunction.js
new file mode 100644
index 0000000..17dd139
--- /dev/null
+++ b/common/globalFunction.js
@@ -0,0 +1,589 @@
+import useUserStore from "../stores/useUserStore";
+import {
+ request,
+ createRequest,
+ uploadFile
+} from "../utils/request";
+import streamRequest, {
+ chatRequest
+} from "../utils/streamRequest.js";
+
+export const CloneDeep = (props) => {
+ if (typeof props !== 'object' || props === null) {
+ return props
+ }
+
+ let result
+ if (props) {
+ result = []
+ } else {
+ result = {}
+ }
+
+ for (let key in props) {
+ if (props.hasOwnProperty(key)) {
+ result[key] = CloneDeep(props[key])
+ }
+ }
+
+ return result
+}
+
+
+export const msg = (title, duration = 1500, mask = false, icon = 'none', image) => {
+ if (Boolean(title) === false) {
+ return;
+ }
+ uni.showToast({
+ title,
+ duration,
+ mask,
+ icon,
+ image
+ });
+}
+
+const prePage = () => {
+ let pages = getCurrentPages();
+ let prePage = pages[pages.length - 2];
+ return prePage.$vm;
+}
+
+
+
+/**
+ * 页面跳转封装,支持 query 参数传递和返回回调
+ * @param {string} url - 跳转路径
+ * @param {object} options
+ * @param {boolean} options.needLogin - 是否需要登录
+ * @param {object} options.query - 携带参数
+ * @param {function} options.onBack - 页面返回时的回调(目标页调用 uni.navigateBack 时传递数据)
+ */
+export const navTo = function(url, {
+ needLogin = false,
+ query = {},
+ onBack = null
+} = {}) {
+ const userStore = useUserStore();
+
+ if (needLogin && !userStore.hasLogin) {
+ uni.navigateTo({
+ url: '/pages/login/login'
+ });
+ return;
+ }
+
+ const queryStr = Object.entries(query)
+ .map(([key, val]) => `${key}=${encodeURIComponent(val)}`)
+ .join('&');
+ const finalUrl = queryStr ? `${url}?${queryStr}` : url;
+
+ if (onBack) {
+ const pages = getCurrentPages();
+ const currentPage = pages[pages.length - 1];
+ currentPage.__onBackCallback__ = onBack;
+ }
+
+ uni.navigateTo({
+ url: finalUrl
+ });
+};
+
+export const navBack = function({
+ delta = 1,
+ data = null,
+ fallbackUrl = '/pages/index/index'
+} = {}) {
+ const pages = getCurrentPages();
+
+ if (pages.length > 1) {
+ const prevPage = pages[pages.length - 1 - delta];
+
+ // 如果上一页存在回调函数,调用
+ if (data && prevPage?.__onBackCallback__) {
+ prevPage.__onBackCallback__(data);
+ }
+
+ uni.navigateBack({
+ delta
+ });
+ } else {
+ // 没有可返回的页面,直接跳转 fallback 页面
+ uni.reLaunch({
+ url: fallbackUrl
+ });
+ }
+};
+// // 默认返回上一页
+// navBack();
+
+// // 返回上两层
+// navBack(2);
+
+// // 没有历史页面时跳转首页
+// navBack(1, '/pages/home/home');
+
+function getdeviceInfo() {
+ const globalData = {
+ statusBarHeight: 0, // 状态导航栏高度
+ topHeight: 0, // 距离顶部高度
+ navHeight: 0, // 总体高度
+ windowHeight: 0, // 可使用窗口高度
+ tabBarHight: 0, //底部导航栏高度
+ };
+ let systemInfo = uni.getSystemInfoSync()
+ globalData.windowHeight = systemInfo.screenHeight
+ // 底部导航栏
+ globalData.tabBarHight = systemInfo.screenHeight - systemInfo.safeArea.bottom
+ // 状态栏高度
+ globalData.statusBarHeight = systemInfo.statusBarHeight
+ // #ifdef MP-MP-WEIXIN
+ let menuButtonInfo = uni.getMenuButtonBoundingClientRect()
+ // 胶囊距离顶部高度
+ globalData.topHeight = menuButtonInfo.top
+ // 胶囊高度
+ globalData.navHeight = menuButtonInfo.height
+ // #endif
+ return {
+ ...globalData
+ }
+}
+
+function sleep(time) {
+ return new Promise((resolve) => setTimeout(resolve, time))
+}
+const cloneDeep = (obj) => {
+ // 1.1 判断是否是对象
+ const isObject = (obj) => (typeof obj === 'object' || typeof obj === 'function') && obj !== 'null'
+
+ if (!isObject(obj)) {
+ throw new Error('参数不是对象')
+ }
+ // 1.3 如果参数为数组,则复制数组各元素,否则复制对象属性
+ const newObject = Array.isArray(obj) ? [...obj] : {
+ ...obj
+ }
+ // 1.4 迭代
+ Object.keys(newObject).forEach((key) => {
+ // 1.5 判断如果遍历到的属性值为对象,则继续递归cloneDeep
+ if (isObject(newObject[key])) {
+ newObject[key] = cloneDeep(newObject[key])
+ }
+ })
+ return newObject
+}
+
+const CopyText = (text) => {
+ let input = document.createElement('textarea');
+ input.value = text;
+ document.body.appendChild(input);
+ input.select();
+ let flag = document.execCommand('copy')
+ if (flag) {
+ message.success('成功复制到剪贴板')
+ } else {
+ message.success('复制失败')
+ }
+ document.body.removeChild(input)
+}
+
+// 柯里化 降低使用范围,提高适用性
+function Exp(regExp) {
+ return (str) => {
+ return regExp.test(str)
+ }
+}
+
+const checkingPhoneRegExp = Exp(/^1[3-9]{1}\d{9}/)
+// 手机号校验 checkingPhoneRegExp(phone)
+
+const checkingEmailRegExp = Exp(/^[a-z0-9_\.-]+@[a-z0-9_\.-]+[a-z0-9]{2,6}$/i)
+// 邮箱校验 checkingEmailRegExp(email)
+
+
+function throttle(fn, delay = 300) {
+ let valid = true
+ let savedArgs = null // 参数存储器
+ let savedContext = null // 上下文存储器
+
+ return function(...args) {
+ // 保存当前参数和上下文
+ savedArgs = args
+ savedContext = this
+
+ if (!valid) return false
+ valid = false
+ setTimeout(() => {
+ fn.apply(savedContext, savedArgs)
+ valid = true
+ savedArgs = null // 清空存储
+ savedContext = null
+ }, delay)
+ }
+}
+
+function debounce(fun, delay) {
+ return function(args) {
+ let that = this
+ let _args = args
+ clearTimeout(fun.id)
+ fun.id = setTimeout(function() {
+ fun.call(that, _args)
+ }, delay)
+ }
+}
+
+
+function toRad(degree) {
+ return degree * Math.PI / 180;
+}
+
+function haversine(lat1, lon1, lat2, lon2) {
+ const R = 6371; // 地球半径,单位为公里
+ const a1 = toRad(lat1);
+ const a2 = toRad(lat2);
+ const b1 = toRad(lat2 - lat1);
+ const b2 = toRad(lon2 - lon1);
+
+ const a = Math.sin(b1 / 2) * Math.sin(b1 / 2) +
+ Math.cos(a1) * Math.cos(a2) * Math.sin(b2 / 2) * Math.sin(b2 / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ const distance = R * c; // 计算得到的距离,单位为公里
+
+ return distance;
+}
+
+export function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) {
+ const R = 6371; // 地球平均半径,单位为公里
+ const dLat = deg2rad(lat2 - lat1);
+ const dLon = deg2rad(lon2 - lon1);
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
+ Math.sin(dLon / 2) * Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ const d = R * c;
+ return {
+ km: d,
+ m: d * 1000
+ };
+}
+
+// 将角度转换为弧度
+function deg2rad(deg) {
+ return deg * (Math.PI / 180);
+}
+
+function vacanciesTo(vacancies) {
+ if (vacancies >= 0) {
+ return vacancies + "人"
+ } else {
+ return '不限人数'
+ }
+}
+
+function salaryGlobal(type = 'min') {
+ const salay = [2, 5, 10, 15, 20, 25, 30, 50, 80];
+ const salaymax = [2, 5, 10, 15, 20, 25, 30, 50, 80, 100];
+ const salarys = salay.map((item, index) => ({
+ label: item + 'k',
+ value: item * 1000,
+ children: CloneDeep(salaymax).splice(index).map((vItem) => ({
+ label: vItem + 'k',
+ value: vItem * 1000,
+ }))
+ }))
+
+ return salarys
+}
+
+class CustomSystem {
+ constructor() {
+ const systemInfo = uni.getSystemInfoSync();
+ this.systemInfo = systemInfo
+ }
+}
+const customSystem = new CustomSystem()
+
+function setCheckedNodes(nodes, ids) {
+ const isClear = ids.length === 0;
+
+ nodes.forEach((firstLayer) => {
+ // 每次处理都先重置
+ firstLayer.checkednumber = 0;
+
+ const traverse = (node) => {
+ if (isClear) {
+ node.checked = false;
+ } else {
+ node.checked = ids.includes(node.id);
+ }
+
+ if (node !== firstLayer && node.checked) {
+ firstLayer.checkednumber++;
+ }
+
+ if (node.children && node.children.length) {
+ node.children.forEach(child => traverse(child));
+ }
+ };
+
+ traverse(firstLayer);
+ });
+
+ return nodes;
+}
+const formatTotal = (total) => {
+ if (total < 10) return total.toString(); // 直接返回小于 10 的数
+
+ const magnitude = Math.pow(10, Math.floor(Math.log10(total))); // 计算数量级
+ const roundedTotal = Math.floor(total / magnitude) * magnitude; // 去掉零头
+
+ return `${roundedTotal}+`;
+};
+
+export function formatDate(isoString) {
+ const date = new Date(isoString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从 0 开始,需要 +1
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+}
+
+export function insertSortData(data, attribute = 'createTime') {
+ const sortedData = data.sort((a, b) => new Date(b[attribute]) - new Date(a[attribute])); // 按时间降序排序
+ const result = [];
+ let lastDate = '';
+ let lastTitle = ''
+
+
+ const now = new Date();
+ const todayStr = now.toISOString().split('T')[0]; // 获取今天的日期字符串
+ const yesterday = new Date(now.setDate(now.getDate() - 1)).toISOString().split('T')[0]; // 获取昨天的日期字符串
+ const twoDaysAgo = new Date(now.setDate(now.getDate() - 1)).toISOString().split('T')[0]; // 获取前天的日期字符串
+
+ sortedData.forEach(item => {
+ const itemAttribute = item[attribute].replace('T', ' ')
+ const itemDate = itemAttribute.split(' ')[0]; // 提取日期部分
+
+ let title = itemDate;
+ if (itemDate === todayStr) {
+ title = '今天';
+ } else if (itemDate === yesterday) {
+ title = '昨天';
+ } else if (itemDate === twoDaysAgo) {
+ title = '前天';
+ }
+
+ if (lastDate !== itemDate) {
+ result.push({
+ title,
+ isTitle: true
+ });
+ lastDate = itemDate;
+ lastTitle = title;
+ }
+
+ result.push({
+ ...item,
+ isTitle: false
+ });
+ });
+
+ return [result, lastTitle];
+}
+
+function getWeeksOfMonth(year, month) {
+ const firstDay = new Date(year, month - 1, 1); // 当月第一天
+ const lastDay = new Date(year, month, 0); // 当月最后一天
+ const weeks = [];
+ let week = [];
+
+ for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) {
+ // 补充第一周的上个月日期
+ if (week.length === 0 && d.getDay() !== 1) {
+ let prevMonday = new Date(d);
+ prevMonday.setDate(d.getDate() - (d.getDay() === 0 ? 6 : d.getDay() - 1));
+ while (prevMonday < d) {
+ week.push({
+ year: prevMonday.getFullYear(),
+ month: prevMonday.getMonth() + 1,
+ day: prevMonday.getDate(),
+ fullDate: getLocalYYYYMMDD(prevMonday), // 修正
+ weekday: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][prevMonday.getDay()],
+ isCurrent: false // 上个月日期
+ });
+ prevMonday.setDate(prevMonday.getDate() + 1);
+ }
+ }
+
+ // 添加当前月份的日期
+ week.push({
+ year: d.getFullYear(),
+ month: d.getMonth() + 1,
+ day: d.getDate(),
+ fullDate: getLocalYYYYMMDD(d), // 修正
+ weekday: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][d.getDay()],
+ isCurrent: true // 当前月的日期
+ });
+
+ // 如果到了月末但当前周未满7天,需要补足到周日
+ if (d.getTime() === lastDay.getTime() && week.length < 7) {
+ let nextDay = new Date(d);
+ nextDay.setDate(d.getDate() + 1);
+ while (week.length < 7) {
+ week.push({
+ year: nextDay.getFullYear(),
+ month: nextDay.getMonth() + 1,
+ day: nextDay.getDate(),
+ fullDate: getLocalYYYYMMDD(nextDay), // 修正
+ weekday: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][nextDay.getDay()],
+ isCurrent: false // 下个月日期
+ });
+ nextDay.setDate(nextDay.getDate() + 1);
+ }
+ }
+
+ // 如果本周满了(7天)或者到了月末
+ if (week.length === 7 || d.getTime() === lastDay.getTime()) {
+ weeks.push([...week]); // 存入当前周
+ week = []; // 清空,准备下一周
+ }
+ }
+
+ return weeks;
+}
+
+// 新增工具函数:将日期格式化为本地 YYYY-MM-DD 字符串
+function getLocalYYYYMMDD(date) {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
+function isFutureDate(dateStr) {
+ const inputDate = new Date(dateStr);
+ const today = new Date();
+
+ // 只比较年月日,不考虑具体时间
+ today.setHours(0, 0, 0, 0);
+ inputDate.setHours(0, 0, 0, 0);
+
+ return inputDate > today;
+}
+
+function parseQueryParams(url = window.location.href) {
+ const queryString = url.split('?')[1]?.split('#')[0];
+ const params = {};
+ if (!queryString) return params;
+
+ queryString.split('&').forEach(param => {
+ const [key, value] = param.split('=');
+ if (key) {
+ params[decodeURIComponent(key)] = decodeURIComponent(value || '');
+ }
+ });
+
+ return params;
+}
+
+function formatFileSize(bytes) {
+ if (bytes < 1024) return bytes + ' B'
+ else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
+ else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
+ else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
+}
+
+
+function sendingMiniProgramMessage(data = {
+ text: 'hello'
+}, action = 'defalut') {
+ jWeixin.miniProgram.postMessage({
+ data,
+ action
+ });
+}
+
+function copyText(text) {
+ uni.setClipboardData({
+ data: text,
+ showToast: false,
+ success(res) {
+ msg('复制成功')
+ },
+ });
+}
+
+function appendScriptTagElement(src) {
+ if (!src) return null;
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = () => {
+ resolve()
+ };
+ script.onerror = () => {
+ reject()
+ };
+ document.body.appendChild(script);
+ })
+}
+
+function isInWechatMiniProgramWebview() {
+ const ua = navigator.userAgent.toLowerCase()
+ return ua.includes('miniprogram') || window.__wxjs_environment === 'miniprogram'
+}
+
+function isEmptyObject(obj) {
+ return obj && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length === 0;
+}
+
+
+export const $api = {
+ msg,
+ prePage,
+ sleep,
+ request,
+ createRequest,
+ streamRequest,
+ chatRequest,
+ insertSortData,
+ uploadFile,
+ formatFileSize,
+ sendingMiniProgramMessage,
+ copyText
+}
+
+
+
+export default {
+ $api,
+ navTo,
+ navBack,
+ cloneDeep,
+ formatDate,
+ getdeviceInfo,
+ checkingPhoneRegExp,
+ checkingEmailRegExp,
+ throttle,
+ debounce,
+ haversine,
+ getDistanceFromLatLonInKm,
+ vacanciesTo,
+ salaryGlobal,
+ customSystem,
+ setCheckedNodes,
+ formatTotal,
+ getWeeksOfMonth,
+ isFutureDate,
+ parseQueryParams,
+ appendScriptTagElement,
+ insertSortData,
+ isInWechatMiniProgramWebview,
+ isEmptyObject,
+}
\ No newline at end of file
diff --git a/components/AppLayout/AppLayout.vue b/components/AppLayout/AppLayout.vue
new file mode 100644
index 0000000..e9b405f
--- /dev/null
+++ b/components/AppLayout/AppLayout.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/CollapseTransition/CollapseTransition.vue b/components/CollapseTransition/CollapseTransition.vue
new file mode 100644
index 0000000..9ea5df4
--- /dev/null
+++ b/components/CollapseTransition/CollapseTransition.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/MsgTips/MsgTips.vue b/components/MsgTips/MsgTips.vue
new file mode 100644
index 0000000..36a7c28
--- /dev/null
+++ b/components/MsgTips/MsgTips.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/NoBouncePage/NoBouncePage.vue b/components/NoBouncePage/NoBouncePage.vue
new file mode 100644
index 0000000..f44477f
--- /dev/null
+++ b/components/NoBouncePage/NoBouncePage.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/Salary-Expectation/Salary-Expectation.vue b/components/Salary-Expectation/Salary-Expectation.vue
new file mode 100644
index 0000000..a986375
--- /dev/null
+++ b/components/Salary-Expectation/Salary-Expectation.vue
@@ -0,0 +1,17 @@
+
+ {{ salaryText }}
+
+
+
diff --git a/components/TikTok/TikTok.vue b/components/TikTok/TikTok.vue
new file mode 100644
index 0000000..1338850
--- /dev/null
+++ b/components/TikTok/TikTok.vue
@@ -0,0 +1,312 @@
+
+
+
+ handleClick(index, e)">
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/convert-distance/convert-distance.vue b/components/convert-distance/convert-distance.vue
new file mode 100644
index 0000000..a50ba27
--- /dev/null
+++ b/components/convert-distance/convert-distance.vue
@@ -0,0 +1,26 @@
+
+ {{ distance }}
+
+
+
+
+
diff --git a/components/dict-Label/dict-Label.vue b/components/dict-Label/dict-Label.vue
new file mode 100644
index 0000000..3759d32
--- /dev/null
+++ b/components/dict-Label/dict-Label.vue
@@ -0,0 +1,11 @@
+
+ {{ dictLabel(dictType, value) }}
+
+
+
+
+
diff --git a/components/dict-tree-Label/dict-tree-Label.vue b/components/dict-tree-Label/dict-tree-Label.vue
new file mode 100644
index 0000000..654289a
--- /dev/null
+++ b/components/dict-tree-Label/dict-tree-Label.vue
@@ -0,0 +1,11 @@
+
+ {{ industryLabel(dictType, value) }}
+
+
+
+
+
diff --git a/components/empty/empty.vue b/components/empty/empty.vue
new file mode 100644
index 0000000..4344136
--- /dev/null
+++ b/components/empty/empty.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
diff --git a/components/expected-station/expected-station.vue b/components/expected-station/expected-station.vue
new file mode 100644
index 0000000..3cd0f92
--- /dev/null
+++ b/components/expected-station/expected-station.vue
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/loadmore/loadmore.vue b/components/loadmore/loadmore.vue
new file mode 100644
index 0000000..0d5a7a5
--- /dev/null
+++ b/components/loadmore/loadmore.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
diff --git a/components/matchingDegree/matchingDegree.vue b/components/matchingDegree/matchingDegree.vue
new file mode 100644
index 0000000..1406c40
--- /dev/null
+++ b/components/matchingDegree/matchingDegree.vue
@@ -0,0 +1,18 @@
+
+ {{ matchingText }}
+
+
+
+
+
diff --git a/components/md-render/md-render.vue b/components/md-render/md-render.vue
new file mode 100644
index 0000000..4bc7762
--- /dev/null
+++ b/components/md-render/md-render.vue
@@ -0,0 +1,357 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/renderCompanys/renderCompanys.vue b/components/renderCompanys/renderCompanys.vue
new file mode 100644
index 0000000..5a1456d
--- /dev/null
+++ b/components/renderCompanys/renderCompanys.vue
@@ -0,0 +1,133 @@
+
+
+
+
+ {{ job.name }}
+
+
+
+
+
+
+
+
+ 在招职位·
+ {{ job.totalRecruitment || '-' }}
+ 个
+
+
+
+
+
+
+
+
+ {{ vacanciesTo(job.vacancies) }}
+
+
+
+
+
+
+
+
+
diff --git a/components/renderJobs/renderJobs.vue b/components/renderJobs/renderJobs.vue
new file mode 100644
index 0000000..bcb138c
--- /dev/null
+++ b/components/renderJobs/renderJobs.vue
@@ -0,0 +1,149 @@
+
+
+
+
+ {{ job.jobTitle }}
+
+
+
+
+ {{ job.companyName }}
+
+
+
+
+
+
+
+
+ {{ vacanciesTo(job.vacancies) }}
+
+
+
+ {{ job.postingDate }}
+
+
+
+
+
+
+
+ {{ job.title }}
+
+
+
+
+
+
+
diff --git a/components/screening-job-requirements/screening-job-requirements.vue b/components/screening-job-requirements/screening-job-requirements.vue
new file mode 100644
index 0000000..475f9f3
--- /dev/null
+++ b/components/screening-job-requirements/screening-job-requirements.vue
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ {{ item.label }}
+ handleSelect(item.key, e)">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/selectFilter/selectFilter.vue b/components/selectFilter/selectFilter.vue
new file mode 100644
index 0000000..69db35e
--- /dev/null
+++ b/components/selectFilter/selectFilter.vue
@@ -0,0 +1,374 @@
+
+
+
+
+
+
+
diff --git a/components/selectJobs/selectJobs.vue b/components/selectJobs/selectJobs.vue
new file mode 100644
index 0000000..22c6aac
--- /dev/null
+++ b/components/selectJobs/selectJobs.vue
@@ -0,0 +1,289 @@
+
+
+
+
+
+
+
+
+
diff --git a/components/selectPopup/selectPopup.vue b/components/selectPopup/selectPopup.vue
new file mode 100644
index 0000000..c91a6f7
--- /dev/null
+++ b/components/selectPopup/selectPopup.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+
+
diff --git a/components/selectPopup/selectPopupPlugin.js b/components/selectPopup/selectPopupPlugin.js
new file mode 100644
index 0000000..c00e25a
--- /dev/null
+++ b/components/selectPopup/selectPopupPlugin.js
@@ -0,0 +1,26 @@
+// plugins/selectPopup.js
+import {
+ createApp
+} from 'vue';
+import SelectPopup from './selectPopup.vue';
+
+export default {
+ install(app) {
+ const popupApp = createApp(SelectPopup);
+ // #ifdef H5
+ const popupInstance = popupApp.mount(document.createElement('div'));
+ document.body.appendChild(popupInstance.$el);
+
+ // 提供 open 方法
+ const openPopup = (config) => {
+ popupInstance.open(config);
+ };
+ // #endif
+
+ // #ifndef H5
+ const openPopup = (config) => {};
+ // #endif
+ // 提供给所有组件使用
+ app.provide('openSelectPopup', openPopup);
+ }
+};
\ No newline at end of file
diff --git a/components/tabbar/midell-box.vue b/components/tabbar/midell-box.vue
new file mode 100644
index 0000000..89ec8b2
--- /dev/null
+++ b/components/tabbar/midell-box.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+ {{ item.badge }}
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..6ca4098
--- /dev/null
+++ b/config.js
@@ -0,0 +1,73 @@
+export default {
+ // baseUrl: 'http://39.98.44.136:8080', // 测试
+ baseUrl: 'https://qd.zhaopinzao8dian.com/api', // 测试
+ // sseAI+
+ // StreamBaseURl: 'http://39.98.44.136:8000',
+ StreamBaseURl: 'https://qd.zhaopinzao8dian.com/ai',
+ // StreamBaseURl: 'https://qd.zhaopinzao8dian.com/ai/test',
+ // 语音转文字
+ // vioceBaseURl: 'ws://39.98.44.136:8080/speech-recognition',
+ vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/speech-recognition',
+ // 语音合成
+ speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis',
+ // indexedDB
+ DBversion: 2,
+ // 只使用本地缓寸的数据
+ OnlyUseCachedDB: true,
+ // 使用模拟定位
+ UsingSimulatedPositioning: true,
+ // 应用信息
+ appInfo: {
+ // 应用名称
+ name: "青岛市就业服务",
+ // 地区名
+ areaName: '青岛市',
+ // AI名称
+ AIName: '小红',
+ // 应用版本
+ version: "1.0.0",
+ // 应用logo
+ logo: "",
+ // 官方网站
+ site_url: "",
+ // 政策协议
+ agreements: [{
+ title: "隐私政策",
+ url: ""
+ },
+ {
+ title: "用户服务协议",
+ url: ""
+ }
+ ]
+ },
+ // AI -> 上传文件数量
+ allowedFileNumber: 2,
+ // AI -> 上传文件类型
+ allowedFileTypes: [
+ "text/plain", // .txt
+ "text/markdown", // .md
+ "text/html", // .html
+ "application/msword", // .doc
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
+ "application/pdf", // .pdf
+ "application/vnd.ms-powerpoint", // .ppt
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
+ "text/csv", // .csv
+ "application/vnd.ms-excel", // .xls
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" // .xlsx
+ ],
+ // 首页询问 -> 推荐权重
+ weights: {
+ categories: 1, //岗位
+ experience: 0.3, //经验
+ salary: 0.5, // 薪资
+ areas: 0.5 // 区域
+ },
+ shareConfig: {
+ baseUrl: 'https://qd.zhaopinzao8dian.com',
+ title: '找工作,用 AI 更高效|青岛市智能求职平台',
+ desc: '融合海量岗位、智能简历匹配、竞争力分析,助你精准锁定理想职位!',
+ imgUrl: 'https://qd.zhaopinzao8dian.com/file/csn/qd_shareLogo.jpg',
+ }
+}
\ No newline at end of file
diff --git a/directives/collapse.js b/directives/collapse.js
new file mode 100644
index 0000000..6fbea1b
--- /dev/null
+++ b/directives/collapse.js
@@ -0,0 +1,84 @@
+// directives/collapse.js
+export default {
+ mounted(el, binding) {
+ el._collapse = {
+ duration: binding.arg ? parseInt(binding.arg) : 300, // 使用指令参数设置 duration
+ expanded: binding.value,
+ };
+
+ el.style.overflow = 'hidden';
+ el.style.transition = `height ${el._collapse.duration}ms ease, opacity ${el._collapse.duration}ms ease`;
+
+ if (!binding.value) {
+ el.style.height = '0px';
+ el.style.opacity = '0';
+ } else {
+ setTimeout(() => {
+ getHeight(el).then((height) => {
+ el.style.height = height + 'px';
+ el.style.opacity = '1';
+ });
+ }, 0);
+ }
+ },
+
+ updated(el, binding) {
+ const duration = el._collapse.duration;
+ const isShow = binding.value;
+
+ if (isShow === el._collapse.expanded) return;
+
+ el._collapse.expanded = isShow;
+
+ if (isShow) {
+ getHeight(el).then((height) => {
+ el.style.transition = `none`;
+ el.style.height = '0px';
+ el.style.opacity = '0';
+
+ // 动画开始
+ requestAnimationFrame(() => {
+ el.style.transition = `height ${duration}ms ease, opacity ${duration}ms ease`;
+ el.style.height = height + 'px';
+ el.style.opacity = '1';
+
+ // 动画结束后设置为 auto(避免内容变化导致高度错误)
+ setTimeout(() => {
+ el.style.height = 'auto';
+ }, duration);
+ });
+ });
+ } else {
+ getHeight(el).then((height) => {
+ console.log(height)
+ el.style.height = height + 'px';
+ el.style.opacity = '1';
+
+ requestAnimationFrame(() => {
+ el.style.height = '0px';
+ el.style.opacity = '0';
+ });
+ });
+ }
+ },
+
+ unmounted(el) {
+ delete el._collapse;
+ },
+};
+
+// 获取元素高度(兼容 H5 和小程序)
+function getHeight(el) {
+ return new Promise((resolve) => {
+ // #ifdef H5
+ resolve(el.scrollHeight);
+ // #endif
+
+ // #ifndef H5
+ const query = uni.createSelectorQuery();
+ query.select(el).boundingClientRect((res) => {
+ resolve(res?.height || 0);
+ }).exec();
+ // #endif
+ });
+}
\ No newline at end of file
diff --git a/directives/fade.js b/directives/fade.js
new file mode 100644
index 0000000..02139ef
--- /dev/null
+++ b/directives/fade.js
@@ -0,0 +1,23 @@
+export default {
+ mounted(el, binding) {
+ const duration = binding.arg ? parseInt(binding.arg) : 300;
+ el.style.transition = `opacity ${duration}ms ease`;
+ el.style.opacity = binding.value ? '1' : '0';
+ if (!binding.value) el.style.display = 'none';
+ },
+ updated(el, binding) {
+ const duration = binding.arg ? parseInt(binding.arg) : 300;
+
+ if (binding.value) {
+ el.style.display = '';
+ requestAnimationFrame(() => {
+ el.style.opacity = '1';
+ });
+ } else {
+ el.style.opacity = '0';
+ setTimeout(() => {
+ el.style.display = 'none';
+ }, duration);
+ }
+ }
+};
\ No newline at end of file
diff --git a/hook/useColumnCount.js b/hook/useColumnCount.js
new file mode 100644
index 0000000..991f515
--- /dev/null
+++ b/hook/useColumnCount.js
@@ -0,0 +1,71 @@
+// composables/useColumnCount.js
+import {
+ ref,
+ onMounted,
+ onUnmounted,
+ watch
+} from 'vue'
+
+export function useColumnCount(onChange = () => {}) {
+ const columnCount = ref(0)
+ const columnSpace = ref(2)
+
+
+ // const calcColumn = () => {
+ // const width = uni.getSystemInfoSync().windowWidth
+ // console.log(width)
+ // const count = Math.min(5, Math.floor(width / 375) + 1)
+ // if (count !== columnCount.value) {
+ // columnCount.value = count < 2 ? 2 : count
+ // }
+ // }
+ const calcColumn = () => {
+ const width = uni.getSystemInfoSync().windowWidth
+
+ let count = 2
+ if (width >= 1000) {
+ count = 5
+ } else if (width >= 750) {
+ count = 4
+ } else if (width >= 500) {
+ count = 3
+ } else {
+ count = 2
+ }
+
+ if (count !== columnCount.value) {
+ columnCount.value = count
+ }
+
+ // 计算间距:count=2 => 1,count=5 => 2,中间线性插值
+ const spacing = 2 - (count - 2) * (1 / 3)
+ // console.log('列数:', count, '间距:', spacing.toFixed(2))
+ columnSpace.value = spacing
+ }
+
+ onMounted(() => {
+ columnCount.value = 2
+ calcColumn()
+ // if (process.client) {
+ window.addEventListener('resize', calcColumn)
+ // }
+ })
+
+ onUnmounted(() => {
+ // if (process.client) {
+ window.removeEventListener('resize', calcColumn)
+ // }
+ })
+
+ // 列数变化时执行回调
+ watch(columnCount, (newVal, oldVal) => {
+ if (newVal !== oldVal) {
+ onChange(newVal)
+ }
+ })
+
+ return {
+ columnCount,
+ columnSpace
+ }
+}
\ No newline at end of file
diff --git a/hook/usePagination.js b/hook/usePagination.js
new file mode 100644
index 0000000..147aecb
--- /dev/null
+++ b/hook/usePagination.js
@@ -0,0 +1,173 @@
+import {
+ ref,
+ reactive,
+ watch,
+ isRef,
+ nextTick
+} from 'vue'
+
+export function usePagination(
+ requestFn,
+ transformFn,
+ options = {}
+) {
+ const list = ref([])
+ const loading = ref(false)
+ const error = ref(false)
+ const finished = ref(false)
+ const firstLoading = ref(true)
+ const empty = ref(false)
+
+ const {
+ pageSize = 10,
+ search = {},
+ autoWatchSearch = false,
+ debounceTime = 300,
+ autoFetch = false,
+
+ // 字段映射
+ dataKey = 'rows',
+ totalKey = 'total',
+
+ // 分页字段名映射
+ pageField = 'current',
+ sizeField = 'pageSize',
+
+ onBeforeRequest,
+ onAfterRequest
+ } = options
+
+ const pageState = reactive({
+ page: 1,
+ pageSize: isRef(pageSize) ? pageSize.value : pageSize,
+ total: 0,
+ maxPage: 1,
+ search: isRef(search) ? search.value : search
+ })
+
+ let debounceTimer = null
+
+ const fetchData = async (type = 'refresh') => {
+ if (loading.value) return Promise.resolve()
+ console.log(type)
+ loading.value = true
+ error.value = false
+
+ if (typeof onBeforeRequest === 'function') {
+ try {
+ onBeforeRequest(type, pageState)
+ } catch (err) {
+ console.warn('onBeforeRequest 执行异常:', err)
+ }
+ }
+
+ if (type === 'refresh') {
+ pageState.page = 1
+ finished.value = false
+ if (list.value.length === 0) {
+ firstLoading.value = true
+ }
+ } else if (type === 'loadMore') {
+ if (pageState.page >= pageState.maxPage) {
+ loading.value = false
+ finished.value = true
+ return Promise.resolve('no more')
+ }
+ pageState.page += 1
+ }
+
+ const params = {
+ ...pageState.search,
+ [pageField]: pageState.page,
+ [sizeField]: pageState.pageSize,
+ }
+
+ try {
+ const res = await requestFn(params)
+
+ const rawData = res[dataKey]
+ const total = res[totalKey] || 99999999
+ console.log(total, rawData)
+ const data = typeof transformFn === 'function' ? transformFn(rawData) : rawData
+
+ if (type === 'refresh') {
+ list.value = data
+ } else {
+ list.value.push(...data)
+ }
+
+ pageState.total = total
+ pageState.maxPage = Math.ceil(total / pageState.pageSize)
+
+ finished.value = list.value.length >= total
+ empty.value = list.value.length === 0
+ } catch (err) {
+ console.error('分页请求失败:', err)
+ error.value = true
+ } finally {
+ loading.value = false
+ firstLoading.value = false
+
+ if (typeof onAfterRequest === 'function') {
+ try {
+ onAfterRequest(type, pageState, {
+ error: error.value
+ })
+ } catch (err) {
+ console.warn('onAfterRequest 执行异常:', err)
+ }
+ }
+ }
+ }
+
+ const refresh = () => fetchData('refresh')
+ const loadMore = () => fetchData('loadMore')
+
+ const resetPagination = () => {
+ list.value = []
+ pageState.page = 1
+ pageState.total = 0
+ pageState.maxPage = 1
+ finished.value = false
+ error.value = false
+ firstLoading.value = true
+ empty.value = false
+ }
+
+ if (autoWatchSearch && isRef(search)) {
+ watch(search, (newVal) => {
+ pageState.search = newVal
+ clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => {
+ refresh()
+ }, debounceTime)
+ }, {
+ deep: true
+ })
+ }
+
+ watch(pageSize, (newVal) => {
+ pageState.pageSize = newVal
+ }, {
+ deep: true
+ })
+
+ if (autoFetch) {
+ nextTick(() => {
+ refresh()
+ })
+ }
+
+ return {
+ list,
+ loading,
+ error,
+ finished,
+ firstLoading,
+ empty,
+ pageState,
+ refresh,
+ loadMore,
+ resetPagination
+ }
+}
\ No newline at end of file
diff --git a/hook/useRealtimeRecorder.js b/hook/useRealtimeRecorder.js
new file mode 100644
index 0000000..265f797
--- /dev/null
+++ b/hook/useRealtimeRecorder.js
@@ -0,0 +1,258 @@
+import {
+ ref,
+ onUnmounted
+} from 'vue'
+import {
+ $api,
+
+} from '../common/globalFunction';
+
+import config from '@/config'
+
+export function useAudioRecorder() {
+ const isRecording = ref(false)
+ const isStopping = ref(false)
+ const isSocketConnected = ref(false)
+ const recordingDuration = ref(0)
+
+ const audioDataForDisplay = ref(new Array(16).fill(0))
+ const volumeLevel = ref(0)
+
+ const recognizedText = ref('')
+ const lastFinalText = ref('')
+
+ let audioStream = null
+ let audioContext = null
+ let audioInput = null
+ let scriptProcessor = null
+ let websocket = null
+ let durationTimer = null
+
+ const generateUUID = () => {
+ return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11)
+ .replace(/[018]/g, c =>
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
+ ).replace(/-/g, '')
+ }
+
+ const fetchWsUrl = async () => {
+ const res = await $api.createRequest('/app/speech/getToken')
+ if (res.code !== 200) throw new Error('无法获取语音识别 wsUrl')
+ const wsUrl = res.msg
+ return wsUrl
+ }
+
+ function extractWsParams(wsUrl) {
+ const url = new URL(wsUrl)
+ const appkey = url.searchParams.get('appkey')
+ const token = url.searchParams.get('token')
+ return {
+ appkey,
+ token
+ }
+ }
+
+
+ const connectWebSocket = async () => {
+ const wsUrl = await fetchWsUrl()
+ const {
+ appkey,
+ token
+ } = extractWsParams(wsUrl)
+ return new Promise((resolve, reject) => {
+ websocket = new WebSocket(wsUrl)
+ websocket.binaryType = 'arraybuffer'
+
+ websocket.onopen = () => {
+ isSocketConnected.value = true
+
+ // 发送 StartTranscription 消息(参考 demo.html)
+ const startTranscriptionMessage = {
+ header: {
+ appkey: appkey, // 不影响使用,可留空或由 wsUrl 带入
+ namespace: 'SpeechTranscriber',
+ name: 'StartTranscription',
+ task_id: generateUUID(),
+ message_id: generateUUID()
+ },
+ payload: {
+ format: 'pcm',
+ sample_rate: 16000,
+ enable_intermediate_result: true,
+ enable_punctuation_prediction: true,
+ enable_inverse_text_normalization: true
+ }
+ }
+ websocket.send(JSON.stringify(startTranscriptionMessage))
+ resolve()
+ }
+
+ websocket.onerror = (e) => {
+ isSocketConnected.value = false
+ reject(e)
+ }
+
+ websocket.onclose = () => {
+ isSocketConnected.value = false
+ }
+
+ websocket.onmessage = (e) => {
+ const msg = JSON.parse(e.data)
+ const name = msg?.header?.name
+ const payload = msg?.payload
+
+ switch (name) {
+ case 'TranscriptionResultChanged': {
+ // 中间识别文本(可选:使用 stash_result.unfixedText 更精确)
+ const text = payload?.unfixed_result || payload?.result || ''
+ lastFinalText.value = text
+ break
+ }
+ case 'SentenceBegin': {
+ // 可选:开始新的一句,重置状态
+ // console.log('开始新的句子识别')
+ break
+ }
+ case 'SentenceEnd': {
+ const text = payload?.result || ''
+ const confidence = payload?.confidence || 0
+ if (text && confidence > 0.5) {
+ recognizedText.value += text
+ lastFinalText.value = ''
+ // console.log('识别完成:', {
+ // text,
+ // confidence
+ // })
+ }
+ break
+ }
+ case 'TranscriptionStarted': {
+ // console.log('识别任务已开始')
+ break
+ }
+ case 'TranscriptionCompleted': {
+ lastFinalText.value = ''
+ // console.log('识别全部完成')
+ break
+ }
+ case 'TaskFailed': {
+ console.error('识别失败:', msg?.header?.status_text)
+ break
+ }
+ default:
+ console.log('未知消息类型:', name, msg)
+ break
+ }
+ }
+ })
+ }
+
+ const startRecording = async () => {
+ if (isRecording.value) return
+ try {
+ recognizedText.value = ''
+ lastFinalText.value = ''
+ await connectWebSocket()
+
+ audioStream = await navigator.mediaDevices.getUserMedia({
+ audio: true
+ })
+ audioContext = new(window.AudioContext || window.webkitAudioContext)({
+ sampleRate: 16000
+ })
+ audioInput = audioContext.createMediaStreamSource(audioStream)
+ scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1)
+
+ scriptProcessor.onaudioprocess = (event) => {
+ const input = event.inputBuffer.getChannelData(0)
+ const pcm = new Int16Array(input.length)
+ let sum = 0
+ for (let i = 0; i < input.length; ++i) {
+ const s = Math.max(-1, Math.min(1, input[i]))
+ pcm[i] = s * 0x7FFF
+ sum += s * s
+ }
+
+ volumeLevel.value = Math.sqrt(sum / input.length)
+ audioDataForDisplay.value = Array(16).fill(volumeLevel.value)
+
+ if (websocket?.readyState === WebSocket.OPEN) {
+ websocket.send(pcm.buffer)
+ }
+ }
+
+ audioInput.connect(scriptProcessor)
+ scriptProcessor.connect(audioContext.destination)
+
+ isRecording.value = true
+ recordingDuration.value = 0
+ durationTimer = setInterval(() => recordingDuration.value++, 1000)
+ } catch (err) {
+ console.error('启动失败:', err)
+ cleanup()
+ }
+ }
+
+ const stopRecording = () => {
+ if (!isRecording.value || isStopping.value) return
+ isStopping.value = true
+
+ if (websocket?.readyState === WebSocket.OPEN) {
+ websocket.send(JSON.stringify({
+ header: {
+ namespace: 'SpeechTranscriber',
+ name: 'StopTranscription',
+ message_id: generateUUID()
+ }
+ }))
+ websocket.close()
+ }
+
+ cleanup()
+ isStopping.value = false
+ }
+
+ const cancelRecording = () => {
+ if (!isRecording.value || isStopping.value) return
+ isStopping.value = true
+ websocket?.close()
+ cleanup()
+ isStopping.value = false
+ }
+
+ const cleanup = () => {
+ clearInterval(durationTimer)
+
+ scriptProcessor?.disconnect()
+ audioInput?.disconnect()
+ audioStream?.getTracks().forEach(track => track.stop())
+ audioContext?.close()
+
+ audioStream = null
+ audioContext = null
+ audioInput = null
+ scriptProcessor = null
+ websocket = null
+
+ isRecording.value = false
+ isSocketConnected.value = false
+ }
+
+ onUnmounted(() => {
+ if (isRecording.value) stopRecording()
+ })
+
+ return {
+ isRecording,
+ isStopping,
+ isSocketConnected,
+ recordingDuration,
+ audioDataForDisplay,
+ volumeLevel,
+ recognizedText,
+ lastFinalText,
+ startRecording,
+ stopRecording,
+ cancelRecording
+ }
+}
\ No newline at end of file
diff --git a/hook/useScrollDirection.js b/hook/useScrollDirection.js
new file mode 100644
index 0000000..8d483f3
--- /dev/null
+++ b/hook/useScrollDirection.js
@@ -0,0 +1,49 @@
+import {
+ ref
+} from 'vue'
+
+export function useScrollDirection(options = {}) {
+ const {
+ threshold = 200, // 滚动偏移阈值
+ throttleTime = 100, // 节流时间(毫秒)
+ onChange = null // 滚动方向变化的回调
+ } = options
+
+ const lastScrollTop = ref(0)
+ const accumulatedScroll = ref(0)
+ const isScrollingDown = ref(false)
+ let lastInvoke = 0
+
+ function handleScroll(e) {
+ const now = Date.now()
+ if (now - lastInvoke < throttleTime) return
+ lastInvoke = now
+
+ const scrollTop = e.detail.scrollTop
+ const delta = scrollTop - lastScrollTop.value
+ accumulatedScroll.value += delta
+
+ if (accumulatedScroll.value > threshold) {
+ if (!isScrollingDown.value) {
+ isScrollingDown.value = true
+ onChange?.(true) // 通知变更为向下
+ }
+ accumulatedScroll.value = 0
+ }
+
+ if (accumulatedScroll.value < -threshold) {
+ if (isScrollingDown.value) {
+ isScrollingDown.value = false
+ onChange?.(false) // 通知变更为向上
+ }
+ accumulatedScroll.value = 0
+ }
+
+ lastScrollTop.value = scrollTop
+ }
+
+ return {
+ isScrollingDown,
+ handleScroll
+ }
+}
\ No newline at end of file
diff --git a/hook/useSpeechReader.js b/hook/useSpeechReader.js
new file mode 100644
index 0000000..9e2d549
--- /dev/null
+++ b/hook/useSpeechReader.js
@@ -0,0 +1,136 @@
+import {
+ ref,
+ onBeforeUnmount,
+ onMounted
+} from 'vue'
+import {
+ onHide,
+ onUnload
+} from '@dcloudio/uni-app'
+
+
+
+export function useSpeechReader() {
+ const isSpeaking = ref(false)
+ const isPaused = ref(false)
+ let utterance = null
+
+ const cleanMarkdown = (text) => {
+ return formatTextForSpeech(text)
+ }
+
+ const speak = (text, options = {
+ lang: 'zh-CN',
+ rate: 0.9,
+ pitch: 1.2
+ }) => {
+ cancelAudio() // 重置之前的
+ // const voices = speechSynthesis.getVoices()
+ // const chineseVoices = voices.filter(v => v.lang.includes('zh'))
+ const speechText = extractSpeechText(text);
+ utterance = new SpeechSynthesisUtterance(speechText)
+ // utterance.lang = options.lang || 'zh'
+ utterance.rate = options.rate || 1
+ utterance.pitch = options.pitch || 1.1 // 音调(0 - 2,偏高比较柔和)
+
+ utterance.onend = () => {
+ isSpeaking.value = false
+ isPaused.value = false
+ }
+
+ speechSynthesis.speak(utterance)
+ isSpeaking.value = true
+ isPaused.value = false
+ }
+
+ const pause = () => {
+ if (isSpeaking.value && !isPaused.value) {
+ speechSynthesis.pause()
+ isPaused.value = true
+ }
+ }
+
+ const resume = () => {
+ if (isSpeaking.value && isPaused.value) {
+ speechSynthesis.resume()
+ isPaused.value = false
+ }
+ }
+
+ const cancelAudio = () => {
+ speechSynthesis.cancel()
+ isSpeaking.value = false
+ isPaused.value = false
+ }
+ // 页面刷新/关闭时
+ onMounted(() => {
+ if (typeof window !== 'undefined') {
+ window.addEventListener('beforeunload', cancelAudio)
+ }
+ })
+
+ onBeforeUnmount(() => {
+ cancelAudio()
+ if (typeof window !== 'undefined') {
+ window.removeEventListener('beforeunload', cancelAudio)
+ }
+ })
+
+ onHide(cancelAudio)
+ onUnload(cancelAudio)
+
+ return {
+ speak,
+ pause,
+ resume,
+ cancelAudio,
+ isSpeaking,
+ isPaused,
+ }
+}
+
+function extractSpeechText(markdown) {
+ const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
+ const jobs = [];
+ let match;
+ let lastJobEndIndex = 0;
+ let firstJobStartIndex = -1;
+
+ // 提取岗位 json 数据及前后位置
+ while ((match = jobRegex.exec(markdown)) !== null) {
+ const jobStr = match[1];
+ try {
+ const job = JSON.parse(jobStr);
+ jobs.push(job);
+ if (firstJobStartIndex === -1) {
+ firstJobStartIndex = match.index;
+ }
+ lastJobEndIndex = jobRegex.lastIndex;
+ } catch (e) {
+ console.warn('JSON 解析失败', e);
+ }
+ }
+
+ // 提取引导语(第一个 job-json 之前的文字)
+ const guideText = firstJobStartIndex > 0 ?
+ markdown.slice(0, firstJobStartIndex).trim() :
+ '';
+
+ // 提取结束语(最后一个 job-json 之后的文字)
+ const endingText = lastJobEndIndex < markdown.length ?
+ markdown.slice(lastJobEndIndex).trim() :
+ '';
+
+ // 岗位信息格式化为语音文本
+ const jobTexts = jobs.map((job, index) => {
+ return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
+ });
+
+ // 拼接总语音内容
+ const finalTextParts = [];
+ if (guideText) finalTextParts.push(guideText);
+ finalTextParts.push(...jobTexts);
+ if (endingText) finalTextParts.push(endingText);
+
+ return finalTextParts.join('\n');
+}
\ No newline at end of file
diff --git a/hook/useTTSPlayer.js b/hook/useTTSPlayer.js
new file mode 100644
index 0000000..3dd85aa
--- /dev/null
+++ b/hook/useTTSPlayer.js
@@ -0,0 +1,249 @@
+import {
+ ref,
+ onUnmounted,
+ onBeforeUnmount,
+ onMounted
+} from 'vue'
+import {
+ onHide,
+ onUnload
+} from '@dcloudio/uni-app'
+import WavDecoder from '@/lib/wav-decoder@1.3.0.js'
+
+export function useTTSPlayer(wsUrl) {
+ const isSpeaking = ref(false)
+ const isPaused = ref(false)
+ const isComplete = ref(false)
+
+ const audioContext = new(window.AudioContext || window.webkitAudioContext)()
+ let playTime = audioContext.currentTime
+ let sourceNodes = []
+ let socket = null
+ let sampleRate = 16000
+ let numChannels = 1
+ let isHeaderDecoded = false
+ let pendingText = null
+
+ let currentPlayId = 0
+ let activePlayId = 0
+
+ const speak = (text) => {
+ currentPlayId++
+ const myPlayId = currentPlayId
+ reset()
+ pendingText = text
+ activePlayId = myPlayId
+ }
+
+ const pause = () => {
+ if (audioContext.state === 'running') {
+ audioContext.suspend()
+ isPaused.value = true
+ isSpeaking.value = false
+ }
+ }
+
+ const resume = () => {
+ if (audioContext.state === 'suspended') {
+ audioContext.resume()
+ isPaused.value = false
+ isSpeaking.value = true
+ }
+ }
+
+ const cancelAudio = () => {
+ stop()
+ }
+
+ const stop = () => {
+ isSpeaking.value = false
+ isPaused.value = false
+ isComplete.value = false
+ playTime = audioContext.currentTime
+
+ sourceNodes.forEach(node => {
+ try {
+ node.stop()
+ node.disconnect()
+ } catch (e) {}
+ })
+ sourceNodes = []
+
+ if (socket) {
+ socket.close()
+ socket = null
+ }
+
+ isHeaderDecoded = false
+ pendingText = null
+ }
+
+ const reset = () => {
+ stop()
+ isSpeaking.value = false
+ isPaused.value = false
+ isComplete.value = false
+ playTime = audioContext.currentTime
+ initWebSocket()
+ }
+
+ const initWebSocket = () => {
+ const thisPlayId = currentPlayId
+ socket = new WebSocket(wsUrl)
+ socket.binaryType = 'arraybuffer'
+
+ socket.onopen = () => {
+ if (pendingText && thisPlayId === activePlayId) {
+ const seepdText = extractSpeechText(pendingText)
+ socket.send(seepdText)
+ pendingText = null
+ }
+ }
+
+ socket.onmessage = async (e) => {
+ if (thisPlayId !== activePlayId) return // 忽略旧播放的消息
+
+ if (typeof e.data === 'string') {
+ try {
+ const msg = JSON.parse(e.data)
+ if (msg.status === 'complete') {
+ isComplete.value = true
+ setTimeout(() => {
+ if (thisPlayId === activePlayId) {
+ isSpeaking.value = false
+ }
+ }, (playTime - audioContext.currentTime) * 1000)
+ }
+ } catch (e) {
+ console.log('[TTSPlayer] 文本消息:', e.data)
+ }
+ } else if (e.data instanceof ArrayBuffer) {
+ if (!isHeaderDecoded) {
+ try {
+ const decoded = await WavDecoder.decode(e.data)
+ sampleRate = decoded.sampleRate
+ numChannels = decoded.channelData.length
+ decoded.channelData.forEach((channel, i) => {
+ const audioBuffer = audioContext.createBuffer(1, channel.length,
+ sampleRate)
+ audioBuffer.copyToChannel(channel, 0)
+ playBuffer(audioBuffer)
+ })
+ isHeaderDecoded = true
+ } catch (err) {
+ console.error('WAV 解码失败:', err)
+ }
+ } else {
+ const pcm = new Int16Array(e.data)
+ const audioBuffer = pcmToAudioBuffer(pcm, sampleRate, numChannels)
+ playBuffer(audioBuffer)
+ }
+ }
+ }
+ }
+
+ const pcmToAudioBuffer = (pcm, sampleRate, numChannels) => {
+ const length = pcm.length / numChannels
+ const audioBuffer = audioContext.createBuffer(numChannels, length, sampleRate)
+ for (let ch = 0; ch < numChannels; ch++) {
+ const channelData = audioBuffer.getChannelData(ch)
+ for (let i = 0; i < length; i++) {
+ const sample = pcm[i * numChannels + ch]
+ channelData[i] = sample / 32768
+ }
+ }
+ return audioBuffer
+ }
+
+ const playBuffer = (audioBuffer) => {
+ if (!isSpeaking.value) {
+ playTime = audioContext.currentTime
+ }
+ const source = audioContext.createBufferSource()
+ source.buffer = audioBuffer
+ source.connect(audioContext.destination)
+ source.start(playTime)
+ sourceNodes.push(source)
+ playTime += audioBuffer.duration
+ isSpeaking.value = true
+ }
+
+ onUnmounted(() => {
+ stop()
+ })
+
+ // 页面刷新/关闭时
+ onMounted(() => {
+ if (typeof window !== 'undefined') {
+ window.addEventListener('beforeunload', cancelAudio)
+ }
+ })
+
+ onBeforeUnmount(() => {
+ cancelAudio()
+ if (typeof window !== 'undefined') {
+ window.removeEventListener('beforeunload', cancelAudio)
+ }
+ })
+
+ onHide(cancelAudio)
+ onUnload(cancelAudio)
+
+ initWebSocket()
+
+ return {
+ speak,
+ pause,
+ resume,
+ cancelAudio,
+ isSpeaking,
+ isPaused,
+ isComplete
+ }
+}
+
+function extractSpeechText(markdown) {
+ const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
+ const jobs = [];
+ let match;
+ let lastJobEndIndex = 0;
+ let firstJobStartIndex = -1;
+
+ // 提取岗位 json 数据及前后位置
+ while ((match = jobRegex.exec(markdown)) !== null) {
+ const jobStr = match[1];
+ try {
+ const job = JSON.parse(jobStr);
+ jobs.push(job);
+ if (firstJobStartIndex === -1) {
+ firstJobStartIndex = match.index;
+ }
+ lastJobEndIndex = jobRegex.lastIndex;
+ } catch (e) {
+ console.warn('JSON 解析失败', e);
+ }
+ }
+
+ // 提取引导语(第一个 job-json 之前的文字)
+ const guideText = firstJobStartIndex > 0 ?
+ markdown.slice(0, firstJobStartIndex).trim() :
+ '';
+
+ // 提取结束语(最后一个 job-json 之后的文字)
+ const endingText = lastJobEndIndex < markdown.length ?
+ markdown.slice(lastJobEndIndex).trim() :
+ '';
+
+ // 岗位信息格式化为语音文本
+ const jobTexts = jobs.map((job, index) => {
+ return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
+ });
+
+ // 拼接总语音内容
+ const finalTextParts = [];
+ if (guideText) finalTextParts.push(guideText);
+ finalTextParts.push(...jobTexts);
+ if (endingText) finalTextParts.push(endingText);
+
+ return finalTextParts.join('\n');
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5a1d9cd
--- /dev/null
+++ b/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+