From b0b74a2b9f917982fc19952d40b17c5c086e674d Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 8 Sep 2026 17:53:58 +0800 Subject: [PATCH] feat(storefront): purchase popup shows live price - calc_price.dspy preview endpoint + changed-event refresh on storage_gb/valid_months/charge_mode (same cost basis as purchase_realtime) --- scripts/load_path.py | 1 + wwwroot/storefront/api/calc_price.dspy | 64 +++++++++++++++ wwwroot/storefront/api/purchase_confirm.dspy | 84 +++++++++++++++++++- 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 wwwroot/storefront/api/calc_price.dspy diff --git a/scripts/load_path.py b/scripts/load_path.py index efa6724..a84599c 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -147,6 +147,7 @@ PATHS_LOGINED = [ f"/{MOD}/storefront/api/purchase.dspy", f"/{MOD}/storefront/api/purchase_confirm.dspy", f"/{MOD}/storefront/api/purchase_realtime.dspy", + f"/{MOD}/storefront/api/calc_price.dspy", f"/{MOD}/storefront/api/my_subscriptions.dspy", # CRUD auto-generated .dspy (new tables) diff --git a/wwwroot/storefront/api/calc_price.dspy b/wwwroot/storefront/api/calc_price.dspy new file mode 100644 index 0000000..3c7505a --- /dev/null +++ b/wwwroot/storefront/api/calc_price.dspy @@ -0,0 +1,64 @@ +# 购买弹窗实时算价:按当前输入参数试算售价(与 purchase_realtime 扣款同口径,只读不写) +# 存储:storage_gb × valid_months(meter_mode=direct 直购) +# 产线:charge_mode month/year(年付=月价×10,months 因子由定价映射解析) +from ahserver.serverenv import ServerEnv + +product_id = params_kw.get('product_id', '') +userid = await get_user() + +if not product_id: + result = {'success': False, 'message': '缺少产品ID'} +elif not userid: + result = {'success': False, 'message': '请先登录'} +else: + try: + userorgid = await get_userorgid() + dbname = get_module_dbname('product_management') + async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, product_type FROM product WHERE id=${id}$ AND status='1'", + {'id': product_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + raise Exception('产品不存在或已下架') + product_type = getattr(recs[0], 'product_type', '') or '' + + # usage_data 构造与 purchase_realtime 完全一致(同口径预览,所见即所付) + usage_data = None + if product_type == 'workspace_storage': + storage_gb = float(params_kw.get('storage_gb') or 0) + valid_months = max(1, int(params_kw.get('valid_months') or 1)) + if storage_gb <= 0: + result = {'success': False, 'message': '请填写购买容量(GB)'} + else: + usage_data = {'meter_mode': 'direct', 'storage_gb': storage_gb, + 'valid_months': valid_months} + elif product_type == 'pipeline': + cm = params_kw.get('charge_mode') or 'month' + if cm not in ('month', 'year'): + cm = 'month' + usage_data = {'charge_mode': cm} + else: + usage_data = {} + + if usage_data is not None: + env = ServerEnv() + cost = await env.calculate_product_cost( + product_id=product_id, usage_data=usage_data, + user_org_id=userorgid) + if not cost or cost.get('success') is False: + result = {'success': False, + 'message': (cost or {}).get('message', '定价计算失败')} + else: + result = { + 'success': True, + 'amount': float(cost.get('amount', 0) or 0), + 'original_amount': float(cost.get('original_amount', 0) or 0), + 'discount': float(cost.get('discount', 1.0) or 1.0), + 'product_type': product_type, + } + except Exception as e: + debug(f'calc_price error: {format_exc()}') + result = {'success': False, 'message': str(e)[:200]} + +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/storefront/api/purchase_confirm.dspy b/wwwroot/storefront/api/purchase_confirm.dspy index 1806b70..2119a48 100644 --- a/wwwroot/storefront/api/purchase_confirm.dspy +++ b/wwwroot/storefront/api/purchase_confirm.dspy @@ -50,12 +50,86 @@ else: rows.append(_row('产品名称', getattr(p, 'product_name', ''), '#222', True)) + # ── 实时算价(2026-09-08 用户定夺:弹窗显示价格,时长/容量/周期变化实时更新)── + # 初始价服务端算好(与 purchase_realtime 扣款同口径:calculate_product_cost + # 含客户折扣,所见即所付);输入变化时前端 fetch calc_price.dspy 刷新。 + env = ServerEnv() + init_usage = None + if product_type == 'workspace_storage': + init_usage = {'meter_mode': 'direct', 'storage_gb': 100, 'valid_months': 1} + elif product_type == 'pipeline': + init_usage = {'charge_mode': 'month'} + init_price_text = '应付金额:—' + if init_usage is not None: + try: + _c = await env.calculate_product_cost( + product_id=product_id, usage_data=init_usage, + user_org_id=userorgid) + if _c and _c.get('success') is not False: + _amt = float(_c.get('amount', 0) or 0) + _orig = float(_c.get('original_amount', 0) or 0) + _disc = float(_c.get('discount', 1.0) or 1.0) + init_price_text = '应付金额:¥ %.2f' % _amt + if abs(_disc - 1.0) > 1e-9: + init_price_text += '(原价 ¥ %.2f × 折扣 %.4g)' % (_orig, _disc) + else: + init_price_text = '算价失败:%s' % ((_c or {}).get('message', '') or '未知')[:80] + except Exception as _pe: + init_price_text = '算价失败:%s' % str(_pe)[:80] + price_text_widget = { + 'widgettype': 'Text', + 'id': 'buy_price_text', + 'options': { + 'text': init_price_text, + 'fontSize': '16px', 'color': '#e74c3c', 'fontWeight': '700', + 'padding': '10px 0 2px 0' + } + } + # 参数输入区(存储需容量+月数;产线选月付/年付;账号走默认月租) # ⚠️ 控件名必须是 bricks 实际注册的:数字输入 = UiInt(不是 InputNumber, # 该名未注册 → widgetBuild 返回 null,控件静默不渲染,2026-09-06 修复)。 # UiType 系控件同时传 name + id:name 供 getValue/表单,id 供 getWidgetById 定位 # (widgetBuild 用 set_id(desc.id) 覆盖 DOM id)。 param_widgets = [] + + # 实时刷新价格的共享 script:读当前输入 → fetch calc_price → set_text。 + # UiInt 的 resultValue() 在 changed 触发时 this.value 已更新,直接读 DOM 控件值最稳。 + # getWidgetById 必须传第二参数(查找起点),否则 TypeError(2026-09-06 既有 bug)。 + price_refresh_script = ( + "var pid='" + product_id + "';" + "var pt='" + product_type + "';" + "var params=new URLSearchParams();" + "params.append('product_id',pid);" + "if(pt=='workspace_storage'){" + " var gb=bricks.getWidgetById('buy_storage_gb',bricks.app.root);" + " var mo=bricks.getWidgetById('buy_valid_months',bricks.app.root);" + " var gbv=gb?gb.resultValue():0; var mov=mo?mo.resultValue():1;" + " if(gbv&&gbv>0)params.append('storage_gb',gbv);" + " if(mov&&mov>0)params.append('valid_months',mov);" + "}else if(pt=='pipeline'){" + " var cm=bricks.getWidgetById('buy_charge_mode',bricks.app.root);" + " params.append('charge_mode',cm?(cm.value||'month'):'month');" + "}" + "var tw=bricks.getWidgetById('buy_price_text',bricks.app.root);" + "if(tw)tw.set_text('计算中...');" + "fetch('/product_management/storefront/api/calc_price.dspy'," + "{method:'POST',body:params}).then(function(r){return r.json()}).then(function(d){" + " var t=bricks.getWidgetById('buy_price_text',bricks.app.root); if(!t)return;" + " if(d.success){var s='应付金额:¥ '+(Math.round(d.amount*100)/100).toFixed(2);" + " if(Math.abs((d.discount||1)-1)>1e-9){" + " s+='(原价 ¥ '+(Math.round(d.original_amount*100)/100).toFixed(2)" + " +' × 折扣 '+d.discount+')';}" + " t.set_text(s);t.dom_element.style.color='#e74c3c';" + " }else{t.set_text('算价失败:'+(d.message||''));t.dom_element.style.color='#e67e22';}" + "}).catch(function(e){" + " var t=bricks.getWidgetById('buy_price_text',bricks.app.root);" + " if(t){t.set_text('算价请求失败:'+e);t.dom_element.style.color='#e67e22';}" + "})" + ) + _price_bind = [{'wid': 'self', 'event': 'changed', 'actiontype': 'script', + 'target': 'self', 'script': price_refresh_script}] + if product_type == 'workspace_storage': param_widgets.append({ 'widgettype': 'HBox', @@ -64,7 +138,8 @@ else: {'widgettype': 'Text', 'options': { 'text': '购买容量(GB)', 'width': '90px', 'fontSize': '13px', 'color': '#666'}}, {'widgettype': 'UiInt', 'id': 'buy_storage_gb', - 'options': {'name': 'buy_storage_gb', 'value': 100, 'width': '120px'}} + 'options': {'name': 'buy_storage_gb', 'value': 100, 'width': '120px'}, + 'binds': _price_bind} ] }) param_widgets.append({ @@ -74,7 +149,8 @@ else: {'widgettype': 'Text', 'options': { 'text': '购买月数', 'width': '90px', 'fontSize': '13px', 'color': '#666'}}, {'widgettype': 'UiInt', 'id': 'buy_valid_months', - 'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'}} + 'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'}, + 'binds': _price_bind} ] }) rows.append(_row('计费方式', '按容量直购(元/GB月)', '#666')) @@ -89,7 +165,8 @@ else: {'widgettype': 'UiCode', 'id': 'buy_charge_mode', 'options': {'name': 'buy_charge_mode', 'value': 'month', 'width': '200px', 'data': [{'value': 'month', 'text': '包月(30天)'}, - {'value': 'year', 'text': '包年(365天,付10个月)'}]}} + {'value': 'year', 'text': '包年(365天,付10个月)'}]}, + 'binds': _price_bind} ] }) rows.append(_row('计费方式', '订阅制使用费(模型/存储消耗另按量计费)', '#666')) @@ -188,6 +265,7 @@ else: {'widgettype': 'Title4', 'options': {'text': '确认购买', 'fontWeight': '700', 'marginBottom': '12px'}}, {'widgettype': 'VBox', 'options': {'gap': '0', 'marginBottom': '8px'}, 'subwidgets': rows}, {'widgettype': 'VBox', 'options': {'gap': '0', 'marginBottom': '8px'}, 'subwidgets': param_widgets}, + price_text_widget, { 'widgettype': 'Text', 'options': {