feat(web): v2 看板前端接入真实数据接口

前端样式完全沿用 2.0 设计稿,仅替换数据来源。

- templates/index.html 改用设计稿 DOM 结构与类名,引用 styles.css + weapi.js + app.js
- static/css/styles.css 引入 2.0 设计稿样式
- static/js/weapi.js 新增,封装 /api/v2/* 调用为全局 WeApi
- static/js/app.js 重写:保留设计稿全部渲染函数与 CSS 类名,
  数据源由 demo-data.js 静态假数据改为真实接口,
  补充异步加载器、加载/错误状态机与重试按钮
- static/js/demo-data.js 保留为设计稿原始假数据对照
This commit is contained in:
admin committed 2026-09-11 18:21:06 +08:00
1 parent a114f3f407
commit 8af75454a4
5 files changed
+804 -1441

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
/* ==========================================================================
阅间 · 数据层(v2)
--------------------------------------------------------------------------
把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。
设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据,
这里替换为真实接口调用,字段名与设计稿保持一致。
========================================================================== */
(function (global) {
'use strict';
const BASE = '/api/v2';
async function request(path, params) {
const url = new URL(BASE + path, global.location.origin);
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
});
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
if (!response.ok) {
const message = (payload && (payload.error || payload.detail)) || `请求失败(${response.status})`;
const err = new Error(message);
err.status = response.status;
err.payload = payload;
throw err;
}
return payload;
}
/* ------------------------------ 工具函数 ------------------------------ */
function durationText(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const hours = Math.floor(total / 3600);
const minutes = Math.round((total % 3600) / 60);
return hours ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
}
function percentText(value) {
const num = Number(value) || 0;
return `${num}%`;
}
/* ------------------------------ 接口封装 ------------------------------ */
const WeApi = {
durationText,
percentText,
status() {
return request('/status');
},
/**
* 书架数据
* @param {string} tab all|reading|done|wish
* @param {{keyword?:string, limit?:number, offset?:number, progress?:boolean}} options
*/
shelf(tab, options) {
const opts = options || {};
return request('/shelf', {
tab: tab || 'all',
keyword: opts.keyword || '',
limit: opts.limit,
offset: opts.offset,
progress: opts.progress ? 1 : 0,
});
},
dashboard(period) {
return request('/dashboard', { period: period || 'week' });
},
notebooks() {
return request('/notebooks');
},
/** bookId 传 'all' 时聚合笔记最多的前 limit 本 */
notes(bookId, limit) {
return request('/notes', { book_id: bookId, limit: limit });
},
book(bookId) {
return request('/book/' + encodeURIComponent(bookId));
},
reviews(bookId, type, count) {
return request(`/book/${encodeURIComponent(bookId)}/reviews`, { type: type || 0, count: count || 10 });
},
highlights(bookId, chapterUid) {
return request(`/book/${encodeURIComponent(bookId)}/highlights`, { chapter_uid: chapterUid || 0 });
},
underlineThoughts(bookId, chapterUid, range) {
return request(`/book/${encodeURIComponent(bookId)}/underline-thoughts`, {
chapter_uid: chapterUid,
range: range,
});
},
thought(reviewId) {
return request('/thought/' + encodeURIComponent(reviewId));
},
archives() {
return request('/archives');
},
recommend(maxIdx) {
return request('/recommend', { maxIdx: maxIdx || 0 });
},
search(keyword, scope) {
return request('/search', { keyword: keyword, scope: scope || 'books' });
},
};
global.WeApi = WeApi;
})(window);