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