Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 280e418ccf |
@@ -0,0 +1,34 @@
|
||||
"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version)
|
||||
|
||||
Revision ID: feedback_submit_env
|
||||
Revises: ad_pangle_daily_revenue
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
|
||||
admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列,
|
||||
SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。
|
||||
历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "feedback_submit_env"
|
||||
down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True))
|
||||
op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True))
|
||||
op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True))
|
||||
op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("feedback") as batch_op:
|
||||
batch_op.drop_column("android_version")
|
||||
batch_op.drop_column("rom_name")
|
||||
batch_op.drop_column("device_model")
|
||||
batch_op.drop_column("app_version")
|
||||
@@ -125,8 +125,9 @@ def list_users(
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
||||
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
|
||||
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
|
||||
uids = {r.user_id for r in records}
|
||||
if not uids:
|
||||
return
|
||||
@@ -562,7 +563,25 @@ def list_feedbacks(
|
||||
sort_col = sort_cols.get(sort_by, Feedback.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def feedback_summary(db: Session) -> dict:
|
||||
"""反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。"""
|
||||
rows = db.execute(
|
||||
select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status)
|
||||
).all()
|
||||
by_status = {status: int(count) for status, count in rows}
|
||||
return {
|
||||
"pending": by_status.get("pending", 0) + by_status.get("new", 0),
|
||||
"adopted": by_status.get("adopted", 0),
|
||||
"rejected": by_status.get("rejected", 0),
|
||||
"total": sum(by_status.values()),
|
||||
}
|
||||
|
||||
|
||||
def list_analytics_events(
|
||||
@@ -978,21 +997,66 @@ def user_coin_records(
|
||||
return rows[offset:offset + limit], (offset + limit if has_more else None), total
|
||||
|
||||
|
||||
def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None:
|
||||
"""给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照:
|
||||
trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、
|
||||
app_version(提交版本号列,= 提交时我们 app 的 versionName)。
|
||||
comparison_record_id 为空 / 关联记录查不到 → 全 None。"""
|
||||
cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None}
|
||||
rows = (
|
||||
db.execute(
|
||||
select(
|
||||
ComparisonRecord.id,
|
||||
ComparisonRecord.trace_id,
|
||||
ComparisonRecord.trace_url,
|
||||
ComparisonRecord.device_model,
|
||||
ComparisonRecord.rom_name,
|
||||
ComparisonRecord.android_version,
|
||||
ComparisonRecord.app_version,
|
||||
).where(ComparisonRecord.id.in_(cids))
|
||||
).all()
|
||||
if cids
|
||||
else []
|
||||
)
|
||||
cmap = {row.id: row for row in rows}
|
||||
for r in records:
|
||||
row = cmap.get(r.comparison_record_id)
|
||||
r.trace_id = row.trace_id if row else None
|
||||
r.trace_url = row.trace_url if row else None
|
||||
r.device_model = row.device_model if row else None
|
||||
r.rom_name = row.rom_name if row else None
|
||||
r.android_version = row.android_version if row else None
|
||||
r.app_version = row.app_version if row else None
|
||||
|
||||
|
||||
def list_price_reports(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[PriceReport], int | None, int]:
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。
|
||||
join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。"""
|
||||
stmt = select(PriceReport)
|
||||
if status:
|
||||
stmt = stmt.where(PriceReport.status == status)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PriceReport.user_id == user_id)
|
||||
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
|
||||
|
||||
sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at}
|
||||
sort_col = sort_cols.get(sort_by, PriceReport.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
_attach_price_report_comparison(db, items)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def price_report_summary(db: Session) -> dict:
|
||||
|
||||
@@ -10,7 +10,12 @@ from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||
from app.admin.schemas.feedback import (
|
||||
FeedbackApproveRequest,
|
||||
FeedbackOut,
|
||||
FeedbackRejectRequest,
|
||||
FeedbackSummary,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
@@ -59,6 +64,11 @@ def list_feedbacks(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=FeedbackSummary, summary="反馈审核统计(各状态计数)")
|
||||
def feedback_summary(db: AdminDb) -> FeedbackSummary:
|
||||
return FeedbackSummary.model_validate(queries.feedback_summary(db))
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
|
||||
@@ -37,11 +37,14 @@ def list_price_reports(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[PriceReportOut]:
|
||||
items, next_cursor, total = queries.list_price_reports(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
db, status=status, user_id=user_id,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[PriceReportOut.model_validate(r) for r in items],
|
||||
|
||||
@@ -23,6 +23,14 @@ class FeedbackOut(BaseModel):
|
||||
reviewed_by_admin_id: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
||||
app_version: str | None = None
|
||||
device_model: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
|
||||
|
||||
class FeedbackApproveRequest(BaseModel):
|
||||
@@ -37,3 +45,12 @@ class FeedbackApproveRequest(BaseModel):
|
||||
class FeedbackRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||
|
||||
|
||||
class FeedbackSummary(BaseModel):
|
||||
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
|
||||
|
||||
pending: int
|
||||
adopted: int
|
||||
rejected: int
|
||||
total: int
|
||||
|
||||
@@ -31,6 +31,17 @@ class PriceReportOut(BaseModel):
|
||||
reward_coins: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
# 联表瞬态字段:phone/nickname 由 _attach_user_info 挂(展示完整手机号、点击查该用户全部上报);
|
||||
# 其余由 _attach_price_report_comparison 从关联比价记录挂:trace_*(Trace 列点跳调试链接)、
|
||||
# device_model/rom_name/android_version(机型OS版本列)、app_version(提交版本号列)
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
trace_id: str | None = None
|
||||
trace_url: str | None = None
|
||||
device_model: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
|
||||
|
||||
class PriceReportRejectRequest(BaseModel):
|
||||
|
||||
@@ -61,6 +61,11 @@ async def submit_feedback(
|
||||
content: str = Form(...),
|
||||
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
||||
contact: str = Form(default=""),
|
||||
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
|
||||
app_version: str = Form(default=""),
|
||||
device_model: str = Form(default=""),
|
||||
rom_name: str = Form(default=""),
|
||||
android_version: str = Form(default=""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
) -> FeedbackOut:
|
||||
content = content.strip()
|
||||
@@ -86,6 +91,8 @@ async def submit_feedback(
|
||||
|
||||
fb = feedback_repo.create_feedback(
|
||||
db, user_id=user.id, content=content, contact=contact, images=urls,
|
||||
app_version=app_version.strip(), device_model=device_model.strip(),
|
||||
rom_name=rom_name.strip(), android_version=android_version.strip(),
|
||||
)
|
||||
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
@@ -142,22 +142,3 @@ app.mount(
|
||||
StaticFiles(directory=str(_media_root)),
|
||||
name="media",
|
||||
)
|
||||
|
||||
# 业务 H5 同源托管(/h5)。与 /api/v1 同 host → H5 用相对路径调后端,免 CORS。
|
||||
# .html 加 no-cache:后端改完用户重开页即拉新(远程热更核心);js/图片走默认缓存。
|
||||
class _NoCacheHTMLStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
resp = await super().get_response(path, scope)
|
||||
# 按响应 content-type 判定,而非请求 path:html=True 时目录式 URL(/h5/x/)
|
||||
# 经 Starlette 内部回落到 index.html,此刻 path 仍是 "x/"(不带 .html)。
|
||||
if resp.headers.get("content-type", "").startswith("text/html"):
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
_h5_root = Path(__file__).resolve().parent.parent / "h5"
|
||||
app.mount(
|
||||
"/h5",
|
||||
_NoCacheHTMLStaticFiles(directory=str(_h5_root), html=True),
|
||||
name="h5",
|
||||
)
|
||||
|
||||
@@ -25,6 +25,11 @@ class Feedback(Base):
|
||||
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
||||
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
|
||||
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) # Build.MODEL
|
||||
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OemDetector os:ColorOS/MIUI/...
|
||||
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
|
||||
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
@@ -17,12 +17,20 @@ def create_feedback(
|
||||
content: str,
|
||||
contact: str,
|
||||
images: list[str] | None,
|
||||
app_version: str | None = None,
|
||||
device_model: str | None = None,
|
||||
rom_name: str | None = None,
|
||||
android_version: str | None = None,
|
||||
) -> Feedback:
|
||||
fb = Feedback(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
contact=contact,
|
||||
images=images or None,
|
||||
app_version=app_version or None,
|
||||
device_model=device_model or None,
|
||||
rom_name=rom_name or None,
|
||||
android_version=android_version or None,
|
||||
status="pending",
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# H5 / WebView 改造方案(app-server 端)
|
||||
|
||||
> 仓库:`shaguabijia-app-server` 分支:`feat/h5-sgbridge` 日期:2026-06-29
|
||||
> 配套文档:`shaguabijia-app-android` 仓库 `docs/H5-WebView改造方案.md`(原生壳 + SGBridge.kt)
|
||||
> 本方案 = **方案1:以本仓已有 `SGBridge` 为统一基准**,安卓退役其 `NativeBridge`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
项目里同时存在**两套互不兼容**的 H5 方案,尚未收敛:
|
||||
|
||||
| | 本仓那套(已在 `main`,PR #89) | 安卓那套(`feat/records-h5`,未进 main) |
|
||||
|---|---|---|
|
||||
| 桥 | `SGBridge`(JS)↔ `SGBridgeNative`(原生),协议丰富 + 事件总线 | `NativeBridge`,仅 4 法 |
|
||||
| 托管 | **设计为**后端同源托管(相对 `/api/v1`、免 CORS) | 本地 assets `file://`(跨域 + 放行 CORS) |
|
||||
| 页面 | `h5/mine/index.html`(7771)+ `h5/shared/bridge.js`+`api.js` | `records`(1778)、`reports`(603) |
|
||||
| 落地 | **半成品**:安卓从无 `SGBridge.kt`;且 `h5/` 实际没挂(StaticFiles 只挂了 `data/media` 头像上传) | 已接进安卓导航但只在分支上 |
|
||||
|
||||
**结论(已对齐):** 目标是「远程后端托管 + 四 tab 全 H5」,该方向天然契合本仓 SGBridge(同源相对路径、`mine` 已成型、`shared/` 已就绪)。故统一到 SGBridge,把安卓的 records/reports 页**迁入本仓** `h5/`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与范围
|
||||
|
||||
**目标:** 由 app-server 同源托管全部业务 H5,配齐 `shared/` 桥与接口封装,支持安卓「原生壳 + 远程 H5」一套桥贯通,可不发版热更。
|
||||
|
||||
**本仓(app-server)范围:**
|
||||
- H5 托管:新增 **FastAPI StaticFiles 挂载 `/h5`** → 指向仓库 `h5/` 目录(同源、免 CORS)
|
||||
- 迁入 records / reports 两页(从安卓 `feat/records-h5` 移植),改用 `SGBridge` / `SGApi`
|
||||
- `shared/bridge.js` 补 **`closePage`(返回)**;`shared/api.js` 已同源就绪(必要时补 multipart 说明)
|
||||
- 远程开关配置接口(返回 per-page `native`/`h5` 标志)
|
||||
- 缓存 / 热更策略(cache-control + 版本)
|
||||
|
||||
**不在本仓(见 android 文档):** `SGBridge.kt`、WebView 宿主、原生/H5 切换接线。
|
||||
|
||||
**后端业务接口:零改动** —— H5 复用现有 `/api/v1/...`(`/compare/records`、`/report`、`/wallet/account`、`/coupon/stats` 等),只是请求方从 Kotlin 改成网页 fetch。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键决策(已锁定)
|
||||
|
||||
| # | 决策 | 选定 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D1 | 桥基准 | **SGBridge**(安卓退役 NativeBridge) | 协议齐、mine 成型、三端对齐 |
|
||||
| D2 | 托管 | **FastAPI StaticFiles 挂 `/h5`** | 同源免 CORS、复用现有 `/media` 方式、nginx 已反代 `/`→uvicorn,dev+prod 都能跑 |
|
||||
| D3 | 离线兜底 | 纯远程 + 安卓侧重试页 | YAGNI;后端不需为离线做特殊处理 |
|
||||
| D4 | 地址 | 安卓用 `BackendUrl.current` 推导 H5 地址(同源) | H5 与 `/api/v1` 同 host,自动跟随环境 |
|
||||
| D5 | 迁移顺序 | records → reports → 首页/福利;mine 最后校准 | 用小页验证桥,风险分散 |
|
||||
|
||||
---
|
||||
|
||||
## 4. H5 托管(D2)
|
||||
|
||||
`app/main.py` 现有挂载(仅头像):
|
||||
|
||||
```python
|
||||
app.mount(settings.MEDIA_URL_PREFIX, StaticFiles(directory=str(_media_root)), name="media")
|
||||
```
|
||||
|
||||
**新增** H5 静态挂载(指向仓库根 `h5/`,`html=True` 让目录路径回落到 `index.html`):
|
||||
|
||||
```python
|
||||
_h5_root = Path(__file__).resolve().parent.parent / "h5" # 仓库根 h5/
|
||||
app.mount("/h5", StaticFiles(directory=str(_h5_root), html=True), name="h5")
|
||||
```
|
||||
|
||||
- 访问:`https://app-api.shaguabijia.com/h5/records/index.html`(prod)/ `http://<dev-host>:8770/h5/...`(debug)。
|
||||
- 与 `/api/v1` **同 host → 同源**:H5 内 `SGApi` 用相对 `/api/v1`,免 CORS、免 `getApiBase`。
|
||||
- prod 可选改由 nginx 直发 `h5/`(绕过 uvicorn)以提性能——本期先用 StaticFiles,nginx 优化留后。
|
||||
|
||||
---
|
||||
|
||||
## 5. H5 工程结构
|
||||
|
||||
```
|
||||
h5/
|
||||
├── shared/
|
||||
│ ├── bridge.js # SGBridge:H5↔原生桥(本期补 closePage)
|
||||
│ └── api.js # SGApi:同源相对 /api/v1 封装(已就绪)
|
||||
├── mine/index.html # 我的页(已成型,M4 按真实 SGBridge.kt 校准)
|
||||
├── records/index.html # ← 从安卓迁入(M1)
|
||||
└── reports/index.html # ← 从安卓迁入(M2)
|
||||
```
|
||||
|
||||
各页头部引入:`<script src="../shared/bridge.js"></script>` + `<script src="../shared/api.js"></script>`。
|
||||
|
||||
---
|
||||
|
||||
## 6. records / reports 页迁入(从安卓移植)
|
||||
|
||||
**搬运 + 改桥(迁移、非重写,UI/CSS/逻辑占绝大部分原样保留):**
|
||||
|
||||
| 安卓原写法(NativeBridge) | 迁入后(SGBridge / SGApi) |
|
||||
|---|---|
|
||||
| `NativeBridge.getApiBase()` 拼绝对 URL(8 处) | **删除**,改相对 `/api/v1/...`(同源) |
|
||||
| `NativeBridge.getToken()`(7 处) | `SGBridge.getToken()`(同名) |
|
||||
| `NativeBridge.onUnauthorized()`(6 处) | 走 `SGApi` 内置 401 → `SGBridge.requestLogin()` |
|
||||
| `NativeBridge.closePage()`(5 处) | `SGBridge.closePage()`(**bridge.js 本期新增**) |
|
||||
| 普通 GET/POST | 改用 `SGApi.get/post('/...')`(自动带 token、处理 401) |
|
||||
|
||||
**上报传图(records)特别说明:** 走 `<input type="file">` + 客户端压缩 + **`FormData` multipart** POST `/api/v1/report`。`SGApi` 只发 JSON,**不覆盖 multipart**,故这段**保留自定义 `fetch`**:URL 改相对 `/api/v1/report`、`Authorization` 头取 `SGBridge.getToken()`。文件选择依赖**安卓宿主 `onShowFileChooser`**(见 android 文档),后端无需改动。
|
||||
|
||||
---
|
||||
|
||||
## 7. `shared/bridge.js` 改动
|
||||
|
||||
- **新增 `closePage()`**:`SGBridge.closePage()` → 调 `SGBridgeNative.closePage()`(无桥时 mock `console.log`)。records/reports 顶栏返回箭头、`?from=profile` 返回都用它。
|
||||
- 其余方法(`getToken`/`requestLogin`/`navigate`/`getAuthState`/事件总线…)已就绪,保持签名,供 mine 及后续 tab 使用。
|
||||
- 浏览器无 `SGBridgeNative` 时走 MOCK,便于本地 `python -m http.server` 或 StaticFiles 直接调样式。
|
||||
|
||||
---
|
||||
|
||||
## 8. SGBridge 接口契约(三端共享)
|
||||
|
||||
> 与 android 文档同表;`shared/bridge.js` 为权威。新增方法三端同步。
|
||||
|
||||
**查询类(同步返回 String):** `getAuthState` / `getToken` ✓ / `getDeviceId` / `getAppVersion` / `getInstalledApps` / `getCouponClaimedToday`
|
||||
**动作类(无返回):** `navigate` / `requestLogin` ✓ / `toast` / `openMeituan` / `startCompare` / `startCouponClaim` / `launchApp` / **`closePage`【新增】✓**
|
||||
**事件类(原生→H5,`_emit`):** `onAuthChange` / `onBalanceChange` / `onSigninChange` / `onResume`
|
||||
(✓ = records 阶段 M1 最小子集)
|
||||
|
||||
---
|
||||
|
||||
## 9. 远程开关配置接口
|
||||
|
||||
- 提供一个轻量配置:返回各页渲染方式 `{ records: "h5"|"native", reports: ..., home: ..., welfare: ..., mine: ... }`。
|
||||
- 安卓 `AppNavHost` 据此决定加载 H5 还是原生页,**不发版即可灰度 / 一键回退**。
|
||||
- 复用现有远程配置/开关基建(参考安卓 `ad-config-remote-delivery`、`comparing-ad-remote-flag` 的下发方式),无则新增一个 `GET /api/v1/app/ui-flags` 之类端点。
|
||||
|
||||
---
|
||||
|
||||
## 10. 缓存与热更
|
||||
|
||||
- H5 是后端托管 → **改完即生效**(用户重开页即拉新),这是远程方案的核心收益。
|
||||
- `index.html` 用 `Cache-Control: no-cache`(或短 TTL)保证及时性;`shared/*.js`、图片等带版本/指纹便于长缓存。StaticFiles 可配响应头或交 nginx 处理。
|
||||
|
||||
---
|
||||
|
||||
## 11. 迁移顺序与风险
|
||||
|
||||
| 里程碑 | server 侧动作 |
|
||||
|---|---|
|
||||
| **M1 records** | 挂 `/h5`;`bridge.js` 加 `closePage`;records 迁入 + 改桥;配 `index.html` no-cache |
|
||||
| **M2 reports** | reports 迁入 + 改桥 |
|
||||
| **M3 首页/福利** | 首页/福利页 H5 化(需 SGBridge P2+ 协议,配合安卓) |
|
||||
| **M4 mine + 收尾** | mine 按真实 SGBridge.kt 校准;远程开关接口上线 |
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| SGBridge 原生侧未验证 | M1 用 records 小页验证(详见 android 文档) |
|
||||
| multipart 上传被误套进 SGApi | 文档明确:上报保留自定义 multipart fetch,不走 SGApi |
|
||||
| 同源被破坏(H5 与 API 不同 host) | 坚持 StaticFiles 同源托管;若改独立前端域名,需回评 SGApi 相对路径策略 |
|
||||
| 缓存导致改了不生效 | `index.html` no-cache + 资源指纹 |
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 664 KiB |
|
Before Width: | Height: | Size: 614 KiB |
|
Before Width: | Height: | Size: 773 KiB |
|
Before Width: | Height: | Size: 695 KiB |
|
Before Width: | Height: | Size: 732 KiB |
|
Before Width: | Height: | Size: 778 KiB |
|
Before Width: | Height: | Size: 960 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 975 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 89 KiB |
@@ -1,34 +0,0 @@
|
||||
<svg width="172" height="148" viewBox="0 0 172 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="buttonShadow" x="0" y="0" width="172" height="148" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="7" flood-color="#D79A1E" flood-opacity=".22"/>
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#FFFFFF" flood-opacity=".88"/>
|
||||
</filter>
|
||||
<linearGradient id="buttonFill" x1="43" y1="16" x2="126" y2="135" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFCE3"/>
|
||||
<stop offset=".38" stop-color="#FFF2A8"/>
|
||||
<stop offset=".72" stop-color="#FFE27A"/>
|
||||
<stop offset="1" stop-color="#FFD15A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="buttonStroke" x1="35" y1="11" x2="136" y2="139" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFF7C2"/>
|
||||
<stop offset=".48" stop-color="#FFE484"/>
|
||||
<stop offset="1" stop-color="#EAB237"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="buttonGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(70 44) rotate(62) scale(86 66)">
|
||||
<stop stop-color="#FFFFFF" stop-opacity=".86"/>
|
||||
<stop offset=".58" stop-color="#FFFFFF" stop-opacity=".24"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="arrowStroke" x1="50" y1="49" x2="125" y2="98" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFAE16"/>
|
||||
<stop offset="1" stop-color="#F08300"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g filter="url(#buttonShadow)">
|
||||
<path d="M45 13H127C151.8 13 166 35.8 166 62V86C166 112.2 151.8 135 127 135H45C20.2 135 6 112.2 6 86V62C6 35.8 20.2 13 45 13Z" fill="url(#buttonFill)" stroke="url(#buttonStroke)" stroke-width="3"/>
|
||||
<path d="M48 19H124C146.1 19 159 38.7 159 63V84C159 108.3 146.1 128 124 128H48C25.9 128 13 108.3 13 84V63C13 38.7 25.9 19 48 19Z" fill="url(#buttonGlow)"/>
|
||||
<path d="M58 51L81 74L58 97" stroke="url(#arrowStroke)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M93 51L116 74L93 97" stroke="url(#arrowStroke)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 917 KiB |
|
Before Width: | Height: | Size: 654 KiB |
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 899 KiB |
|
Before Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 899 KiB |
|
Before Width: | Height: | Size: 482 KiB |
@@ -1,43 +0,0 @@
|
||||
# Shared Icons
|
||||
|
||||
通用图标的单一事实源(canonical source)。每个图标都是独立 SVG 文件,但**实际页面里必须内联 SVG markup**(不用 `<img src>` 或 `<svg src>` 引用),符合 prototypes 仓库"每个 HTML 自包含"原则。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 改图标 → 改这里的 SVG 文件
|
||||
2. 在所有用到的 HTML 里同步替换内联 SVG markup
|
||||
3. DESIGN.md 引用本目录路径作为基准
|
||||
|
||||
## 现有图标
|
||||
|
||||
| 文件 | 用途 | 推荐内联尺寸 |
|
||||
|---|---|---|
|
||||
| `back-chevron.svg` | 顶部导航返回按钮 | 24×24 |
|
||||
| `tab-home-outline.svg` | 底 tab "首页" 未选中态 | 24×24 |
|
||||
| `tab-home-filled.svg` | 底 tab "首页" 选中态(黄填充)| 24×24 |
|
||||
| `tab-welfare-outline.svg` | 底 tab "福利" 未选中态 | 24×24 |
|
||||
| `tab-welfare-filled.svg` | 底 tab "福利" 选中态(黄填充)| 24×24 |
|
||||
| `tab-profile-outline.svg` | 底 tab "我的" 未选中态 | 24×24 |
|
||||
| `tab-profile-filled.svg` | 底 tab "我的" 选中态(黄填充)| 24×24 |
|
||||
|
||||
## 标准 button 包装
|
||||
|
||||
```html
|
||||
<button class="btn-back" onclick="goBack()" aria-label="返回">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
```css
|
||||
.btn-back {
|
||||
width: 44px; height: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: none; padding: 0;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-back:active { opacity: 0.5; }
|
||||
.btn-back svg { width: 24px; height: 24px; display: block; }
|
||||
```
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 217 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12l9-8 9 8" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9" fill="#FFD600" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 368 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12l9-8 9 8"/>
|
||||
<path d="M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 272 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="4" fill="#FFD600" stroke="#1A1A1A" stroke-width="2"/>
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#FFD600" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 257 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="8" width="18" height="4" rx="1" fill="#FFD600" stroke="#1A1A1A" stroke-width="2"/>
|
||||
<path d="M5 12v7a1 1 0 001 1h12a1 1 0 001-1v-7" fill="#FFD600" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 8v12" stroke="#1A1A1A" stroke-width="2"/>
|
||||
<path d="M7.5 8C6 8 5 6.5 6 5.5S9 4 9.5 5.5c.4 1.2 2.5 2.5 2.5 2.5s2.1-1.3 2.5-2.5C15 4 17 4.5 18 5.5s-1 2.5-2.5 2.5" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 611 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="8" width="18" height="4" rx="1"/>
|
||||
<path d="M12 8v12"/>
|
||||
<path d="M5 12v7a1 1 0 001 1h12a1 1 0 001-1v-7"/>
|
||||
<path d="M7.5 8C6 8 5 6.5 6 5.5S9 4 9.5 5.5c.4 1.2 2.5 2.5 2.5 2.5s2.1-1.3 2.5-2.5C15 4 17 4.5 18 5.5s-1 2.5-2.5 2.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 432 B |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 200 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<clipPath id="appIconClip">
|
||||
<rect x="0" y="0" width="512" height="512" rx="105" ry="105"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="512" height="512" rx="105" ry="105" fill="rgb(255, 217, 61)"/>
|
||||
<image href="sb-brand.png" x="0" y="0" width="512" height="512" preserveAspectRatio="none" clip-path="url(#appIconClip)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 446 B |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 912 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1009 KiB |
|
Before Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 515 KiB |
|
Before Width: | Height: | Size: 450 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 724 KiB |
|
Before Width: | Height: | Size: 1001 KiB |