v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据
从「演示数据壳子」走到「真的能连家里的服务器用」。
登录(用户 2026-10-17「完整的登录功能」)
· 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
· 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
· FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
设备名取机型(marketName),并上报 platform / app_version
设备会话(v57,用户 2026-10-17)
· 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
· 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
(离线超 24h → 只锁界面、不清数据、联网自动恢复)——
混成一种就会违背用户要的"一直联网就不用每天输密码"
· 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
让登录/校验类请求跳过,避免"两个地方同时跳登录页")
· 退出登录:先清本地令牌(主防线),再尽力通知服务端
· 设置页显示版本号与「这台设备」
数据层
· FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
· 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
· 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致
验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
|
||||
*
|
||||
* 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。
|
||||
*
|
||||
* 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼:
|
||||
* · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻),
|
||||
* 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`,
|
||||
* 比直接算坐标画线**难得多**、也更难调;
|
||||
* · 连线要**画在节点下面**(否则线会压在头像上)——
|
||||
* Canvas 一次画完,天然有先后顺序。
|
||||
* ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。
|
||||
* 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。
|
||||
*
|
||||
* 布局规则(与"从上往下读家谱"的直觉一致):
|
||||
* · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上);
|
||||
* · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边;
|
||||
* · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。
|
||||
* ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**,
|
||||
* 力导向会把人随机甩开,反而看不懂谁是长辈。
|
||||
*/
|
||||
|
||||
import { FbColors, FbMetric } from '../common/Theme';
|
||||
import { FbKinPerson } from '../model/Types';
|
||||
|
||||
/** 一个节点在画布上的位置 */
|
||||
interface KinNode {
|
||||
p: FbKinPerson;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
@ComponentV2
|
||||
export struct FbKinGraphView {
|
||||
/** 家谱里的人 */
|
||||
@Param people: FbKinPerson[] = [];
|
||||
/** 我(居中显示、加粗) */
|
||||
@Param meId: number = 0;
|
||||
/** 点某个节点(弹详情) */
|
||||
@Event onPick: (id: number) => void = (id: number): void => {
|
||||
};
|
||||
|
||||
/** 画布上下文(必须在组件里创建,不能跨组件共享) */
|
||||
private settings: RenderingContextSettings = new RenderingContextSettings(true);
|
||||
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
|
||||
/** 画布尺寸(onAreaChange 拿到) */
|
||||
@Local cw: number = 0;
|
||||
@Local ch: number = 0;
|
||||
/** 算好的节点位置(画与点击都用它) */
|
||||
private nodes: KinNode[] = [];
|
||||
|
||||
/** 节点半径(vp) */
|
||||
private readonly R: number = 22;
|
||||
|
||||
build() {
|
||||
Canvas(this.ctx)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(FbColors.paper)
|
||||
// ⚠️ `onReady` 只在**首帧**触发一次;后续尺寸变化要靠 `onAreaChange`。
|
||||
// 只用 onReady 的话,切到家谱页(宽高这时才定下来)会画不出东西。
|
||||
.onReady((): void => {
|
||||
this.redraw();
|
||||
})
|
||||
.onAreaChange((_old: Area, now: Area): void => {
|
||||
this.cw = Number(now.width);
|
||||
this.ch = Number(now.height);
|
||||
this.redraw();
|
||||
})
|
||||
// 🔴 `Canvas` **没有**"点到了哪个图形"的概念 —— 命中检测要自己算。
|
||||
// 画的位置由 `layout()` 定,点击时拿同一份 `this.nodes` 比距离即可。
|
||||
.onClick((ev: ClickEvent): void => {
|
||||
const x: number = ev.x;
|
||||
const y: number = ev.y;
|
||||
// 命中半径放宽一点(手指比鼠标粗):用 1.35×R 更"跟手"
|
||||
const hit: number = this.R * 1.35;
|
||||
for (const n of this.nodes) {
|
||||
const dx: number = x - n.x;
|
||||
const dy: number = y - n.y;
|
||||
if (dx * dx + dy * dy <= hit * hit) {
|
||||
this.onPick(n.p.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重画整张图(**先算位置、再画线、最后画节点**)。
|
||||
*
|
||||
* ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。
|
||||
* Canvas 没有 z-index,只能靠绘制顺序。
|
||||
*/
|
||||
private redraw(): void {
|
||||
if (this.cw <= 0 || this.ch <= 0 || this.people.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.layout();
|
||||
this.drawEdges();
|
||||
this.drawNodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 算节点位置(按代数分层)。
|
||||
*
|
||||
* ⚠️ 结果存进 `this.nodes` —— 点击命中检测要用**同一份坐标**,
|
||||
* 重算一遍容易出现"看到的和点到的不一样"。
|
||||
*/
|
||||
private layout(): void {
|
||||
// ---- ① 按代数分组 ----
|
||||
const byGen: Map<number, FbKinPerson[]> = new Map<number, FbKinPerson[]>();
|
||||
for (const p of this.people) {
|
||||
const cur: FbKinPerson[] | undefined = byGen.get(p.gen);
|
||||
if (cur !== undefined) {
|
||||
cur.push(p);
|
||||
} else {
|
||||
byGen.set(p.gen, [p]);
|
||||
}
|
||||
}
|
||||
const gens: number[] = [];
|
||||
byGen.forEach((_v: FbKinPerson[], k: number): void => {
|
||||
gens.push(k);
|
||||
});
|
||||
// ⚠️ 必须按**数值**排(Map 的键直接 sort 会按字符串:-1,-2,0,1)
|
||||
gens.sort((a: number, b: number): number => a - b);
|
||||
|
||||
// ---- ② 纵向:每代一条水平带,均分高度 ----
|
||||
const padTop: number = 46; // 给顶部代数标签留位置
|
||||
const padBottom: number = 26;
|
||||
const bandH: number = (this.ch - padTop - padBottom) / Math.max(1, gens.length);
|
||||
|
||||
const nodes: KinNode[] = [];
|
||||
for (let gi: number = 0; gi < gens.length; gi++) {
|
||||
const g: number = gens[gi];
|
||||
const ps: FbKinPerson[] = byGen.get(g) !== undefined ? (byGen.get(g) as FbKinPerson[]) : [];
|
||||
const y: number = padTop + bandH * gi + bandH / 2;
|
||||
|
||||
// ---- ③ 横向:同代均分 ----
|
||||
// ⚠️ 左右各留 `R + 8`,否则第一个/最后一个节点会贴边、头像被切掉。
|
||||
const padX: number = this.R + 10;
|
||||
const usable: number = this.cw - padX * 2;
|
||||
const step: number = ps.length > 1 ? usable / (ps.length - 1) : 0;
|
||||
// ⚠️ 只有一个人时放**正中间**(step=0 会让它贴在左边)
|
||||
const startX: number = ps.length > 1 ? padX : this.cw / 2;
|
||||
|
||||
for (let i: number = 0; i < ps.length; i++) {
|
||||
nodes.push({ p: ps[i], x: startX + step * i, y: y });
|
||||
}
|
||||
}
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
/** 按 id 找节点 */
|
||||
private nodeOf(id: number): KinNode | undefined {
|
||||
for (const n of this.nodes) {
|
||||
if (n.p.id === id) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 画连线 */
|
||||
private drawEdges(): void {
|
||||
const c: CanvasRenderingContext2D = this.ctx;
|
||||
c.lineWidth = 1.6;
|
||||
c.strokeStyle = '#B9A896';
|
||||
|
||||
for (const n of this.nodes) {
|
||||
// ---- 父母 → 子女(折线:下-横-下)----
|
||||
const parents: number[] = n.p.parents;
|
||||
for (const pid of parents) {
|
||||
const par: KinNode | undefined = this.nodeOf(pid);
|
||||
if (par === undefined) {
|
||||
continue;
|
||||
}
|
||||
// 折线中点取两者的横向中点,纵向上 1/3 处拐
|
||||
const midY: number = par.y + (n.y - par.y) * 0.55;
|
||||
c.beginPath();
|
||||
c.moveTo(par.x, par.y + this.R);
|
||||
c.lineTo(par.x, midY);
|
||||
c.lineTo(n.x, midY);
|
||||
c.lineTo(n.x, n.y - this.R);
|
||||
c.stroke();
|
||||
}
|
||||
|
||||
// ---- 夫妻(水平短线,**画粗一点**,与血缘线区分)----
|
||||
for (const sid of n.p.spouses) {
|
||||
// 只画一次(按 id 大小去重,否则同一对会画两条)
|
||||
if (sid < n.p.id) {
|
||||
continue;
|
||||
}
|
||||
const sp: KinNode | undefined = this.nodeOf(sid);
|
||||
if (sp === undefined) {
|
||||
continue;
|
||||
}
|
||||
c.save();
|
||||
c.lineWidth = 3;
|
||||
c.strokeStyle = '#C97B6A';
|
||||
c.beginPath();
|
||||
c.moveTo(n.x + this.R, n.y);
|
||||
c.lineTo(sp.x - this.R, sp.y);
|
||||
c.stroke();
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 画节点(圆 + 首字 + 称谓) */
|
||||
private drawNodes(): void {
|
||||
const c: CanvasRenderingContext2D = this.ctx;
|
||||
for (const n of this.nodes) {
|
||||
const isMe: boolean = n.p.id === this.meId;
|
||||
|
||||
// 圆底(用头像色)
|
||||
c.beginPath();
|
||||
c.arc(n.x, n.y, this.R, 0, Math.PI * 2);
|
||||
c.fillStyle = n.p.avatarColor !== '' ? n.p.avatarColor : '#8A6B4B';
|
||||
c.fill();
|
||||
// 「我」加一圈描边,一眼认出中心
|
||||
if (isMe) {
|
||||
c.lineWidth = 2.5;
|
||||
c.strokeStyle = '#A84A28';
|
||||
c.stroke();
|
||||
}
|
||||
|
||||
// 名字首字
|
||||
c.font = '16px sans-serif';
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.fillStyle = '#FFFFFF';
|
||||
const nm: string = n.p.shown !== '' ? n.p.shown : n.p.name;
|
||||
c.fillText(nm.length > 0 ? nm.substring(0, 1) : '?', n.x, n.y);
|
||||
|
||||
// 名字(圆下方)
|
||||
c.font = isMe ? 'bold 12px sans-serif' : '12px sans-serif';
|
||||
c.fillStyle = isMe ? '#A84A28' : '#2C231D';
|
||||
// ⚠️ 名字可能很长("青岛西海岸电影城"那种)—— 截断,别糊成一片
|
||||
const label: string = nm.length > 5 ? nm.substring(0, 5) + '…' : nm;
|
||||
c.fillText(label, n.x, n.y + this.R + 11);
|
||||
|
||||
// 称谓(名字下面,小字灰色)
|
||||
if (n.p.label !== '' && n.p.label !== '我') {
|
||||
c.font = '10px sans-serif';
|
||||
c.fillStyle = '#8A7A6A';
|
||||
c.fillText(n.p.label, n.x, n.y + this.R + 24);
|
||||
}
|
||||
|
||||
// ---- 代数标签(每层左边)----
|
||||
// ⚠️ 只在每层**第一个**节点旁写,避免重复画
|
||||
const first: KinNode | undefined = this.firstOfGen(n.p.gen);
|
||||
if (first !== undefined && first.p.id === n.p.id) {
|
||||
c.font = '11px sans-serif';
|
||||
c.textAlign = 'left';
|
||||
c.fillStyle = '#8A7A6A';
|
||||
c.fillText(this.genLabel(n.p.gen), 8, n.y - this.R - 8);
|
||||
c.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 某代里排在最前的那个节点(用来放代数标签) */
|
||||
private firstOfGen(gen: number): KinNode | undefined {
|
||||
for (const n of this.nodes) {
|
||||
if (n.p.gen === gen) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 代数 → 层名(与家谱页的分层标题同口径) */
|
||||
private genLabel(g: number): string {
|
||||
if (g === 0) {
|
||||
return '我这一辈';
|
||||
}
|
||||
if (g === -1) {
|
||||
return '父母辈';
|
||||
}
|
||||
if (g <= -2) {
|
||||
return '祖辈';
|
||||
}
|
||||
if (g === 1) {
|
||||
return '子女辈';
|
||||
}
|
||||
return '孙辈';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user