v76 旅程:信息铺得开 + 每段行程各自成卡 + 照片可点开 / 视频可播
用户 2026-10-22(四条毛病,同一个根因:信息往一行里挤):
「那个城市……如果一行显不下的话,应该显示在下边一行,把所有的城市全列出来。
就跟那个人员一样,你人员特别多的时候,一行显示不下,就直接往下放就行了。」
「点进详情之后……现在人员显示不下的话,你直接都放在右边,都溢出到这个块外面去了……
应该直接顺着往下铺就行了,所以它长一点无所谓。」
「实际行程它应该也是一段一段的,不要把它整个实际行程放在一块,
就是每一个行程都是一个一个块。然后里面的照片要可以点击查看,视频要能够播放。」
根因不是"忘了加滚动",而是 **`Row` 在 ArkUI 里不换行**:
塞不下的子节点会被压缩、或直接横向溢出父容器。四处都改成
`Flex({ wrap: FlexWrap.Wrap })`。
- `Types.ets`:`FbTripItem` 新增 `cities: string[]`(整串只能截断);
`FbTripNodePhoto` 新增 `mime` / `size`
- `FbParse.parseTrips` 新增 `cityListOf()`:优先用服务端 `cities` 数组,
只给整串时按 `·` 拆回来("显示不全"不再从别的入口漏进来)
- 列表卡:城市从「📍 一行长串 + 省略号」改成一颗颗 chip,日期单独占一行
- 详情页:城市 / 参与的人 / 每段的参与人 全部改 `Flex wrap`
(实测「黑龙江」那趟 5 个人自动排成 2 行,不再溢出卡片)
- **每段行程各自一张卡**:组头退成章节标题(不带卡片底),
`nodeCard` 自己带白底 + 圆角 + 左侧阶段色条(取 `n.stage`)
- 照片:点缩略图开全屏查看器(`PanGesture` 左右滑、上一张/下一张、关闭)
- 视频:按 `mime` 前缀认出视频(`is_image` 只有两态),缩略块 + ▶ 角标,
点开用系统 `Video` 播放(`media.php` 支持 Range,可拖进度条)
🔴 真机抓到的布局 bug:`Stack` 子节点上的 `.align(Alignment.Bottom/TopEnd)`
**完全无效** —— 关闭按钮和翻页条双双掉到屏幕正中。改成铺满的 Column +
`layoutWeight(1)` 撑开,并给控制层 `hitTestBehavior(Transparent)`
保证"点背景关闭"仍穿透。
验证:真机 33/0(含"城市 chip 数 == 统计里个城市"、"相邻行程卡有实际间隙"、
"点视频块起播放器且无放不出来");守卫 953/0;反向验证 181/0
(其中一条注入暴露了我新写断言的假绿,已改成结构化顺序判据)。
This commit is contained in:
@@ -271,7 +271,7 @@ export function parseTrips(json: string): FbTripItem[] {
|
|||||||
const rows: RawTrip[] = asArray<RawTrip>(json);
|
const rows: RawTrip[] = asArray<RawTrip>(json);
|
||||||
const out: FbTripItem[] = [];
|
const out: FbTripItem[] = [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const cities: string[] = arr<string>(r.cities);
|
const cities: string[] = cityListOf(r);
|
||||||
const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at));
|
const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at));
|
||||||
const it: FbTripItem = {
|
const it: FbTripItem = {
|
||||||
id: n(r.id),
|
id: n(r.id),
|
||||||
@@ -285,6 +285,7 @@ export function parseTrips(json: string): FbTripItem[] {
|
|||||||
⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */
|
⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */
|
||||||
coverKey: s(r.cover_key),
|
coverKey: s(r.cover_key),
|
||||||
city: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
|
city: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
|
||||||
|
cities: cities,
|
||||||
summary: s(r.summary),
|
summary: s(r.summary),
|
||||||
attendeeCount: n(r.attendee_count),
|
attendeeCount: n(r.attendee_count),
|
||||||
};
|
};
|
||||||
@@ -293,6 +294,43 @@ export function parseTrips(json: string): FbTripItem[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 城市的**列表**(卡片上一颗颗 chip 换行用)。
|
||||||
|
*
|
||||||
|
* 🔴 v76(用户 2026-10-22):「如果一行显不下的话,应该显示在下边一行,
|
||||||
|
* 把所有的城市全列出来。」
|
||||||
|
* ⇒ 端上必须要数组,不能只有一个 `city` 整串 —— 整串只能截断。
|
||||||
|
*
|
||||||
|
* ⚠️ 服务端 `Trip::peek()` 同时给 `cities`(数组,`Trip::cities()` 的原值)
|
||||||
|
* 与 `city`(`implode(' · ', $cities)`)。**优先用数组**;
|
||||||
|
* 万一将来某条接口只给整串(老版本 / 单条接口),按同一个分隔符拆回来,
|
||||||
|
* 这样"显示不全"这个毛病不会再从别的入口漏进来。
|
||||||
|
*/
|
||||||
|
function cityListOf(r: RawTrip): string[] {
|
||||||
|
const arr0: string[] = arr<string>(r.cities);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const c of arr0) {
|
||||||
|
const t: string = c.trim();
|
||||||
|
if (t !== '' && out.indexOf(t) < 0) {
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.length > 0) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const whole: string = s(r.city);
|
||||||
|
if (whole === '') {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const p of whole.split('·')) {
|
||||||
|
const t: string = p.trim();
|
||||||
|
if (t !== '' && out.indexOf(t) < 0) {
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** 纪念日 → FbAnniversaryItem */
|
/** 纪念日 → FbAnniversaryItem */
|
||||||
export function parseDays(json: string): FbAnniversaryItem[] {
|
export function parseDays(json: string): FbAnniversaryItem[] {
|
||||||
const rows: RawDay[] = asArray<RawDay>(json);
|
const rows: RawDay[] = asArray<RawDay>(json);
|
||||||
|
|||||||
@@ -405,7 +405,12 @@ function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
|
|||||||
id: numOf(a, 'id'),
|
id: numOf(a, 'id'),
|
||||||
key: key,
|
key: key,
|
||||||
name: strOf(a, 'name'),
|
name: strOf(a, 'name'),
|
||||||
isImage: numOf(a, 'is_image') === 1
|
isImage: numOf(a, 'is_image') === 1,
|
||||||
|
/* 🔴 v76:`mime` / `size` 是「照片可点开、视频能播放」的前提。
|
||||||
|
⚠️ 服务端 `Attachment::list()` 是 `SELECT *`,这两列本来就有;
|
||||||
|
以前端上不取,所以只能靠 `is_image` 二选一(视频 vs 图片分不开)。 */
|
||||||
|
mime: strOf(a, 'mime'),
|
||||||
|
size: numOf(a, 'size')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -114,22 +114,22 @@ export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', '
|
|||||||
export const FB_DEMO_TRIPS: FbTripItem[] = [
|
export const FB_DEMO_TRIPS: FbTripItem[] = [
|
||||||
{
|
{
|
||||||
id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03',
|
id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03',
|
||||||
nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', summary: '海边住了两晚,早上赶海、下午爬山。',
|
nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', cities: ['青岛'], summary: '海边住了两晚,早上赶海、下午爬山。',
|
||||||
attendeeCount: 3
|
attendeeCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16',
|
id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16',
|
||||||
nodeCount: 6, doneCount: 6, coverKey: '', city: '烟台', summary: '带孩子去看海,吃了顿海鲜。',
|
nodeCount: 6, doneCount: 6, coverKey: '', city: '烟台', cities: ['烟台'], summary: '带孩子去看海,吃了顿海鲜。',
|
||||||
attendeeCount: 4
|
attendeeCount: 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04',
|
id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04',
|
||||||
nodeCount: 12, doneCount: 12, coverKey: '', city: '西安', summary: '兵马俑、城墙、回民街。',
|
nodeCount: 12, doneCount: 12, coverKey: '', city: '西安', cities: ['西安'], summary: '兵马俑、城墙、回民街。',
|
||||||
attendeeCount: 3
|
attendeeCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24',
|
id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24',
|
||||||
nodeCount: 0, doneCount: 0, coverKey: '', city: '哈尔滨', summary: '',
|
nodeCount: 0, doneCount: 0, coverKey: '', city: '哈尔滨', cities: ['哈尔滨'], summary: '',
|
||||||
attendeeCount: 0
|
attendeeCount: 0
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -403,6 +403,15 @@ export interface FbTripItem {
|
|||||||
* 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。
|
* 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。
|
||||||
*/
|
*/
|
||||||
city: string;
|
city: string;
|
||||||
|
/**
|
||||||
|
* 城市列表(已去重,服务端 `cities` 原样)。
|
||||||
|
*
|
||||||
|
* 🔴 v76(用户 2026-10-22):「那个城市……如果一行显不下的话,应该显示在下边一行,
|
||||||
|
* 把所有的城市全列出来。就跟那个人员一样。」
|
||||||
|
* ⇒ 卡片上不再拼成一个长串(那样只能截断),改成**一颗颗 chip 自动换行**。
|
||||||
|
* ⚠️ `city` 保留:有的地方(如按城市搜索)还是想要一个整串。
|
||||||
|
*/
|
||||||
|
cities: string[];
|
||||||
/** 简介(网页版列表卡截 80 字显示) */
|
/** 简介(网页版列表卡截 80 字显示) */
|
||||||
summary: string;
|
summary: string;
|
||||||
/** 同行人数(所有行程参与人的合集,与网页版一致) */
|
/** 同行人数(所有行程参与人的合集,与网页版一致) */
|
||||||
@@ -482,8 +491,19 @@ export interface FbTripNodePhoto {
|
|||||||
key: string;
|
key: string;
|
||||||
/** 原始文件名(非图片才有意义) */
|
/** 原始文件名(非图片才有意义) */
|
||||||
name: string;
|
name: string;
|
||||||
/** 1 = 图片,可直接铺缩略图 */
|
/** 1 = 图片,可直接铺缩略图(服务端 `is_image`,按内容嗅探得到) */
|
||||||
isImage: boolean;
|
isImage: boolean;
|
||||||
|
/**
|
||||||
|
* 内容嗅探出来的 MIME(服务端 `attachments.mime`)。
|
||||||
|
*
|
||||||
|
* 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看,视频要能够播放。」
|
||||||
|
* ⇒ 端上第一次需要区分「图片 / 视频 / 其它」,而 `is_image` 只有两态。
|
||||||
|
* ⚠️ 判视频一律用 `mime` 前缀 + 扩展名兜底,**不看 `is_image === false`**
|
||||||
|
* —— 那份 pdf/word 也不是图片,但不是视频。
|
||||||
|
*/
|
||||||
|
mime: string;
|
||||||
|
/** 字节数(0 = 服务端没给) */
|
||||||
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */
|
/** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */
|
||||||
|
|||||||
@@ -216,6 +216,36 @@ function fbStageTone(stage: string): FbStageTone {
|
|||||||
return new FbStageTone('blue', '#2F6FB0');
|
return new FbStageTone('blue', '#2F6FB0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件到底是什么 —— `'image'` / `'video'` / `'file'`。
|
||||||
|
*
|
||||||
|
* 🔴 v76(用户 2026-10-22):「照片要可以点击查看,视频要能够播放。」
|
||||||
|
* ⇒ 端上必须能把视频从附件里挑出来,而 `is_image` 只有两态(true / false),
|
||||||
|
* "false" 里混着视频、PDF、Word —— 只按它判会把 PDF 当视频去播。
|
||||||
|
*
|
||||||
|
* ⚠️ 判据口径照服务端 `Attachment::kind()`:
|
||||||
|
* ① 先看 `is_image`(服务端按**内容嗅探**得出,比扩展名可靠);
|
||||||
|
* ② 再看 `mime` 前缀 `video/`;
|
||||||
|
* ③ `mime` 为空(老数据)时才退到扩展名 —— 不要一上来就信扩展名,
|
||||||
|
* 用户把 `a.mp4` 改成 `a.jpg` 也是有的。
|
||||||
|
*/
|
||||||
|
function fbPhotoKind(a: FbTripNodePhoto): string {
|
||||||
|
if (a.isImage) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
const mime: string = a.mime.toLowerCase();
|
||||||
|
if (mime.indexOf('video/') === 0) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
const dot: number = a.key.lastIndexOf('.');
|
||||||
|
const ext: string = dot >= 0 ? a.key.substring(dot + 1).toLowerCase() : '';
|
||||||
|
if (mime === '' &&
|
||||||
|
(ext === 'mp4' || ext === 'm4v' || ext === 'webm' || ext === 'mov' || ext === 'mkv' || ext === 'ts')) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
@ComponentV2
|
@ComponentV2
|
||||||
export struct FbTripDetailPage {
|
export struct FbTripDetailPage {
|
||||||
|
|
||||||
@@ -260,6 +290,18 @@ export struct FbTripDetailPage {
|
|||||||
@Local delNodeId: number = 0;
|
@Local delNodeId: number = 0;
|
||||||
@Local deleting: boolean = false;
|
@Local deleting: boolean = false;
|
||||||
|
|
||||||
|
/* ---- v76:照片查看器 / 视频播放器 ----
|
||||||
|
🔴 用户 2026-10-22:「里面的照片要可以点击查看,视频要能够播放。」
|
||||||
|
⚠️ 两个都是**全屏覆盖层**,所以用"非空即开着"的语义
|
||||||
|
(`viewerKeys` 空 / `videoKey` 空 = 关着),不用额外的布尔开关 ——
|
||||||
|
少一个开关就少一处"关了浮层忘了复位"的漏洞。 */
|
||||||
|
/** 查看器里的图片键列表(空 = 关着) */
|
||||||
|
@Local viewerKeys: string[] = [];
|
||||||
|
@Local viewerIndex: number = 0;
|
||||||
|
/** 正在播放的视频键(空 = 关着) */
|
||||||
|
@Local videoKey: string = '';
|
||||||
|
@Local videoName: string = '';
|
||||||
|
|
||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
/* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载,
|
/* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载,
|
||||||
但 `FB_BACK` 的语义是"后登记的先被问",同名会让注销互相踩。 */
|
但 `FB_BACK` 的语义是"后登记的先被问",同名会让注销互相踩。 */
|
||||||
@@ -279,6 +321,18 @@ export struct FbTripDetailPage {
|
|||||||
* 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。
|
* 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。
|
||||||
*/
|
*/
|
||||||
private backOnce(): boolean {
|
private backOnce(): boolean {
|
||||||
|
/* 🔴 v76:查看器/播放器是**最上面那一层**,必须第一个关,
|
||||||
|
否则按返回会直接退出详情页,而用户以为只是"关掉大图"。 */
|
||||||
|
if (this.videoKey !== '') {
|
||||||
|
this.videoKey = '';
|
||||||
|
this.videoName = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.viewerKeys.length > 0) {
|
||||||
|
this.viewerKeys = [];
|
||||||
|
this.viewerIndex = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (this.delNodeId > 0) {
|
if (this.delNodeId > 0) {
|
||||||
this.delNodeId = 0;
|
this.delNodeId = 0;
|
||||||
return true;
|
return true;
|
||||||
@@ -342,6 +396,46 @@ export struct FbTripDetailPage {
|
|||||||
fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0;
|
fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v76:全屏查看器 / 播放器也要让底部悬浮栏让位(否则大图底下漏出一条悬浮栏)。
|
||||||
|
⚠️ 同样是"一个 @Monitor 盯一个变量",两个分开写 —— 它们互斥,直接赋值即可。 */
|
||||||
|
@Monitor('viewerKeys')
|
||||||
|
onViewerChanged(): void {
|
||||||
|
fbAppState().sheetCount = this.viewerKeys.length > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Monitor('videoKey')
|
||||||
|
onVideoChanged(): void {
|
||||||
|
fbAppState().sheetCount = this.videoKey !== '' ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开某一组图片的大图查看器(从第几张开始) */
|
||||||
|
private openViewer(keys: string[], at: number): void {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.viewerKeys = keys;
|
||||||
|
// ⚠️ 必须夹到 [0, n):点第 3 张时若列表刚被删短,直接赋会越界
|
||||||
|
this.viewerIndex = at < 0 ? 0 : (at >= keys.length ? keys.length - 1 : at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看器翻页。
|
||||||
|
* ⚠️ 取模归一到 [0, n):直接加减会让 index 越界或变负数(相册里踩过)。
|
||||||
|
*/
|
||||||
|
private viewerStep(delta: number): void {
|
||||||
|
const n: number = this.viewerKeys.length;
|
||||||
|
if (n === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开一个视频(`key` 是裸 key,播放地址现拼,不带缓存) */
|
||||||
|
private openVideo(a: FbTripNodePhoto): void {
|
||||||
|
this.videoKey = a.key;
|
||||||
|
this.videoName = a.name !== '' ? a.name : '视频';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三个接口并发取:本体 / 统计 / 节点。
|
* 三个接口并发取:本体 / 统计 / 节点。
|
||||||
*
|
*
|
||||||
@@ -556,6 +650,16 @@ export struct FbTripDetailPage {
|
|||||||
if (this.delNodeId > 0) {
|
if (this.delNodeId > 0) {
|
||||||
this.delNodeConfirm()
|
this.delNodeConfirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 🔴 v76:照片查看器 / 视频播放器放在**最后一个位置**。
|
||||||
|
在 `Stack` 里"后写的盖在上面",所以它压住所有浮层 —— 用户点了照片
|
||||||
|
就该直接看到大图,而不是"点开又冒出一个浮层把它盖住"。 */
|
||||||
|
if (this.viewerKeys.length > 0) {
|
||||||
|
this.photoViewer()
|
||||||
|
}
|
||||||
|
if (this.videoKey !== '') {
|
||||||
|
this.videoPlayer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@@ -695,8 +799,13 @@ export struct FbTripDetailPage {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
|
/* 城市 chip 墙 —— **一行放不下就换行,全部列出来**。
|
||||||
|
🔴 v76(用户 2026-10-22):「如果一行显不下的话,应该显示在下边一行,
|
||||||
|
把所有的城市全列出来。就跟那个人员一样。」
|
||||||
|
⚠️ 原来是 `Row({space:6})`:`Row` **不会换行**,城市多了会被压扁
|
||||||
|
(每个 chip 缩到只剩一两个字)或者直接溢出卡片。 */
|
||||||
if (this.detail !== null && this.detail.cities.length > 0) {
|
if (this.detail !== null && this.detail.cities.length > 0) {
|
||||||
Row({ space: 6 }) {
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
ForEach(this.detail.cities, (c: string) => {
|
ForEach(this.detail.cities, (c: string) => {
|
||||||
Text(c)
|
Text(c)
|
||||||
.fontSize(FbMetric.fontTiny)
|
.fontSize(FbMetric.fontTiny)
|
||||||
@@ -704,6 +813,7 @@ export struct FbTripDetailPage {
|
|||||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||||
.backgroundColor(fbAlpha(this.accent, 0.1))
|
.backgroundColor(fbAlpha(this.accent, 0.1))
|
||||||
.borderRadius(FbMetric.radiusPill)
|
.borderRadius(FbMetric.radiusPill)
|
||||||
|
.margin({ right: 6, bottom: 6 })
|
||||||
}, (c: string): string => 'city_' + c)
|
}, (c: string): string => 'city_' + c)
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
@@ -719,7 +829,14 @@ export struct FbTripDetailPage {
|
|||||||
.fontSize(FbMetric.fontTiny)
|
.fontSize(FbMetric.fontTiny)
|
||||||
.fontColor(FbColors.faint)
|
.fontColor(FbColors.faint)
|
||||||
.width('100%')
|
.width('100%')
|
||||||
Row({ space: 6 }) {
|
/* 🔴 v76(用户 2026-10-22):「把那个人员……现在人员显示不下的话,
|
||||||
|
你直接都放在右边,都溢出到这个块外面去了,这都不好看。
|
||||||
|
然后那个应该直接顺着往下铺就行了,所以它长一点无所谓。」
|
||||||
|
⚠️ 原来是 `Row({space:6})` —— `Row` **不换行**,人一多就横向溢出卡片。
|
||||||
|
改成 `Flex({wrap})`:一行放不下自动换到下一行,卡片跟着变高。
|
||||||
|
⚠️ 原来那个 `maxLines(1)` 也保留(名字太长自己截断,别把 chip 撑爆),
|
||||||
|
但整条 chip 不再会被挤到卡片外面。 */
|
||||||
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
ForEach(this.detail.attendees, (p: FbTripNodePerson) => {
|
ForEach(this.detail.attendees, (p: FbTripNodePerson) => {
|
||||||
Row({ space: 4 }) {
|
Row({ space: 4 }) {
|
||||||
FbAvatar({
|
FbAvatar({
|
||||||
@@ -733,11 +850,14 @@ export struct FbTripDetailPage {
|
|||||||
.fontSize(FbMetric.fontTiny)
|
.fontSize(FbMetric.fontTiny)
|
||||||
.fontColor(FbColors.muted)
|
.fontColor(FbColors.muted)
|
||||||
.maxLines(1)
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.constraintSize({ maxWidth: 120 })
|
||||||
}
|
}
|
||||||
.padding({ left: 4, right: 8, top: 2, bottom: 2 })
|
.padding({ left: 4, right: 8, top: 2, bottom: 2 })
|
||||||
.backgroundColor(FbColors.inputBg)
|
.backgroundColor(FbColors.inputBg)
|
||||||
.borderRadius(FbMetric.radiusPill)
|
.borderRadius(FbMetric.radiusPill)
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.margin({ right: 6, bottom: 6 })
|
||||||
}, (p: FbTripNodePerson): string => 'tp_' + p.id.toString())
|
}, (p: FbTripNodePerson): string => 'tp_' + p.id.toString())
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
@@ -858,15 +978,21 @@ export struct FbTripDetailPage {
|
|||||||
stageGroup(stage: string, title: string, hint: string) {
|
stageGroup(stage: string, title: string, hint: string) {
|
||||||
if (this.nodesOf(stage).length > 0) {
|
if (this.nodesOf(stage).length > 0) {
|
||||||
Column({ space: 0 }) {
|
Column({ space: 0 }) {
|
||||||
// ---- 组头 ----
|
/* ---- 组头:**只当分隔标识,不再套一张大卡** ----
|
||||||
|
🔴 v76(用户 2026-10-22):「它那个实际行程啊,它应该也是一段一段的,
|
||||||
|
不要把它整个实际行程放在一块,就是每一个行程都是一个一个块。」
|
||||||
|
⇒ 组头退成一条"章节标题",每段行程各自一张卡(见 `nodeCard`)。
|
||||||
|
⚠️ 原来整组是一张白卡、里面用一条灰细线分隔各段 —— 视觉上
|
||||||
|
仍然"糊成一块",看不出这是一段一段的记录。 */
|
||||||
Row({ space: 8 }) {
|
Row({ space: 8 }) {
|
||||||
FbTag({ text: title, tone: fbStageTone(stage).tag })
|
FbTag({ text: title, tone: fbStageTone(stage).tag })
|
||||||
Text(this.nodesOf(stage).length.toString() + ' 段')
|
Text(this.nodesOf(stage).length.toString() + ' 段')
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.fontColor(FbColors.faint)
|
.fontColor(FbColors.faint)
|
||||||
|
Column().layoutWeight(1)
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ top: 12, bottom: 2 })
|
.padding({ top: 16, bottom: 4 })
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
Text(hint)
|
Text(hint)
|
||||||
@@ -880,13 +1006,7 @@ export struct FbTripDetailPage {
|
|||||||
}, (n: FbTripNode): string => 'nd_' + n.id.toString())
|
}, (n: FbTripNode): string => 'nd_' + n.id.toString())
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
|
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.backgroundColor(FbColors.surface)
|
|
||||||
.borderRadius(FbMetric.radius)
|
|
||||||
.border({ width: { left: 3 }, color: fbStageTone(stage).hex })
|
|
||||||
// ⚠️ 同上:不写左右 margin(外层已有 pagePad padding),只留纵向间距
|
|
||||||
.margin({ top: FbMetric.cardGap })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,9 +1164,9 @@ export struct FbTripDetailPage {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⑧ 参与人
|
// ⑧ 参与人(v76:`Flex wrap` —— 人多时顺着往下铺,不横向溢出卡片)
|
||||||
if (n.members.length > 0) {
|
if (n.members.length > 0) {
|
||||||
Row({ space: 5 }) {
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
ForEach(n.members, (p: FbTripNodePerson) => {
|
ForEach(n.members, (p: FbTripNodePerson) => {
|
||||||
Row({ space: 3 }) {
|
Row({ space: 3 }) {
|
||||||
FbAvatar({
|
FbAvatar({
|
||||||
@@ -1060,23 +1180,63 @@ export struct FbTripDetailPage {
|
|||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.fontColor(FbColors.muted)
|
.fontColor(FbColors.muted)
|
||||||
.maxLines(1)
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.constraintSize({ maxWidth: 100 })
|
||||||
}
|
}
|
||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.margin({ right: 8, bottom: 4 })
|
||||||
}, (p: FbTripNodePerson): string => 'nm_' + n.id.toString() + '_' + p.id.toString())
|
}, (p: FbTripNodePerson): string => 'nm_' + n.id.toString() + '_' + p.id.toString())
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⑨ 照片墙(只铺图片;视频/文件端上先不做,见 `parseNodes` 的说明)
|
/* ⑨ 照片墙 / 视频
|
||||||
if (this.photosOf(n).length > 0) {
|
🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看,视频要能够播放。」
|
||||||
Row({ space: 5 }) {
|
⚠️ 原来这里只铺图片、56×56、**点了没反应**,视频压根不显示
|
||||||
ForEach(this.photosOf(n), (ph: FbTripNodePhoto) => {
|
(注释里写着"视频/文件端上先不做")。现在:
|
||||||
Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get()))
|
① 图片:56 缩略图,点开全屏大图(可左右滑看同段的所有图);
|
||||||
.width(56)
|
② 视频:56 方块 + ▶ 角标 + 文件名,点开用 `Video` 播放。
|
||||||
.height(56)
|
⚠️ 缩略图用 `Wrap` 排:一段行程带十几张照片时不能横向溢出卡片。 */
|
||||||
.objectFit(ImageFit.Cover)
|
if (this.imagesOf(n).length > 0 || this.videosOf(n).length > 0) {
|
||||||
.borderRadius(6)
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
|
ForEach(this.imagesOf(n), (ph: FbTripNodePhoto) => {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get()))
|
||||||
|
.width(64)
|
||||||
|
.height(64)
|
||||||
|
.objectFit(ImageFit.Cover)
|
||||||
|
}
|
||||||
|
.width(64)
|
||||||
|
.height(64)
|
||||||
|
.borderRadius(6)
|
||||||
|
.clip(true)
|
||||||
|
.margin({ right: 6, bottom: 6 })
|
||||||
|
.onClick((): void => {
|
||||||
|
// 点第几张就从第几张开始看(不是每次都从第一张)
|
||||||
|
this.openViewer(this.imageKeysOf(n), this.imagesOf(n).indexOf(ph));
|
||||||
|
})
|
||||||
}, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString())
|
}, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString())
|
||||||
|
|
||||||
|
ForEach(this.videosOf(n), (vd: FbTripNodePhoto) => {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
// 视频没有服务端封面 ⇒ 给深色底 + 播放三角,比放一张坏图诚实
|
||||||
|
Column()
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor('#2A2320')
|
||||||
|
Text('▶')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontColor('#FFFFFFCC')
|
||||||
|
}
|
||||||
|
.width(64)
|
||||||
|
.height(64)
|
||||||
|
.borderRadius(6)
|
||||||
|
.clip(true)
|
||||||
|
.margin({ right: 6, bottom: 6 })
|
||||||
|
.onClick((): void => {
|
||||||
|
this.openVideo(vd);
|
||||||
|
})
|
||||||
|
}, (vd: FbTripNodePhoto): string => 'vd_' + vd.id.toString())
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
}
|
}
|
||||||
@@ -1108,11 +1268,197 @@ export struct FbTripDetailPage {
|
|||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.padding({ left: 10, bottom: 10 })
|
|
||||||
.border({ width: { left: 1 }, color: FbColors.line })
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.alignItems(VerticalAlign.Top)
|
.alignItems(VerticalAlign.Top)
|
||||||
|
/* 🔴 v76(用户 2026-10-22):「每一个行程都是一个一个块。」
|
||||||
|
⇒ 每段行程**自己一张卡**:白底 + 圆角 + 左侧阶段色条 + 卡片内边距,
|
||||||
|
不是原来"整组一张大卡、段与段之间一条灰细线"。
|
||||||
|
⚠️ 色条取 `n.stage`(节点自带)而不是外层入参 —— 将来把某一段从
|
||||||
|
「计划」改到「实际」,颜色自动跟着走,不用同步两个地方。 */
|
||||||
|
.padding(FbMetric.cardPad)
|
||||||
|
.backgroundColor(FbColors.surface)
|
||||||
|
.borderRadius(FbMetric.radiusS)
|
||||||
|
.border({ width: { left: 3 }, color: fbStageTone(n.stage).hex })
|
||||||
|
.margin({ bottom: 10 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照片查看器(全屏大图 + 左右滑翻页)。
|
||||||
|
*
|
||||||
|
* 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看。」
|
||||||
|
* ⚠️ 照 `FbMovieDetailSheet` 那套查看器写(同一套手势、同一套关闭方式),
|
||||||
|
* 不要另起一种"点一下放大一点、再点一下缩回去"的交互 ——
|
||||||
|
* 两个页面同一功能长得不一样,用户会以为其中一个是坏的。
|
||||||
|
* ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张",裁边缘就失去意义
|
||||||
|
* (只有缩略图才用 `Cover`)。
|
||||||
|
* 🔴 左右滑用 **`PanGesture`** 不用 `SwipeGesture`:后者要求"快速划动"才识别,
|
||||||
|
* 在复杂布局里慢划经常不触发(日程页实测过)。
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
photoViewer() {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
// ① 背景(点它关闭)
|
||||||
|
Column()
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor('#E6000000')
|
||||||
|
.onClick((): void => {
|
||||||
|
this.viewerKeys = [];
|
||||||
|
this.viewerIndex = 0;
|
||||||
|
})
|
||||||
|
|
||||||
|
// ② 大图 + 左右滑翻页
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
Image(fbImageSrc(this.viewerKeys[this.viewerIndex], FB_TOKEN_STORE.get()))
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.objectFit(ImageFit.Contain)
|
||||||
|
.gesture(
|
||||||
|
PanGesture({ direction: PanDirection.Horizontal })
|
||||||
|
.onActionEnd((event: GestureEvent): void => {
|
||||||
|
const dx: number = event.offsetX;
|
||||||
|
const dy: number = event.offsetY;
|
||||||
|
// 位移太小 = 手抖;竖着划 = 用户想上下看,不该翻页
|
||||||
|
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 左滑看下一张,右滑看上一张(与相册一致)
|
||||||
|
this.viewerStep(dx < 0 ? 1 : -1);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('76%')
|
||||||
|
|
||||||
|
/* ③ 顶部关闭 + 底部翻页条
|
||||||
|
🔴🔴 v76 真机实测踩到:原来这两块各自写 `.align(Alignment.Bottom)` /
|
||||||
|
`.align(Alignment.TopEnd)`,以为能贴到 Stack 的角上 —— 实测**完全无效**,
|
||||||
|
两块都掉到了屏幕正中(关闭按钮跑到 x=518..569,正中间)。
|
||||||
|
⇒ 别再靠 `.align()` 定位;用**铺满的 Column + SpaceBetween** 顶到底:
|
||||||
|
第一行放关闭(右对齐),中间 layoutWeight(1) 撑开,
|
||||||
|
最后一行放翻页条。这样位置只由布局决定,与对齐属性无关。
|
||||||
|
⚠️ `hitTestBehavior(Transparent)` 必须有:这一层是**透明的**,
|
||||||
|
它只负责摆按钮的位置;中间大片空白要让点击穿下去给 ① 的遮罩
|
||||||
|
(否则"点背景关闭"失效)。Transparent = 自身与子节点响应、不阻挡兄弟。
|
||||||
|
*/
|
||||||
|
Column() {
|
||||||
|
Row() {
|
||||||
|
Column().layoutWeight(1)
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('✕')
|
||||||
|
.fontSize(FbMetric.fontTitle)
|
||||||
|
.fontColor('#FFFFFFCC')
|
||||||
|
Text('关闭')
|
||||||
|
.fontSize(9)
|
||||||
|
.fontColor('#FFFFFF88')
|
||||||
|
}
|
||||||
|
.padding({ left: 18, right: 6, top: 8, bottom: 8 })
|
||||||
|
.alignItems(HorizontalAlign.End)
|
||||||
|
.onClick((): void => {
|
||||||
|
this.viewerKeys = [];
|
||||||
|
this.viewerIndex = 0;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Column().layoutWeight(1)
|
||||||
|
|
||||||
|
Row({ space: 18 }) {
|
||||||
|
Text('‹ 上一张')
|
||||||
|
.fontSize(FbMetric.fontSmall)
|
||||||
|
.fontColor('#FFFFFFCC')
|
||||||
|
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||||
|
.onClick((): void => {
|
||||||
|
this.viewerStep(-1);
|
||||||
|
})
|
||||||
|
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerKeys.length.toString())
|
||||||
|
.fontSize(FbMetric.fontTiny)
|
||||||
|
.fontColor('#FFFFFF99')
|
||||||
|
Text('下一张 ›')
|
||||||
|
.fontSize(FbMetric.fontSmall)
|
||||||
|
.fontColor('#FFFFFFCC')
|
||||||
|
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||||
|
.onClick((): void => {
|
||||||
|
this.viewerStep(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding({ top: 8, bottom: 48 })
|
||||||
|
.hitTestBehavior(HitTestMode.Transparent)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频播放器(全屏居中 + 系统控件)。
|
||||||
|
*
|
||||||
|
* 🔴 v76(用户 2026-10-22):「视频要能够播放。」
|
||||||
|
* ⚠️ 走 `media.php?k=…&token=…` 这个代理拿字节:它**支持 Range(206)**
|
||||||
|
* (`media.php` 第 6 段),所以拖进度条、边下边播都正常。
|
||||||
|
* 直接把 OSS 地址塞给播放器是不行的 —— 桶是私有的,而且没有令牌。
|
||||||
|
* ⚠️ `controls(true)`:进度条/暂停/全屏都用系统的,不要自己画一套
|
||||||
|
* (自己画的那套在真机上连"拖进度条"都做不到)。
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
videoPlayer() {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
Column()
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor('#F2000000')
|
||||||
|
.onClick((): void => {
|
||||||
|
this.videoKey = '';
|
||||||
|
this.videoName = '';
|
||||||
|
})
|
||||||
|
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(this.videoName)
|
||||||
|
.fontSize(FbMetric.fontSmall)
|
||||||
|
.fontColor('#FFFFFFCC')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text('✕')
|
||||||
|
.fontSize(FbMetric.fontTitle)
|
||||||
|
.fontColor('#FFFFFFCC')
|
||||||
|
.padding({ left: 14, right: 2, top: 4, bottom: 4 })
|
||||||
|
.onClick((): void => {
|
||||||
|
this.videoKey = '';
|
||||||
|
this.videoName = '';
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
|
Video({
|
||||||
|
src: fbImageSrc(this.videoKey, FB_TOKEN_STORE.get())
|
||||||
|
})
|
||||||
|
.width('100%')
|
||||||
|
.aspectRatio(16 / 9)
|
||||||
|
.controls(true)
|
||||||
|
.autoPlay(true)
|
||||||
|
.loop(false)
|
||||||
|
.objectFit(ImageFit.Contain)
|
||||||
|
.backgroundColor('#000000')
|
||||||
|
.borderRadius(8)
|
||||||
|
.onError((): void => {
|
||||||
|
// 播放不了要**说实话**,不能留一块黑屏让人以为在缓冲
|
||||||
|
this.videoName = '这个视频放不出来(格式或网络问题)';
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删一段行程的确认框(自绘,避免动系统弹窗层级) */
|
/** 删一段行程的确认框(自绘,避免动系统弹窗层级) */
|
||||||
@@ -1254,8 +1600,22 @@ export struct FbTripDetailPage {
|
|||||||
return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage);
|
return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一段行程里**能显示**的照片(图片且 key 非空) */
|
/** 一段行程里的**图片**(可点开看大图) */
|
||||||
private photosOf(n: FbTripNode): FbTripNodePhoto[] {
|
private imagesOf(n: FbTripNode): FbTripNodePhoto[] {
|
||||||
return n.attachs.filter((a: FbTripNodePhoto): boolean => a.isImage && a.key !== '');
|
return n.attachs.filter((a: FbTripNodePhoto): boolean => fbPhotoKind(a) === 'image' && a.key !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一段行程里的**视频**(可点开播放) */
|
||||||
|
private videosOf(n: FbTripNode): FbTripNodePhoto[] {
|
||||||
|
return n.attachs.filter((a: FbTripNodePhoto): boolean => fbPhotoKind(a) === 'video' && a.key !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看器要用的键列表(与 `imagesOf` 顺序严格一致 —— 索引要对得上) */
|
||||||
|
private imageKeysOf(n: FbTripNode): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const a of this.imagesOf(n)) {
|
||||||
|
out.push(a.key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* 别把它加回来当"快捷查看":两套并存的代价是两份节点渲染要同时维护。
|
* 别把它加回来当"快捷查看":两套并存的代价是两份节点渲染要同时维护。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
|
import { FbColors, FbMetric, fbTint, FbTint, fbAlpha, FB_MASK } from '../../common/Theme';
|
||||||
import { FbAppState, fbAppState } from '../../common/Store';
|
import { FbAppState, fbAppState } from '../../common/Store';
|
||||||
import { FbTripItem } from '../../model/Types';
|
import { FbTripItem } from '../../model/Types';
|
||||||
import { parseTrips } from '../../data/FbParse';
|
import { parseTrips } from '../../data/FbParse';
|
||||||
@@ -439,27 +439,35 @@ export struct FbTripsPage {
|
|||||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
|
||||||
// 时间 · 城市(一行放得下了 —— 整宽之后不再被小图挤)
|
// 时间(单独一行 —— 城市改成 chip 之后不能再挤在同一行里抢宽度)
|
||||||
Row({ space: 10 }) {
|
if (t.dateRange !== '') {
|
||||||
if (t.dateRange !== '') {
|
Text(t.dateRange)
|
||||||
Text(t.dateRange)
|
.fontSize(FbMetric.fontSmall)
|
||||||
.fontSize(FbMetric.fontSmall)
|
.fontColor(FbColors.muted)
|
||||||
.fontColor(FbColors.muted)
|
.width('100%')
|
||||||
.maxLines(1)
|
}
|
||||||
|
|
||||||
|
/* 城市:**一颗颗 chip,一行放不下就自动换到下一行**,全部列出来。
|
||||||
|
🔴 v76(用户 2026-10-22):「那个城市……如果一行显不下的话,
|
||||||
|
应该显示在下边一行,把所有的城市全列出来。就跟那个人员一样。」
|
||||||
|
⚠️ 原来这里是 `Text('📍 ' + t.city).maxLines(1).ellipsis` ——
|
||||||
|
城市一多就变成「📍 青岛 · 威海 → 济…」,用户看到的就是"显示不全"。
|
||||||
|
`Row` 不会换行,所以必须用 `Flex({ wrap: FlexWrap.Wrap })`。 */
|
||||||
|
if (t.cities.length > 0) {
|
||||||
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
|
ForEach(t.cities, (c: string) => {
|
||||||
|
Text(c)
|
||||||
|
.fontSize(FbMetric.fontTiny)
|
||||||
|
.fontColor(this.accent)
|
||||||
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor(fbAlpha(this.accent, 0.1))
|
||||||
|
.borderRadius(FbMetric.radiusPill)
|
||||||
|
// chip 之间自己留间距(`Flex wrap` 不吃 `space` 参数)
|
||||||
|
.margin({ right: 6, bottom: 6 })
|
||||||
|
}, (c: string): string => 'lc_' + t.id.toString() + '_' + c)
|
||||||
}
|
}
|
||||||
if (t.city !== '') {
|
.width('100%')
|
||||||
Text('📍 ' + t.city)
|
|
||||||
.fontSize(FbMetric.fontSmall)
|
|
||||||
.fontColor(FbColors.muted)
|
|
||||||
.layoutWeight(1)
|
|
||||||
.maxLines(1)
|
|
||||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
||||||
} else {
|
|
||||||
Column().layoutWeight(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
|
||||||
.alignItems(VerticalAlign.Center)
|
|
||||||
|
|
||||||
// 段数 / 已完成 / 同行人数
|
// 段数 / 已完成 / 同行人数
|
||||||
Row({ space: 8 }) {
|
Row({ space: 8 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user