feat: showcase模块 - 产品展示平台(MVP)

- 4张数据表: posts/comments/likes/downloads
- 5种媒体类型: music/mtv/short_video/long_video/ktv
- 社交功能: 点赞(toggle)、评论(嵌套回复)
- KTV付费下载: 购买记录、下载计数
- 11个dspy API端点
- 3个CRUD管理界面(posts/comments/downloads)
- Feed流(类型筛选+分页)、作品详情(浏览计数+点赞状态)
- load_path.py RBAC权限注册
- 符合module/db-table/crud三规范
This commit is contained in:
yumoqing 2026-06-11 14:40:15 +08:00
parent 3bae870d7c
commit 1cce05a119
28 changed files with 931 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# CRUD generated directories
wwwroot/showcase_posts_list/
wwwroot/showcase_comments_list/
wwwroot/showcase_downloads_list/
# Build artifacts
build/
*.egg-info/
__pycache__/
*.pyc

15
init/data.json Normal file
View File

@ -0,0 +1,15 @@
{
"appcodes": [
{
"parentid": "showcase_media_type",
"parentname": "展示作品媒体类型",
"items": [
{"k": "music", "v": "音乐"},
{"k": "mtv", "v": "MTV"},
{"k": "short_video", "v": "短视频"},
{"k": "long_video", "v": "长视频"},
{"k": "ktv", "v": "KTV"}
]
}
]
}

View File

@ -0,0 +1,16 @@
{
"tblname": "showcase_comments",
"alias": "showcase_comments_list",
"title": "评论管理",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["reply_to_user_id"]
},
"editable": {
"new_data_url": "{{entire_url('../api/showcase_comment_create.dspy')}}",
"update_data_url": "{{entire_url('../api/showcase_comment_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/showcase_comment_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,25 @@
{
"tblname": "showcase_downloads",
"alias": "showcase_downloads_list",
"title": "KTV下载记录",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"alters": {
"payment_status": {
"uitype": "code",
"data": [
{"value": "0", "text": "待支付"},
{"value": "1", "text": "已支付"},
{"value": "2", "text": "已退款"}
]
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/showcase_download_create.dspy')}}",
"update_data_url": "{{entire_url('../api/showcase_download_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/showcase_download_delete.dspy')}}"
}
}
}

View File

@ -0,0 +1,43 @@
{
"tblname": "showcase_posts",
"alias": "showcase_posts_list",
"title": "展示作品管理",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["model_input", "model_output"],
"alters": {
"media_type": {
"uitype": "code",
"data": [
{"value": "music", "text": "音乐"},
{"value": "mtv", "text": "MTV"},
{"value": "short_video", "text": "短视频"},
{"value": "long_video", "text": "长视频"},
{"value": "ktv", "text": "KTV"}
]
},
"status": {
"uitype": "code",
"data": [
{"value": "0", "text": "待审核"},
{"value": "1", "text": "已发布"},
{"value": "2", "text": "已下架"}
]
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/showcase_post_create.dspy')}}",
"update_data_url": "{{entire_url('../api/showcase_post_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/showcase_post_delete.dspy')}}"
},
"data_filter": {
"AND": [
{"field": "media_type", "op": "=", "var": "media_type_input"},
{"field": "title", "op": "LIKE", "var": "title_input"},
{"field": "status", "op": "=", "var": "status_input"}
]
}
}
}

View File

@ -0,0 +1,29 @@
{
"summary": [
{
"name": "showcase_comments",
"title": "作品评论表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "post_id", "title": "作品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_id", "title": "评论者ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_name", "title": "评论者名称", "type": "str", "length": 128},
{"name": "parent_id", "title": "父评论ID(嵌套回复)", "type": "str", "length": 32},
{"name": "reply_to_user_id", "title": "回复目标用户ID", "type": "str", "length": 32},
{"name": "reply_to_user_name", "title": "回复目标用户名", "type": "str", "length": 128},
{"name": "content", "title": "评论内容", "type": "text", "nullable": "no"},
{"name": "like_count", "title": "评论点赞数", "type": "int", "default": "0"},
{"name": "reply_count", "title": "回复数", "type": "int", "default": "0"},
{"name": "status", "title": "状态(1正常/0隐藏/2删除)", "type": "str", "length": 1, "default": "1"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_comments_post", "idxtype": "index", "idxfields": ["post_id"]},
{"name": "idx_comments_parent", "idxtype": "index", "idxfields": ["parent_id"]},
{"name": "idx_comments_user", "idxtype": "index", "idxfields": ["user_id"]}
],
"codes": []
}

View File

@ -0,0 +1,25 @@
{
"summary": [
{
"name": "showcase_downloads",
"title": "KTV付费下载记录表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "post_id", "title": "作品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_id", "title": "购买用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "price", "title": "购买价格", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
{"name": "payment_status", "title": "支付状态(0待支付/1已支付/2已退款)", "type": "str", "length": 1, "default": "0"},
{"name": "download_url", "title": "下载链接", "type": "str", "length": 1024},
{"name": "download_count", "title": "已下载次数", "type": "int", "default": "0"},
{"name": "created_at", "title": "购买时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_dl_post", "idxtype": "index", "idxfields": ["post_id"]},
{"name": "idx_dl_user", "idxtype": "index", "idxfields": ["user_id"]},
{"name": "idx_dl_unique", "idxtype": "unique", "idxfields": ["post_id", "user_id"]}
],
"codes": []
}

View File

@ -0,0 +1,21 @@
{
"summary": [
{
"name": "showcase_likes",
"title": "作品点赞表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "post_id", "title": "作品ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "user_id", "title": "点赞用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "created_at", "title": "点赞时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_likes_post", "idxtype": "index", "idxfields": ["post_id"]},
{"name": "idx_likes_user", "idxtype": "index", "idxfields": ["user_id"]},
{"name": "idx_likes_unique", "idxtype": "unique", "idxfields": ["post_id", "user_id"]}
],
"codes": []
}

View File

@ -0,0 +1,50 @@
{
"summary": [
{
"name": "showcase_posts",
"title": "展示作品表",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "author_id", "title": "作者用户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "author_name", "title": "作者名称", "type": "str", "length": 128},
{"name": "title", "title": "作品标题", "type": "str", "length": 256, "nullable": "no"},
{"name": "description", "title": "作品描述", "type": "text"},
{"name": "media_type", "title": "媒体类型(music/mtv/short_video/long_video/ktv)", "type": "str", "length": 32, "nullable": "no"},
{"name": "content_url", "title": "主内容文件URL", "type": "str", "length": 1024},
{"name": "thumbnail_url", "title": "缩略图/封面URL", "type": "str", "length": 1024},
{"name": "duration", "title": "时长(秒)", "type": "int"},
{"name": "file_size", "title": "文件大小(bytes)", "type": "long"},
{"name": "model_name", "title": "生成模型名称", "type": "str", "length": 128},
{"name": "model_input", "title": "模型输入prompt", "type": "text"},
{"name": "model_output", "title": "模型原始输出", "type": "text"},
{"name": "tags", "title": "标签(逗号分隔)", "type": "str", "length": 512},
{"name": "category", "title": "内容分类", "type": "str", "length": 64},
{"name": "like_count", "title": "点赞数", "type": "int", "default": "0"},
{"name": "comment_count", "title": "评论数", "type": "int", "default": "0"},
{"name": "view_count", "title": "浏览数", "type": "int", "default": "0"},
{"name": "download_count", "title": "下载数", "type": "int", "default": "0"},
{"name": "is_featured", "title": "是否精选(0/1)", "type": "str", "length": 1, "default": "0"},
{"name": "status", "title": "状态(0待审核/1已发布/2已下架)", "type": "str", "length": 1, "default": "1"},
{"name": "price", "title": "付费价格(0=免费)", "type": "double", "length": 10, "dec": 2, "default": "0.00"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp"}
],
"indexes": [
{"name": "idx_posts_author", "idxtype": "index", "idxfields": ["author_id"]},
{"name": "idx_posts_media_type", "idxtype": "index", "idxfields": ["media_type"]},
{"name": "idx_posts_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_posts_created", "idxtype": "index", "idxfields": ["created_at"]}
],
"codes": [
{
"field": "media_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='showcase_media_type'"
}
]
}

17
pyproject.toml Normal file
View File

@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "showcase"
version = "1.0.0"
description = "Sage展示平台 - 产品展示、社交互动、KTV付费下载"
requires-python = ">=3.8"
dependencies = [
"sqlor",
"bricks_for_python",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["showcase*"]

76
scripts/load_path.py Normal file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Showcase module RBAC permission registration"""
import os, sys, subprocess
# Find Sage root
SAGE_ROOT = None
for candidate in [os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage")]:
if os.path.isdir(os.path.join(candidate, "wwwroot")):
SAGE_ROOT = candidate
break
if not SAGE_ROOT:
print("ERROR: Sage root not found")
sys.exit(1)
MOD = "showcase"
SET_PERM = os.path.join(SAGE_ROOT, "set_role_perm.py")
PY = os.path.join(SAGE_ROOT, "py3", "bin", "python")
def set_perm(role, path):
cmd = [PY, SET_PERM, role, path]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f" WARN: {path} -> {r.stderr.strip()}")
# Public resources (no auth needed)
PATHS_ANY = [
f"/{MOD}/showcase.css",
f"/{MOD}/showcase.js",
f"/{MOD}/menu.ui",
]
# Authenticated endpoints
PATHS_LOGINED = [
f"/{MOD}",
f"/{MOD}/index.ui",
f"/{MOD}/detail.ui",
# API endpoints
f"/{MOD}/api/showcase_feed.dspy",
f"/{MOD}/api/showcase_post_detail.dspy",
f"/{MOD}/api/showcase_post_create.dspy",
f"/{MOD}/api/showcase_post_update.dspy",
f"/{MOD}/api/showcase_post_delete.dspy",
f"/{MOD}/api/showcase_comment_create.dspy",
f"/{MOD}/api/showcase_comment_update.dspy",
f"/{MOD}/api/showcase_comment_delete.dspy",
f"/{MOD}/api/showcase_comments_list.dspy",
f"/{MOD}/api/showcase_like_toggle.dspy",
f"/{MOD}/api/showcase_download_purchase.dspy",
# CRUD generated directories
f"/{MOD}/showcase_posts_list",
f"/{MOD}/showcase_posts_list/index.ui",
f"/{MOD}/showcase_posts_list/get_showcase_posts_list.dspy",
f"/{MOD}/showcase_posts_list/add_showcase_posts_list.dspy",
f"/{MOD}/showcase_posts_list/update_showcase_posts_list.dspy",
f"/{MOD}/showcase_posts_list/delete_showcase_posts_list.dspy",
f"/{MOD}/showcase_comments_list",
f"/{MOD}/showcase_comments_list/index.ui",
f"/{MOD}/showcase_comments_list/get_showcase_comments_list.dspy",
f"/{MOD}/showcase_comments_list/add_showcase_comments_list.dspy",
f"/{MOD}/showcase_comments_list/update_showcase_comments_list.dspy",
f"/{MOD}/showcase_comments_list/delete_showcase_comments_list.dspy",
f"/{MOD}/showcase_downloads_list",
f"/{MOD}/showcase_downloads_list/index.ui",
f"/{MOD}/showcase_downloads_list/get_showcase_downloads_list.dspy",
f"/{MOD}/showcase_downloads_list/add_showcase_downloads_list.dspy",
f"/{MOD}/showcase_downloads_list/update_showcase_downloads_list.dspy",
f"/{MOD}/showcase_downloads_list/delete_showcase_downloads_list.dspy",
]
print(f"Registering RBAC permissions for {MOD}...")
for p in PATHS_ANY:
set_perm("any", p)
for p in PATHS_LOGINED:
set_perm("logined", p)
print("Done.")

14
showcase/__init__.py Normal file
View File

@ -0,0 +1,14 @@
"""Showcase module - 产品展示平台"""
from .init import (
load_showcase,
create_showcase_post,
update_showcase_post,
delete_showcase_post,
create_showcase_comment,
update_showcase_comment,
delete_showcase_comment,
toggle_showcase_like,
create_showcase_download,
update_showcase_download,
delete_showcase_download,
)

148
showcase/init.py Normal file
View File

@ -0,0 +1,148 @@
"""Showcase module init - 产品展示平台"""
from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
MODULE_NAME = "showcase"
MODULE_VERSION = "1.0.0"
DBNAME = "sage"
db = DBPools()
async def create_showcase_post(data):
"""创建展示作品"""
data['id'] = getID()
async with db.sqlorContext(DBNAME) as sor:
await sor.C('showcase_posts', data)
return {"status": "ok", "id": data['id']}
async def update_showcase_post(data):
"""更新展示作品"""
async with db.sqlorContext(DBNAME) as sor:
await sor.U('showcase_posts', data)
return {"status": "ok"}
async def delete_showcase_post(data):
"""删除展示作品"""
async with db.sqlorContext(DBNAME) as sor:
await sor.D('showcase_posts', data)
return {"status": "ok"}
async def create_showcase_comment(data):
"""创建评论(支持嵌套回复)"""
data['id'] = getID()
async with db.sqlorContext(DBNAME) as sor:
await sor.C('showcase_comments', data)
# 更新作品评论计数
post = await sor.R('showcase_posts', {"id": data['post_id']})
if post:
new_count = (post[0].get('comment_count', 0) or 0) + 1
await sor.U('showcase_posts', {"id": data['post_id'], "comment_count": new_count})
# 如果是回复更新父评论的reply_count
if data.get('parent_id'):
parent = await sor.R('showcase_comments', {"id": data['parent_id']})
if parent:
new_reply = (parent[0].get('reply_count', 0) or 0) + 1
await sor.U('showcase_comments', {"id": data['parent_id'], "reply_count": new_reply})
return {"status": "ok", "id": data['id']}
async def update_showcase_comment(data):
"""更新评论"""
async with db.sqlorContext(DBNAME) as sor:
await sor.U('showcase_comments', data)
return {"status": "ok"}
async def delete_showcase_comment(data):
"""删除评论"""
async with db.sqlorContext(DBNAME) as sor:
await sor.D('showcase_comments', data)
return {"status": "ok"}
async def toggle_showcase_like(post_id, user_id):
"""切换点赞状态"""
async with db.sqlorContext(DBNAME) as sor:
existing = await sor.sqlExe(
"SELECT id FROM showcase_likes WHERE post_id = ${post_id}$ AND user_id = ${user_id}$",
{"post_id": post_id, "user_id": user_id}
)
if existing:
# 取消点赞
await sor.D('showcase_likes', {"id": existing[0]['id']})
post = await sor.R('showcase_posts', {"id": post_id})
if post:
new_count = max(0, (post[0].get('like_count', 0) or 0) - 1)
await sor.U('showcase_posts', {"id": post_id, "like_count": new_count})
return {"status": "ok", "action": "unliked", "liked": False}
else:
# 点赞
like_data = {"id": getID(), "post_id": post_id, "user_id": user_id}
await sor.C('showcase_likes', like_data)
post = await sor.R('showcase_posts', {"id": post_id})
if post:
new_count = (post[0].get('like_count', 0) or 0) + 1
await sor.U('showcase_posts', {"id": post_id, "like_count": new_count})
return {"status": "ok", "action": "liked", "liked": True}
async def create_showcase_download(data):
"""创建KTV下载记录"""
data['id'] = getID()
async with db.sqlorContext(DBNAME) as sor:
await sor.C('showcase_downloads', data)
# 更新作品下载计数
post = await sor.R('showcase_posts', {"id": data['post_id']})
if post:
new_count = (post[0].get('download_count', 0) or 0) + 1
await sor.U('showcase_posts', {"id": data['post_id'], "download_count": new_count})
return {"status": "ok", "id": data['id']}
async def update_showcase_download(data):
"""更新下载记录"""
async with db.sqlorContext(DBNAME) as sor:
await sor.U('showcase_downloads', data)
return {"status": "ok"}
async def delete_showcase_download(data):
"""删除下载记录"""
async with db.sqlorContext(DBNAME) as sor:
await sor.D('showcase_downloads', data)
return {"status": "ok"}
def load_showcase():
"""注册所有函数到ServerEnv"""
env = ServerEnv()
env.create_showcase_post = create_showcase_post
env.update_showcase_post = update_showcase_post
env.delete_showcase_post = delete_showcase_post
# CRUD plural aliases
env.create_showcase_posts = create_showcase_post
env.update_showcase_posts = update_showcase_post
env.delete_showcase_posts = delete_showcase_post
env.create_showcase_comment = create_showcase_comment
env.update_showcase_comment = update_showcase_comment
env.delete_showcase_comment = delete_showcase_comment
env.create_showcase_comments = create_showcase_comment
env.update_showcase_comments = update_showcase_comment
env.delete_showcase_comments = delete_showcase_comment
env.toggle_showcase_like = toggle_showcase_like
env.create_showcase_download = create_showcase_download
env.update_showcase_download = update_showcase_download
env.delete_showcase_download = delete_showcase_download
env.create_showcase_downloads = create_showcase_download
env.update_showcase_downloads = update_showcase_download
env.delete_showcase_downloads = delete_showcase_download
return True

View File

@ -0,0 +1,16 @@
try:
user_id = await get_user()
data = {
"post_id": params_kw.get('post_id', ''),
"user_id": user_id,
"user_name": params_kw.get('user_name', ''),
"parent_id": params_kw.get('parent_id', ''),
"reply_to_user_id": params_kw.get('reply_to_user_id', ''),
"reply_to_user_name": params_kw.get('reply_to_user_name', ''),
"content": params_kw.get('content', ''),
"created_at": curDateString()
}
result = await create_showcase_comment(data)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,5 @@
try:
result = await delete_showcase_comment({"id": params_kw.get('id', '')})
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,10 @@
try:
data = {"id": params_kw.get('id', '')}
for f in ['content', 'status']:
v = params_kw.get(f)
if v is not None:
data[f] = v
result = await update_showcase_comment(data)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,16 @@
try:
db = DBPools()
post_id = params_kw.get('post_id', '')
async with db.sqlorContext('sage') as sor:
# 获取评论列表(含嵌套回复)
comments = await sor.sqlExe(
"""SELECT c.*,
(SELECT COUNT(*) FROM showcase_likes WHERE post_id = c.id) as c_like
FROM showcase_comments c
WHERE c.post_id = ${post_id}$ AND c.status = '1'
ORDER BY c.created_at ASC""",
{"post_id": post_id}
)
return json.dumps({"status": "ok", "data": list(comments)}, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "data": [], "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,38 @@
try:
user_id = await get_user()
post_id = params_kw.get('post_id', '')
db = DBPools()
async with db.sqlorContext('sage') as sor:
# 检查作品是否存在且为付费KTV
posts = await sor.R('showcase_posts', {"id": post_id})
if not posts:
return json.dumps({"status": "error", "message": "作品不存在"}, ensure_ascii=False)
post = posts[0]
price = float(post.get('price', 0))
if price <= 0:
return json.dumps({"status": "error", "message": "该作品免费,无需购买"}, ensure_ascii=False)
# 检查是否已购买
existing = await sor.sqlExe(
"SELECT id FROM showcase_downloads WHERE post_id = ${post_id}$ AND user_id = ${user_id}$ AND payment_status = '1'",
{"post_id": post_id, "user_id": user_id}
)
if existing:
return json.dumps({"status": "ok", "message": "已购买", "download_url": post.get('content_url', '')}, ensure_ascii=False)
# 创建购买记录(模拟支付成功)
dl_data = {
"post_id": post_id,
"user_id": user_id,
"price": price,
"payment_status": "1",
"download_url": post.get('content_url', ''),
"download_count": 1,
"created_at": curDateString()
}
result = await create_showcase_download(dl_data)
return json.dumps({"status": "ok", "message": "购买成功", "download_url": post.get('content_url', '')}, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,35 @@
try:
db = DBPools()
media_type = params_kw.get('media_type', '')
page = int(params_kw.get('page', 1))
page_size = int(params_kw.get('page_size', 20))
offset = (page - 1) * page_size
where = "WHERE status = '1'"
bindparams = {}
if media_type:
where += " AND media_type = ${media_type}$"
bindparams['media_type'] = media_type
async with db.sqlorContext('sage') as sor:
total_r = await sor.sqlExe(
f"SELECT COUNT(*) as cnt FROM showcase_posts {where}", bindparams
)
total = total_r[0]['cnt'] if total_r else 0
posts = await sor.sqlExe(
f"""SELECT id, author_id, author_name, title, description, media_type,
content_url, thumbnail_url, duration, tags, category,
like_count, comment_count, view_count, download_count,
is_featured, price, created_at
FROM showcase_posts {where}
ORDER BY created_at DESC
LIMIT {page_size} OFFSET {offset}""",
bindparams
)
return json.dumps({
"status": "ok", "data": list(posts),
"total": total, "page": page, "page_size": page_size
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "data": [], "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
try:
user_id = await get_user()
post_id = params_kw.get('post_id', '')
result = await toggle_showcase_like(post_id, user_id)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,24 @@
try:
user_id = await get_user()
data = {
"author_id": user_id,
"author_name": params_kw.get('author_name', ''),
"title": params_kw.get('title', ''),
"description": params_kw.get('description', ''),
"media_type": params_kw.get('media_type', 'music'),
"content_url": params_kw.get('content_url', ''),
"thumbnail_url": params_kw.get('thumbnail_url', ''),
"duration": params_kw.get('duration', 0),
"file_size": params_kw.get('file_size', 0),
"model_name": params_kw.get('model_name', ''),
"model_input": params_kw.get('model_input', ''),
"model_output": params_kw.get('model_output', ''),
"tags": params_kw.get('tags', ''),
"category": params_kw.get('category', ''),
"price": float(params_kw.get('price', 0)),
"created_at": curDateString()
}
result = await create_showcase_post(data)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,5 @@
try:
result = await delete_showcase_post({"id": params_kw.get('id', '')})
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,41 @@
try:
db = DBPools()
post_id = params_kw.get('post_id', '')
user_id = await get_user()
async with db.sqlorContext('sage') as sor:
# 获取作品详情
posts = await sor.R('showcase_posts', {"id": post_id})
if not posts:
return json.dumps({"status": "error", "message": "作品不存在"}, ensure_ascii=False)
post = dict(posts[0])
# 增加浏览计数
new_view = (post.get('view_count', 0) or 0) + 1
await sor.U('showcase_posts', {"id": post_id, "view_count": new_view})
post['view_count'] = new_view
# 检查当前用户是否已点赞
liked = await sor.sqlExe(
"SELECT id FROM showcase_likes WHERE post_id = ${post_id}$ AND user_id = ${user_id}$",
{"post_id": post_id, "user_id": user_id}
)
post['is_liked'] = len(liked) > 0
# 检查是否已购买(付费作品)
if float(post.get('price', 0)) > 0:
bought = await sor.sqlExe(
"SELECT id FROM showcase_downloads WHERE post_id = ${post_id}$ AND user_id = ${user_id}$ AND payment_status = '1'",
{"post_id": post_id, "user_id": user_id}
)
post['is_purchased'] = len(bought) > 0
else:
post['is_purchased'] = True
# 移除敏感字段
post.pop('model_input', None)
post.pop('model_output', None)
return json.dumps({"status": "ok", "data": post}, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,14 @@
try:
data = {"id": params_kw.get('id', '')}
for f in ['title', 'description', 'content_url', 'thumbnail_url', 'tags',
'category', 'media_type', 'status', 'is_featured', 'price']:
v = params_kw.get(f)
if v is not None:
data[f] = v
if 'price' in data:
data['price'] = float(data['price'])
data['updated_at'] = curDateString()
result = await update_showcase_post(data)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

24
wwwroot/detail.ui Normal file
View File

@ -0,0 +1,24 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "0px"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"width": "100%", "padding": "16px 24px", "bgcolor": "var(--bricks-card-bg, #fff)", "borderBottom": "1px solid var(--bricks-border, #eee)"},
"subwidgets": [
{
"widgettype": "Button",
"options": {"label": "← 返回", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": "history.back();"}]
},
{"widgettype": "Title", "options": {"text": "作品详情", "fontSize": "20px", "fontWeight": "bold", "flex": "1", "marginLeft": "16px"}}
]
},
{
"widgettype": "VBox",
"id": "showcase_detail_content",
"options": {"width": "100%", "flex": "1", "padding": "24px", "overflow": "auto"}
}
]
}

61
wwwroot/index.ui Normal file
View File

@ -0,0 +1,61 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "0px"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"width": "100%", "padding": "16px 24px", "bgcolor": "var(--bricks-card-bg, #fff)", "borderBottom": "1px solid var(--bricks-border, #eee)"},
"subwidgets": [
{"widgettype": "Title", "options": {"text": "展示平台", "fontSize": "22px", "fontWeight": "bold", "flex": "1"}},
{
"widgettype": "HBox",
"options": {"gap": "8px"},
"subwidgets": [
{
"widgettype": "Button",
"options": {"label": "全部", "id": "btn_all", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "registerfunction", "target": "filterByType", "options": {"type": ""}}]
},
{
"widgettype": "Button",
"options": {"label": "🎵 音乐", "id": "btn_music", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "registerfunction", "target": "filterByType", "options": {"type": "music"}}]
},
{
"widgettype": "Button",
"options": {"label": "🎬 MTV", "id": "btn_mtv", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "registerfunction", "target": "filterByType", "options": {"type": "mtv"}}]
},
{
"widgettype": "Button",
"options": {"label": "📱 短视频", "id": "btn_short", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "registerfunction", "target": "filterByType", "options": {"type": "short_video"}}]
},
{
"widgettype": "Button",
"options": {"label": "🎞️ 长视频", "id": "btn_long", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "registerfunction", "target": "filterByType", "options": {"type": "long_video"}}]
},
{
"widgettype": "Button",
"options": {"label": "🎤 KTV", "id": "btn_ktv", "cfontsize": 0.85},
"binds": [{"wid": "self", "event": "click", "actiontype": "registerfunction", "target": "filterByType", "options": {"type": "ktv"}}]
}
]
}
]
},
{
"widgettype": "VBox",
"id": "showcase_feed_container",
"options": {"width": "100%", "flex": "1", "padding": "16px 24px", "overflow": "auto"},
"subwidgets": [
{
"widgettype": "ResponsableBox",
"id": "showcase_feed_grid",
"options": {"gap": "16px", "minWidth": "280px"}
}
]
}
]
}

72
wwwroot/showcase.css Normal file
View File

@ -0,0 +1,72 @@
/* Showcase platform styles */
.showcase-card {
background: var(--bricks-card-bg, #fff);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid var(--bricks-border, #eee);
}
.showcase-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.showcase-card .thumb {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
}
.showcase-card .info {
padding: 12px;
}
.showcase-card .title {
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.showcase-card .meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #888;
}
.showcase-card .badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
margin-bottom: 6px;
}
.badge-music { background: #e8f5e9; color: #2e7d32; }
.badge-mtv { background: #e3f2fd; color: #1565c0; }
.badge-short_video { background: #fff3e0; color: #e65100; }
.badge-long_video { background: #fce4ec; color: #c62828; }
.badge-ktv { background: #f3e5f5; color: #6a1b9a; }
.comment-item {
padding: 12px 0;
border-bottom: 1px solid var(--bricks-border, #eee);
}
.comment-item .comment-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.comment-item .comment-user { font-weight: 600; font-size: 13px; }
.comment-item .comment-time { font-size: 11px; color: #999; }
.comment-item .comment-body { font-size: 14px; line-height: 1.5; }
.comment-reply {
margin-left: 32px;
padding-left: 12px;
border-left: 2px solid var(--bricks-border, #eee);
}

75
wwwroot/showcase.js Normal file
View File

@ -0,0 +1,75 @@
// Showcase platform JavaScript
const MEDIA_LABELS = {
music: '🎵 音乐', mtv: '🎬 MTV', short_video: '📱 短视频',
long_video: '🎞️ 长视频', ktv: '🎤 KTV'
};
const MEDIA_ICONS = {
music: '🎵', mtv: '🎬', short_video: '📱', long_video: '🎞️', ktv: '🎤'
};
let currentFilter = '';
async function loadFeed(mediaType) {
currentFilter = mediaType || '';
const container = document.getElementById('showcase_feed_grid');
if (!container) return;
const url = `${MODULE_PREFIX}/api/showcase_feed.dspy?media_type=${currentFilter}&page=1&page_size=30`;
try {
const resp = await fetch(url);
const result = await resp.json();
if (result.status !== 'ok' || !result.data) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#999">暂无作品</div>';
return;
}
renderFeedCards(container, result.data);
} catch (e) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#c00">加载失败</div>';
}
}
function renderFeedCards(container, posts) {
if (!posts.length) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#999">暂无作品</div>';
return;
}
container.innerHTML = posts.map(p => `
<div class="showcase-card" onclick="openDetail('${p.id}')">
<div class="thumb">${p.thumbnail_url ? `<img src="${p.thumbnail_url}" style="width:100%;height:100%;object-fit:cover">` : MEDIA_ICONS[p.media_type] || '📄'}</div>
<div class="info">
<span class="badge badge-${p.media_type}">${MEDIA_LABELS[p.media_type] || p.media_type}</span>
<div class="title">${escHtml(p.title)}</div>
<div class="meta">
<span> ${p.like_count || 0}</span>
<span>💬 ${p.comment_count || 0}</span>
<span>👁 ${p.view_count || 0}</span>
${parseFloat(p.price) > 0 ? `<span>💰 ¥${p.price}</span>` : ''}
</div>
</div>
</div>
`).join('');
}
function openDetail(postId) {
window.location.href = `${MODULE_PREFIX}/detail.ui?post_id=${postId}`;
}
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Register filterByType function
if (typeof registerFunction !== 'undefined') {
registerFunction('filterByType', function(opts) {
loadFeed(opts.type);
});
}
// Auto-load on page ready
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('showcase_feed_grid')) {
loadFeed('');
}
});