terry-staging #16
@@ -5,11 +5,39 @@
|
||||
>
|
||||
> **核心结论:后端目前几乎已经满足前端全部需求。本次重设计与 bug 修复基本是纯前端工作。后端真正需要新增的只有少量「可选项」,外加几处需要确认的契约。请不要把已完成的功能再派一遍。**
|
||||
|
||||
日期:2026-06-02
|
||||
日期:2026-06-02(2026-06-02 更新:新增 §0.5 部署阻塞)
|
||||
相关分支:`terry-wallet-login`
|
||||
|
||||
---
|
||||
|
||||
## 0.5 🔴 关键阻塞:线上后端是旧版本,收藏与 TokenPocket 端点未部署
|
||||
|
||||
> 源码(`Arkie-Library-Backend`)里这些端点都有;但**当前线上 / dev 代理指向的后端**(`https://ark-library.com/apnew`)是**旧构建**,下列路由全部 404。
|
||||
> **前端已就绪,但收藏与 TokenPocket 扫码登录在生产上无法工作,直到后端把含这些路由的版本部署上线。** 这正是用户实测「收藏用不了、扫码登录用不了」的根因。
|
||||
|
||||
curl 实证(经 vite dev 代理打到线上后端,2026-06-02):
|
||||
|
||||
| 端点 | 方法 | 实测状态 | 结论 |
|
||||
|---|---|---|---|
|
||||
| `/api/auth/wallet/nonce` | POST | **200** | ✅ 已部署 |
|
||||
| `/api/auth/wallet/verify` | POST | **400**(入参错误)| ✅ 已部署 |
|
||||
| `/api/auth/wallet/me` | GET | **401**(需鉴权)| ✅ 已部署 |
|
||||
| `/api/auth/wallet/tp-login-request` | POST | **404** | ❌ 未部署 |
|
||||
| `/api/auth/wallet/tp-result` | GET | **404** | ❌ 未部署 |
|
||||
| `/api/auth/wallet/tp-callback` | POST | **404** | ❌ 未部署 |
|
||||
| `/api/me/favorites` | GET | **404**(应为 401)| ❌ 未部署 |
|
||||
| `/api/me/favorites/ids` | GET | **404** | ❌ 未部署 |
|
||||
| `/api/me/favorites/{id}` | POST | **404** | ❌ 未部署 |
|
||||
|
||||
**后端动作(必做,按优先级最高):**
|
||||
1. 部署包含 favorites(`internal/handlers/favorites.go`)+ TokenPocket(`internal/handlers/wallet_tp.go`)路由的后端版本(`cmd/server/main.go` 已注册这些路由)。
|
||||
2. 为 TokenPocket 登录设置 `PUBLIC_BASE_URL`(`buildTokenPocketSignURL` 需要它生成回调 URL,否则 tp-login-request 会 500)。
|
||||
3. 确保 `wallet_tp_login_requests` / `user_favorites` 等表已迁移(`EnsureWalletAuthSchema` 会建表)。
|
||||
|
||||
> 复测命令:`curl -s -o /dev/null -w "%{http_code}" -X POST https://ark-library.com/apnew/api/auth/wallet/tp-login-request -d '{}'` 应返回 200(且 body 的 `qrUrl` 形如 `tpoutside://pull.activity?param=...`)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话给后端
|
||||
|
||||
> 钱包认证、TokenPocket 扫码、收藏列表/筛选/分页/可用性,**都已实现且符合前端契约**。
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[100] flex flex-col items-center gap-2 px-4 md:bottom-6"
|
||||
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[200] flex flex-col items-center gap-2 px-4 md:bottom-6"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
|
||||
@@ -183,8 +183,15 @@ export const enDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "TokenPocket login",
|
||||
walletTpMobileDesc:
|
||||
"Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.",
|
||||
walletTpLoginBtn: "Log in with TokenPocket",
|
||||
walletTpWaiting: "Waiting for your signature in TokenPocket…",
|
||||
walletTpReopen: "Reopen TokenPocket",
|
||||
favoritesFilters: "Filters",
|
||||
favoriteSessionExpired: "Your session expired. Please sign in again.",
|
||||
loadFailed: "Could not load your favorites.",
|
||||
walletChooseDesktop:
|
||||
"Choose the wallet you want to use. On desktop, install the matching browser extension.",
|
||||
walletChooseMobile: "Choose a wallet app to open this site.",
|
||||
|
||||
@@ -183,8 +183,15 @@ export const idDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "Masuk TokenPocket",
|
||||
walletTpMobileDesc:
|
||||
"Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.",
|
||||
walletTpLoginBtn: "Masuk dengan TokenPocket",
|
||||
walletTpWaiting: "Menunggu tanda tangan Anda di TokenPocket…",
|
||||
walletTpReopen: "Buka kembali TokenPocket",
|
||||
favoritesFilters: "Filter",
|
||||
favoriteSessionExpired: "Sesi Anda telah berakhir. Silakan masuk lagi.",
|
||||
loadFailed: "Gagal memuat favorit Anda.",
|
||||
walletChooseDesktop:
|
||||
"Pilih dompet yang ingin digunakan. Di desktop, pasang ekstensi browser yang sesuai.",
|
||||
walletChooseMobile: "Pilih aplikasi dompet untuk membuka situs ini.",
|
||||
|
||||
@@ -203,9 +203,16 @@ export const jaDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "TokenPocket ログイン",
|
||||
walletTpMobileDesc:
|
||||
"TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。",
|
||||
walletTpLoginBtn: "TokenPocket でログイン",
|
||||
walletTpWaiting: "TokenPocket での署名を待っています…",
|
||||
walletTpReopen: "TokenPocket を再度開く",
|
||||
favoritesFilters: "フィルター",
|
||||
favoriteSessionExpired:
|
||||
"セッションの有効期限が切れました。もう一度ログインしてください。",
|
||||
loadFailed: "お気に入りを読み込めませんでした。",
|
||||
walletChooseDesktop:
|
||||
"使用するウォレットを選択してください。デスクトップの場合は対応するブラウザ拡張機能をインストールしてください。",
|
||||
walletChooseMobile: "このサイトを開くウォレットアプリを選択してください。",
|
||||
|
||||
@@ -181,8 +181,15 @@ export const koDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "TokenPocket 로그인",
|
||||
walletTpMobileDesc:
|
||||
"TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.",
|
||||
walletTpLoginBtn: "TokenPocket으로 로그인",
|
||||
walletTpWaiting: "TokenPocket에서 서명을 기다리는 중…",
|
||||
walletTpReopen: "TokenPocket 다시 열기",
|
||||
favoritesFilters: "필터",
|
||||
favoriteSessionExpired: "세션이 만료되었습니다. 다시 로그인해 주세요.",
|
||||
loadFailed: "즐겨찾기를 불러오지 못했습니다.",
|
||||
walletChooseDesktop:
|
||||
"사용할 지갑을 선택하세요. 데스크톱에서는 해당 브라우저 확장 프로그램을 설치하세요.",
|
||||
walletChooseMobile: "이 사이트를 열 지갑 앱을 선택하세요.",
|
||||
|
||||
@@ -183,8 +183,15 @@ export const msDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "Log masuk TokenPocket",
|
||||
walletTpMobileDesc:
|
||||
"Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.",
|
||||
walletTpLoginBtn: "Log masuk dengan TokenPocket",
|
||||
walletTpWaiting: "Menunggu tandatangan anda dalam TokenPocket…",
|
||||
walletTpReopen: "Buka semula TokenPocket",
|
||||
favoritesFilters: "Penapis",
|
||||
favoriteSessionExpired: "Sesi anda telah tamat. Sila log masuk semula.",
|
||||
loadFailed: "Gagal memuatkan kegemaran anda.",
|
||||
walletChooseDesktop:
|
||||
"Pilih dompet yang ingin anda gunakan. Pada desktop, pasang sambungan pelayar yang sepadan.",
|
||||
walletChooseMobile: "Pilih aplikasi dompet untuk membuka laman ini.",
|
||||
|
||||
@@ -181,8 +181,15 @@ export const viDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "Đăng nhập TokenPocket",
|
||||
walletTpMobileDesc:
|
||||
"Mở TokenPocket để ký, rồi quay lại đây để hoàn tất. Bạn vẫn ở trong trình duyệt này thay vì trình duyệt trong ví.",
|
||||
walletTpLoginBtn: "Đăng nhập bằng TokenPocket",
|
||||
walletTpWaiting: "Đang chờ bạn ký trong TokenPocket…",
|
||||
walletTpReopen: "Mở lại TokenPocket",
|
||||
favoritesFilters: "Bộ lọc",
|
||||
favoriteSessionExpired: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
|
||||
loadFailed: "Không thể tải mục yêu thích của bạn.",
|
||||
walletChooseDesktop:
|
||||
"Chọn ví bạn muốn dùng. Trên máy tính, hãy cài tiện ích mở rộng trình duyệt tương ứng.",
|
||||
walletChooseMobile: "Chọn ứng dụng ví để mở trang này.",
|
||||
|
||||
@@ -174,8 +174,15 @@ export const zhDict: Dict = {
|
||||
walletTokenPocket: "TokenPocket",
|
||||
walletMetaMask: "MetaMask",
|
||||
walletImToken: "imToken",
|
||||
walletTokenPocketLogin: "TokenPocket 登录",
|
||||
walletTpMobileDesc:
|
||||
"在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。",
|
||||
walletTpLoginBtn: "使用 TokenPocket 登录",
|
||||
walletTpWaiting: "等待你在 TokenPocket 中完成签名…",
|
||||
walletTpReopen: "重新打开 TokenPocket",
|
||||
favoritesFilters: "筛选",
|
||||
favoriteSessionExpired: "登录已过期,请重新登录。",
|
||||
loadFailed: "无法加载收藏,请稍后重试。",
|
||||
walletChooseDesktop: "选择你要使用的钱包。电脑端需要先安装对应浏览器插件。",
|
||||
walletChooseMobile: "选择钱包 App 打开本站。",
|
||||
walletDesktopHint:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
@@ -132,6 +133,10 @@ export function WalletLoginModal() {
|
||||
|
||||
const signInjected = async () => {
|
||||
setError("");
|
||||
if (!getInjectedEthereum()) {
|
||||
setError(t("walletNoBrowserWalletDesc"));
|
||||
return;
|
||||
}
|
||||
setState("signing");
|
||||
await signInInjected()
|
||||
.catch((err) => {
|
||||
@@ -146,13 +151,19 @@ export function WalletLoginModal() {
|
||||
openWalletDeepLink(kind);
|
||||
};
|
||||
|
||||
const startTokenPocketQr = async () => {
|
||||
// TokenPocket login. The backend returns a `tpoutside://pull.activity` deep
|
||||
// link (a one-off SIGN request, not a dApp-browser link). On mobile we open
|
||||
// it directly so TokenPocket only asks for a signature and then returns to
|
||||
// THIS browser — the poll below finishes login here, no in-app browser. On
|
||||
// desktop we render it as a QR to scan from a phone.
|
||||
const startTokenPocketLogin = async () => {
|
||||
setError("");
|
||||
setState("tpLoading");
|
||||
try {
|
||||
const req = await createTokenPocketLoginRequest();
|
||||
setTpRequest(req);
|
||||
setState("tpPolling");
|
||||
if (mobileDevice) window.location.href = req.qrUrl;
|
||||
} catch {
|
||||
setState("idle");
|
||||
setError(t("walletTpQrFailed"));
|
||||
@@ -189,8 +200,8 @@ export function WalletLoginModal() {
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
{!mobileDevice || hasInjected ? (
|
||||
<>
|
||||
{/* Injected wallet: browser extension (desktop) or in-wallet browser. */}
|
||||
{hasInjected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInjected()}
|
||||
@@ -203,15 +214,82 @@ export function WalletLoginModal() {
|
||||
? t("walletUseCurrent")
|
||||
: t("walletInjected")}
|
||||
</button>
|
||||
{!mobileDevice ? (
|
||||
<p className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-xs leading-5 text-neutral-400">
|
||||
{t("walletDesktopHint")}
|
||||
</p>
|
||||
) : !mobileDevice ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInjected()}
|
||||
className="flex items-center justify-center gap-2 rounded-2xl border border-ark-gold/50 bg-ark-gold/5 px-4 py-3 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10"
|
||||
>
|
||||
{t("walletInjected")}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
{/* TokenPocket login — universal path that returns to this browser. */}
|
||||
<div className="grid gap-2 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||
<p className="text-sm font-semibold text-neutral-100">
|
||||
{t("walletTokenPocketLogin")}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-neutral-400">
|
||||
{mobileDevice
|
||||
? t("walletTpMobileDesc")
|
||||
: t("walletTokenPocketQrDesc")}
|
||||
</p>
|
||||
{!tpRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startTokenPocketLogin()}
|
||||
disabled={state === "tpLoading"}
|
||||
className="mt-1 inline-flex items-center justify-center gap-2 justify-self-start rounded-full bg-ark-gold px-4 py-2 text-sm font-semibold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
<WalletBrandIcon kind="tokenPocket" size={20} />
|
||||
{state === "tpLoading"
|
||||
? t("loading")
|
||||
: mobileDevice
|
||||
? t("walletTpLoginBtn")
|
||||
: t("walletGenerateQr")}
|
||||
</button>
|
||||
) : mobileDevice ? (
|
||||
<div className="mt-1 grid gap-2 rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-4 py-3">
|
||||
<p className="flex items-center gap-2 text-xs leading-5 text-neutral-300">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
||||
{t("walletTpWaiting")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = tpRequest.qrUrl;
|
||||
}}
|
||||
className="justify-self-start rounded-full border border-ark-gold/40 px-3 py-1.5 text-xs font-semibold text-ark-gold transition hover:bg-ark-gold/10"
|
||||
>
|
||||
{t("walletTpReopen")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
|
||||
<QRCodeSVG value={tpRequest.qrUrl} size={180} level="M" />
|
||||
<p className="text-xs font-medium text-neutral-700">
|
||||
{t("walletQrUseAnotherDevice")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Other methods: open a wallet app (mobile) and WalletConnect QR. */}
|
||||
<div className="rounded-2xl border border-dashed border-white/15">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOther((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-semibold text-ark-gold transition hover:text-ark-gold2"
|
||||
>
|
||||
<span>{t("walletOtherMethods")}</span>
|
||||
<span>{showOther ? "−" : "+"}</span>
|
||||
</button>
|
||||
|
||||
{showOther ? (
|
||||
<div className="grid gap-4 px-4 pb-4">
|
||||
{mobileDevice && !hasInjected ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm font-medium text-neutral-300">
|
||||
<p className="text-sm font-semibold text-neutral-200">
|
||||
{t("walletOpenWalletApp")}
|
||||
</p>
|
||||
{appWallets.map((option) => (
|
||||
@@ -219,7 +297,7 @@ export function WalletLoginModal() {
|
||||
key={option.kind}
|
||||
type="button"
|
||||
onClick={() => openApp(option.kind)}
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left text-base font-semibold text-neutral-100 transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-left text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
||||
>
|
||||
<WalletBrandIcon kind={option.kind} />
|
||||
<span>{t(option.labelKey)}</span>
|
||||
@@ -249,50 +327,16 @@ export function WalletLoginModal() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-white/15">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOther((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-semibold text-ark-gold transition hover:text-ark-gold2"
|
||||
>
|
||||
<span>{t("walletOtherMethods")}</span>
|
||||
<span>{showOther ? "−" : "+"}</span>
|
||||
</button>
|
||||
|
||||
{showOther ? (
|
||||
<div className="grid gap-4 px-4 pb-4">
|
||||
{/* TokenPocket QR — stable path for China users (works on desktop too). */}
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm font-semibold text-neutral-200">
|
||||
{t("walletTokenPocketQr")}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-neutral-400">
|
||||
{t("walletTokenPocketQrDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startTokenPocketQr()}
|
||||
disabled={state === "tpLoading"}
|
||||
className="justify-self-start rounded-full bg-ark-gold px-4 py-2 text-sm font-semibold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{state === "tpLoading"
|
||||
? t("loading")
|
||||
: t("walletGenerateQr")}
|
||||
</button>
|
||||
{tpRequest ? (
|
||||
<div className="mt-1 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
|
||||
<QRCodeSVG value={tpRequest.qrUrl} size={180} level="M" />
|
||||
<p className="text-xs font-medium text-neutral-700">
|
||||
{t("walletQrUseAnotherDevice")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */}
|
||||
<div className="grid gap-2 border-t border-white/10 pt-4">
|
||||
{/* MetaMask / imToken QR via WalletConnect — needs a real id. */}
|
||||
<div
|
||||
className={
|
||||
mobileDevice && !hasInjected
|
||||
? "grid gap-2 border-t border-white/10 pt-4"
|
||||
: "grid gap-2"
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-semibold text-neutral-200">
|
||||
{t("walletRainbowFallback")}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user