添加排行榜
3
AGENTS.md
Normal file
@ -0,0 +1,3 @@
|
||||
common 里存放环境的js 如果有参数 ?env=test 就走test api
|
||||
如果没有参数默认是 线上的api
|
||||
如果env=local 就是本地的api
|
||||
33
Makefile
Normal file
@ -0,0 +1,33 @@
|
||||
PORT ?= 30000
|
||||
HOST ?= 127.0.0.1
|
||||
PAGE ?= /
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
@URL="http://$(HOST):$(PORT)$(PAGE)"; \
|
||||
echo "Serving H5 at $$URL"; \
|
||||
if curl -fsS "http://$(HOST):$(PORT)/__h5_dev_server__/health" 2>/dev/null | grep -q "ok"; then \
|
||||
echo "H5 dev server is already running on http://$(HOST):$(PORT)/"; \
|
||||
open "$$URL"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
if curl -fsS "http://$(HOST):$(PORT)/" >/dev/null 2>&1; then \
|
||||
echo "Port $(PORT) is occupied by a server without the H5 API proxy."; \
|
||||
echo "Stop that process, then run make run again."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
python3 scripts/h5_dev_server.py --host "$(HOST)" --port "$(PORT)" --directory "$$(pwd)" & \
|
||||
SERVER_PID=$$!; \
|
||||
trap 'kill $$SERVER_PID 2>/dev/null || true' INT TERM EXIT; \
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do \
|
||||
if curl -fsS "http://$(HOST):$(PORT)/__h5_dev_server__/health" >/dev/null 2>&1; then \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 0.2; \
|
||||
done; \
|
||||
if ! curl -fsS "http://$(HOST):$(PORT)/__h5_dev_server__/health" | grep -q "ok"; then \
|
||||
echo "H5 dev server is not ready on http://$(HOST):$(PORT)/"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
open "$$URL"; \
|
||||
wait $$SERVER_PID
|
||||
179
common/api.js
Normal file
@ -0,0 +1,179 @@
|
||||
var test_api = "https://api-test.global-interaction.com/";
|
||||
var local_api = "http://localhost:13000/";
|
||||
var default_api = "https://api.global-interaction.com/";
|
||||
|
||||
(function () {
|
||||
var API_ENV_KEY = "hyapp_h5_env";
|
||||
var ACCESS_TOKEN_KEY = "hyapp_access_token";
|
||||
var APP_CODE_KEY = "hyapp_app_code";
|
||||
var memoryEnv = "";
|
||||
var memoryAccessToken = "";
|
||||
var memoryAppCode = "";
|
||||
|
||||
function readQuery(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
function normalizeBaseURL(value) {
|
||||
return String(value || "").replace(/\/+$/, "") + "/";
|
||||
}
|
||||
|
||||
function isLocalDevOrigin() {
|
||||
var host = window.location.hostname;
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
||||
}
|
||||
|
||||
function shouldPersist() {
|
||||
return !isLocalDevOrigin();
|
||||
}
|
||||
|
||||
function clearLocalDevCache() {
|
||||
if (shouldPersist()) return;
|
||||
window.localStorage.removeItem(API_ENV_KEY);
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
window.localStorage.removeItem(APP_CODE_KEY);
|
||||
}
|
||||
|
||||
function devProxyBase(path) {
|
||||
return normalizeBaseURL(window.location.origin + path);
|
||||
}
|
||||
|
||||
function resolveEnv() {
|
||||
var env = readQuery("env");
|
||||
if (env === "test" || env === "local") {
|
||||
memoryEnv = env;
|
||||
if (shouldPersist()) window.localStorage.setItem(API_ENV_KEY, env);
|
||||
return env;
|
||||
}
|
||||
if (memoryEnv) return memoryEnv;
|
||||
if (!shouldPersist()) return "";
|
||||
return window.localStorage.getItem(API_ENV_KEY) || "";
|
||||
}
|
||||
|
||||
function resolveBaseURL() {
|
||||
var env = resolveEnv();
|
||||
if (env === "test") return isLocalDevOrigin() ? devProxyBase("/__api_test__/") : normalizeBaseURL(test_api);
|
||||
if (env === "local") return isLocalDevOrigin() ? devProxyBase("/__api_local__/") : normalizeBaseURL(local_api);
|
||||
return normalizeBaseURL(default_api);
|
||||
}
|
||||
|
||||
function buildURL(path, query) {
|
||||
var url = new URL(path.replace(/^\/+/, ""), resolveBaseURL());
|
||||
if (query) {
|
||||
Object.keys(query).forEach(function (key) {
|
||||
var value = query[key];
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function getAccessToken() {
|
||||
if (memoryAccessToken) return memoryAccessToken;
|
||||
if (!shouldPersist()) return "";
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
function getAppCode() {
|
||||
var queryAppCode = readQuery("app_code") || readQuery("appCode");
|
||||
if (queryAppCode) {
|
||||
memoryAppCode = queryAppCode;
|
||||
if (shouldPersist()) window.localStorage.setItem(APP_CODE_KEY, queryAppCode);
|
||||
return queryAppCode;
|
||||
}
|
||||
if (memoryAppCode) return memoryAppCode;
|
||||
if (!shouldPersist()) return "lalu";
|
||||
return window.localStorage.getItem(APP_CODE_KEY) || "lalu";
|
||||
}
|
||||
|
||||
function setAccessToken(token) {
|
||||
memoryAccessToken = token || "";
|
||||
if (token) {
|
||||
if (shouldPersist()) window.localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
return;
|
||||
}
|
||||
if (shouldPersist()) window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function setAppCode(appCode) {
|
||||
memoryAppCode = appCode || "";
|
||||
if (appCode) {
|
||||
if (shouldPersist()) window.localStorage.setItem(APP_CODE_KEY, appCode);
|
||||
return;
|
||||
}
|
||||
if (shouldPersist()) window.localStorage.removeItem(APP_CODE_KEY);
|
||||
}
|
||||
|
||||
function parseResponse(response) {
|
||||
return response.text().then(function (text) {
|
||||
var payload = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
var httpError = new Error(payload.message || response.statusText || "request_failed");
|
||||
httpError.status = response.status;
|
||||
httpError.payload = payload;
|
||||
throw httpError;
|
||||
}
|
||||
var code = payload && payload.code;
|
||||
var isErrorCode = (typeof code === "number" && code !== 0) || (typeof code === "string" && code !== "" && code !== "OK");
|
||||
if (isErrorCode) {
|
||||
var apiError = new Error(payload.message || "api_request_failed");
|
||||
apiError.code = payload.code;
|
||||
apiError.request_id = payload.request_id;
|
||||
apiError.payload = payload;
|
||||
throw apiError;
|
||||
}
|
||||
return payload && Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : payload;
|
||||
});
|
||||
}
|
||||
|
||||
function request(path, options) {
|
||||
var opts = options || {};
|
||||
var headers = Object.assign({}, opts.headers || {});
|
||||
var token = getAccessToken();
|
||||
var appCode = getAppCode();
|
||||
if (token) headers.Authorization = "Bearer " + token;
|
||||
if (appCode) headers["X-App-Code"] = appCode;
|
||||
if (!shouldPersist()) headers["Cache-Control"] = "no-cache";
|
||||
|
||||
var body = opts.body;
|
||||
if (body && typeof body === "object" && !(body instanceof FormData)) {
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return window.fetch(buildURL(path, opts.query), {
|
||||
method: opts.method || "GET",
|
||||
headers: headers,
|
||||
body: body,
|
||||
cache: shouldPersist() ? "default" : "no-store",
|
||||
credentials: opts.credentials || "omit"
|
||||
}).then(parseResponse);
|
||||
}
|
||||
|
||||
window.HyAppAPI = {
|
||||
baseURL: resolveBaseURL,
|
||||
buildURL: buildURL,
|
||||
getAccessToken: getAccessToken,
|
||||
getAppCode: getAppCode,
|
||||
setAccessToken: setAccessToken,
|
||||
setAppCode: setAppCode,
|
||||
request: request,
|
||||
get: function (path, query) {
|
||||
return request(path, { method: "GET", query: query });
|
||||
},
|
||||
post: function (path, body, query) {
|
||||
return request(path, { method: "POST", body: body, query: query });
|
||||
},
|
||||
put: function (path, body, query) {
|
||||
return request(path, { method: "PUT", body: body, query: query });
|
||||
},
|
||||
patch: function (path, body, query) {
|
||||
return request(path, { method: "PATCH", body: body, query: query });
|
||||
},
|
||||
delete: function (path, query) {
|
||||
return request(path, { method: "DELETE", query: query });
|
||||
}
|
||||
};
|
||||
clearLocalDevCache();
|
||||
})();
|
||||
8
common/jsbrdge.js
Normal file
@ -0,0 +1,8 @@
|
||||
(function () {
|
||||
if (!window.HyAppBridge) {
|
||||
var script = document.createElement("script");
|
||||
var current = document.currentScript && document.currentScript.src;
|
||||
script.src = current ? current.replace(/jsbrdge\.js(?:\?.*)?$/, "jsbridge.js") : "/common/jsbridge.js";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
})();
|
||||
43
common/jsbridge.js
Normal file
@ -0,0 +1,43 @@
|
||||
(function () {
|
||||
function post(action, payload) {
|
||||
var message = JSON.stringify({ action: action, payload: payload || {} });
|
||||
|
||||
if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
|
||||
return window.flutter_inappwebview.callHandler("HyAppBridge", action, payload || {});
|
||||
}
|
||||
if (window.HyAppBridgeChannel && window.HyAppBridgeChannel.postMessage) {
|
||||
window.HyAppBridgeChannel.postMessage(message);
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (window.FlutterBridge && window.FlutterBridge.postMessage) {
|
||||
window.FlutterBridge.postMessage(message);
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.HyAppBridge) {
|
||||
window.webkit.messageHandlers.HyAppBridge.postMessage({ action: action, payload: payload || {} });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent("hyapp:bridge-message", { detail: { action: action, payload: payload || {} } }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
window.HyAppBridge = {
|
||||
post: post,
|
||||
back: function () {
|
||||
return post("back");
|
||||
},
|
||||
openUser: function (userId) {
|
||||
return post("openUser", { user_id: String(userId || "") });
|
||||
},
|
||||
openRoom: function (roomId) {
|
||||
return post("openRoom", { room_id: String(roomId || "") });
|
||||
},
|
||||
openRankNote: function (boardType) {
|
||||
return post("openRankNote", { board_type: String(boardType || "") });
|
||||
},
|
||||
ready: function (payload) {
|
||||
return post("ready", payload || {});
|
||||
}
|
||||
};
|
||||
})();
|
||||
72
common/params.js
Normal file
@ -0,0 +1,72 @@
|
||||
(function () {
|
||||
var TOKEN_KEYS = ["token", "access_token", "accessToken"];
|
||||
var APP_CODE_KEYS = ["app_code", "appCode"];
|
||||
var USER_READY_EVENT = "hyapp:user-ready";
|
||||
var USER_ERROR_EVENT = "hyapp:user-error";
|
||||
|
||||
function query() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var hash = window.location.hash || "";
|
||||
if (hash.indexOf("?") >= 0) {
|
||||
new URLSearchParams(hash.split("?").slice(1).join("?")).forEach(function (value, key) {
|
||||
if (!params.has(key)) params.set(key, value);
|
||||
});
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function first(params, keys) {
|
||||
for (var i = 0; i < keys.length; i += 1) {
|
||||
var value = params.get(keys[i]);
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parse() {
|
||||
var params = query();
|
||||
var token = first(params, TOKEN_KEYS);
|
||||
var appCode = first(params, APP_CODE_KEYS) || window.localStorage.getItem("hyapp_app_code") || "lalu";
|
||||
|
||||
if (window.HyAppAPI) {
|
||||
if (token) window.HyAppAPI.setAccessToken(token);
|
||||
if (appCode) window.HyAppAPI.setAppCode(appCode);
|
||||
}
|
||||
|
||||
return {
|
||||
token: token || (window.HyAppAPI && window.HyAppAPI.getAccessToken ? window.HyAppAPI.getAccessToken() : ""),
|
||||
app_code: appCode,
|
||||
raw: params
|
||||
};
|
||||
}
|
||||
|
||||
function emit(name, detail) {
|
||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||
}
|
||||
|
||||
function loadUser() {
|
||||
var current = parse();
|
||||
if (!current.token || !window.HyAppAPI) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return window.HyAppAPI.get("/api/v1/users/me/overview").then(function (userInfo) {
|
||||
current.user = userInfo;
|
||||
window.HyAppParams.current = current;
|
||||
emit(USER_READY_EVENT, current);
|
||||
return current;
|
||||
}).catch(function (error) {
|
||||
current.error = error;
|
||||
window.HyAppParams.current = current;
|
||||
emit(USER_ERROR_EVENT, current);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
window.HyAppParams = {
|
||||
parse: parse,
|
||||
loadUser: loadUser,
|
||||
current: parse(),
|
||||
USER_READY_EVENT: USER_READY_EVENT,
|
||||
USER_ERROR_EVENT: USER_ERROR_EVENT
|
||||
};
|
||||
})();
|
||||
267
docs/Flutter-H5-排行榜对接文档.md
Normal file
@ -0,0 +1,267 @@
|
||||
# Flutter H5 排行榜对接文档
|
||||
|
||||
本文说明 Flutter App 如何打开 H5 排行榜、向 H5 传递登录态,以及接收 H5 通过 JSBridge 发回的导航事件。
|
||||
|
||||
## 入口地址
|
||||
|
||||
本地开发:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:30000/rank/index.html?env=local&token={access_token}&app_code=lalu
|
||||
```
|
||||
|
||||
测试环境:
|
||||
|
||||
```text
|
||||
https://{h5-test-domain}/rank/index.html?env=test&token={access_token}&app_code=lalu
|
||||
```
|
||||
|
||||
线上环境:
|
||||
|
||||
```text
|
||||
https://{h5-domain}/rank/index.html?token={access_token}&app_code=lalu
|
||||
```
|
||||
|
||||
`env` 规则:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| 不传 | H5 请求 `https://api.global-interaction.com/` |
|
||||
| `env=test` | H5 请求 `https://api-test.global-interaction.com/` |
|
||||
| `env=local` | H5 请求 `http://localhost:13000/`,用于本机 gateway 联调 |
|
||||
|
||||
## URL 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `token` | 是 | App 登录后拿到的 access token。H5 会作为 `Authorization: Bearer <token>` 调后端。 |
|
||||
| `access_token` | 否 | `token` 的兼容别名。 |
|
||||
| `accessToken` | 否 | `token` 的兼容别名。 |
|
||||
| `app_code` | 是 | App 编码,当前传 `lalu`。H5 会作为 `X-App-Code` 请求头。 |
|
||||
| `appCode` | 否 | `app_code` 的兼容别名。 |
|
||||
| `env` | 否 | API 环境切换,见上表。 |
|
||||
|
||||
H5 启动后会调用:
|
||||
|
||||
```http
|
||||
GET /api/v1/users/me/overview
|
||||
Authorization: Bearer <token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
这个接口用于确认登录态并缓存当前用户信息。排行榜数据使用同一个 token 和 app code 请求。
|
||||
|
||||
## H5 页面能力
|
||||
|
||||
排行榜页面地址:
|
||||
|
||||
```text
|
||||
/rank/index.html
|
||||
```
|
||||
|
||||
页面内包含三个榜单,通过 tab 切换:
|
||||
|
||||
| H5 Tab | 后端 `board_type` | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Wealth` | `sent` | 财富榜,按用户送礼总金币排行 |
|
||||
| `Room` | `room` | 房间榜,按房间收礼总金币排行 |
|
||||
| `Charm` | `received` | 魅力榜,按用户收礼总金币排行 |
|
||||
|
||||
时间维度:
|
||||
|
||||
| H5 Tab | 后端 `period` |
|
||||
| --- | --- |
|
||||
| `Daily` | `today` |
|
||||
| `Weekly` | `week` |
|
||||
| `Monthly` | `month` |
|
||||
|
||||
排行榜接口:
|
||||
|
||||
```http
|
||||
GET /api/v1/activities/user-leaderboards?board_type=sent&period=today&page=1&page_size=20
|
||||
Authorization: Bearer <token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
H5 会分页加载,`page_size=20`。如果无 token 或接口失败,页面会展示 Figma 默认占位数据,不阻塞页面打开。
|
||||
|
||||
## Flutter JSBridge
|
||||
|
||||
H5 会按以下优先级尝试发送消息:
|
||||
|
||||
1. `window.flutter_inappwebview.callHandler("HyAppBridge", action, payload)`
|
||||
2. `window.HyAppBridgeChannel.postMessage(jsonString)`
|
||||
3. `window.FlutterBridge.postMessage(jsonString)`
|
||||
4. `window.webkit.messageHandlers.HyAppBridge.postMessage({ action, payload })`
|
||||
|
||||
Flutter 只需要实现其中一种即可。推荐统一注册 `HyAppBridge`。
|
||||
|
||||
### 消息格式
|
||||
|
||||
除 `flutter_inappwebview.callHandler` 外,postMessage 的消息是 JSON 字符串:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "openUser",
|
||||
"payload": {
|
||||
"user_id": "10001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### H5 发出的 action
|
||||
|
||||
| action | payload | 触发场景 | Flutter 行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ready` | `{ "page": "rank" }` | H5 初始化完成 | 可关闭加载态 |
|
||||
| `back` | `{}` | 用户点击左上角返回 | 关闭 WebView 或返回上一页 |
|
||||
| `openUser` | `{ "user_id": "10001" }` | 点击财富榜/魅力榜用户头像、榜单项 | 打开用户详情页 |
|
||||
| `openRoom` | `{ "room_id": "room_id" }` | 点击房间榜房间头像、榜单项 | 进入或打开房间详情 |
|
||||
| `openRankNote` | `{ "board_type": "sent" }` | 点击右上角问号 | 当前 H5 已内置说明页;Flutter 可只打点,不需要跳转 |
|
||||
|
||||
`board_type` 取值:
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | --- |
|
||||
| `sent` | 财富榜 |
|
||||
| `room` | 房间榜 |
|
||||
| `received` | 魅力榜 |
|
||||
|
||||
## Flutter 示例
|
||||
|
||||
### flutter_inappwebview
|
||||
|
||||
```dart
|
||||
final url = Uri.parse(
|
||||
'http://127.0.0.1:30000/rank/index.html'
|
||||
'?env=local'
|
||||
'&token=${Uri.encodeComponent(accessToken)}'
|
||||
'&app_code=lalu',
|
||||
);
|
||||
|
||||
InAppWebView(
|
||||
initialUrlRequest: URLRequest(url: WebUri(url.toString())),
|
||||
onWebViewCreated: (controller) {
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'HyAppBridge',
|
||||
callback: (args) async {
|
||||
final action = args.isNotEmpty ? args[0] as String : '';
|
||||
final payload = args.length > 1 && args[1] is Map
|
||||
? Map<String, dynamic>.from(args[1] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
switch (action) {
|
||||
case 'ready':
|
||||
// 关闭 loading
|
||||
break;
|
||||
case 'back':
|
||||
Navigator.of(context).maybePop();
|
||||
break;
|
||||
case 'openUser':
|
||||
final userId = payload['user_id']?.toString() ?? '';
|
||||
if (userId.isNotEmpty) {
|
||||
// TODO: 打开用户详情页
|
||||
}
|
||||
break;
|
||||
case 'openRoom':
|
||||
final roomId = payload['room_id']?.toString() ?? '';
|
||||
if (roomId.isNotEmpty) {
|
||||
// TODO: 打开房间或房间详情页
|
||||
}
|
||||
break;
|
||||
case 'openRankNote':
|
||||
// H5 内部已展示说明页,这里可做埋点
|
||||
break;
|
||||
}
|
||||
return {'ok': true};
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### webview_flutter JavaScriptChannel
|
||||
|
||||
如果使用 `webview_flutter`,可以注入 `HyAppBridgeChannel`:
|
||||
|
||||
```dart
|
||||
final controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'HyAppBridgeChannel',
|
||||
onMessageReceived: (message) {
|
||||
final data = jsonDecode(message.message) as Map<String, dynamic>;
|
||||
final action = data['action']?.toString() ?? '';
|
||||
final payload = data['payload'] is Map
|
||||
? Map<String, dynamic>.from(data['payload'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
switch (action) {
|
||||
case 'ready':
|
||||
break;
|
||||
case 'back':
|
||||
Navigator.of(context).maybePop();
|
||||
break;
|
||||
case 'openUser':
|
||||
final userId = payload['user_id']?.toString() ?? '';
|
||||
break;
|
||||
case 'openRoom':
|
||||
final roomId = payload['room_id']?.toString() ?? '';
|
||||
break;
|
||||
}
|
||||
},
|
||||
)
|
||||
..loadRequest(Uri.parse(rankUrl));
|
||||
```
|
||||
|
||||
## 后端依赖
|
||||
|
||||
H5 依赖以下 Gateway HTTP 接口:
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/users/me/overview` | 解析 token 后获取当前用户信息 |
|
||||
| `GET /api/v1/activities/user-leaderboards` | 获取排行榜 |
|
||||
|
||||
业务接口返回 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
H5 也兼容旧的 `code: 0` 成功格式。
|
||||
|
||||
## 本地联调
|
||||
|
||||
在 `hyapp-h5` 目录运行:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
默认打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:30000/rank/index.html?env=local
|
||||
```
|
||||
|
||||
Flutter 真机调本机服务时,`env=local` 对 H5 来说会请求 `http://localhost:13000/`。如果 WebView 运行在真机上,`localhost` 指向手机自身,不是开发机。真机联调建议:
|
||||
|
||||
1. 使用测试环境 `env=test`。
|
||||
2. 或把 H5/API 部署到同一局域网可访问地址。
|
||||
3. 或后续给 H5 增加可配置 `api_base` 参数。
|
||||
|
||||
## 验收点
|
||||
|
||||
1. Flutter 打开 H5 时带上 `token` 和 `app_code`。
|
||||
2. H5 首次加载后发出 `ready`。
|
||||
3. 点击左上角返回,Flutter 收到 `back` 并关闭 WebView。
|
||||
4. 点击财富榜/魅力榜用户,Flutter 收到 `openUser`。
|
||||
5. 点击房间榜房间,Flutter 收到 `openRoom`。
|
||||
6. 切换 `Wealth / Room / Charm` 和 `Daily / Weekly / Monthly` 时,H5 正确请求排行榜接口。
|
||||
7. 点击右上角问号,H5 展示说明页,返回后仍停留在排行榜页。
|
||||
@ -1 +0,0 @@
|
||||
111
|
||||
BIN
rank/assets/charm-bg-a.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
rank/assets/charm-bg-b.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
rank/assets/coin.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
rank/assets/default-avatar-large.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
rank/assets/default-avatar-mid.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
rank/assets/default-avatar-small.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
rank/assets/note-bg.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
rank/assets/note-border.png
Normal file
|
After Width: | Height: | Size: 831 B |
BIN
rank/assets/period-active.png
Normal file
|
After Width: | Height: | Size: 931 KiB |
BIN
rank/assets/reward.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
rank/assets/side-frame-blue.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
rank/assets/side-frame-red.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
rank/assets/top-frame.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
rank/assets/vip.png
Normal file
|
After Width: | Height: | Size: 469 KiB |
BIN
rank/assets/wealth-bg.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
481
rank/index.html
Normal file
@ -0,0 +1,481 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>Ranking</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
:root { --app-scale: 1; }
|
||||
html, body { margin: 0; width: 100%; min-height: 100%; overflow-x: hidden; background: #130a2b; font-family: Arial, Helvetica, sans-serif; color: #fff; }
|
||||
button { border: 0; padding: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; outline: none; }
|
||||
.app-viewport { position: relative; width: 375px; min-height: 812px; margin: 0 auto; transform: scale(var(--app-scale)); transform-origin: top center; }
|
||||
.page { position: relative; width: 375px; min-height: 812px; margin: 0; overflow-x: hidden; background: var(--page-bg, #7c2e33); }
|
||||
.rank-page { padding-bottom: 126px; }
|
||||
.rank-bg { position: absolute; left: -79px; top: var(--bg-top, -208px); width: 533px; height: 801px; object-fit: cover; filter: var(--bg-filter, none); pointer-events: none; }
|
||||
.rank-bg.charm-layer { opacity: .72; filter: none; }
|
||||
.top-shade { position: absolute; inset: 0 0 auto; height: 156px; pointer-events: none; background: linear-gradient(180deg, var(--shade, #3a1110), rgba(58,17,16,0)); }
|
||||
.nav { position: relative; height: 88px; z-index: 2; }
|
||||
.back { position: absolute; left: 16px; top: 10px; width: 24px; height: 24px; }
|
||||
.back::before { content: ""; position: absolute; left: 8px; top: 5px; width: 12px; height: 12px; border-left: 2px solid #fff; border-bottom: 2px solid #fff; transform: rotate(45deg); }
|
||||
.help { position: absolute; right: 18px; top: 11px; width: 18px; height: 18px; border: 1px solid rgba(255,255,255,.72); border-radius: 50%; color: rgba(255,255,255,.86); font-size: 13px; line-height: 17px; text-align: center; }
|
||||
.tabs { position: absolute; left: 60px; right: 58px; top: 13px; display: grid; grid-template-columns: repeat(3, 1fr); align-items: start; }
|
||||
.tab { position: relative; height: 32px; font-size: 16px; line-height: 16px; color: #e2e2e2; text-align: center; text-transform: capitalize; }
|
||||
.tab.active { color: #ffc219; font-weight: 600; }
|
||||
.tab.active::after { content: ""; position: absolute; left: 50%; bottom: 1px; width: 16px; height: 3px; margin-left: -8px; border-radius: 31px; background: #ffc219; }
|
||||
.periods { position: absolute; left: 65px; top: 48px; width: 258px; height: 29px; border: 1px solid #ffc376; border-radius: 15px; background: #23180a; z-index: 2; }
|
||||
.period { position: absolute; top: 0; width: 86px; height: 29px; color: #fff; font-size: 12px; line-height: 29px; text-align: center; }
|
||||
.period[data-period="today"] { left: 0; }
|
||||
.period[data-period="week"] { left: 86px; }
|
||||
.period[data-period="month"] { left: 172px; }
|
||||
.period.active { color: #492307; font-weight: 600; }
|
||||
.period.active::before { content: ""; position: absolute; left: -6px; top: -5px; width: 92px; height: 36px; background: url("assets/period-active.png") center/100% 100% no-repeat; z-index: -1; }
|
||||
.podium { position: relative; z-index: 1; height: 351px; margin-top: -44px; }
|
||||
.podium-item { position: absolute; text-align: center; }
|
||||
.podium-item .avatar { position: absolute; border-radius: 50%; object-fit: cover; }
|
||||
.podium-frame { position: absolute; pointer-events: none; }
|
||||
.podium-name { position: absolute; left: 50%; transform: translateX(-50%); width: 128px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; line-height: 12px; text-transform: capitalize; }
|
||||
.badges { position: absolute; display: flex; gap: 4px; justify-content: center; }
|
||||
.badges img { width: 12px; height: 12px; }
|
||||
.score-pill { position: absolute; left: 50%; transform: translateX(-50%); min-width: 116px; height: 18px; display: flex; align-items: center; justify-content: center; gap: 3px; font-size: 10px; line-height: 10px; background: linear-gradient(90deg, rgba(35,24,10,0), var(--score-bg-soft, rgba(35,24,10,.72)) 16%, var(--score-bg, #23180a) 33%, var(--score-bg, #23180a) 65%, var(--score-bg-soft, rgba(35,24,10,.72)) 80%, rgba(35,24,10,0)); }
|
||||
.coin { width: 12px; height: 12px; object-fit: cover; flex: 0 0 auto; }
|
||||
.top1 { left: 87px; top: 17px; width: 200px; height: 223px; }
|
||||
.top1 .podium-frame { left: 0; top: 0; width: 200px; height: 200px; }
|
||||
.top1 .avatar { left: 60px; top: 60px; width: 80px; height: 80px; }
|
||||
.top1 .podium-name { top: 175px; }
|
||||
.top1 .badges { left: 71px; top: 191px; }
|
||||
.top1 .score-pill { top: 205px; }
|
||||
.top2, .top3 { top: 198px; width: 154px; height: 181px; }
|
||||
.top2 { left: 10px; }
|
||||
.top3 { right: 10px; }
|
||||
.top2 .podium-frame, .top3 .podium-frame { left: 0; top: 0; width: 154px; height: 154px; }
|
||||
.top2 .avatar, .top3 .avatar { left: 45px; top: 45px; width: 64px; height: 64px; }
|
||||
.top2 .podium-name, .top3 .podium-name { top: 137px; }
|
||||
.top2 .badges, .top3 .badges { left: 47px; top: 153px; }
|
||||
.top2 .score-pill, .top3 .score-pill { top: 167px; }
|
||||
.list { position: relative; z-index: 1; padding: 44px 16px 16px; }
|
||||
.rank-row { width: 100%; height: 52px; margin-bottom: 8px; display: grid; grid-template-columns: 26px 40px minmax(0, 1fr) 74px; align-items: center; gap: 0; border: 1px solid #ffc376; border-radius: 4px; background: var(--row-bg, rgba(35,24,10,.8)); text-align: left; }
|
||||
.rank-no { font-size: 12px; text-align: center; }
|
||||
.row-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
|
||||
.row-main { min-width: 0; padding-left: 8px; text-align: left; }
|
||||
.row-name { max-width: 172px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; line-height: 14px; }
|
||||
.row-badges { display: flex; gap: 4px; margin-top: 5px; }
|
||||
.row-badges img { width: 12px; height: 12px; }
|
||||
.row-score { display: flex; align-items: center; justify-content: flex-end; gap: 3px; padding-right: 18px; font-size: 12px; white-space: nowrap; }
|
||||
.row-score .coin { width: 14px; height: 14px; }
|
||||
.my-rank { position: absolute; left: 0; bottom: 0; z-index: 4; width: 375px; height: 80px; border: 1px solid #fcd98f; border-bottom: 0; border-radius: 10px 10px 0 0; background: linear-gradient(180deg, var(--mine-a, #bb1a1c), var(--mine-b, #601718)); box-shadow: inset 0 0 4px #000; overflow: hidden; }
|
||||
.my-rank::before { content: ""; position: absolute; inset: 0; opacity: .08; background: radial-gradient(circle at 50% -20%, #fff 0, transparent 35%), repeating-radial-gradient(circle at 20px 20px, rgba(255,255,255,.5) 0 1px, transparent 2px 16px); pointer-events: none; }
|
||||
.my-rank .rank-row { position: absolute; left: 16px; right: 16px; top: 12px; margin: 0; border-color: transparent; background: transparent; }
|
||||
.home-indicator { position: absolute; left: 50%; bottom: 9px; z-index: 5; width: 134px; height: 5px; margin-left: -67px; border-radius: 100px; background: #fff; }
|
||||
.empty { min-height: 180px; opacity: 0; }
|
||||
.rank-state { position: relative; z-index: 1; min-height: 180px; display: flex; align-items: center; justify-content: center; padding: 24px 16px; color: rgba(255,255,255,.72); font-size: 12px; line-height: 16px; text-align: center; }
|
||||
.loading-overlay { position: absolute; inset: 0; min-height: 812px; z-index: 20; display: none; align-items: center; justify-content: center; background: rgba(19,10,43,.32); backdrop-filter: blur(1px); pointer-events: auto; }
|
||||
.loading-overlay.active { display: flex; }
|
||||
.cat-loader { width: 72px; height: 72px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(0,0,0,.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
|
||||
.cat-loader svg { width: 54px; height: 54px; overflow: visible; animation: cat-float 1.1s ease-in-out infinite; }
|
||||
.cat-loader .cat-line { fill: none; stroke: #fff; stroke-width: 3.4; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.cat-loader .cat-eye { transform-origin: center; animation: cat-blink 1.6s ease-in-out infinite; }
|
||||
.cat-loader .cat-whisker { opacity: .9; animation: cat-whisker 1.1s ease-in-out infinite; }
|
||||
@keyframes cat-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||
@keyframes cat-blink { 0%, 72%, 100% { transform: scaleY(1); } 80%, 88% { transform: scaleY(.18); } }
|
||||
@keyframes cat-whisker { 0%, 100% { opacity: .62; } 50% { opacity: 1; } }
|
||||
.note-page { display: none; min-height: 812px; background: #130a2b; overflow: hidden; }
|
||||
.note-page.active { display: block; }
|
||||
.rank-page.hidden { display: none; }
|
||||
.note-bg { position: absolute; left: -13px; top: -253px; width: 568px; height: 1065px; object-fit: cover; }
|
||||
.note-nav { position: relative; height: 60px; z-index: 2; }
|
||||
.note-title { position: absolute; top: 13px; left: 0; right: 0; font-size: 18px; line-height: 18px; text-align: center; font-weight: 600; }
|
||||
.note-tabs { position: relative; z-index: 2; height: 45px; display: grid; grid-template-columns: repeat(3, 1fr); padding: 0 24px; }
|
||||
.note-tab { position: relative; height: 32px; font-size: 18px; line-height: 18px; color: rgba(255,255,255,.5); text-transform: capitalize; text-align: left; }
|
||||
.note-tab:nth-child(2) { text-align: center; }
|
||||
.note-tab:nth-child(3) { text-align: right; }
|
||||
.note-tab.active { color: #fff; }
|
||||
.note-tab.active::after { content: ""; position: absolute; left: 50%; bottom: 0; width: 16px; height: 3px; margin-left: -8px; border-radius: 31px; background: #fff; }
|
||||
.note-copy { position: relative; z-index: 2; margin: 2px 24px 0; width: 327px; min-height: 56px; font-size: 16px; line-height: 20px; }
|
||||
.reward-panel { position: relative; z-index: 2; margin: 14px 24px 0; width: 327px; height: 474px; border: 2px solid #fff09b; background: #2a1351; box-shadow: inset 0 0 80px rgba(104,68,180,.7); }
|
||||
.reward-panel::before, .reward-panel::after { content: ""; position: absolute; top: -12px; width: 163px; height: 27px; background: url("assets/note-border.png") center/100% 100% no-repeat; }
|
||||
.reward-panel::before { left: 0; }
|
||||
.reward-panel::after { right: 0; transform: scaleX(-1); }
|
||||
.reward-title { margin-top: 26px; font-size: 20px; line-height: 20px; font-weight: 800; text-align: center; color: #fff; text-shadow: 0 2px 0 #be1e21; }
|
||||
.reward-periods { position: relative; width: 295px; height: 36px; margin: 16px auto 0; border-radius: 8px; background: rgba(255,255,255,.08); display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.reward-period { height: 32px; margin: 2px; border-radius: 8px; font-size: 14px; line-height: 32px; text-align: center; }
|
||||
.reward-period.active { background: #fff; color: #71179a; }
|
||||
.reward-grid { width: 295px; margin: 16px auto 0; display: grid; grid-template-columns: repeat(2, 140px); gap: 8px 15px; }
|
||||
.reward-card { height: 166px; border-radius: 8px; background: rgba(255,255,255,.08); text-align: center; overflow: hidden; }
|
||||
.reward-card h3 { margin: 8px 0 8px; font-size: 16px; line-height: 16px; color: #fff; text-shadow: 0 2px 0 #be1e21; }
|
||||
.reward-card img.reward { width: 80px; height: 80px; object-fit: contain; }
|
||||
.reward-name { margin-top: 4px; font-size: 14px; line-height: 14px; }
|
||||
.reward-value { margin-top: 6px; display: flex; justify-content: center; align-items: center; gap: 2px; font-size: 12px; line-height: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="appViewport" class="app-viewport">
|
||||
<main id="rankPage" class="page rank-page">
|
||||
<img id="rankBg" class="rank-bg" src="assets/wealth-bg.png" alt="">
|
||||
<img id="rankBgCharm" class="rank-bg charm-layer" src="assets/charm-bg-b.png" alt="" hidden>
|
||||
<div class="top-shade"></div>
|
||||
<section class="nav">
|
||||
<button class="back" id="backButton" aria-label="Back"></button>
|
||||
<div class="tabs" id="boardTabs">
|
||||
<button class="tab active" data-board="sent">Wealth</button>
|
||||
<button class="tab" data-board="room">Room</button>
|
||||
<button class="tab" data-board="received">Charm</button>
|
||||
</div>
|
||||
<button class="help" id="helpButton" aria-label="Note">?</button>
|
||||
<div class="periods" id="periodTabs">
|
||||
<button class="period active" data-period="today">Daily</button>
|
||||
<button class="period" data-period="week">Weekly</button>
|
||||
<button class="period" data-period="month">Monthly</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="podium" id="podium"></section>
|
||||
<section class="list" id="rankList"></section>
|
||||
<section class="my-rank" id="myRank"></section>
|
||||
<div class="home-indicator"></div>
|
||||
</main>
|
||||
|
||||
<main id="notePage" class="page note-page">
|
||||
<img class="note-bg" src="assets/note-bg.png" alt="">
|
||||
<section class="note-nav">
|
||||
<button class="back" id="noteBackButton" aria-label="Back"></button>
|
||||
<div class="note-title">Note</div>
|
||||
</section>
|
||||
<section class="note-tabs" id="noteTabs">
|
||||
<button class="note-tab active" data-board="sent">Wealth</button>
|
||||
<button class="note-tab" data-board="room">Room</button>
|
||||
<button class="note-tab" data-board="received">Charm</button>
|
||||
</section>
|
||||
<div class="note-copy" id="noteCopy"></div>
|
||||
<section class="reward-panel">
|
||||
<div class="reward-title">Ranking Rewards</div>
|
||||
<div class="reward-periods" id="rewardPeriods">
|
||||
<button class="reward-period active" data-period="today">Today</button>
|
||||
<button class="reward-period" data-period="week">This Week</button>
|
||||
<button class="reward-period" data-period="month">Monthly</button>
|
||||
</div>
|
||||
<div class="reward-grid" id="rewardGrid"></div>
|
||||
</section>
|
||||
<div class="home-indicator"></div>
|
||||
</main>
|
||||
<div id="loadingOverlay" class="loading-overlay" aria-hidden="true">
|
||||
<div class="cat-loader" role="status" aria-label="Loading">
|
||||
<svg viewBox="0 0 64 64" aria-hidden="true">
|
||||
<path class="cat-line" d="M17 27 13 12 25 20" />
|
||||
<path class="cat-line" d="M47 27 51 12 39 20" />
|
||||
<path class="cat-line" d="M14 34c0-12 8-20 18-20s18 8 18 20c0 11-8 18-18 18S14 45 14 34Z" />
|
||||
<path class="cat-line cat-eye" d="M25 33h.1" />
|
||||
<path class="cat-line cat-eye" d="M39 33h.1" />
|
||||
<path class="cat-line" d="M32 39v2" />
|
||||
<path class="cat-line" d="M27 44c2 2 8 2 10 0" />
|
||||
<path class="cat-line cat-whisker" d="M21 39h-8" />
|
||||
<path class="cat-line cat-whisker" d="M22 43l-8 3" />
|
||||
<path class="cat-line cat-whisker" d="M43 39h8" />
|
||||
<path class="cat-line cat-whisker" d="M42 43l8 3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../common/api.js"></script>
|
||||
<script src="../common/params.js"></script>
|
||||
<script src="../common/jsbridge.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var ASSETS = {
|
||||
sent: { bg: "assets/wealth-bg.png", bg2: "", bgTop: "-208px", page: "#7c2e33", shade: "#3a1110", row: "rgba(35,24,10,.8)", score: "#23180a", mineA: "#bb1a1c", mineB: "#601718" },
|
||||
room: { bg: "assets/wealth-bg.png", bg2: "", bgTop: "-208px", bgFilter: "hue-rotate(175deg) saturate(1.35) brightness(.7)", page: "#110f3d", shade: "#18103a", row: "rgba(9,13,35,.8)", score: "#090d23", mineA: "#1a4abb", mineB: "#040513" },
|
||||
received: { bg: "assets/charm-bg-a.png", bg2: "assets/charm-bg-b.png", bgTop: "-208px", page: "#110f3d", shade: "#2f103a", row: "rgba(34,9,35,.8)", score: "#1a0923", mineA: "#551abb", mineB: "#1a0a2b" }
|
||||
};
|
||||
var state = { board: "sent", period: "today", page: 1, pageSize: 20, total: 0, items: [], myRank: null, loading: false, loaded: false, error: null };
|
||||
var sampleNames = ["Namenamename", "Namenamename", "Namenamename", "Namenamename", "Namenamename", "Namenamename", "Namenamename", "Namenamename", "Namenamename"];
|
||||
var noteCopy = {
|
||||
sent: "The ranking is based on the total number of coins users have spent on gifts.",
|
||||
room: "The ranking is based on the total number of coins received by rooms.",
|
||||
received: "The ranking is based on the total number of coins users have received from gifts."
|
||||
};
|
||||
|
||||
function byId(id) { return document.getElementById(id); }
|
||||
function queryValue(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
function mockEnabled() {
|
||||
return queryValue("mock") === "1";
|
||||
}
|
||||
function money(value) {
|
||||
var num = Number(value || 0);
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(num % 1000000 === 0 ? 0 : 1) + "m";
|
||||
if (num >= 1000) return (num / 1000).toFixed(num % 1000 === 0 ? 0 : 1) + "k";
|
||||
return String(num);
|
||||
}
|
||||
function userName(item) {
|
||||
return (item.user && (item.user.username || item.user.display_user_id || item.user.user_id)) || (item.room && (item.room.title || item.room.room_short_id || item.room.room_id)) || item.room_id || item.user_id || "Namenamename";
|
||||
}
|
||||
function avatar(item, size) {
|
||||
if (item.user && item.user.avatar) return item.user.avatar;
|
||||
if (item.room && item.room.cover_url) return item.room.cover_url;
|
||||
if (size === "large") return "assets/default-avatar-large.png";
|
||||
if (size === "mid") return "assets/default-avatar-mid.png";
|
||||
return "assets/default-avatar-small.png";
|
||||
}
|
||||
function sampleItems() {
|
||||
return sampleNames.map(function (name, index) {
|
||||
var rank = index + 1;
|
||||
var base = rank <= 3 ? 123400 : 102400;
|
||||
if (state.board === "room") {
|
||||
return { rank: rank, room_id: "room_" + rank, gift_value: base, room: { room_id: "room_" + rank, title: name } };
|
||||
}
|
||||
return { rank: rank, user_id: String(10000 + rank), gift_value: base, user: { user_id: String(10000 + rank), username: name } };
|
||||
});
|
||||
}
|
||||
function badges() {
|
||||
var html = "";
|
||||
for (var i = 0; i < 4; i += 1) html += '<img src="assets/vip.png" alt="">';
|
||||
return html;
|
||||
}
|
||||
function score(value, className) {
|
||||
return '<div class="' + className + '"><img class="coin" src="assets/coin.png" alt="">' + money(value) + "</div>";
|
||||
}
|
||||
function normalizeItem(raw) {
|
||||
var item = raw || {};
|
||||
var user = item.user || {};
|
||||
var room = item.room || {};
|
||||
var userId = item.user_id || user.user_id || "";
|
||||
var roomId = item.room_id || room.room_id || "";
|
||||
return {
|
||||
rank: Number(item.rank || 0),
|
||||
user_id: String(userId || ""),
|
||||
room_id: String(roomId || ""),
|
||||
gift_value: Number(item.gift_value || item.giftValue || item.value || 0),
|
||||
gift_count: Number(item.gift_count || item.giftCount || 0),
|
||||
transaction_count: Number(item.transaction_count || item.transactionCount || 0),
|
||||
last_gift_at_ms: Number(item.last_gift_at_ms || item.lastGiftAtMs || 0),
|
||||
user: userId ? {
|
||||
user_id: String(userId),
|
||||
display_user_id: user.display_user_id || user.displayUserId || "",
|
||||
username: user.username || user.nickname || user.name || "",
|
||||
avatar: user.avatar || user.avatar_url || user.avatarUrl || ""
|
||||
} : null,
|
||||
room: roomId ? {
|
||||
room_id: String(roomId),
|
||||
room_short_id: room.room_short_id || room.roomShortId || "",
|
||||
title: room.title || room.name || "",
|
||||
cover_url: room.cover_url || room.coverUrl || room.room_avatar || room.roomAvatar || room.avatar || ""
|
||||
} : null
|
||||
};
|
||||
}
|
||||
function normalizePayload(data, reset) {
|
||||
var items = (data && Array.isArray(data.items) ? data.items : []).map(normalizeItem).filter(function (item) { return item.rank > 0; });
|
||||
state.total = Number((data && data.total) || items.length || 0);
|
||||
state.page = Number((data && data.page) || state.page);
|
||||
state.pageSize = Number((data && (data.page_size || data.pageSize)) || state.pageSize);
|
||||
state.myRank = data && data.my_rank ? normalizeItem(data.my_rank) : null;
|
||||
state.items = reset ? items : state.items.concat(items);
|
||||
state.loaded = true;
|
||||
state.error = null;
|
||||
}
|
||||
function visibleItems() {
|
||||
if (state.items.length) return state.items;
|
||||
return mockEnabled() ? sampleItems() : [];
|
||||
}
|
||||
function renderPodium() {
|
||||
var items = visibleItems();
|
||||
var top = [items[0], items[1], items[2]];
|
||||
var html = "";
|
||||
if (top[0]) {
|
||||
html += '<button class="podium-item top1" data-index="0"><img class="avatar" src="' + avatar(top[0], "large") + '" alt=""><img class="podium-frame" src="assets/top-frame.png" alt=""><div class="podium-name">' + userName(top[0]) + '</div><div class="badges">' + badges() + '</div>' + score(top[0].gift_value, "score-pill") + "</button>";
|
||||
}
|
||||
if (top[1]) {
|
||||
html += '<button class="podium-item top2" data-index="1"><img class="avatar" src="' + avatar(top[1], "mid") + '" alt=""><img class="podium-frame" src="assets/side-frame-blue.png" alt=""><div class="podium-name">' + userName(top[1]) + '</div><div class="badges">' + badges() + '</div>' + score(top[1].gift_value, "score-pill") + "</button>";
|
||||
}
|
||||
if (top[2]) {
|
||||
html += '<button class="podium-item top3" data-index="2"><img class="avatar" src="' + avatar(top[2], "mid") + '" alt=""><img class="podium-frame" src="assets/side-frame-red.png" alt=""><div class="podium-name">' + userName(top[2]) + '</div><div class="badges">' + badges() + '</div>' + score(top[2].gift_value, "score-pill") + "</button>";
|
||||
}
|
||||
byId("podium").innerHTML = html;
|
||||
}
|
||||
function row(item, mine) {
|
||||
if (!item) return "";
|
||||
return '<button class="rank-row" data-rank="' + item.rank + '"><div class="rank-no">' + item.rank + '</div><img class="row-avatar" src="' + avatar(item, "small") + '" alt=""><div class="row-main"><div class="row-name">' + userName(item) + '</div><div class="row-badges">' + badges() + '</div></div>' + score(item.gift_value, "row-score") + "</button>";
|
||||
}
|
||||
function renderList() {
|
||||
var items = visibleItems();
|
||||
if (!items.length) {
|
||||
byId("rankList").innerHTML = '<div class="rank-state">' + (state.error ? "Network error" : ((state.loading || !state.loaded) ? "Loading" : "No ranking data")) + '</div>';
|
||||
byId("myRank").innerHTML = "";
|
||||
return;
|
||||
}
|
||||
byId("rankList").innerHTML = items.slice(3).map(function (item) { return row(item); }).join("") + '<div class="empty"></div>';
|
||||
byId("myRank").innerHTML = row(state.myRank || items[3] || items[0]);
|
||||
}
|
||||
function applyTheme() {
|
||||
var cfg = ASSETS[state.board];
|
||||
document.documentElement.style.setProperty("--page-bg", cfg.page);
|
||||
document.documentElement.style.setProperty("--shade", cfg.shade);
|
||||
document.documentElement.style.setProperty("--row-bg", cfg.row);
|
||||
document.documentElement.style.setProperty("--score-bg", cfg.score);
|
||||
document.documentElement.style.setProperty("--score-bg-soft", cfg.score.replace(")", ", .72)").replace("rgb", "rgba"));
|
||||
document.documentElement.style.setProperty("--mine-a", cfg.mineA);
|
||||
document.documentElement.style.setProperty("--mine-b", cfg.mineB);
|
||||
document.documentElement.style.setProperty("--bg-top", cfg.bgTop);
|
||||
document.documentElement.style.setProperty("--bg-filter", cfg.bgFilter || "none");
|
||||
byId("rankBg").src = cfg.bg;
|
||||
byId("rankBgCharm").hidden = !cfg.bg2;
|
||||
if (cfg.bg2) byId("rankBgCharm").src = cfg.bg2;
|
||||
}
|
||||
function syncTabs() {
|
||||
document.querySelectorAll("[data-board]").forEach(function (node) { node.classList.toggle("active", node.dataset.board === state.board); });
|
||||
document.querySelectorAll("[data-period]").forEach(function (node) { node.classList.toggle("active", node.dataset.period === state.period); });
|
||||
}
|
||||
function activePage() {
|
||||
return byId("notePage").classList.contains("active") ? byId("notePage") : byId("rankPage");
|
||||
}
|
||||
function resizeApp() {
|
||||
var designWidth = 375;
|
||||
var scale = Math.min(window.innerWidth / designWidth, 1.25);
|
||||
var page = activePage();
|
||||
var height = Math.max(812, page.scrollHeight);
|
||||
document.documentElement.style.setProperty("--app-scale", String(scale));
|
||||
byId("appViewport").style.height = height + "px";
|
||||
document.body.style.minHeight = Math.ceil(height * scale) + "px";
|
||||
}
|
||||
function scheduleResize() {
|
||||
window.requestAnimationFrame(resizeApp);
|
||||
}
|
||||
function syncLoading() {
|
||||
var overlay = byId("loadingOverlay");
|
||||
overlay.classList.toggle("active", state.loading);
|
||||
overlay.setAttribute("aria-hidden", state.loading ? "false" : "true");
|
||||
}
|
||||
function render() {
|
||||
applyTheme();
|
||||
syncTabs();
|
||||
syncLoading();
|
||||
renderPodium();
|
||||
renderList();
|
||||
scheduleResize();
|
||||
}
|
||||
function load(reset) {
|
||||
if (state.loading) return Promise.resolve();
|
||||
if (reset) {
|
||||
state.page = 1;
|
||||
state.items = [];
|
||||
state.total = 0;
|
||||
state.myRank = null;
|
||||
state.loaded = false;
|
||||
state.error = null;
|
||||
render();
|
||||
}
|
||||
if (mockEnabled()) {
|
||||
state.loaded = true;
|
||||
state.error = null;
|
||||
return Promise.resolve(render());
|
||||
}
|
||||
if (!window.HyAppAPI || !window.HyAppAPI.getAccessToken()) {
|
||||
state.loaded = true;
|
||||
state.error = new Error("missing_token");
|
||||
return Promise.resolve(render());
|
||||
}
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
render();
|
||||
return window.HyAppAPI.get("/api/v1/activities/user-leaderboards", {
|
||||
board_type: state.board,
|
||||
period: state.period,
|
||||
page: state.page,
|
||||
page_size: state.pageSize
|
||||
}).then(function (data) {
|
||||
normalizePayload(data, reset);
|
||||
render();
|
||||
}).catch(function (error) {
|
||||
state.loaded = true;
|
||||
state.error = error;
|
||||
render();
|
||||
}).finally(function () {
|
||||
state.loading = false;
|
||||
render();
|
||||
});
|
||||
}
|
||||
function openItem(item) {
|
||||
if (!item) return;
|
||||
if (state.board === "room") {
|
||||
window.HyAppBridge.openRoom(item.room_id || (item.room && item.room.room_id));
|
||||
} else {
|
||||
window.HyAppBridge.openUser(item.user_id || (item.user && item.user.user_id));
|
||||
}
|
||||
}
|
||||
function showNote() {
|
||||
byId("rankPage").classList.add("hidden");
|
||||
byId("notePage").classList.add("active");
|
||||
byId("noteCopy").textContent = noteCopy[state.board];
|
||||
renderRewards();
|
||||
scheduleResize();
|
||||
window.HyAppBridge.openRankNote(state.board);
|
||||
}
|
||||
function hideNote() {
|
||||
byId("notePage").classList.remove("active");
|
||||
byId("rankPage").classList.remove("hidden");
|
||||
scheduleResize();
|
||||
}
|
||||
function renderRewards() {
|
||||
byId("noteCopy").textContent = noteCopy[state.board];
|
||||
byId("rewardGrid").innerHTML = [1, 2, 3, 4].map(function () {
|
||||
return '<div class="reward-card"><h3>Top1</h3><img class="reward" src="assets/reward.png" alt=""><div class="reward-name">Namename</div><div class="reward-value"><img class="coin" src="assets/coin.png" alt="">10000/7days</div></div>';
|
||||
}).join("");
|
||||
scheduleResize();
|
||||
}
|
||||
byId("boardTabs").addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-board]");
|
||||
if (!target || target.dataset.board === state.board) return;
|
||||
state.board = target.dataset.board;
|
||||
load(true);
|
||||
});
|
||||
byId("noteTabs").addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-board]");
|
||||
if (!target) return;
|
||||
state.board = target.dataset.board;
|
||||
syncTabs();
|
||||
renderRewards();
|
||||
});
|
||||
byId("periodTabs").addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-period]");
|
||||
if (!target || target.dataset.period === state.period) return;
|
||||
state.period = target.dataset.period;
|
||||
load(true);
|
||||
});
|
||||
byId("rewardPeriods").addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-period]");
|
||||
if (!target) return;
|
||||
state.period = target.dataset.period;
|
||||
syncTabs();
|
||||
});
|
||||
byId("podium").addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-index]");
|
||||
if (target) openItem(visibleItems()[Number(target.dataset.index)]);
|
||||
});
|
||||
byId("rankList").addEventListener("click", function (event) {
|
||||
var target = event.target.closest("[data-rank]");
|
||||
if (!target) return;
|
||||
var item = visibleItems().find(function (candidate) { return String(candidate.rank) === target.dataset.rank; });
|
||||
openItem(item);
|
||||
});
|
||||
byId("myRank").addEventListener("click", function () { openItem(state.myRank); });
|
||||
byId("backButton").addEventListener("click", function () { window.HyAppBridge.back(); });
|
||||
byId("helpButton").addEventListener("click", showNote);
|
||||
byId("noteBackButton").addEventListener("click", hideNote);
|
||||
window.addEventListener("scroll", function () {
|
||||
if (!state.total || state.items.length >= state.total || state.loading) return;
|
||||
if (window.innerHeight + window.scrollY > document.body.scrollHeight - 180) {
|
||||
state.page += 1;
|
||||
load(false);
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", resizeApp);
|
||||
window.addEventListener("orientationchange", scheduleResize);
|
||||
render();
|
||||
window.HyAppBridge.ready({ page: "rank" });
|
||||
load(true).finally(function () {
|
||||
window.HyAppParams.loadUser().catch(function () { return null; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
192
scripts/h5_dev_server.py
Normal file
@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.server
|
||||
import os
|
||||
import socketserver
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
PROXIES = {
|
||||
"/__api_test__/": "https://api-test.global-interaction.com/",
|
||||
"/__api_local__/": "http://localhost:13000/",
|
||||
}
|
||||
|
||||
HOP_BY_HOP_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
|
||||
|
||||
class H5DevHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def do_OPTIONS(self) -> None:
|
||||
if self.proxy_base() is None:
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(204)
|
||||
self.send_cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/__h5_dev_server__/health":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
return
|
||||
self.handle_request()
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
if self.path == "/__h5_dev_server__/health":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
return
|
||||
if self.proxy_base() is None:
|
||||
super().do_HEAD()
|
||||
return
|
||||
self.proxy_request()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self.handle_request()
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self.handle_request()
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self.handle_request()
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self.handle_request()
|
||||
|
||||
def handle_request(self) -> None:
|
||||
if self.proxy_base() is None:
|
||||
super().do_GET()
|
||||
return
|
||||
self.proxy_request()
|
||||
|
||||
def proxy_base(self) -> tuple[str, str] | None:
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
for prefix, target in PROXIES.items():
|
||||
if parsed.path.startswith(prefix):
|
||||
return prefix, target
|
||||
return None
|
||||
|
||||
def proxy_request(self) -> None:
|
||||
match = self.proxy_base()
|
||||
if match is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
prefix, target_base = match
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
target_path = parsed.path[len(prefix):]
|
||||
target_url = urllib.parse.urljoin(target_base, target_path)
|
||||
if parsed.query:
|
||||
target_url = target_url + "?" + parsed.query
|
||||
|
||||
body = None
|
||||
if self.command in {"POST", "PUT", "PATCH"}:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in self.headers.items()
|
||||
if key.lower() not in HOP_BY_HOP_HEADERS
|
||||
}
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
|
||||
request = urllib.request.Request(
|
||||
target_url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=self.command,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
self.send_response(response.status)
|
||||
self.copy_response_headers(response.headers)
|
||||
self.send_cors_headers()
|
||||
self.end_headers()
|
||||
self.write_body(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
self.send_response(error.code)
|
||||
self.copy_response_headers(error.headers)
|
||||
self.send_cors_headers()
|
||||
self.end_headers()
|
||||
self.write_body(error.read())
|
||||
except Exception as error:
|
||||
self.send_response(502)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_cors_headers()
|
||||
self.end_headers()
|
||||
message = str(error).replace('"', '\\"')
|
||||
self.write_body((f'{{"code":"PROXY_ERROR","message":"{message}"}}').encode("utf-8"))
|
||||
|
||||
def write_body(self, body: bytes) -> None:
|
||||
if self.command == "HEAD":
|
||||
return
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except BrokenPipeError:
|
||||
return
|
||||
|
||||
def copy_response_headers(self, headers: http.client.HTTPMessage) -> None:
|
||||
for key, value in headers.items():
|
||||
if key.lower() in HOP_BY_HOP_HEADERS:
|
||||
continue
|
||||
if key.lower() == "content-encoding":
|
||||
continue
|
||||
self.send_header(key, value)
|
||||
|
||||
def send_cors_headers(self) -> None:
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Authorization,Content-Type,X-App-Code,X-Request-ID")
|
||||
|
||||
def end_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("Expires", "0")
|
||||
super().end_headers()
|
||||
|
||||
|
||||
class ReusableTCPServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=30000)
|
||||
parser.add_argument("--directory", default=os.getcwd())
|
||||
args = parser.parse_args()
|
||||
|
||||
handler = lambda *handler_args, **handler_kwargs: H5DevHandler(
|
||||
*handler_args,
|
||||
directory=args.directory,
|
||||
**handler_kwargs,
|
||||
)
|
||||
with ReusableTCPServer((args.host, args.port), handler) as server:
|
||||
print(f"Serving H5 at http://{args.host}:{args.port}/")
|
||||
print("API proxy: /__api_test__/ -> https://api-test.global-interaction.com/")
|
||||
print("API proxy: /__api_local__/ -> http://localhost:13000/")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||