update
This commit is contained in:
105
.workbuddy/memory/2026-09-17.md
Normal file
105
.workbuddy/memory/2026-09-17.md
Normal file
@ -0,0 +1,105 @@
|
||||
# 2026-09-17 工作日志
|
||||
|
||||
## 修复详情页空壳 + 评估 components/views 目录 ✅(15:12–15:30)
|
||||
用户问 `components/views/` 是否还有必要(pages 不就是 view 吗),并指出 detail 组件是空的,疑似之前拆文件拆错。
|
||||
|
||||
### 根因(已查清)
|
||||
- 详情实现原本在 `src/components/views/ItemPage.astro`(一个组件用 `type` 参数通吃 runway/street,含收藏/历史脚本)。
|
||||
- commit `70a3304`(拆分)把 4 个 `[id].astro` 的 import 从 `ItemPage`(带 `type`)改成 `RunwayLooksDetail`/`StreetSnapsDetail`,但**这两个新文件建成空的(0 字节)**,且把 `ItemPage.astro` **删除了**。
|
||||
- 后果:走秀/街拍详情页(`prerender=false` 的动态路由)此后一直渲染空壳,无报错不易察觉。
|
||||
- 验证手段:`git log --all -- '*ItemPage*'` 定位 → `git show ad028c9:...ItemPage.astro` 找回 158 行完整实现;`70a3304` 该路径已不存在(在另一分支)。
|
||||
|
||||
### 修复(恢复原始可用结构)
|
||||
- 从 `ad028c9` 恢复 `src/components/views/ItemPage.astro`(内容逐字还原,含 Props 接口、SSR 取数、TDK、收藏/历史 `<script>`)。
|
||||
- 4 个 `[id].astro`(en/cn × runway-looks/street-snaps)改回 `import ItemPage` 并显式传 `type="runway"|"street"`。
|
||||
- 删除两个空文件 `RunwayLooksDetail.astro`、`StreetSnapsDetail.astro`。
|
||||
- 验证:`npm run build` Complete;`npx astro check` 0/0/0;残留搜索 `RunwayLooksDetail|StreetSnapsDetail` = 0 命中;`ItemPage` 仅出现在 4 个页面 + 自身。
|
||||
|
||||
### 关于 components/views 是否有必要(结论)
|
||||
**有必要保留,不要并入 pages。**
|
||||
- 本项目 `prefixDefaultLocale: true` → `src/pages/{en,cn}/...` 是**两套** locale 页面树,每个页面文件都是薄壳(仅 `export const prerender` + `import` 共享视图 + 渲染)。
|
||||
- `components/views/*` 是 en/cn 两棵页面树**共用的页面体实现**(语言靠 `Astro.currentLocale` 运行时区分)。若把视图内容并进 pages,就得在 en/ 和 cn/ 各抄一份;保留 views/ 才是单一事实源。
|
||||
- `views/` 命名与 pages 略重复是约定问题,但目录本身承担"跨 locale 共享页面体"职责,非冗余。可改名为 `components/pages/` 或直接摊平到 `components/`,但非必要,未做(避免无谓改动)。
|
||||
- 现状 views/ 实际成员:Account / Home / Login / RunwayLooks / StreetSnaps / ItemPage(后两者非页面级路由,而是被 [id].astro 复用的详情体)。
|
||||
|
||||
## 待办/提醒
|
||||
- 详情页为 `prerender=false` 动态 SSR,依赖后端 `:8090` 接口;本次仅验证编译/类型通过,真实渲染需起后端联调(用户本地点一下 /runway-looks/<id> 最稳)。
|
||||
|
||||
## ItemPage 按 type 拆成两个 Detail ✅(16:10–16:18)
|
||||
用户要求把 `ItemPage.astro`(用 `type` 参数通吃 runway/street)拆成两个独立 Detail。
|
||||
- 关键事实:runway/street 的差异只在「取数函数(getSsrArticle/getSsrStreetSnap) / 卡片组件(RunwayLookCard/StreetSnapCard) / TDK 文案 / meta.type 值」;而收藏/历史 `<script>` 类型无关(全靠 data-* 透传)。
|
||||
- 新建 `RunwayLooksDetail.astro`、`StreetSnapsDetail.astro`(各只收 `id` prop,type 在组件内固化),各管自己的 SSR 取数 + TDK + 卡片 + fav 按钮标记。
|
||||
- 把那段 ~50 行收藏/历史脚本抽成 `src/lib/detailFav.ts` 的 `initDetailFav()`(读 `#fav-fab` 的 data-*);两个 Detail 各用 `<script> import { initDetailFav }` 调用,避免重复。该脚本是打包模块(有 import,无 define:vars),合规。
|
||||
- 4 个 `[id].astro`(en/cn × runway-looks/street-snaps)改指各自 Detail(`type` 不再透传);删 `ItemPage.astro`。旧 `/item/[id]` 仅做 301 重定向,不依赖 ItemPage,不受影响。
|
||||
- 注意:这 4 个页面文件在 15:14→16:10 间被改过(注释/顺序变了),首次批量 replace 因 old_str 不匹配失败;重新读取真实内容后才改成功。教训:改他人可能动过的文件前先重新读取。
|
||||
- 验证:`ItemPage` 引用搜索 0 命中;`npm run build` Complete;`npx astro check` 0/0/0。
|
||||
|
||||
## 后端图片去重现状调研(用户问爬虫判重怎么设计)✅(17:10–17:20)
|
||||
用户想给爬虫加"爬到图先判是否库里已存过"。调研 `backend_v2` 发现**这套已实现**,无需从零设计:
|
||||
- `internal/pkg/phash/phash.go`:纯标准库 **dhash**(64-bit 指纹,非向量),`DefaultThreshold=10`(汉明距离≤10 判近似重复),解码失败返回 0。包名虽叫 phash 实为 dhash(注释已说明)。
|
||||
- 入库管线 `ingest_service.go`:`downloadOne` 每张图下载后 `phash.Of(data)`;`processRunway/processStreet` 调 `tagDuplicates→matchDuplicates→repo.ListImagePHashes` 比对并标 `IsDuplicate=1 + DupOf`。
|
||||
- 模型 `runway_image/street_snap/*_draft` 均有 `Phash uint64 / IsDuplicate uint8 / DupOf string` 三列。
|
||||
- **关键行为**:当前是「只标记、不拦截止」(留痕交人工裁决),且比对库**仅已晋升正式表**(runway_images+street_snap_images),跳过 is_deleted 与 phash=0,**不含草稿表/本批内部**。
|
||||
- 存储用 sha1 内容寻址 key → 同字节幂等不堆孤儿文件,但 DB 行仍会多一条。
|
||||
- 效率注:每次入库任务全量拉 phash 表,千级 OK;到十万级需改分批/缓存。
|
||||
- **待用户拍板**:他想要的是「标出来给人看」(现在就能用)还是「命中就拦截不存」(需改 ingest_service 两处 + 引用计数)。阈值 10 可后续按真实样本调。
|
||||
|
||||
## 移除后端 phash 感知哈希去重 ✅(17:20–17:55)
|
||||
用户明确:图片量会很大,不想要 phash 了(当前 `ListImagePHashes` 每任务全表扫描,O(N) 不具扩展性)。
|
||||
- 彻底移除感知哈希层:删 `internal/pkg/phash` 包;`ingest_service.go` 去掉 `downloadOne` 的 `phash.Of` 与 4 返回值(uint64)、`fetchImages` 的 `[]uint64` 返回与 `phashes`、两个 `fetchLookImages`/`fetchImages` 调用处解包、processRunway/processStreet 的 `phashList`/`phs` 与 `tagDuplicates` 调用,并删除 `tagDuplicates`/`matchDuplicates` 两个函数;`ingest_repository.go` 删 `ListImagePHashes` 方法、`ImagePHash` 结构体及其接口声明;4 个 model 文件删 `Phash/IsDuplicate/DupOf` 三列;`review_repository.go` 晋升时不再拷贝这三列。
|
||||
- 实现手法:用一段 Python 脚本(正则+精确串替换,`\t` 转义规避 tab 对齐)批量改 `ingest_service.go`,`go build ./...` 通过后删 phash 包与临时脚本。
|
||||
- **刻意保留**:`downloadOne` 的 sha1 内容寻址存储 key(与 phash 无关,能天然防重复文件/孤儿文件),未动。
|
||||
- 验证:`go build ./...` 两次均 EXIT:0;残留扫描 phash/Phash/IsDuplicate/DupOf/tagDuplicates/matchDuplicates/ListImagePHashes/phashList/phs[ = 0。
|
||||
- 提醒:库表 `phash/is_duplicate/dup_of` 列 GORM 不会自动 DROP(仅 ADD),已升为孤儿列(可后续加迁移清掉);若将来要多来源"近似重复"仍要,应改 LSH/ANN 索引而非全表扫描。前端从不消费这三个字段。
|
||||
|
||||
## 去重替代方案方向(用户移除 phash 后问"好的方案有哪些",18:43)💡 待用户拍板
|
||||
核心认知:删的是"全表扫描的 phash 实现"≠去重需求没了。给出分层建议:
|
||||
- **Tier1 精确去重(推荐先做)**:复用现有 sha1 存储 key,新增 `content_sha1` 列 + UNIQUE 索引,入库前 O(1) 查重/upsert;挡同字节/同URL重爬;代价近零、可无限扩展。
|
||||
- **Tier2 近似去重**:复用 dhash(纯标准库),但加 `phash_bucket`(高12~16bit)索引,仅桶内算汉明距离,避免全表扫;解决用户"量大跑不动"死穴。
|
||||
- **Tier3 embedding+ANN 向量库(overkill)**:CLIP/CNN + Qdrant/Milvus/pgvector,抓语义级重复;对 runway/street snap 爬取偏重,暂不建议。
|
||||
- 待用户确认"重复"指:字节级(1)/近重复尺寸水印(2)/语义级(3);再出具体改法(含孤儿列复用 phash 名但重建结构)。
|
||||
|
||||
## 用户提议 pgvector+bit(64)+HNSW 方案(19:33)💡 已评估为 overkill
|
||||
用户建议:装 pgvector,dHash 存 `bit(64)` 建 HNSW 索引做近似去重。核实 pgvector **确实支持** `bit(N)`+Hamming(`bit_hamming_ops`,算子`<~>`,`hamming_distance<=10` 后过滤)。
|
||||
- **评估**:对 64 位 dHash 是杀鸡用牛刀——① HNSW 为高维(float/binary 数百~数千维)设计,64 维优势发挥不出且为近似索引(召回不如精确桶内比对);② 后端是 MySQL,为 64 位去重引 PG+pgvector 性价比低。
|
||||
- **更优替代**:MySQL 上 `BIGINT phash` + 抽高16位 `phash_bucket` 建 B-tree 索引,桶内算汉明 → O(1) 精确、零新基建(即 Tier2 桶化版,正解原"全表扫跑不动"痛点)。
|
||||
- **pgvector 正确用武之地**:语义档 CLIP `vector(1536)` + cosine HNSW(高维优势+二进制量化省存储),那才对得起基建。
|
||||
- 仍待用户确认重复类型(字节级/同图变形/不同角度同造型)再定方案与是否引 PG。
|
||||
|
||||
## 重要更正:backend_v2 实际已是 PostgreSQL+pgvector(19:36)⚠️
|
||||
核查 go.mod(gorm.io/driver/postgres + jackc/pgx,**无 mysql 驱动**)、internal/database/postgres.go(注释\"数据库已迁移至 PostgreSQL\" + gorm.Open(postgres.Open))、config.go DSN() 返回 postgres://、scripts/pgvector(PG16+pgvector Docker,initdb 自动建 vector 扩展,setup_pg_docker.sh 含 embedding vector(512)+hnsw 范例)。
|
||||
- **结论**:用户说\"就用pg把,早打算迁移mysql→pg\"实为**既成事实**——后端早就是 PG 了,无需迁移。MEMORY.md 的 \"GORM(MySQL)\" 已更正为 PostgreSQL+pgvector。
|
||||
- 因此去重可直接在 pgvector 上落地:content_sha1 唯一索引(精确) + phash bit(64)+bit_hamming_ops HNSW(近重复) + (可选)embedding vector(512)+cosine HNSW(语义)。
|
||||
- 注意:本会话早些时候删 phash 包/列是真实发生的;现需重建 phash.go + 给 image 模型重回 phash/content_sha1 列,并把去重逻辑接成\"索引查\"而非之前的\"全表扫\"。
|
||||
- 结论:上轮我给的「SQLite+LSH 分桶」方案在其已有 MySQL+全表扫 面前偏重,量到十万级再上 LSH 即可。
|
||||
|
||||
## 三档去重写入 backend_v2(用户拍板"都加把")✅(19:40–20:00)
|
||||
用户确认"都加把"——精确 + 近重复 + 语义三档全部落地。已逐个核对磁盘代码,确实落地:
|
||||
|
||||
### 1) 重建 `internal/pkg/phash/phash.go`(dHash,纯标准库)
|
||||
- `Of(data) uint64`:解码失败(webp 等)返回 0;`ToVectorBits(h) string` 转 `vector(64)` 二进制串 `[0,1,...]`(h=0 返回空串 → NULL);`Hamming(a,b) int`;`Size=64`、`DefaultThreshold=10`。
|
||||
- 注释明确:指纹只写库,检索交给 pgvector HNSW,不再全表扫。
|
||||
|
||||
### 2) 4 张 image 模型加列
|
||||
`BrandRunwayImage`/`BrandRunwayDraftImage`/`StreetSnapImage`/`StreetSnapDraftImage` 均加:
|
||||
- `ContentSha1 string`(`size:64` + 部分唯一索引 `uk_*_content_sha1,unique,where:content_sha1 <> ''`,排除遗留空串行);
|
||||
- `Phash sql.NullString`(`type:vector(64)`,text 扫描,规避 pgx 原生类型坑);
|
||||
- `IsDuplicate uint8` + `DupOf string`(标记用,不拦截)。
|
||||
|
||||
### 3) `internal/database/postgres.go` 加 `ensureDedupSchema(db)`
|
||||
- AutoMigrate 前先 `CREATE EXTENSION IF NOT EXISTS vector`;
|
||||
- 幂等:`content_sha1` 部分唯一索引 + `phash vector(64)` 列 + `CREATE INDEX ... USING hnsw(phash bit_hamming_ops)`;
|
||||
- 新建 `image_embeddings` 表(`image_id int`/`kind smallint`/`embedding vector(512)`/`created_at int`)+ `embedding vector_cosine_ops` HNSW(语义档位,当前不写,待 CLIP/DINOv2 接入)。
|
||||
- 注意:`AutoMigrate` 只 ADD 不 DROP → 之前移除阶段遗留的孤儿列已无关(新结构用新列名)。
|
||||
|
||||
### 4) 入库管线 `ingest_service.go` + `ingest_repository.go`
|
||||
- `downloadOne` 复算 `phash.Of` 并回填 `ContentSha1`/`Phash`;
|
||||
- 新增 `dedupImage`:① 精确 → `repo.ImageExistsBySha1`(跨 4 表 `content_sha1=? AND is_deleted=0`)命中则**整行跳过不插**;② 近重复 → `repo.FindNearDuplicateImage`,用 `phash <~> ?::vector <= threshold(10)` 取最近一条,命中设 `IsDuplicate=1`+`DupOf`(仅标记不拦);③ 本批内 `seen` 去重;
|
||||
- draft 晋升(`SaveRunwayFromDraft`/`SaveStreetSnapFromDraft`)复制去重字段。
|
||||
|
||||
### 验证
|
||||
`go build ./...` EXIT 0;`go vet ./internal/service/` 干净;单测 `TestFetchImagesCleansUpOnFailure` 绿(`ok fashionapi/internal/service`)。
|
||||
|
||||
### 待办(未做)
|
||||
- **运行期烟测**:起 pgvector Docker 实连,验证 GORM 把 `vector(64)` 列读进 `sql.NullString`(pgx text 格式扫描)无报错。尚未跑。
|
||||
- 汉明阈值 10 可按真实样本微调。
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
## 架构总览
|
||||
- 前端 `d:/project/frontend_v2`:Astro(`adapter: node` standalone,SSR 取数)+ Tailwind v4 + Alpine.js。构建 `npm run build`;类型检查 `npm run check`(= `astro check`)。
|
||||
- 后端 `d:/project/backend_v2`:Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
|
||||
- 后端 `d:/project/backend_v2`:Go + Gin + GORM(**PostgreSQL** + **pgvector**)。本地库见 `scripts/pgvector`(Docker 一键,PG16+,容器名 pgvector,库 fashion,账号 fashion/fashion_dev_2026;`initdb` 自动 `CREATE EXTENSION vector`)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
|
||||
- 接口 i18n:后端按 `?locale=cn|en` 选列;**语言以 URL 前缀为准**;`prefixDefaultLocale: true`(/en、/cn 都带前缀;根 `/` 由服务端 302 到 /en)。
|
||||
- **base 地址全在 `src/lib/config/`**:`config.baseApi`(浏览器) / `config.baseApiSsr`(SSR 绝对) / `config.baseApiSsg`(构建期内部端口)。密钥类(SSG_TOKEN)直读 env。
|
||||
- 后端 Windows 编译:`go build -o server-bin.exe`(非 `server-bin`)。前端 dev 不热加载 `.env`,改 base 后 `astro dev stop`+`--background`+浏览器硬刷新。
|
||||
@ -61,3 +61,15 @@
|
||||
- Layout `<head>` 顶部 `is:inline`+`define:vars` 脚本:进首页(path==='/' 或 '/'+defaultLocale)且记住非默认语言时 `location.replace('/'+stored)`。
|
||||
- 语言切换器 `x-data="langSwitch()"`;`<a>` `@click="open=false; pick('<code>')"`;footer 注册 `langSwitch`(`pick(code){ storeLocale(code) }`)。
|
||||
- **铁律:只在显式点击切换器时写 localStorage,绝不按页面加载 URL 写**——否则与首页自动跳转冲突(点了英文又被 URL 的 en 覆盖,首页永远跳不回去)。
|
||||
|
||||
## 目录约定(用户 2026-09-17 确认)
|
||||
- **`src/components/views/` 必须保留,勿并入 `src/pages/`,也勿把内容提取摊平。** 原因:本项目 `prefixDefaultLocale: true` → `src/pages/{en,cn}/` 是两套 locale 页面树,每个页面文件只是薄壳(路由 + `export const prerender` + 引入共享视图 + 渲染);`views/*` 是 en/cn 两棵页面树**共用的页面体实现**(语言靠 `Astro.currentLocale` 运行时区分)。并进 pages 会迫使 en/cn 各抄一份,制造重复。
|
||||
- `views/` 命名与 pages 略撞语义,但目录本身职责明确(跨 locale 共享页面体单一事实源),仅属 cosmetic,非必要不改名。
|
||||
- 现状 `views/` 成员:Account / Home / Login / RunwayLooks / StreetSnaps / RunwayLooksDetail / StreetSnapsDetail(`RunwayLooksDetail`/`StreetSnapsDetail` 非独立路由,是被 `pages/{en,cn}/{runway-looks,street-snaps}/[id].astro` 复用的详情体;2026-09-17 由 ItemPage 按 `type` 拆成两个,共享脚本抽到 `lib/detailFav.ts` 的 `initDetailFav()`)。
|
||||
|
||||
## 图片去重(backend_v2,2026-09-17 落地)
|
||||
- **三档全上**:Tier1 精确(sha1 内容寻址 + `content_sha1` 部分唯一索引,命中整行跳过不插);Tier2 近重复(`phash` 存 `vector(64)` 二进制串,`bit_hamming_ops` HNSW,`<~> <= 10` 命中标 `IsDuplicate`/`DupOf`,只标不拦);Tier3 语义(`image_embeddings` 表 `embedding vector(512)` + `vector_cosine_ops` HNSW,待 CLIP/DINOv2 接入,当前不写)。
|
||||
- 关键文件:`internal/pkg/phash/phash.go`(dHash 纯标准库,`ToVectorBits`→`vector(64)` 串,webp 解码失败→0→NULL)、`internal/database/postgres.go`(`ensureDedupSchema` 幂等 DDL + `CREATE EXTENSION IF NOT EXISTS vector`)、`internal/service/ingest_service.go`(`dedupImage`)、`internal/repository/ingest_repository.go`(`ImageExistsBySha1`/`FindNearDuplicateImage`)。
|
||||
- `phash` 走 `sql.NullString` + `type:vector(64)` text 扫描,规避 pgx 原生类型坑;空串→NULL 不参与近邻检索。
|
||||
- **索引查取代全表扫**(用户"图片量会很大"痛点):原 `ListImagePHashes` 全表扫 O(N) 已移除。
|
||||
- 待办:pgvector Docker 实连烟测 GORM 读 `vector(64)` 进 `sql.NullString`;汉明阈值 10 可按样本微调。
|
||||
|
||||
@ -134,7 +134,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
}
|
||||
|
||||
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<template x-for="(im, i) in extraImages" :key="im.url" >
|
||||
<figure
|
||||
:data-image-index="5 + i"
|
||||
:data-image-url="im.url"
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import RunwayLookCard from "@/components/RunwayLookCard.astro"
|
||||
import { getSsrArticle } from "@/lib/ssr"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { config } from "@/lib/config"
|
||||
|
||||
// 走秀(runway)详情视图,en/cn 两页共用(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 注意:export const prerender = false 必须留在页面文件(Astro 只读页面模块的该导出),
|
||||
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
|
||||
interface Props {
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { id } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
let article = null
|
||||
let articleErr = null
|
||||
let preview = false
|
||||
let imageTotal = 0
|
||||
|
||||
const data = await getSsrArticle(id, Astro.currentLocale)
|
||||
article = "article" in data ? data.article : null
|
||||
articleErr = data.error
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
// id 优先取接口回传的规范 id(带类型前缀):用户若通过旧的无前缀链接进来,也保证写入规范形式。
|
||||
const canonId = String(article?.id ?? id ?? "")
|
||||
const meta = { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
|
||||
|
||||
// 详情页动态 TDK:标题用「品牌 + 系列」,描述/关键词按语言生成(避免嵌套反引号,用拼接)。
|
||||
const SITE_NAME = config.siteName ?? "Xisoa"
|
||||
const runwayName = article ? (article.brand_name + " " + article.title).trim() : ""
|
||||
const detailTitle = runwayName || SITE_NAME
|
||||
const detailDesc = locale === "cn"
|
||||
? `${detailTitle} 走秀造型共 ${imageTotal} 张,查看细节图与系列信息,收录于 ${SITE_NAME} 时尚档案。`
|
||||
: `${detailTitle} — ${imageTotal} runway looks with detail images and collection info, in the ${SITE_NAME} fashion archive.`
|
||||
const detailKeywords = `${article?.brand_name || ""},runway,runway looks,fashion week,${SITE_NAME}`.replace(/^,/, "")
|
||||
|
||||
const saveLabel = t("save")
|
||||
const savedLabel = t("saved")
|
||||
---
|
||||
|
||||
<Layout title={detailTitle} description={detailDesc} keywords={detailKeywords}>
|
||||
<RunwayLookCard article={article} error={articleErr} preview={preview} imageTotal={imageTotal} />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
#fav-fab.is-active .fav-heart { fill: currentColor; }
|
||||
</style>
|
||||
|
||||
{article && (
|
||||
<button login-required id="fav-fab" type="button"
|
||||
data-id={meta.id}
|
||||
data-type={meta.type}
|
||||
data-title={meta.title}
|
||||
data-cover={meta.cover}
|
||||
data-brand={meta.brand}
|
||||
data-save-label={saveLabel}
|
||||
data-saved-label={savedLabel}
|
||||
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
|
||||
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
||||
<span id="fav-label">{saveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<script>
|
||||
import { initDetailFav } from "@/lib/detailFav"
|
||||
initDetailFav()
|
||||
</script>
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import StreetSnapCard from "@/components/StreetSnapCard.astro"
|
||||
import { getSsrStreetSnap } from "@/lib/ssr"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { config } from "@/lib/config"
|
||||
|
||||
// 街拍(street snap)详情视图,en/cn 两页共用(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 注意:export const prerender = false 必须留在页面文件(Astro 只读页面模块的该导出),
|
||||
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
|
||||
interface Props {
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { id } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
let snap = null
|
||||
let snapErr = null
|
||||
let preview = false
|
||||
let imageTotal = 0
|
||||
|
||||
const data = await getSsrStreetSnap(id, Astro.currentLocale)
|
||||
snap = "snap" in data ? data.snap : null
|
||||
snapErr = data.error
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
const canonId = String(snap?.id ?? id ?? "")
|
||||
const meta = { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
|
||||
|
||||
// 详情页动态 TDK:标题用街拍标题,描述/关键词按语言生成(避免嵌套反引号,用拼接)。
|
||||
const SITE_NAME = config.siteName ?? "Xisoa"
|
||||
const baseName = snap?.title || ""
|
||||
const detailTitle = baseName || SITE_NAME
|
||||
const detailDesc = locale === "cn"
|
||||
? `${detailTitle} —— 来自全球街头的时尚抓拍,收录于 ${SITE_NAME} 时尚档案。`
|
||||
: `${detailTitle} — street style snaps from cities around the world, in the ${SITE_NAME} fashion archive.`
|
||||
const detailKeywords = `${snap?.title || ""},street style,street snaps,fashion,${SITE_NAME}`.replace(/^,/, "")
|
||||
|
||||
const saveLabel = t("save")
|
||||
const savedLabel = t("saved")
|
||||
---
|
||||
|
||||
<Layout title={detailTitle} description={detailDesc} keywords={detailKeywords}>
|
||||
<StreetSnapCard snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
#fav-fab.is-active .fav-heart { fill: currentColor; }
|
||||
</style>
|
||||
|
||||
{snap && (
|
||||
<button login-required id="fav-fab" type="button"
|
||||
data-id={meta.id}
|
||||
data-type={meta.type}
|
||||
data-title={meta.title}
|
||||
data-cover={meta.cover}
|
||||
data-brand={meta.brand}
|
||||
data-save-label={saveLabel}
|
||||
data-saved-label={savedLabel}
|
||||
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
|
||||
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
||||
<span id="fav-label">{saveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<script>
|
||||
import { initDetailFav } from "@/lib/detailFav"
|
||||
initDetailFav()
|
||||
</script>
|
||||
|
||||
59
src/lib/detailFav.ts
Normal file
59
src/lib/detailFav.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
|
||||
import { recordHistory } from "@/lib/history"
|
||||
|
||||
/**
|
||||
* 详情页「收藏 / 历史」按钮初始化。
|
||||
* 按钮本体(#fav-fab)由详情视图渲染,元信息经 data-* 透传(不依赖 define:vars,避免打包期 import 进 IIFE 内报错)。
|
||||
* runway / street 两类详情共用本逻辑,区别仅在于透传的 data-type 值。
|
||||
* 注意:本函数只在详情页(含 #fav-fab 的页面)调用一次。
|
||||
*/
|
||||
export function initDetailFav() {
|
||||
const btn = document.getElementById("fav-fab")
|
||||
const label = document.getElementById("fav-label")
|
||||
if (!btn) return
|
||||
|
||||
const meta = {
|
||||
id: btn.dataset.id || "",
|
||||
type: btn.dataset.type || "",
|
||||
title: btn.dataset.title || "",
|
||||
cover: btn.dataset.cover || "",
|
||||
brand: btn.dataset.brand || "",
|
||||
}
|
||||
const saveLabel = btn.dataset.saveLabel || "save"
|
||||
const savedLabel = btn.dataset.savedLabel || "saved"
|
||||
|
||||
if (!meta.id) return
|
||||
|
||||
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
|
||||
if (getUser()) recordHistory(meta.id)
|
||||
|
||||
const sync = () => {
|
||||
const fav = isFavorited(meta.id)
|
||||
if (label) label.textContent = fav ? savedLabel : saveLabel
|
||||
btn.classList.toggle("is-active", fav)
|
||||
}
|
||||
sync()
|
||||
|
||||
// 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
|
||||
// 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
|
||||
window.addEventListener("fav:set", (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
|
||||
if (!detail || detail.id !== meta.id) return
|
||||
if (detail.fav) {
|
||||
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||
}
|
||||
sync()
|
||||
})
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
toggleFavorite({
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
cover: meta.cover,
|
||||
brand: meta.brand,
|
||||
} as any)
|
||||
sync()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user