feat: card repository and home three-column layout
This commit is contained in:
@@ -60,6 +60,51 @@ body {
|
|||||||
.page { display: none; height: 100%; }
|
.page { display: none; height: 100%; }
|
||||||
.page.active { display: flex; flex-direction: column; }
|
.page.active { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* 首页三栏布局:三等分纵向列,卡片向下堆叠 */
|
||||||
|
/* Home three-column layout: three equal vertical columns, cards stack downward */
|
||||||
|
.tri-col {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片:柔光玻璃拟态(复用 Task 4 token,非纯色背景) */
|
||||||
|
/* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */
|
||||||
|
.card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 大字时间卡:主时间大字 + 日期副行 */
|
||||||
|
/* Big time card: large clock + date subtitle */
|
||||||
|
.time-card .big {
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-card .sub {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* 安卓 APP 列表页:搜索框 + 网格 */
|
/* 安卓 APP 列表页:搜索框 + 网格 */
|
||||||
/* Android app list page: search box + grid */
|
/* Android app list page: search box + grid */
|
||||||
.search { padding: 16px; }
|
.search { padding: 16px; }
|
||||||
|
|||||||
@@ -22,9 +22,11 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="js/bridge.js"></script>
|
<script src="js/bridge.js"></script>
|
||||||
|
<script src="js/cards/cards.js"></script>
|
||||||
<script src="js/router.js"></script>
|
<script src="js/router.js"></script>
|
||||||
<script src="js/pages/applist.js"></script>
|
<script src="js/pages/applist.js"></script>
|
||||||
<script src="js/pages/webapplist.js"></script>
|
<script src="js/pages/webapplist.js"></script>
|
||||||
|
<script src="js/pages/home.js"></script>
|
||||||
<script src="js/webapp/tabs.js"></script>
|
<script src="js/webapp/tabs.js"></script>
|
||||||
<script src="js/webapp/topbar.js"></script>
|
<script src="js/webapp/topbar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 卡片三栏降级布局纯函数(UMD:Node require / 浏览器挂 window.cards)
|
||||||
|
// Card three-column fallback layout pure function (UMD: Node require / browser window.cards)
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||||
|
else root.cards = factory();
|
||||||
|
})(this, function () {
|
||||||
|
// cards: [{id, priority, enabled}],time 卡为内置始终保留
|
||||||
|
// 返回三栏分配结果,优先级数字小者优先;disabled 的卡不占位
|
||||||
|
// cards: [{id, priority, enabled}], time card is builtin and always kept
|
||||||
|
// Returns the three-column assignment; smaller priority comes first; disabled cards are skipped
|
||||||
|
function layout(cards) {
|
||||||
|
const columns = [[], [], []];
|
||||||
|
const active = cards
|
||||||
|
.filter((c) => c.enabled !== false)
|
||||||
|
.sort((a, b) => a.priority - b.priority);
|
||||||
|
active.forEach((c, i) => columns[i % 3].push(c.id));
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
return { layout };
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 首页三栏卡片:拉取目录 -> cards.layout 分配三栏 -> 渲染(time 卡大字 + 其他占位)
|
||||||
|
// Home three-column cards: fetch catalog -> cards.layout assigns 3 columns -> render
|
||||||
|
// (big time card + generic placeholders for the rest)
|
||||||
|
window.updateHomeCards = async function () {
|
||||||
|
const page = document.querySelector('[data-page="home"]');
|
||||||
|
page.innerHTML = `<div class="tri-col" id="tri-col">
|
||||||
|
<div class="col" id="col-0"></div><div class="col" id="col-1"></div><div class="col" id="col-2"></div>
|
||||||
|
</div>`;
|
||||||
|
let catalog = [];
|
||||||
|
try {
|
||||||
|
catalog = JSON.parse(await bridge.call('fetchCards'));
|
||||||
|
} catch (e) {
|
||||||
|
// 桥接不可用(如未接线)时降级为内置时间卡
|
||||||
|
// Degrade to builtin time card when the bridge is unavailable (e.g. not wired yet)
|
||||||
|
catalog = [{ id: 'time', priority: 0, enabled: true }];
|
||||||
|
}
|
||||||
|
const cols = cards.layout(catalog);
|
||||||
|
cols.forEach((ids, i) => {
|
||||||
|
const col = document.getElementById(`col-${i}`);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (id === 'time') col.appendChild(renderTimeCard());
|
||||||
|
else col.appendChild(renderGenericCard(id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
startTimeCardTicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 内置大字时间卡:当前时间 + 日期
|
||||||
|
// Builtin big time card: current time + date
|
||||||
|
function renderTimeCard() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card time-card';
|
||||||
|
const now = new Date();
|
||||||
|
el.innerHTML = `<div class="big">${fmtTime(now)}</div><div class="sub">${fmtDate(now)}</div>`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他卡片占位:仅显示 id,后续 Task 接具体卡片渲染
|
||||||
|
// Other cards placeholder: show id only; concrete renderers land in later tasks
|
||||||
|
function renderGenericCard(id) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card';
|
||||||
|
el.textContent = id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(d) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
const days = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日 周${days[d.getDay()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每 10s 刷新一次大字时间卡
|
||||||
|
// Refresh the big time card every 10 seconds
|
||||||
|
let timeTicker = null;
|
||||||
|
function startTimeCardTicker() {
|
||||||
|
if (timeTicker) return;
|
||||||
|
timeTicker = setInterval(() => {
|
||||||
|
const card = document.querySelector('.time-card');
|
||||||
|
if (!card) return;
|
||||||
|
const now = new Date();
|
||||||
|
card.querySelector('.big').textContent = fmtTime(now);
|
||||||
|
card.querySelector('.sub').textContent = fmtDate(now);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package top.yeij.hearth.card
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import top.yeij.hearth.cache.CacheManager
|
||||||
|
import top.yeij.hearth.webapp.HttpClient
|
||||||
|
|
||||||
|
// 首页卡片条目:id 唯一标识,priority 数字小者排前,entry 为卡片渲染入口
|
||||||
|
// Home card entry: id is the unique key, smaller priority comes first, entry is the render entry point
|
||||||
|
data class Card(val id: String, val name: String, val priority: Int, val entry: String)
|
||||||
|
|
||||||
|
// 卡片目录仓库:拉取成功缓存并解析,失败回退缓存,无目录/无缓存返回内置 time 卡
|
||||||
|
// Card catalog repository: cache+parse on success, fall back to cache on failure,
|
||||||
|
// return builtin time card when there is no catalog or no cache
|
||||||
|
class CardRepository(
|
||||||
|
private val http: HttpClient,
|
||||||
|
private val cache: CacheManager,
|
||||||
|
private val catalogUrl: String,
|
||||||
|
) {
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
fun fetchCatalog(): List<Card> {
|
||||||
|
val body = http.get(catalogUrl)
|
||||||
|
if (body != null) {
|
||||||
|
cache.save(KEY, body)
|
||||||
|
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network")
|
||||||
|
return parse(body)
|
||||||
|
}
|
||||||
|
val cached = cache.load(KEY)
|
||||||
|
if (cached != null) {
|
||||||
|
Log.d(TAG, "fetchCatalog: network failed, using cache")
|
||||||
|
return parse(cached)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "fetchCatalog: no network and no cache, return builtin time card")
|
||||||
|
return builtin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析目录 JSON:{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项
|
||||||
|
// Parse catalog JSON; skip malformed or missing-field entries
|
||||||
|
private fun parse(body: String): List<Card> {
|
||||||
|
val root = gson.fromJson(body, Map::class.java)
|
||||||
|
val list = root["cards"] as? List<*> ?: return builtin()
|
||||||
|
val parsed = list.mapNotNull { m ->
|
||||||
|
val map = m as? Map<*, *> ?: return@mapNotNull null
|
||||||
|
val id = map["id"] as? String ?: return@mapNotNull null
|
||||||
|
val name = map["name"] as? String ?: return@mapNotNull null
|
||||||
|
val priority = (map["priority"] as? Number)?.toInt() ?: return@mapNotNull null
|
||||||
|
val entry = map["entry"] as? String ?: return@mapNotNull null
|
||||||
|
Card(id, name, priority, entry)
|
||||||
|
}
|
||||||
|
// 内置 time 卡始终保留(追加在目录卡之后)
|
||||||
|
// builtin time card is always kept (appended after catalog cards)
|
||||||
|
return parsed + builtin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内置卡:大字时间卡 priority 0,无目录时兜底
|
||||||
|
// Builtin card: big time card at priority 0, fallback when no catalog
|
||||||
|
private fun builtin(): List<Card> = listOf(Card("time", "大字时间", 0, "cards/time-card.html"))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HearthCard"
|
||||||
|
private const val KEY = "card-catalog"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package top.yeij.hearth.webview
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import top.yeij.hearth.app.AppRepository
|
import top.yeij.hearth.app.AppRepository
|
||||||
|
import top.yeij.hearth.card.Card
|
||||||
|
import top.yeij.hearth.card.CardRepository
|
||||||
import top.yeij.hearth.webapp.WebApp
|
import top.yeij.hearth.webapp.WebApp
|
||||||
import top.yeij.hearth.webapp.WebAppRepository
|
import top.yeij.hearth.webapp.WebAppRepository
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ class JsBridge(
|
|||||||
private val darkMode: Boolean,
|
private val darkMode: Boolean,
|
||||||
private val appRepository: AppRepository? = null,
|
private val appRepository: AppRepository? = null,
|
||||||
private val webAppRepository: WebAppRepository? = null,
|
private val webAppRepository: WebAppRepository? = null,
|
||||||
|
private val cardRepository: CardRepository? = null,
|
||||||
) {
|
) {
|
||||||
private val gson = com.google.gson.Gson()
|
private val gson = com.google.gson.Gson()
|
||||||
|
|
||||||
@@ -42,4 +45,14 @@ class JsBridge(
|
|||||||
// Return the H5 web app manifest JSON (empty array when no repository wired)
|
// Return the H5 web app manifest JSON (empty array when no repository wired)
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList<WebApp>())
|
fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList<WebApp>())
|
||||||
|
|
||||||
|
// 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡)
|
||||||
|
// Return the home card catalog JSON (empty array when no repository wired;
|
||||||
|
// fetchCatalog guarantees the builtin time card when wired)
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun fetchCards(): String {
|
||||||
|
val cards = cardRepository?.fetchCatalog() ?: emptyList<Card>()
|
||||||
|
Log.d("HearthBridge", "fetchCards: ${cards.size} cards")
|
||||||
|
return gson.toJson(cards)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package top.yeij.hearth.card
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import top.yeij.hearth.cache.CacheManager
|
||||||
|
import top.yeij.hearth.webapp.HttpClient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class CardRepositoryTest {
|
||||||
|
@Test
|
||||||
|
fun fetchCatalog_success_parsesCards_andKeepsBuiltinTime() {
|
||||||
|
val http = object : HttpClient {
|
||||||
|
override fun get(url: String) =
|
||||||
|
"""{"version":1,"cards":[{"id":"weather","name":"天气","priority":2,"entry":"cards/weather.html"}]}"""
|
||||||
|
}
|
||||||
|
val repo = CardRepository(
|
||||||
|
http,
|
||||||
|
CacheManager(File(System.getProperty("java.io.tmpdir"), "c1")),
|
||||||
|
"http://fake",
|
||||||
|
)
|
||||||
|
val cards = repo.fetchCatalog()
|
||||||
|
assertEquals(setOf("weather", "time"), cards.map { it.id }.toSet())
|
||||||
|
assertEquals("天气", cards.first { it.id == "weather" }.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchCatalog_failure_usesCache() {
|
||||||
|
val dir = File(System.getProperty("java.io.tmpdir"), "c2")
|
||||||
|
val cm = CacheManager(dir)
|
||||||
|
cm.save(
|
||||||
|
"card-catalog",
|
||||||
|
"""{"cards":[{"id":"weather","name":"天气","priority":2,"entry":"cards/weather.html"}]}""",
|
||||||
|
)
|
||||||
|
val http = object : HttpClient { override fun get(url: String): String? = null }
|
||||||
|
val repo = CardRepository(http, cm, "http://fake")
|
||||||
|
val cards = repo.fetchCatalog()
|
||||||
|
assertTrue(cards.any { it.id == "weather" })
|
||||||
|
assertTrue(cards.any { it.id == "time" })
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchCatalog_failure_withoutCache_returnsBuiltinTime() {
|
||||||
|
val dir = File(System.getProperty("java.io.tmpdir"), "c3")
|
||||||
|
val http = object : HttpClient { override fun get(url: String): String? = null }
|
||||||
|
val repo = CardRepository(http, CacheManager(dir), "http://fake")
|
||||||
|
val cards = repo.fetchCatalog()
|
||||||
|
assertEquals(1, cards.size)
|
||||||
|
assertEquals("time", cards[0].id)
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ package top.yeij.hearth.webview
|
|||||||
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import top.yeij.hearth.card.CardRepository
|
||||||
|
import top.yeij.hearth.cache.CacheManager
|
||||||
|
import top.yeij.hearth.webapp.HttpClient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class JsBridgeTest {
|
class JsBridgeTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -11,4 +15,27 @@ class JsBridgeTest {
|
|||||||
assertTrue(json.contains("\"widthPx\":800"))
|
assertTrue(json.contains("\"widthPx\":800"))
|
||||||
assertTrue(json.contains("\"darkMode\":true"))
|
assertTrue(json.contains("\"darkMode\":true"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchCards_withoutRepository_returnsEmptyArray() {
|
||||||
|
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
|
||||||
|
assertTrue(bridge.fetchCards() == "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchCards_withRepository_returnsBuiltinTimeCard() {
|
||||||
|
val dir = File(System.getProperty("java.io.tmpdir"), "jb-cards")
|
||||||
|
val repo = CardRepository(
|
||||||
|
object : HttpClient { override fun get(url: String): String? = null },
|
||||||
|
CacheManager(dir),
|
||||||
|
"http://fake",
|
||||||
|
)
|
||||||
|
val bridge = JsBridge(
|
||||||
|
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
|
||||||
|
cardRepository = repo,
|
||||||
|
)
|
||||||
|
val json = bridge.fetchCards()
|
||||||
|
assertTrue(json.contains("\"id\":\"time\""))
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// cards.js 纯函数测试:三栏降级布局(time 卡恒第一列、disabled 卡跳过、priority 升序分配)
|
||||||
|
// cards.js pure-function tests: three-column fallback layout
|
||||||
|
// (time always first column, disabled cards skipped, priority ascending)
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { layout } = require('../app/src/main/assets/h5/js/cards/cards.js');
|
||||||
|
|
||||||
|
test('time card always in first column', () => {
|
||||||
|
const cols = layout([
|
||||||
|
{ id: 'time', priority: 0 },
|
||||||
|
{ id: 'media', priority: 1 },
|
||||||
|
{ id: 'weather', priority: 2 },
|
||||||
|
{ id: 'calendar', priority: 3 },
|
||||||
|
]);
|
||||||
|
assert.strictEqual(cols[0][0], 'time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled card skipped', () => {
|
||||||
|
const cols = layout([
|
||||||
|
{ id: 'time', priority: 0 },
|
||||||
|
{ id: 'media', priority: 1, enabled: false },
|
||||||
|
{ id: 'weather', priority: 2 },
|
||||||
|
]);
|
||||||
|
assert.strictEqual(cols[1][0], 'weather');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('priority ascending fills columns round-robin', () => {
|
||||||
|
const cols = layout([
|
||||||
|
{ id: 'c', priority: 3 },
|
||||||
|
{ id: 'a', priority: 1 },
|
||||||
|
{ id: 'b', priority: 2 },
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(cols, [['a'], ['b'], ['c']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input yields three empty columns', () => {
|
||||||
|
assert.deepStrictEqual(layout([]), [[], [], []]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user