product_management/wwwroot/storefront/api/purchase_confirm.dspy

286 lines
15 KiB
Plaintext
Raw Permalink 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.

# 购买确认页:算价预览 + 余额显示 + 参数输入 + 确认支付
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)