# 购买确认页:算价预览 + 余额显示 + 参数输入 + 确认支付 from ahserver.serverenv import ServerEnv product_id = params_kw.get('product_id', '') userid = await get_user() result = {'widgettype': 'Text', 'options': {'otext': '加载中...', 'text': '加载中...', 'i18n': True}} if not product_id: result = {'widgettype': 'Text', 'options': {'otext': '缺少产品ID', 'text': '缺少产品ID', 'i18n': True, 'color': '#e74c3c'}} elif not userid: result = {'widgettype': 'Text', 'options': {'otext': '请先登录后购买', 'text': '请先登录后购买', 'i18n': True, 'color': '#e74c3c'}} else: userorgid = await get_userorgid() dbname = get_module_dbname('product_management') try: async with DBPools().sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT * FROM product WHERE id=${id}$ AND status='1'", {'id': product_id}) if not recs: raise Exception('产品不存在或已下架') p = recs[0] product_type = getattr(p, 'product_type', '') or '' # 余额 balance = None try: env = ServerEnv() acc_dbname = env.get_module_dbname('accounting') async with DBPools().sqlorContext(acc_dbname) as asor: balance = await env.getCustomerBalance(asor, userorgid) except Exception: pass rows = [] def _row(label, value, color='#333', bold=False): return { 'widgettype': 'HBox', 'options': {'padding': '8px 0', 'gap': '8px'}, 'subwidgets': [ {'widgettype': 'Text', 'options': { 'text': label, 'width': '90px', 'fontSize': '13px', 'color': '#666', 'fontWeight': '600' if bold else '400'}}, {'widgettype': 'Text', 'options': { 'text': str(value), 'fontSize': '13px', 'color': color, 'fontWeight': '700' if bold else '400'}} ] } 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 # 仅让利(折扣<1)显示原价;折扣≥1 不显示(2026-09-08 用户定夺, # 与展示页 get_customer_price_display 同规则) if _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);" " var mo=bricks.getWidgetById('buy_valid_months',bricks.app);" " 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);" " params.append('charge_mode',cm?(cm.value||'month'):'month');" "}" "var tw=bricks.getWidgetById('buy_price_text',bricks.app);" "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); if(!t)return;" " if(d.success){var s='应付金额:¥ '+(Math.round(d.amount*100)/100).toFixed(2);" " if((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);" " 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', 'options': {'padding': '8px 0', 'gap': '8px', 'alignItems': 'center'}, 'subwidgets': [ {'widgettype': 'Text', 'options': { 'otext': '购买容量(GB)', 'text': '购买容量(GB)', 'i18n': True, 'width': '90px', 'fontSize': '13px', 'color': '#666'}}, {'widgettype': 'UiInt', 'id': 'buy_storage_gb', 'options': {'name': 'buy_storage_gb', 'value': 100, 'width': '120px'}, 'binds': _price_bind} ] }) param_widgets.append({ 'widgettype': 'HBox', 'options': {'padding': '8px 0', 'gap': '8px', 'alignItems': 'center'}, 'subwidgets': [ {'widgettype': 'Text', 'options': { 'otext': '购买月数', 'text': '购买月数', 'i18n': True, 'width': '90px', 'fontSize': '13px', 'color': '#666'}}, {'widgettype': 'UiInt', 'id': 'buy_valid_months', 'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'}, 'binds': _price_bind} ] }) rows.append(_row('计费方式', '按容量直购(元/GB月)', '#666')) elif product_type == 'pipeline': # 产线使用费:月付 / 年付(年费=月费×10,即付10个月用12个月) param_widgets.append({ 'widgettype': 'HBox', 'options': {'padding': '8px 0', 'gap': '8px', 'alignItems': 'center'}, 'subwidgets': [ {'widgettype': 'Text', 'options': { 'otext': '订阅周期', 'text': '订阅周期', 'i18n': True, 'width': '90px', 'fontSize': '13px', 'color': '#666'}}, {'widgettype': 'UiCode', 'id': 'buy_charge_mode', 'options': {'name': 'buy_charge_mode', 'value': 'month', 'width': '200px', 'data': [{'value': 'month', 'otext': '包月(30天)', 'text': '包月(30天)', 'i18n': True}, {'value': 'year', 'otext': '包年(365天,付10个月)', 'text': '包年(365天,付10个月)', 'i18n': True}]}, 'binds': _price_bind} ] }) rows.append(_row('计费方式', '订阅制使用费(模型/存储消耗另按量计费)', '#666')) elif product_type == 'account': rows.append(_row('计费方式', '订阅制(月/年/试用)', '#666')) else: rows.append(_row('计费方式', '按量计费', '#666')) # 余额展示 if balance is None: balance_text = '未开户' balance_color = '#e67e22' else: balance_text = '¥ %.2f' % balance balance_color = '#27ae60' if balance > 0 else '#e74c3c' rows.append(_row('账户余额', balance_text, balance_color, True)) # 确认支付按钮:script+fetch 提交,确保读取输入框当前值 # ⚠️ getWidgetById 必须传第二参数(查找起点):bricks.getWidgetById(idset, from_widget) # 里 get_by_id 直接读 fromw.dom_element,from_widget 为 undefined 会抛 TypeError # → 点击支付无任何反应(既有 bug,2026-09-06 修复)。用 bricks.app.root 作起点。 # UiInt 取值用 resultValue()(返回 int),UiCode 取值用 .value(返回字符串)。 is_storage = (product_type == 'workspace_storage') is_pipeline = (product_type == 'pipeline') pay_script = ( "var pid='" + product_id + "';" "var params=new URLSearchParams();" "params.append('product_id',pid);" ) if is_storage: pay_script += ( "var gb=bricks.getWidgetById('buy_storage_gb',bricks.app);" "var mo=bricks.getWidgetById('buy_valid_months',bricks.app);" "var gbv=gb?gb.resultValue():100; var mov=mo?mo.resultValue():1;" "if(!gbv||gbv<=0){alert('请输入购买容量');return;}" "if(!mov||mov<=0){alert('请输入购买月数');return;}" "params.append('storage_gb',gbv);" "params.append('valid_months',mov);" ) if is_pipeline: pay_script += ( "var cm=bricks.getWidgetById('buy_charge_mode',bricks.app);" "var cmv=cm?(cm.value||'month'):'month';" "params.append('charge_mode',cmv);" ) pay_script += ( "var btn=this; btn.disabled=true;" "fetch('/product_management/storefront/api/purchase_realtime.dspy'," "{method:'POST',body:params}).then(function(r){return r.json()})" ".then(function(msg){btn.disabled=false;" "if(msg.widgettype){bricks.widgetBuild(msg).then(function(w){if(w)w.open()})}" "else{alert(msg.message||'支付完成')}})" ".catch(function(e){btn.disabled=false;alert('支付请求失败: '+e)})" ) pay_btn = { 'widgettype': 'Button', 'options': { 'label': '确认支付', 'bgcolor': '#e74c3c', 'color': '#fff', 'padding': '10px 36px', 'borderRadius': '6px', 'fontWeight': '600', 'marginTop': '16px' }, 'binds': [{ 'wid': 'self', 'event': 'click', 'actiontype': 'script', 'target': 'self', 'script': pay_script }] } cancel_btn = { 'widgettype': 'Button', 'options': { 'label': '取消', 'bgcolor': '#f0f0f0', 'color': '#666', 'padding': '10px 24px', 'borderRadius': '6px', 'marginTop': '16px' }, 'binds': [{ 'wid': 'self', 'event': 'click', 'actiontype': 'script', 'target': 'self', 'script': 'if(window.closePopup) closePopup()' }] } result = { 'widgettype': 'VBox', 'options': {'width': '100%', 'padding': '16px'}, 'subwidgets': [ {'widgettype': 'Title4', 'options': {'otext': '确认购买', 'text': '确认购买', 'i18n': True, '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': { 'otext': '支付将从账户余额实时扣款并完成记账,购买成功后权益立即生效。', 'text': '支付将从账户余额实时扣款并完成记账,购买成功后权益立即生效。', 'i18n': True, 'fontSize': '12px', 'color': '#999', 'marginTop': '8px'} }, {'widgettype': 'HBox', 'options': {'gap': '12px', 'marginTop': '8px'}, 'subwidgets': [pay_btn, cancel_btn]} ] } except Exception as e: debug(f'purchase_confirm error: {format_exc()}') result = {'widgettype': 'Text', 'options': {'text': str(e), 'color': '#e74c3c'}} return json.dumps(result, ensure_ascii=False)