Files
lucky_shop/store/index.js

657 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import Http from '../common/js/http.js'
import { themeConfig } from '../common/js/config-external.js'
import configExternal from '../common/js/config-external.js'
import util from '../common/js/util.js'
import ThemeOverride from '../common/js/theme-override.js'
import { sanitizeFlatMap, mergeThemeBuckets } from '../common/js/merge-theme.js'
import PrivacyManager from '../common/js/privacy.js'
import LangConfig from '../common/js/lang.js'
// themeColor 结果缓存(避免每次都全量遍历 theme 对象)
// key: JSON.stringify(theme) + tabBarHeight, value: CSS变量字符串
let _themeColorCache = null;
let _themeColorCacheKey = '';
const store = new Vuex.Store({
state: {
token: null,
siteInfo: null,
memberInfo: null,
tabBarList: null,
siteState: 1,
themeStyle: (function() { try { return uni.getStorageSync('themeStyle') || ''; } catch(e) { return ''; } })(),
themeColor: (function() { try { return uni.getStorageSync('themeColor') || ''; } catch(e) { return ''; } })(),
addonIsExist: {
bundling: 0,
coupon: 0,
discount: 0,
fenxiao: 0,
gift: 0,
groupbuy: 0,
manjian: 0,
memberconsume: 0,
memberrecharge: 0,
memberregister: 0,
membersignin: 0,
memberwithdraw: 0,
memberrecommend: 0,
pintuan: 0,
pointexchange: 0,
seckill: 0,
store: 0,
topic: 0,
bargain: 0,
membercancel: 0,
servicer: 0,
supermember: 0,
giftcard: 0,
divideticket: 0,
scenefestival: 0,
birthdaygift: 0,
pinfan: 0,
form: 0
},
authInfo: {}, // 授权信息
flRefresh: 0,
location: null, // 定位信息
defaultImg: {
goods: '',
head: '',
store: '',
article: '',
aiAgent: ''
},
cartList: {},
cartIds: [],
cartNumber: 0,
cartMoney: 0,
cartChange: 0,
wechatConfigStatus: 0,
bottomNavHidden: false, // 底部导航是否隐藏,true:隐藏,false:显示
aiUnreadCount: 10, // AI未读消息数量
globalAIKefuConfig: null, // AI客服配置
customerServiceType: 'ai',
globalStoreConfig: null, // 门店配置
globalStoreInfo: null, // 门店信息
defaultStoreInfo: null, // 默认门店
cartPosition: null, // 购物车所在位置
componentRefresh: 0, // 组件刷新
servicerConfig: null, // 客服配置
wxworkConfig: null, // 企业微信配置
diySeckillInterval: 0,
diyGroupPositionObj: {},
diyGroupShowModule: '',
tabBarHeight: '56px',
packageEntryTab: {}, // 记录每个分包的"入口 tab" { 'pages_goods': '/pages_goods/category', ... }
// 由 diy-bottom-nav.updateCurrentRoute → resolveEntryTab 写入
// 语义:
// - 同包内导航: 当前包第一个 tab (用户一直在包内浏览)
// - 跨包导航: history-walk 找最近的 tab 根页面 (从哪个 tab 出发)
// - 深链/冷启动 (无历史): 不写入 (iOS HIG — 无来源时不点亮)
mapConfig: {
tencent_map_key: '',
wap_is_open: 1,
wap_valid_time: 0,
},
copyright: null,
floatingButtonConfig: null, // 来自 /api/config/init 的 data.floating_button (浮动侧边栏配置)
initStatus: false,
offlineWhiteList: ['pages_order/payment', 'pages_order/list', 'pages_order/detail'],//线下支付白名单
canReceiveRegistergiftInfo: {
status: false,
path: ''
},
},
mutations: {
// 设置是否可以领取新人礼
setCanReceiveRegistergiftInfo(state, data) {
state.canReceiveRegistergiftInfo = data;
},
// 设置那些组件展示
setDiyGroupShowModule(state, data) {
state.diyGroupShowModule = data;
},
// 设置diyGroup中组件原有高度,通过他们来实现在首页的定位
setDiyGroupPositionObj(state, data) {
state.diyGroupPositionObj = Object.assign({}, state.diyGroupPositionObj, data);
},
setSiteState(state, siteStateVal) {
state.siteState = siteStateVal;
},
setThemeStyle(state, value) {
state.themeStyle = value
uni.setStorageSync('themeStyle', value); // 初始化数据调用
},
setTabBarList(state, value) {
state.tabBarList = value;
},
setAddonIsExist(state, value) {
state.addonIsExist = value;
uni.setStorageSync('addonIsExist', value); // 初始化数据调用
},
setToken(state, value) {
state.token = value;
if (value) {
uni.setStorageSync('token', value); // 初始化数据调用
} else {
uni.removeStorageSync('token');
}
},
setAuthinfo(state, value) {
state.authInfo = value;
},
setflRefresh(state, flRefreshVal) {
state.flRefresh = flRefreshVal;
},
setLocation(state, value) {
var date = new Date();
date.setSeconds(60 * state.mapConfig.wap_valid_time);
value.valid_time = date.getTime() / 1000; // 定位信息 5分钟内有效,过期后将重新获取定位信息
state.location = value;
uni.setStorageSync('location', value); // 初始化数据调用
},
setDefaultImg(state, value) {
state.defaultImg = value;
uni.setStorageSync('defaultImg', value); // 初始化数据调用
},
setSiteInfo(state, value) {
state.siteInfo = value;
uni.setStorageSync('siteInfo', value); // 初始化数据调用
},
setShopInfo(state, value) {
state.shopInfo = value;
uni.setStorageSync('shopInfo', value); // 初始化数据调用
// 语言切换开关 fallback: 后台 shop_info.ischina 决定(老「双语模式」)。
// 新配置下发后由 setFloatingButtonConfig 里的 lang_switch.enabled 接管,
// 这里的 ischina 只是配置缺失(老缓存响应)时的兜底。
// 关闭时强制返回默认中文,并清掉用户此前持久化到 storage 的语种,
// 满足"后台关闭双语/小程序后,刷新重新进入必须是中文"的需求。
try { LangConfig.setLangSwitchEnabled(!!(value && value.ischina)); } catch (e) {}
},
// 浮动侧边栏配置 (来自 /api/config/init → data.floating_button)
setFloatingButtonConfig(state, value) {
state.floatingButtonConfig = value || null;
uni.setStorageSync('floatingButtonConfig', value || null);
// 与 setShopInfo 的 setLangSwitchEnabled 配对: 新配置存在时以新配置为准
try {
if (value && value.lang_switch) {
LangConfig.setLangSwitchEnabled(Number(value.lang_switch.enabled) === 1);
}
} catch (e) {}
},
setCartChange(state) {
state.cartChange += 1;
},
setBottomNavHidden(state, value) {
state.bottomNavHidden = value;
},
setglobalAIKefuConfig(state, value) {
state.globalAIKefuConfig = value;
uni.setStorageSync('globalAIKefuConfig', value); // 初始化数据调用
},
setCustomerServiceType(state, type) {
state.customerServiceType = type;
},
setGlobalStoreConfig(state, value) {
state.globalStoreConfig = value;
uni.setStorageSync('globalStoreConfig', value); // 初始化数据调用
},
setGlobalStoreInfo(state, value) {
state.globalStoreInfo = value;
uni.setStorageSync('globalStoreInfo', value); // 初始化数据调用
},
setDefaultStoreInfo(state, value) {
state.defaultStoreInfo = value;
uni.setStorageSync('defaultStoreInfo', value); // 初始化数据调用
},
setCartPosition(state, value) {
state.cartPosition = value;
},
setComponentRefresh(state) {
state.componentRefresh += 1;
},
// 客服配置
setServicerConfig(state, value) {
state.servicerConfig = value;
uni.setStorageSync('servicerConfig', value);
},
// 企业微信配置
setWxworkConfig(state, value) {
state.wxworkConfig = value;
uni.setStorageSync('wxworkConfig', value);
},
setDiySeckillInterval(state, value) {
state.diySeckillInterval = value;
},
setTabBarHeight(state, value) {
state.tabBarHeight = value;
},
setPackageEntryTab(state, value) {
state.packageEntryTab = value;
},
setMapConfig(state, value) {
state.mapConfig = value;
uni.setStorageSync('mapConfig', value);
},
setCopyright(state, value) {
state.copyright = value;
uni.setStorageSync('copyright', value);
},
setMemberInfo(state, value) {
// 会员被锁定后,清除会员登录信息
if (value && value.status == 0) {
value = null;
}
state.memberInfo = value;
if (value) {
uni.setStorageSync('memberInfo', value);
} else {
// 会员为空时,清除会员登录信息
uni.removeStorageSync('memberInfo');
this.commit('setToken', '');
this.dispatch('emptyCart');
//uni.removeStorageSync('authInfo');
}
},
setCartNumber(state, cartNumber) {
state.cartNumber = cartNumber
},
setCartList(state, value) {
state.cartList = value;
},
setCartIds(state, value) {
state.cartIds = value;
},
setCartMoney(state, value) {
state.cartMoney = value;
},
setInitStatus(state, value) {
state.initStatus = value
},
setWechatConfigStatus(state, value) {
state.wechatConfigStatus = value
},
// 设置AI未读消息数量
setAiUnreadCount(state, value) {
state.aiUnreadCount = value;
},
// 设置主题颜色
setThemeColor(state, value) {
state.themeColor = value;
}
},
getters: {
// AI智能助手配置
globalAIKefuConfig: state => state.globalAIKefuConfig,
// AI未读消息数量
aiUnreadCount: state => state.aiUnreadCount,
},
actions: {
init() {
return new Promise(async (resolve, reject) => {
Http.sendRequest({
url: '/api/config/init',
success: async res => {
var data = res.data;
if (data) {
// 走新 action: 加载预设 + 远端覆写合并 + 应用
// (不直接 commit setThemeStyle + themeColorSet,避免漏远端)
await this.dispatch('applyThemeWithRemote', data.style_theme?.name || themeConfig.defaultTheme);
// 底部导航
this.commit('setTabBarList', data.diy_bottom_nav);
this.commit('setAddonIsExist', data.addon_is_exist);
this.commit('setDefaultImg', data.default_img);
this.commit('setSiteInfo', data.site_info);
this.commit('setServicerConfig', data.servicer);
// 企业微信配置
if (data?.wxwork_config) {
this.commit('setWxworkConfig', data.wxwork_config);
}
this.commit('setCopyright', data.copyright);
this.commit('setMapConfig', data.map_config);
this.commit('setglobalAIKefuConfig', data.ai_agent_config);
this.commit('setGlobalStoreConfig', data.store_config);
//联系我们
this.commit('setShopInfo', data.shop_info);
// 浮动侧边栏配置; 缺失时显式置 null → 前端走老行为兜底
this.commit('setFloatingButtonConfig', data.floating_button || null);
this.commit('setWechatConfigStatus', data?.wechat_config_status);
// 默认总店
if (data.store_info) {
this.commit('setDefaultStoreInfo', data.store_info);
} else {
// 清空不存在的门店信息
this.commit('setDefaultStoreInfo', null);
this.commit('setGlobalStoreInfo', null);
}
this.commit('setInitStatus', true)
// 远程下发隐私弹窗 URL/路由规则 (可选)
// 字段约定:
// data.privacy_url_rules = { mode?: 'all'|'only'|'skip',
// patterns?: string[],
// merge?: 'replace'|'union'|'intersect',
// sign?: string (HMAC 签名, 与后端约定) }
// 与 App.vue 中声明的本地 base 融合:
// - 字段缺失 → 用本地 base
// - mode 远程有值 → 远程覆盖; 否则用本地
// - patterns 按 merge 字段决定 (默认 replace)
// 签名校验 (可选):
// - privacyConfig.signSecret 非空 → PrivacyManager.verifyRemotePatch 校验 patch.sign
// - 校验失败 → applyWithMerge 内部 fallback 到 base, 不应用 patch
// 融合完成后 emit 'privacy:rules-updated', 持久化到 storage (跨冷启动恢复)
try {
if (data && data.privacy_url_rules && typeof data.privacy_url_rules === 'object') {
// base 必须从 PrivacyManager.__getBaseRules() 读取
// App.vue 顶层 script 已通过 setBaseRules() 登记
// 若登记失败 (例如 HMR / 测试环境), 退化为 'all'+[] 但跳过本次应用
const baseRules = (PrivacyManager && typeof PrivacyManager.__getBaseRules === 'function')
? PrivacyManager.__getBaseRules()
: null
if (baseRules && (baseRules.mode || (baseRules.patterns && baseRules.patterns.length))) {
// applyWithMerge 内部已调 verifyRemotePatch, 不通过的 patch 静默退回 base
PrivacyManager.applyWithMerge(baseRules, data.privacy_url_rules)
} else {
console.warn('[privacy] base rules not registered, skip remote patch apply')
}
}
} catch (e) {
console.warn('[privacy] apply remote rules fail', e)
}
resolve(data);
}
}
});
})
},
// 查询购物车列表、总数量、总价格
getCartNumber() {
Http.sendRequest({
url: '/api/cart/lists',
data: {},
success: res => {
if (res.code == 0) {
let list = {};
let ids = [];
let totalMoney = 0;
let totalNum = 0;
if (res.data.length) {
res.data.forEach((item) => {
let cart = {
cart_id: item.cart_id,
goods_id: item.goods_id,
sku_id: item.sku_id,
num: item.num,
discount_price: item.discount_price,
min_buy: item.min_buy,
stock: item.stock,
};
if (!list['goods_' + cart.goods_id]) {
list['goods_' + cart.goods_id] = {};
}
list['goods_' + cart.goods_id]['max_buy'] = item.max_buy;
list['goods_' + cart.goods_id]['goods_name'] = item.goods_name;
list['goods_' + cart.goods_id]['sku_' + cart.sku_id] = cart;
ids.push(cart.cart_id);
});
for (let goods in list) {
let num = 0;
let money = 0;
for (let sku in list[goods]) {
let item = list[goods][sku];
if (typeof item == 'object') {
num += item.num;
money += parseFloat(item.discount_price) * parseInt(item.num);
}
}
list[goods].num = num;
list[goods].total_money = money;
totalNum += num;
totalMoney += money;
}
}
this.commit('setCartList', list);
this.commit('setCartIds', ids);
this.commit('setCartNumber', totalNum);
this.commit('setCartMoney', totalMoney);
}
}
});
},
// 清空购物车 ns-goods-sku-index组件中引用
emptyCart() {
this.commit('setCartList', {});
this.commit('setCartIds', []);
this.commit('setCartNumber', 0);
this.commit('setCartMoney', 0);
},
// 计算购物车数量、价格
cartCalculate() {
let ids = [];
let totalMoney = 0;
let totalNum = 0;
for (let k in this.state.cartList) {
let item = this.state.cartList[k];
let num = 0;
let money = 0;
for (let sku in item) {
if (typeof item[sku] == 'object') {
num += item[sku].num;
money += parseFloat(item[sku].discount_price) * parseInt(item[sku].num);
ids.push(item[sku].cart_id);
}
}
item.num = num;
item.total_money = money;
totalNum += num;
totalMoney += money;
}
this.commit('setCartNumber', totalNum);
this.commit('setCartMoney', totalMoney);
this.commit('setCartIds', ids);
},
// 生成主题颜色CSS变量(带缓存)
themeColorSet() {
let theme = this.state.themeStyle;
if (!theme?.main_color || !theme?.aux_color) return;
try {
// H5 端 CSS 变量值若是 rpx 字符串,浏览器原生无法解析
// (uni-app PostCSS 只在静态 SCSS 中把 rpx 编译成 px,JS 运行时
// setProperty('--xxx','20rpx') 写入后 var() 解析失败)
// 这里把 token 值里的 rpx 串转成 px,其它值原样保留。
// 仅 #ifdef H5 生效;小程序原生支持 rpx,不需要转换。
const rpxToPx = (() => {
let pxPerRpx = 0;
try {
if (typeof window !== 'undefined' && window.innerWidth) {
pxPerRpx = window.innerWidth / 750;
}
} catch (e) { /* 非 H5 环境,保持原值 */ }
return (v) => {
if (pxPerRpx <= 0) return v;
if (typeof v !== 'string') return v;
if (v.indexOf('rpx') === -1) return v;
return v.replace(/(-?\d+(?:\.\d+)?)rpx\b/gi, (m, n) => {
return (parseFloat(n) * pxPerRpx) + 'px';
});
};
})();
const cacheKey = (typeof theme === 'object' ? JSON.stringify(theme) : String(theme))
+ '|' + (this.state.tabBarHeight || '')
+ '|' + (typeof window !== 'undefined' ? (window.innerWidth || 0) : 0);
if (_themeColorCache && _themeColorCacheKey === cacheKey) {
if (this.state.themeColor !== _themeColorCache) {
this.commit('setThemeColor', _themeColorCache);
}
return;
}
let themeColor = `--base-color:${theme.main_color};--base-help-color:${theme.aux_color};`;
if (this.state.tabBarHeight != '56px') themeColor += `--tab-bar-height:${this.state.tabBarHeight};`
Object.keys(theme).forEach(key => {
let data = theme[key];
if (typeof (data) == "object") {
Object.keys(data).forEach(k => {
themeColor += '--' + k.replace(/_/g, "-") + ':' + rpxToPx(data[k]) + ';';
});
} else if (typeof (key) == "string" && key) {
themeColor += '--' + key.replace(/_/g, "-") + ':' + rpxToPx(data) + ';';
}
});
for (let i = 9; i >= 5; i--) {
let color = util.colourBlend(theme.main_color, '#ffffff', (i / 10));
themeColor += `--base-color-light-${i}:${color};`;
}
_themeColorCache = themeColor;
_themeColorCacheKey = cacheKey;
this.commit('setThemeColor', themeColor);
} catch (e) {
console.error('设置主题颜色失败', e);
}
},
/**
* 加载预设 + 拉远端覆写 + 深合并 + 走原 setThemeStyle/themeColorSet 链路
* 关键: 应用层一行不动 — Vuex.themeStyle / themeColor / 页面 wrapper / CSS var
* 全部保持兼容,所有改动只发生在"加载"环节
*
* remote-only 模式兼容:
* - 首次调用时 loadAllPresets 可能尚未完成,loadTheme() 会拿到 webpack stub
* (只含 default preset). 如果用户选了非 default preset(green/blue/...),
* loadTheme 返回 undefined.
* - 兜底: 订阅 theme:presets-updated 事件,远端拉完后重新应用.
* 一次性订阅(once 语义,触发后即解绑),避免后续切主题时重复触发.
*
* @param {string} presetName style_color.js 中的 preset 名(default/green/blue/...)
* @returns {Promise<{ ok: boolean, source: string, applied: object }>}
*/
async applyThemeWithRemote({ state, commit, dispatch }, presetName) {
const finalPresetName = presetName || themeConfig.defaultTheme;
// 工具:订阅一次 presets-updated 后自动解绑
const subscribeOnce = (cb) => {
let called = false;
const handler = async () => {
if (called) return;
called = true;
try { uni.$off && uni.$off('theme:presets-updated', handler); } catch (e) {}
try { await cb(); } catch (e) { console.error('[theme] presets-updated reapply fail', e); }
};
try {
uni.$on && uni.$on('theme:presets-updated', handler);
} catch (e) {
console.warn('[theme] uni.$on bind fail', e);
}
return () => {
if (called) return;
called = true;
try { uni.$off && uni.$off('theme:presets-updated', handler); } catch (e) {}
};
};
// 工具:真正加载 + 合并 + 提交
// 关键: 调用前清空 configExternal.loadedConfigs 缓存,避免第一次拿到 stub 后
// 远端 preset 到达时 loadTheme 直接返回缓存而不读 ThemeOverride.
const doApply = async (name) => {
try {
if (configExternal && configExternal.loadedConfigs) {
delete configExternal.loadedConfigs[`theme_${name}`];
}
} catch (e) { /* 忽略 */ }
const base = await configExternal.loadTheme(name);
if (!base) return null;
const brandOverrides = ThemeOverride.getByTheme('brand') || {};
const merged = mergeThemeBuckets(
sanitizeFlatMap(base),
sanitizeFlatMap(brandOverrides)
);
commit('setThemeStyle', merged);
dispatch('themeColorSet');
return merged;
};
try {
// 1) 尝试同步加载
const first = await doApply(finalPresetName);
// 2) 加载失败(非 default preset 在 stub 中不存在)或 first 为空
// → 等远端 preset 拉完后重试
if (!first) {
console.log('[theme] preset 暂不可用,等待远端 preset 拉完:', finalPresetName);
// 不阻塞当前调用方,返回空结果(应用层会显示 stub default)
// 远端到后会自动重应用
subscribeOnce(async () => {
const retry = await doApply(finalPresetName);
if (retry) {
console.log('[theme] 远端 preset 到达,重应用成功:', finalPresetName);
} else {
console.warn('[theme] 远端 preset 到达后仍找不到:', finalPresetName);
}
});
return { ok: false, source: 'waiting-presets', applied: null };
}
// 3) 首次成功,但远端 preset 也许之后会到(用于其他 preset 的切换/覆写)
// 兜底订阅一次,如果远端 preset 包含了更全的 token 也能再补一次
subscribeOnce(async () => {
const retry = await doApply(finalPresetName);
if (retry) {
console.log('[theme] 远端 preset 补充,重应用:', finalPresetName);
}
});
return { ok: true, source: 'merged', applied: first };
} catch (e) {
console.error('[theme] applyThemeWithRemote 失败,回退到纯预设:', e);
// 兜底: 至少把预设应用了,避免页面无主题
try {
const base = await configExternal.loadTheme(finalPresetName);
if (base) {
commit('setThemeStyle', base);
dispatch('themeColorSet');
return { ok: false, source: 'preset-only-fallback', applied: base };
}
} catch (e2) {
console.error('[theme] 纯预设回退也失败:', e2);
}
return { ok: false, source: 'fail', applied: null };
}
}
}
})
export default store