feat: 新增商家产品商城页面(storefront)
- 左侧可收缩产品分类树 - 右侧产品列表(卡片式)和产品详情 - 未登录可浏览,登录后显示购买按钮 - 页面和API注册到load_path.py
This commit is contained in:
parent
265081c43f
commit
6dae1676ef
@ -41,6 +41,11 @@ MOD = "product_management"
|
||||
# any — 无需登录(仅静态资源和菜单)
|
||||
PATHS_ANY = [
|
||||
f"/{MOD}/menu.ui",
|
||||
f"/{MOD}/storefront",
|
||||
f"/{MOD}/storefront/index.ui",
|
||||
f"/{MOD}/storefront/api/get_categories.dspy",
|
||||
f"/{MOD}/storefront/api/get_products.dspy",
|
||||
f"/{MOD}/storefront/api/get_product_detail.dspy",
|
||||
]
|
||||
|
||||
# logined — 需要认证的页面和 API
|
||||
@ -147,6 +152,9 @@ PATHS_LOGINED = [
|
||||
f"/{MOD}/api/usage_logs.dspy",
|
||||
f"/{MOD}/api/usage_stats.dspy",
|
||||
|
||||
# Storefront purchase (requires login)
|
||||
f"/{MOD}/storefront/api/purchase.dspy",
|
||||
|
||||
# CRUD auto-generated .dspy (new tables)
|
||||
f"/{MOD}/product_resource_list/get_product_resource.dspy",
|
||||
f"/{MOD}/product_resource_list/add_product_resource.dspy",
|
||||
|
||||
16
wwwroot/storefront/api/get_categories.dspy
Normal file
16
wwwroot/storefront/api/get_categories.dspy
Normal file
@ -0,0 +1,16 @@
|
||||
dbname = get_module_dbname('product_management')
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
sql = """select id, name, parent_id, icon from product_category
|
||||
where status = '1' and has_product = '1'"""
|
||||
|
||||
ns = params_kw.copy()
|
||||
id = ns.get('id')
|
||||
if id and id != '0' and id != 'null' and id != 'undefined':
|
||||
sql += " and parent_id = ${id}$"
|
||||
else:
|
||||
sql += " and (parent_id is null or parent_id = '' or parent_id = '0')"
|
||||
sql += " order by sort_order, name "
|
||||
|
||||
recs = await sor.sqlExe(sql, ns)
|
||||
|
||||
return json.dumps(recs or [], ensure_ascii=False)
|
||||
153
wwwroot/storefront/api/get_product_detail.dspy
Normal file
153
wwwroot/storefront/api/get_product_detail.dspy
Normal file
@ -0,0 +1,153 @@
|
||||
product_id = params_kw.get('id', '')
|
||||
userid = await get_user()
|
||||
dbname = get_module_dbname('product_management')
|
||||
|
||||
if not product_id:
|
||||
result = {'widgettype': 'Text', 'options': {'text': '请选择一个产品'}}
|
||||
else:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
sql = """SELECT p.*, pc.name as category_name, pc.description as category_description
|
||||
FROM product p
|
||||
LEFT JOIN product_category pc ON p.category_id = pc.id
|
||||
WHERE p.id = ${id}$ AND p.status = '1'"""
|
||||
recs = await sor.sqlExe(sql, {'id': product_id})
|
||||
|
||||
if not recs:
|
||||
result = {'widgettype': 'Text', 'options': {'text': '产品不存在或已下架'}}
|
||||
else:
|
||||
p = recs[0]
|
||||
price_text = f"¥{float(p.price or 0):.2f}" if p.price else '按量计费'
|
||||
detail = p.detail_intro or p.brief_intro or '暂无详细介绍'
|
||||
|
||||
# Parse extra_json for additional info
|
||||
extra_items = []
|
||||
extra_str = p.extra_json or ''
|
||||
if extra_str:
|
||||
try:
|
||||
extra = json.loads(extra_str)
|
||||
if isinstance(extra, dict):
|
||||
for k, v in extra.items():
|
||||
extra_items.append({'key': k, 'value': str(v)})
|
||||
except:
|
||||
pass
|
||||
|
||||
info_rows = [
|
||||
{'label': '产品编码', 'value': p.product_code or ''},
|
||||
{'label': '产品分类', 'value': p.category_name or ''},
|
||||
{'label': '产品类型', 'value': p.product_type or ''},
|
||||
{'label': '价格', 'value': price_text},
|
||||
]
|
||||
for ei in extra_items:
|
||||
info_rows.append({'label': ei['key'], 'value': ei['value']})
|
||||
|
||||
info_widgets = []
|
||||
for row in info_rows:
|
||||
info_widgets.append({
|
||||
'widgettype': 'HBox',
|
||||
'options': {'padding': '6px 0', 'gap': '8px'},
|
||||
'subwidgets': [
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': f"{row['label']}:",
|
||||
'fontWeight': '600',
|
||||
'width': '80px',
|
||||
'fontSize': '13px'
|
||||
}
|
||||
},
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {'text': row['value'], 'fontSize': '13px'}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Build action buttons
|
||||
buttons = []
|
||||
if userid:
|
||||
buttons.append({
|
||||
'widgettype': 'Button',
|
||||
'options': {
|
||||
'label': '立即购买',
|
||||
'bgcolor': '#3498db',
|
||||
'color': '#fff',
|
||||
'padding': '10px 32px',
|
||||
'borderRadius': '6px',
|
||||
'fontWeight': '600'
|
||||
},
|
||||
'binds': [
|
||||
{
|
||||
'wid': 'self',
|
||||
'event': 'click',
|
||||
'actiontype': 'urlwidget',
|
||||
'target': 'PopupWindow',
|
||||
'popup_options': {'title': '购买结果', 'cwidth': 20, 'cheight': 8},
|
||||
'options': {
|
||||
'method': 'POST',
|
||||
'url': '{{entire_url("./api/purchase.dspy")}}',
|
||||
'params': {'product_id': p.id}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
else:
|
||||
buttons.append({
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': '登录后即可购买',
|
||||
'fontSize': '13px',
|
||||
'color': '#999'
|
||||
}
|
||||
})
|
||||
|
||||
subwidgets = [
|
||||
{
|
||||
'widgettype': 'Title3',
|
||||
'options': {'text': p.product_name, 'fontWeight': '700'}
|
||||
},
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': price_text,
|
||||
'fontSize': '20px',
|
||||
'fontWeight': '700',
|
||||
'color': '#e74c3c',
|
||||
'marginTop': '8px',
|
||||
'marginBottom': '16px'
|
||||
}
|
||||
},
|
||||
{
|
||||
'widgettype': 'VBox',
|
||||
'options': {'gap': '0', 'marginBottom': '16px'},
|
||||
'subwidgets': info_widgets
|
||||
},
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': detail,
|
||||
'fontSize': '14px',
|
||||
'lineHeight': '1.6',
|
||||
'whiteSpace': 'pre-wrap',
|
||||
'marginTop': '12px',
|
||||
'marginBottom': '20px'
|
||||
}
|
||||
},
|
||||
{
|
||||
'widgettype': 'HBox',
|
||||
'options': {'gap': '12px', 'marginTop': '12px'},
|
||||
'subwidgets': buttons
|
||||
}
|
||||
]
|
||||
|
||||
result = {
|
||||
'widgettype': 'VBox',
|
||||
'options': {
|
||||
'width': '100%',
|
||||
'padding': '20px',
|
||||
'borderRadius': '8px',
|
||||
'border': '1px solid #e0e0e0'
|
||||
},
|
||||
'subwidgets': subwidgets
|
||||
}
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
123
wwwroot/storefront/api/get_products.dspy
Normal file
123
wwwroot/storefront/api/get_products.dspy
Normal file
@ -0,0 +1,123 @@
|
||||
category_id = params_kw.get('category_id', '')
|
||||
org_id = (await get_userorgid()) or '0'
|
||||
dbname = get_module_dbname('product_management')
|
||||
|
||||
if not category_id:
|
||||
result = {'widgettype': 'Text', 'options': {'text': '请从左侧选择一个产品分类'}}
|
||||
else:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
# Get all sub-category IDs recursively
|
||||
all_ids = [category_id]
|
||||
queue = [category_id]
|
||||
while queue:
|
||||
pid = queue.pop(0)
|
||||
children = await sor.sqlExe(
|
||||
"SELECT id FROM product_category WHERE parent_id = ${pid}$ AND status = '1'",
|
||||
{'pid': pid}
|
||||
)
|
||||
for c in (children or []):
|
||||
all_ids.append(c.id)
|
||||
queue.append(c.id)
|
||||
|
||||
# Build IN clause
|
||||
param_keys = []
|
||||
params = {'org_id': org_id}
|
||||
for i, cid in enumerate(all_ids):
|
||||
key = f'cid_{i}'
|
||||
param_keys.append(f'${key}$')
|
||||
params[key] = cid
|
||||
|
||||
placeholders = ','.join(param_keys)
|
||||
sql = f"""SELECT p.id, p.product_name, p.product_code, p.brief_intro,
|
||||
p.product_type, p.price, p.currency, p.icon,
|
||||
pc.name as category_name
|
||||
FROM product p
|
||||
LEFT JOIN product_category pc ON p.category_id = pc.id
|
||||
WHERE p.category_id IN ({placeholders})
|
||||
AND p.org_id = ${org_id}$
|
||||
AND p.status = '1'
|
||||
ORDER BY p.sort_order ASC, p.product_name ASC"""
|
||||
params['org_id'] = org_id
|
||||
products = await sor.sqlExe(sql, params)
|
||||
|
||||
if not products:
|
||||
result = {'widgettype': 'Text', 'options': {'text': '该分类下暂无产品'}}
|
||||
else:
|
||||
cards = []
|
||||
for p in products:
|
||||
price_text = f"¥{float(p.price or 0):.2f}" if p.price else '面议'
|
||||
card = {
|
||||
'widgettype': 'HBox',
|
||||
'options': {
|
||||
'padding': '12px',
|
||||
'borderRadius': '8px',
|
||||
'border': '1px solid #e0e0e0',
|
||||
'marginBottom': '8px',
|
||||
'cursor': 'pointer',
|
||||
'alignItems': 'center',
|
||||
'gap': '16px'
|
||||
},
|
||||
'binds': [
|
||||
{
|
||||
'wid': 'self',
|
||||
'event': 'click',
|
||||
'actiontype': 'urlwidget',
|
||||
'target': 'product_detail',
|
||||
'params_mapping': {
|
||||
'mapping': {'id': 'product_id'},
|
||||
'need_other': False
|
||||
},
|
||||
'options': {
|
||||
'method': 'POST',
|
||||
'url': '{{entire_url("./api/get_product_detail.dspy")}}',
|
||||
'params': {'id': p.id}
|
||||
}
|
||||
}
|
||||
],
|
||||
'subwidgets': [
|
||||
{
|
||||
'widgettype': 'VBox',
|
||||
'options': {'css': 'filler', 'gap': '4px'},
|
||||
'subwidgets': [
|
||||
{
|
||||
'widgettype': 'Title5',
|
||||
'options': {'text': p.product_name, 'fontWeight': '600'}
|
||||
},
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': p.brief_intro or '暂无简介',
|
||||
'fontSize': '13px',
|
||||
'color': '#666'
|
||||
}
|
||||
},
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': f"{p.category_name or ''} | {p.product_code or ''}",
|
||||
'fontSize': '12px',
|
||||
'color': '#999'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'widgettype': 'Text',
|
||||
'options': {
|
||||
'text': price_text,
|
||||
'fontSize': '16px',
|
||||
'fontWeight': '600',
|
||||
'color': '#e74c3c'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
cards.append(card)
|
||||
|
||||
result = {
|
||||
'widgettype': 'VBox',
|
||||
'options': {'width': '100%', 'gap': '0'},
|
||||
'subwidgets': cards
|
||||
}
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
56
wwwroot/storefront/api/purchase.dspy
Normal file
56
wwwroot/storefront/api/purchase.dspy
Normal file
@ -0,0 +1,56 @@
|
||||
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
|
||||
|
||||
try:
|
||||
product_id = params_kw.get('product_id', '')
|
||||
if not product_id:
|
||||
raise ValueError('缺少产品ID')
|
||||
|
||||
userid = await get_user()
|
||||
if not userid:
|
||||
raise ValueError('请先登录')
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
dbname = get_module_dbname('product_management')
|
||||
|
||||
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 ValueError('产品不存在或已下架')
|
||||
|
||||
p = recs[0]
|
||||
order_id = getID()
|
||||
now = timestampstr()
|
||||
order = {
|
||||
'id': order_id,
|
||||
'product_id': p.id,
|
||||
'product_code': p.product_code or '',
|
||||
'product_name': p.product_name or '',
|
||||
'buyer_id': userid,
|
||||
'buyer_org_id': userorgid or '0',
|
||||
'quantity': 1,
|
||||
'unit_price': float(p.price or 0),
|
||||
'total_price': float(p.price or 0),
|
||||
'currency': p.currency or 'CNY',
|
||||
'status': 'pending',
|
||||
'purchase_data': '{}',
|
||||
'created_at': now,
|
||||
'updated_at': now
|
||||
}
|
||||
await sor.C('purchase_orders', order)
|
||||
|
||||
result = {
|
||||
'widgettype': 'Message',
|
||||
'options': {
|
||||
'title': '购买成功',
|
||||
'message': f"已提交购买订单: {p.product_name},订单号: {order_id}",
|
||||
'type': 'success',
|
||||
'timeout': 5
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
result['options'] = {'title': '购买失败', 'message': str(e), 'type': 'error', 'timeout': 5}
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
123
wwwroot/storefront/index.ui
Normal file
123
wwwroot/storefront/index.ui
Normal file
@ -0,0 +1,123 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"padding": "0"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"alignItems": "center",
|
||||
"padding": "16px 24px",
|
||||
"marginBottom": "0"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Title2",
|
||||
"options": {
|
||||
"text": "产品商城",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Filler"
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "浏览产品,点击了解详情",
|
||||
"fontSize": "14px"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"css": "filler",
|
||||
"gap": "0"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "category_sidebar",
|
||||
"options": {
|
||||
"width": "280px",
|
||||
"minWidth": "48px",
|
||||
"borderRight": "1px solid #e0e0e0",
|
||||
"overflowY": "auto"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Tree",
|
||||
"id": "category_tree",
|
||||
"options": {
|
||||
"title": "产品分类",
|
||||
"idField": "id",
|
||||
"textField": "name",
|
||||
"parentField": "parent_id",
|
||||
"dataurl": "{{entire_url('./api/get_categories.dspy')}}"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "product_list",
|
||||
"params_mapping": {
|
||||
"mapping": {
|
||||
"id": "category_id"
|
||||
},
|
||||
"need_other": false
|
||||
},
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"url": "{{entire_url('./api/get_products.dspy')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"css": "filler",
|
||||
"padding": "16px 24px",
|
||||
"overflowY": "auto"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Title4",
|
||||
"options": {
|
||||
"text": "产品列表",
|
||||
"fontWeight": "600",
|
||||
"marginBottom": "12px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "product_list",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"css": "filler"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "product_detail",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"marginTop": "24px"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user