Initial bricks miniprogram implementation

This commit is contained in:
yumoqing 2026-05-19 01:17:14 +08:00
commit 13a770d56d
17 changed files with 646 additions and 0 deletions

34
DESIGN.md Normal file
View File

@ -0,0 +1,34 @@
# Bricks 微信小程序 - 设计文档
## 架构
JSON → BricksParser → setData → WXML 递归模板 → 小程序原生渲染
## 核心文件
- `utils/parser.js` - JSON 解析引擎
- `utils/http.js` - wx.request 封装
- `utils/renderer.js` - 事件分发
- `components/brick/brick.wxml` - 递归模板 (template is="brick")
- `pages/bricks/bricks.js` - 页面入口
## 组件映射
| Bricks Widget | 小程序组件 |
|--------------|----------|
| Text/Title1-6 | `<text class="title-N">` |
| HBox/VBox | `<view class="flex-row/flex-col">` |
| Filler | `<view class="flex-fill">` |
| KeyinText/Input | `<input>` |
| Image | `<image>` |
| Running | `<loading>` |
| VScrollPanel/HScrollPanel | `<scroll-view scroll-y/x>` |
| Modal/Popup | `<view class="modal-overlay">` |
## 事件系统
- urlwidget → wx.navigateTo
- method → Page 方法调用
- event → bindtap/catchtap
- script → wx.request 服务端 RPC
## 限制
- 包体积 2MB → 分包加载
- WXML 不支持 innerHTML → 用 template 递归
- Markdown/Html → 需引入 mp-html 插件

36
app.js Normal file
View File

@ -0,0 +1,36 @@
App({
globalData: {
baseUrl: '',
authToken: '',
bricks: null
},
onLaunch() {
// 加载 bricks 解析引擎
const { BricksParser } = require('./utils/parser')
this.globalData.bricks = new BricksParser()
},
// 工具函数: 对应 JS 版 bricks.extend
extend(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
target[key] = this.extend(target[key] || {}, source[key])
} else {
target[key] = source[key]
}
}
}
return target
},
// 模板变量替换: 对应 bricks.obj_fmtstr
fmt(str, data) {
return str.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
return data[key] !== undefined ? data[key] : match
})
},
// entire_url: 拼接 baseUrl
entireUrl(path) {
const base = this.globalData.baseUrl || ''
return base.replace(/\/$/, '') + '/' + path.replace(/^\//, '')
}
})

11
app.json Normal file
View File

@ -0,0 +1,11 @@
{
"pages": [
"pages/bricks/bricks"
],
"window": {
"navigationBarTitleText": "Bricks",
"navigationBarBackgroundColor": "#ffffff"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}

84
app.wxss Normal file
View File

@ -0,0 +1,84 @@
page {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28rpx;
color: #333;
}
/* Flex 布局 - 对应 HBox/VBox */
.flex-row {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-fill {
flex: 1;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-between {
justify-content: space-between;
}
/* 标题 - 对应 Title1-6 */
.title-1 { font-size: 64rpx; font-weight: bold; }
.title-2 { font-size: 56rpx; font-weight: bold; }
.title-3 { font-size: 48rpx; font-weight: semi-bold; }
.title-4 { font-size: 40rpx; font-weight: medium; }
.title-5 { font-size: 36rpx; font-weight: medium; }
.title-6 { font-size: 32rpx; font-weight: medium; }
/* 文本 */
.text-content { padding: 16rpx 32rpx; line-height: 1.6; }
/* 输入框 */
.input-field {
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 16rpx 24rpx;
margin: 16rpx 32rpx;
font-size: 28rpx;
}
/* Loading - 对应 Running */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx;
}
/* 图片 */
.image-content { width: 100%; height: auto; }
.icon-content { width: 48rpx; height: 48rpx; }
/* 滚动面板 */
.scroll-panel { width: 100%; height: 100%; }
/* Modal */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal-content {
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
max-width: 80%;
}
/* Menu */
.menu-item {
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #eee;
}

27
components/brick/brick.js Normal file
View File

@ -0,0 +1,27 @@
/**
* Bricks 递归组件逻辑
*/
Component({
properties: {
item: {
type: Object,
value: {}
}
},
methods: {
onEvent(e) {
const dataset = e.currentTarget.dataset
const { actiontype, target, methodname, url, script } = dataset
this.triggerEvent('bricksaction', { actiontype, target, methodname, url, script, event: e })
},
onInput(e) {
this.triggerEvent('inputchange', { value: e.detail.value, widget: this.data.item })
},
onCloseModal(e) {
this.triggerEvent('modalclose')
},
stopPropagation() {
// 阻止冒泡
}
}
})

View File

@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

117
components/brick/brick.wxml Normal file
View File

@ -0,0 +1,117 @@
<!--
Bricks 递归组件模板
通过 template is="brick" data="{{item}}" 递归渲染
-->
<template name="brick">
<!-- 文本组件 -->
<block wx:if="{{item.widgettype === 'Text'}}">
<text class="text-content">{{item.options.text}}</text>
</block>
<block wx:if="{{item.widgettype === 'Title1'}}">
<text class="title-1">{{item.options.text}}</text>
</block>
<block wx:if="{{item.widgettype === 'Title2'}}">
<text class="title-2">{{item.options.text}}</text>
</block>
<block wx:if="{{item.widgettype === 'Title3'}}">
<text class="title-3">{{item.options.text}}</text>
</block>
<block wx:if="{{item.widgettype === 'Title4'}}">
<text class="title-4">{{item.options.text}}</text>
</block>
<block wx:if="{{item.widgettype === 'Title5'}}">
<text class="title-5">{{item.options.text}}</text>
</block>
<block wx:if="{{item.widgettype === 'Title6'}}">
<text class="title-6">{{item.options.text}}</text>
</block>
<!-- 布局组件 - HBox/VBox -->
<block wx:if="{{item.widgettype === 'HBox' || item.widgettype === 'FHBox'}}">
<view class="flex-row {{item.widgettype === 'FHBox' ? 'flex-between' : ''}}">
<block wx:for="{{item.subwidgets}}" wx:key="widgettype" wx:for-item="child">
<template is="brick" data="{{item: child}}" />
</block>
</view>
</block>
<block wx:if="{{item.widgettype === 'VBox' || item.widgettype === 'FVBox'}}">
<view class="flex-col {{item.widgettype === 'FVBox' ? 'flex-between' : ''}}">
<block wx:for="{{item.subwidgets}}" wx:key="widgettype" wx:for-item="child">
<template is="brick" data="{{item: child}}" />
</block>
</view>
</block>
<!-- Filler -->
<block wx:if="{{item.widgettype === 'Filler' || item.widgettype === 'HFiller' || item.widgettype === 'VFiller'}}">
<view class="flex-fill"></view>
</block>
<!-- 输入组件 -->
<block wx:if="{{item.widgettype === 'KeyinText'}}">
<input class="input-field" placeholder="{{item.options.placeholder}}" value="{{item.options.value || item.options.text}}" bindinput="onInput" data-widget="{{item}}" />
</block>
<block wx:if="{{item.widgettype === 'Input'}}">
<block wx:if="{{item.options.type === 'password'}}">
<input class="input-field" type="idcard" placeholder="{{item.options.placeholder}}" password="{{true}}" />
</block>
<block wx:else>
<input class="input-field" placeholder="{{item.options.placeholder}}" value="{{item.options.value}}" />
</block>
</block>
<!-- 图片 -->
<block wx:if="{{item.widgettype === 'Image'}}">
<image class="image-content" src="{{item.options.src}}" mode="{{item.options.mode || 'aspectFit'}}" />
</block>
<!-- Icon -->
<block wx:if="{{item.widgettype === 'Icon' || item.widgettype === 'StatedIcon'}}">
<image class="icon-content" src="{{item.options.src}}" />
</block>
<!-- Running (Loading) -->
<block wx:if="{{item.widgettype === 'Running'}}">
<view class="loading">
<loading />
</view>
</block>
<!-- Scroll -->
<block wx:if="{{item.widgettype === 'VScrollPanel'}}">
<scroll-view class="scroll-panel" scroll-y="true">
<block wx:for="{{item.subwidgets}}" wx:key="widgettype" wx:for-item="child">
<template is="brick" data="{{item: child}}" />
</block>
</scroll-view>
</block>
<block wx:if="{{item.widgettype === 'HScrollPanel'}}">
<scroll-view class="scroll-panel" scroll-x="true">
<block wx:for="{{item.subwidgets}}" wx:key="widgettype" wx:for-item="child">
<template is="brick" data="{{item: child}}" />
</block>
</scroll-view>
</block>
<!-- Modal -->
<block wx:if="{{item.widgettype === 'Modal' || item.widgettype === 'Popup'}}">
<view class="modal-overlay" wx:if="{{item.options.visible}}" catchtap="onCloseModal">
<view class="modal-content" catchtap="stopPropagation">
<block wx:for="{{item.subwidgets}}" wx:key="widgettype" wx:for-item="child">
<template is="brick" data="{{item: child}}" />
</block>
</view>
</view>
</block>
<!-- 默认: 递归子组件 -->
<block wx:if="{{item.subwidgets && item.subwidgets.length > 0 && item.widgettype !== 'HBox' && item.widgettype !== 'FHBox' && item.widgettype !== 'VBox' && item.widgettype !== 'FVBox' && item.widgettype !== 'VScrollPanel' && item.widgettype !== 'HScrollPanel' && item.widgettype !== 'Modal' && item.widgettype !== 'Popup'}}">
<block wx:for="{{item.subwidgets}}" wx:key="widgettype" wx:for-item="child">
<template is="brick" data="{{item: child}}" />
</block>
</block>
</template>

View File

@ -0,0 +1 @@
/* 组件样式继承 app.wxss */

74
pages/bricks/bricks.js Normal file
View File

@ -0,0 +1,74 @@
/**
* Bricks 页面入口
*/
const { BricksParser } = require('../../utils/parser')
const { BricksHttp } = require('../../utils/http')
const { BricksRenderer } = require('../../utils/renderer')
Page({
data: {
tree: null
},
onLoad(options) {
this.parser = new BricksParser()
this.http = new BricksHttp()
this.renderer = new BricksRenderer(this)
// 从 URL 参数加载 JSON
if (options.url) {
this.loadFromUrl(decodeURIComponent(options.url))
} else if (options.json) {
this.loadFromJson(decodeURIComponent(options.json))
} else {
// 加载默认示例
this.loadDefault()
}
},
async loadFromUrl(url) {
try {
const json = await this.http.get(url)
const widgetTree = this.parser.parse(JSON.stringify(json))
this.renderer.render(widgetTree)
} catch (e) {
console.error('[Bricks] Load failed:', e)
}
},
loadFromJson(jsonString) {
const widgetTree = this.parser.parse(jsonString)
if (widgetTree) this.renderer.render(widgetTree)
},
loadDefault() {
const defaultJson = JSON.stringify({
widgettype: 'VBox',
subwidgets: [
{ widgettype: 'Title1', options: { text: '欢迎使用 Bricks' } },
{ widgettype: 'Text', options: { text: 'JSON 驱动的跨平台 UI 框架' } },
{ widgettype: 'KeyinText', options: { placeholder: '请输入...' } },
{
widgettype: 'HBox',
subwidgets: [
{ widgettype: 'Text', options: { text: '左侧' } },
{ widgettype: 'Filler' },
{ widgettype: 'Text', options: { text: '右侧' } }
]
}
]
})
this.loadFromJson(defaultJson)
},
onBricksAction(e) {
const { actiontype, url, methodname } = e.detail
if (actiontype === 'urlwidget' && url) {
wx.navigateTo({ url: '/pages/bricks/bricks?url=' + encodeURIComponent(url) })
}
},
onInputChange(e) {
console.log('[Bricks] Input:', e.detail.value)
}
})

6
pages/bricks/bricks.json Normal file
View File

@ -0,0 +1,6 @@
{
"navigationBarTitleText": "Bricks",
"usingComponents": {
"brick": "/components/brick/brick"
}
}

10
pages/bricks/bricks.wxml Normal file
View File

@ -0,0 +1,10 @@
<!--
Bricks 页面入口
引入递归模板,渲染整个 widget 树
-->
<import src="/components/brick/brick.wxml" />
<view class="page-container">
<!-- 递归渲染 -->
<template is="brick" data="{{item: tree}}" />
</view>

7
pages/bricks/bricks.wxss Normal file
View File

@ -0,0 +1,7 @@
@import "/app.wxss";
.page-container {
width: 100%;
min-height: 100vh;
background: #fff;
}

42
project.config.json Normal file
View File

@ -0,0 +1,42 @@
{
"description": "Bricks 微信小程序",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
"postcss": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"useIsolateContext": true,
"nodeModules": false,
"enhance": true,
"useMultiFrameRuntime": true,
"showShadowRootInWxmlPanel": true,
"packNpmManually": false,
"enableEngp": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"showES6CompileOption": false,
"minifyWXML": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"condition": {}
}

4
sitemap.json Normal file
View File

@ -0,0 +1,4 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{ "action": "allow", "page": "*" }]
}

54
utils/http.js Normal file
View File

@ -0,0 +1,54 @@
/**
* HTTP 请求封装 - 对应 JS bricks.HttpJson/HttpText
*/
const app = getApp()
class BricksHttp {
/**
* GET 请求
*/
get(url, params = {}) {
return new Promise((resolve, reject) => {
const fullUrl = app.entireUrl(url)
wx.request({
url: fullUrl,
data: params,
method: 'GET',
header: {
'Content-Type': 'application/json',
'Authorization': app.globalData.authToken ? `Bearer ${app.globalData.authToken}` : ''
},
success: (res) => {
if (res.statusCode === 200) resolve(res.data)
else reject(new Error(`HTTP ${res.statusCode}`))
},
fail: (err) => reject(err)
})
})
}
/**
* POST 请求
*/
post(url, data = {}) {
return new Promise((resolve, reject) => {
const fullUrl = app.entireUrl(url)
wx.request({
url: fullUrl,
data: JSON.stringify(data),
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': app.globalData.authToken ? `Bearer ${app.globalData.authToken}` : ''
},
success: (res) => {
if (res.statusCode === 200) resolve(res.data)
else reject(new Error(`HTTP ${res.statusCode}`))
},
fail: (err) => reject(err)
})
})
}
}
module.exports = { BricksHttp }

67
utils/parser.js Normal file
View File

@ -0,0 +1,67 @@
/**
* Bricks JSON 解析引擎
* bricks JSON 解析为小程序可渲染的数据结构
*/
class BricksParser {
constructor() {
this.widgetTree = null
}
/**
* 解析 JSON 字符串
*/
parse(jsonString) {
try {
const obj = JSON.parse(jsonString)
this.widgetTree = this._parseNode(obj)
return this.widgetTree
} catch (e) {
console.error('[Bricks] Parse error:', e)
return null
}
}
/**
* 递归解析节点
*/
_parseNode(obj) {
if (!obj || typeof obj !== 'object') return null
const node = {
widgettype: obj.widgettype || 'Text',
options: obj.options || {},
binds: obj.binds || [],
subwidgets: [],
// 扁平化事件数据,方便 WXML 绑定
_hasBind: (obj.binds && obj.binds.length > 0),
_bindData: obj.binds ? obj.binds.map(b => ({
event: b.event || 'tap',
actiontype: b.actiontype,
target: b.target,
methodname: b.methodname || b.method,
params: b.params,
url: b.url || (b.options && b.options.url),
script: b.script
})) : []
}
// 递归处理子组件
if (obj.subwidgets && Array.isArray(obj.subwidgets)) {
node.subwidgets = obj.subwidgets.map(child => this._parseNode(child))
}
return node
}
/**
* 解析多个 JSON如分页加载
*/
parseList(jsonArray) {
return jsonArray.map(json => {
if (typeof json === 'string') return this.parse(json)
return this._parseNode(json)
})
}
}
module.exports = { BricksParser }

68
utils/renderer.js Normal file
View File

@ -0,0 +1,68 @@
/**
* 渲染适配 - 处理事件分发和 actiontype
*/
const app = getApp()
class BricksRenderer {
constructor(page) {
this.page = page
this.widgetTree = null
}
/**
* 渲染 widget
*/
render(widgetTree) {
this.widgetTree = widgetTree
this.page.setData({
tree: widgetTree,
treeData: [widgetTree] // WXML 需要数组形式
})
}
/**
* 处理事件绑定
*/
onEvent(e) {
const { actiontype, target, methodname, url, script } = e.currentTarget.dataset
console.log('[Bricks] Event:', actiontype, target)
switch (actiontype) {
case 'urlwidget':
this._handleUrlWidget(url)
break
case 'method':
this._handleMethod(methodname, e)
break
case 'script':
this._handleScript(script)
break
case 'event':
this._handleEvent(e)
break
default:
console.log('[Bricks] Unknown actiontype:', actiontype)
}
}
_handleUrlWidget(url) {
if (!url) return
wx.navigateTo({ url: '/pages/bricks/bricks?url=' + encodeURIComponent(url) })
}
_handleMethod(methodname, e) {
if (methodname && this.page[methodname]) {
this.page[methodname](e)
}
}
_handleScript(script) {
console.log('[Bricks] Script:', script)
}
_handleEvent(e) {
console.log('[Bricks] Event data:', e.detail)
}
}
module.exports = { BricksRenderer }