feat: 新增商家产品商城页面(storefront)

- 左侧可收缩产品分类树
- 右侧产品列表(卡片式)和产品详情
- 未登录可浏览,登录后显示购买按钮
- 页面和API注册到load_path.py
This commit is contained in:
Hermes Agent 2026-06-23 18:19:42 +08:00
parent 265081c43f
commit 6dae1676ef
6 changed files with 479 additions and 0 deletions

View File

@ -41,6 +41,11 @@ MOD = "product_management"
# any — 无需登录(仅静态资源和菜单) # any — 无需登录(仅静态资源和菜单)
PATHS_ANY = [ PATHS_ANY = [
f"/{MOD}/menu.ui", 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 # logined — 需要认证的页面和 API
@ -147,6 +152,9 @@ PATHS_LOGINED = [
f"/{MOD}/api/usage_logs.dspy", f"/{MOD}/api/usage_logs.dspy",
f"/{MOD}/api/usage_stats.dspy", f"/{MOD}/api/usage_stats.dspy",
# Storefront purchase (requires login)
f"/{MOD}/storefront/api/purchase.dspy",
# CRUD auto-generated .dspy (new tables) # CRUD auto-generated .dspy (new tables)
f"/{MOD}/product_resource_list/get_product_resource.dspy", f"/{MOD}/product_resource_list/get_product_resource.dspy",
f"/{MOD}/product_resource_list/add_product_resource.dspy", f"/{MOD}/product_resource_list/add_product_resource.dspy",

View 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)

View 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)

View 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)

View 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
View 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"
}
}
]
}
]
}
]
}