Persist api-server logs and refresh recharge balance

This commit is contained in:
2026-05-15 01:07:39 +08:00
parent 2801b55d2f
commit 8ade75390c
11 changed files with 406 additions and 54 deletions

View File

@@ -69,6 +69,8 @@ npm run dev:web
npm run api-server
```
该命令会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` / `npm run dev:rust` 启动的 Rust `api-server` 也会写入 `logs/api-server/api-server-dev-rust-<timestamp>.log`。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`
查看本地 Rust/SpacetimeDB 日志:
```bash

View File

@@ -93,7 +93,7 @@
2. 若订单已是 `paid`,直接返回订单与账户中心快照。
3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。
4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。
5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只显示“支付已提交”,不提前发放泥点或会员。
5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只在全局支付结果模态显示“支付已提交”,不提前发放泥点或会员。
响应结构:
@@ -144,10 +144,11 @@
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`
- 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。
- native 支付页通过 `wx_pay_result=<requestId>:success|cancel|fail` 回填 web-viewH5 在 `hashchange``focus``pageshow``visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。
- `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。
- `cancel``fail` 只复位按钮、刷新账户中心并展示状态,不调用入账逻辑。
5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压
- `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。
- `cancel``fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。
5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败
6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态
7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
## 5. 验收

View File

@@ -48,7 +48,7 @@ npm run dev:rust
5. 如果确认需要新启动 SpacetimeDB脚本会先检测 `127.0.0.1:3101` 是否可监听;若已占用,输出占用进程并选择从 `3101` 起向后的最近可用端口,再执行 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr <实际地址>`。启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`,后续 publish 与 `api-server` 都使用同一个实际 URL。
6. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:<spacetime-port>` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:<spacetime-port>/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`
7. 执行 `spacetime publish <本地数据库名> --server <实际 SpacetimeDB URL> --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*``GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*``GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。本地启动器会保留终端实时输出,并把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`
9. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`
10. 注入 `RUST_SERVER_TARGET``GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
11. 任一子进程退出时,脚本回收其余子进程。
@@ -119,6 +119,12 @@ npm run dev:rust:logs -- --follow
3. 默认输出到 `logs/spacetime/<database>-<timestamp>.log`,并通过 `tee` 同步显示在终端。
4. `--follow` 仅用于本地追踪,会持续追加到同一个输出文件;停止时用 `Ctrl+C`
api-server 本地持久化日志:
1. `npm run api-server` 默认写入 `logs/api-server/api-server-<timestamp>.log`,同时继续把同一份输出显示在当前终端。
2. `npm run dev` / `npm run dev:rust` 中由脚本启动的 Rust `api-server` 默认写入 `logs/api-server/api-server-dev-rust-<timestamp>.log`;等待 `/healthz` 失败时,脚本会自动输出该日志最后 80 行。
3. 如需固定日志文件,可设置 `GENARRATIVE_API_SERVER_LOG_FILE=logs/api-server/local.log`;如只需更换目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR=logs/api-server`。相对路径都按仓库根目录解析。
联调排错补充:
1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database/<database>/subscribe` 是否指向了未发布的库。

View File

@@ -12,7 +12,16 @@
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
"minifyWXML": true,
"compileWorklet": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"miniprogramRoot": "miniprogram/",
@@ -22,5 +31,6 @@
"include": []
},
"appid": "wx3da23ea14ca66b65",
"editorSetting": {}
}
"editorSetting": {},
"libVersion": "3.15.2"
}

View File

@@ -2,13 +2,20 @@
"libVersion": "3.15.2",
"projectname": "Genarrative",
"setting": {
"urlCheck": true,
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true
"compileHotReLoad": true,
"useApiHook": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

View File

@@ -1,6 +1,11 @@
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
} from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
@@ -67,7 +72,69 @@ export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) {
return mergedEnv;
}
function stopExistingWindowsApiServer() {
export function formatApiServerLogTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
export function resolveApiServerLogFile(
repoRootPath,
env = process.env,
now = new Date(),
) {
const explicitLogFile = String(
env.GENARRATIVE_API_SERVER_LOG_FILE ?? '',
).trim();
if (explicitLogFile) {
return isAbsolute(explicitLogFile)
? explicitLogFile
: resolve(repoRootPath, explicitLogFile);
}
const logDir =
String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() ||
'logs/api-server';
const resolvedLogDir = isAbsolute(logDir)
? logDir
: resolve(repoRootPath, logDir);
return resolve(
resolvedLogDir,
`api-server-${formatApiServerLogTimestamp(now)}.log`,
);
}
function createApiServerLogStream(logFilePath) {
mkdirSync(dirname(logFilePath), { recursive: true });
const logStream = createWriteStream(logFilePath, {
flags: 'a',
encoding: 'utf8',
});
logStream.on('error', (error) => {
console.error(`[api-server] 写入日志失败: ${error.message}`);
});
return logStream;
}
function writeLauncherLog(logStream, message, stream = process.stdout) {
const line = `${message}\n`;
stream.write(line);
if (!logStream.destroyed) {
logStream.write(line);
}
}
function stopExistingWindowsApiServer(logStream) {
if (process.platform !== 'win32') {
return;
}
@@ -104,7 +171,7 @@ function stopExistingWindowsApiServer() {
).trim();
if (output) {
console.log(`[api-server] 已停止旧 api-server 进程: ${output}`);
writeLauncherLog(logStream, `[api-server] 已停止旧 api-server 进程: ${output}`);
}
}
@@ -121,19 +188,55 @@ function main() {
mergedEnv.GENARRATIVE_SPACETIME_TOKEN =
mergedEnv.GENARRATIVE_SPACETIME_TOKEN || '';
const logFilePath = resolveApiServerLogFile(repoRoot, mergedEnv);
const logStream = createApiServerLogStream(logFilePath);
mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFilePath;
let didExit = false;
const exitAfterLogFlush = (code) => {
const finish = () => {
if (didExit) {
return;
}
didExit = true;
process.exit(code);
};
if (logStream.destroyed) {
finish();
return;
}
logStream.end(finish);
setTimeout(finish, 1000).unref();
};
writeLauncherLog(logStream, `[api-server] 日志: ${logFilePath}`);
if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
console.error('[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。');
process.exit(1);
writeLauncherLog(
logStream,
'[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。',
process.stderr,
);
exitAfterLogFlush(1);
return;
}
try {
stopExistingWindowsApiServer();
stopExistingWindowsApiServer(logStream);
} catch (error) {
console.error(`[api-server] 清理旧 api-server 进程失败: ${error.message}`);
process.exit(1);
writeLauncherLog(
logStream,
`[api-server] 清理旧 api-server 进程失败: ${error.message}`,
process.stderr,
);
exitAfterLogFlush(1);
return;
}
console.log(
writeLauncherLog(
logStream,
`[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
@@ -143,22 +246,41 @@ function main() {
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'pipe'],
},
);
child.on('error', (error) => {
console.error(`[api-server] 启动 cargo 失败: ${error.message}`);
process.exit(1);
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk);
logStream.write(chunk);
});
child.on('exit', (code, signal) => {
child.stderr?.on('data', (chunk) => {
process.stderr.write(chunk);
logStream.write(chunk);
});
child.on('error', (error) => {
writeLauncherLog(
logStream,
`[api-server] 启动 cargo 失败: ${error.message}`,
process.stderr,
);
exitAfterLogFlush(1);
});
child.on('close', (code, signal) => {
if (signal) {
console.error(`[api-server] api-server 被信号终止: ${signal}`);
process.exit(1);
writeLauncherLog(
logStream,
`[api-server] api-server 被信号终止: ${signal}`,
process.stderr,
);
exitAfterLogFlush(1);
return;
}
process.exit(code ?? 0);
exitAfterLogFlush(code ?? 0);
});
}

View File

@@ -4,7 +4,11 @@ import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { mergeApiServerEnv } from './api-server-dev.mjs';
import {
formatApiServerLogTimestamp,
mergeApiServerEnv,
resolveApiServerLogFile,
} from './api-server-dev.mjs';
type EnvMap = Record<string, string>;
@@ -92,3 +96,39 @@ describe('api-server-dev env merge', () => {
);
});
});
describe('api-server-dev log file resolution', () => {
const fixedDate = new Date(2026, 4, 15, 6, 7, 8);
test('默认写入 logs/api-server 的时间戳文件', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-'));
try {
expect(formatApiServerLogTimestamp(fixedDate)).toBe('20260515-060708');
expect(resolveApiServerLogFile(tempDir, {}, fixedDate)).toBe(
join(tempDir, 'logs/api-server/api-server-20260515-060708.log'),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('GENARRATIVE_API_SERVER_LOG_FILE 优先于日志目录默认值', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-'));
try {
expect(
resolveApiServerLogFile(
tempDir,
{
GENARRATIVE_API_SERVER_LOG_DIR: 'logs/ignored',
GENARRATIVE_API_SERVER_LOG_FILE: 'logs/custom/api.log',
},
fixedDate,
),
).toBe(join(tempDir, 'logs/custom/api.log'));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -653,11 +653,13 @@ wait_for_api_server() {
local health_url="$1"
local timeout_seconds="$2"
local process_pid="${3:-}"
local log_file="${4:-${API_SERVER_LOG_FILE:-}}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
print_api_server_log_tail "${log_file}"
exit 1
fi
@@ -679,9 +681,58 @@ request.on("error", () => process.exit(1));
done
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
print_api_server_log_tail "${log_file}"
exit 1
}
format_api_server_log_timestamp() {
date +%Y%m%d-%H%M%S
}
normalize_api_server_log_path() {
local path_value="$1"
if [[ "${path_value}" == *\\* ]]; then
path_value="${path_value//\\//}"
fi
echo "${path_value}"
}
resolve_api_server_log_file() {
local explicit_log_file="${GENARRATIVE_API_SERVER_LOG_FILE:-}"
local log_dir="${GENARRATIVE_API_SERVER_LOG_DIR:-${REPO_ROOT}/logs/api-server}"
if [[ -n "${explicit_log_file//[[:space:]]/}" ]]; then
explicit_log_file="$(normalize_api_server_log_path "${explicit_log_file}")"
if [[ "${explicit_log_file}" = /* || "${explicit_log_file}" =~ ^[A-Za-z]:[\\/] ]]; then
echo "${explicit_log_file}"
return
fi
echo "${REPO_ROOT}/${explicit_log_file}"
return
fi
log_dir="$(normalize_api_server_log_path "${log_dir}")"
if [[ ! "${log_dir}" = /* && ! "${log_dir}" =~ ^[A-Za-z]:[\\/] ]]; then
log_dir="${REPO_ROOT}/${log_dir}"
fi
echo "${log_dir}/api-server-dev-rust-$(format_api_server_log_timestamp).log"
}
print_api_server_log_tail() {
local log_file="${1:-}"
if [[ -z "${log_file}" || ! -f "${log_file}" ]]; then
return
fi
echo "[dev:rust] api-server 最近日志: ${log_file}" >&2
tail -n 80 "${log_file}" >&2 || true
}
generate_migration_bootstrap_secret() {
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
@@ -990,22 +1041,26 @@ API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
API_SERVER_LOG_FILE="$(resolve_api_server_log_file)"
mkdir -p "$(dirname -- "${API_SERVER_LOG_FILE}")"
echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}"
echo "[dev:rust] api-server log: ${API_SERVER_LOG_FILE}"
(
cd "${REPO_ROOT}"
GENARRATIVE_API_HOST="${API_HOST}" \
GENARRATIVE_API_PORT="${API_PORT}" \
GENARRATIVE_API_LOG="${API_LOG}" \
GENARRATIVE_API_SERVER_LOG_FILE="${API_SERVER_LOG_FILE}" \
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
) &
) > >(tee -a "${API_SERVER_LOG_FILE}") 2>&1 &
API_PID="$!"
PIDS+=("${API_PID}")
NAMES+=("api-server")
echo "[dev:rust] 等待 api-server 就绪"
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}"
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" "${API_SERVER_LOG_FILE}"
echo "[dev:rust] 启动 vite"
(

View File

@@ -99,6 +99,7 @@
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE``GENARRATIVE_API_SERVER_LOG_DIR`
当前 request context 约定:

View File

@@ -1031,7 +1031,10 @@ test('profile recharge modal buys points through mock channel outside mini progr
'mock',
);
});
expect(await screen.findByText('已到账')).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
@@ -1114,7 +1117,10 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
});
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(await screen.findByText('已到账')).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-1',
);
@@ -1202,7 +1208,9 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(await screen.findByText('已到账')).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
});
test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => {
@@ -1283,7 +1291,10 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(await screen.findByText('支付已取消')).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '支付已取消' }),
).toBeTruthy();
expect(screen.getByText('本次没有扣款,账户状态未发生变化。')).toBeTruthy();
await waitFor(() => {
expect(
within(screen.getByRole('button', { name: /60/u })).getByText(

View File

@@ -1,7 +1,9 @@
import {
ArrowRight,
AlertCircle,
BookOpen,
Camera,
CheckCircle2,
ChevronDown,
ChevronRight,
Clock3,
@@ -26,6 +28,7 @@ import {
Ticket,
UserPlus,
UserRound,
XCircle,
} from 'lucide-react';
import {
type ComponentType,
@@ -222,6 +225,12 @@ type WechatPayResult = {
orderId: string | null;
status: WechatMiniProgramPaymentStatus;
};
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
type RechargePaymentResult = {
kind: RechargePaymentResultKind;
title: string;
message: string;
};
type DiscoverChannel =
| 'recommend'
| 'today'
@@ -2501,7 +2510,6 @@ function ProfileRechargeModal({
center,
isLoading,
error,
success,
submittingProductId,
activeTab,
onTabChange,
@@ -2512,7 +2520,6 @@ function ProfileRechargeModal({
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
submittingProductId: string | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
@@ -2582,11 +2589,6 @@ function ProfileRechargeModal({
</button>
</div>
) : null}
{success ? (
<div className="platform-profile-success mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : null}
{isLoading ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
@@ -2619,6 +2621,62 @@ function ProfileRechargeModal({
);
}
function RechargePaymentResultModal({
result,
onClose,
}: {
result: RechargePaymentResult;
onClose: () => void;
}) {
const Icon =
result.kind === 'success'
? CheckCircle2
: result.kind === 'cancel'
? XCircle
: AlertCircle;
const iconClass =
result.kind === 'success'
? 'text-[var(--platform-success-text)]'
: result.kind === 'cancel'
? 'text-[var(--platform-text-soft)]'
: 'text-[var(--platform-button-danger-text)]';
return (
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="recharge-payment-result-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="px-5 pb-5 pt-6 text-center">
<div
className={`mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 ${iconClass}`}
>
<Icon className="h-8 w-8" aria-hidden="true" />
</div>
<div
id="recharge-payment-result-title"
className="mt-4 text-xl font-black text-[var(--platform-text-strong)]"
>
{result.title}
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
{result.message}
</div>
<button
type="button"
onClick={onClose}
className="platform-primary-button mt-5 w-full rounded-2xl px-4 py-3 text-sm font-black"
>
</button>
</div>
</div>
</div>
);
}
function WalletLedgerModal({
ledger,
fallbackBalance,
@@ -3324,7 +3382,8 @@ export function RpgEntryHomeView({
useState<ProfileRechargeCenterResponse | null>(null);
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points');
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
@@ -3869,27 +3928,56 @@ export function RpgEntryHomeView({
}
if (payResult.status === 'success') {
setRechargeSuccess('支付已提交');
setRechargePaymentResult({
kind: 'pending',
title: '支付已提交',
message: '正在确认到账状态,请稍后查看余额或会员状态。',
});
if (payResult.orderId) {
void confirmWechatRpgProfileRechargeOrder(payResult.orderId)
.then((response) => {
setRechargeCenter(response.center);
setRechargeSuccess(
response.order.status === 'paid' ? '已到账' : '支付已提交',
setRechargePaymentResult(
response.order.status === 'paid'
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付已提交',
message: '正在等待微信支付确认,请稍后查看账户状态。',
},
);
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
})
.catch(() => refreshRechargeState());
.catch(() => {
setRechargePaymentResult({
kind: 'pending',
title: '支付已提交',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
refreshRechargeState();
});
} else {
refreshRechargeState();
}
void onRechargeSuccess?.();
} else if (payResult.status === 'cancel') {
setRechargeSuccess('支付已取消');
setRechargePaymentResult({
kind: 'cancel',
title: '支付已取消',
message: '本次没有扣款,账户状态未发生变化。',
});
refreshRechargeState();
} else {
setRechargeError('微信支付未完成');
setRechargePaymentResult({
kind: 'failed',
title: '支付未完成',
message: '微信支付没有完成,本次不会入账。',
});
refreshRechargeState();
}
@@ -3902,7 +3990,6 @@ export function RpgEntryHomeView({
}
setIsRechargeOpen(true);
setRechargeSuccess(null);
loadRechargeCenter();
};
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
@@ -3915,7 +4002,6 @@ export function RpgEntryHomeView({
: 'mock';
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargeSuccess(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
@@ -3928,7 +4014,11 @@ export function RpgEntryHomeView({
return;
} else {
setRechargeCenter(response.center);
setRechargeSuccess('已到账');
setRechargePaymentResult({
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
});
pendingWechatRechargeOrderIdRef.current = null;
setSubmittingRechargeProductId(null);
}
@@ -5712,7 +5802,6 @@ export function RpgEntryHomeView({
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
success={rechargeSuccess}
submittingProductId={submittingRechargeProductId}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
@@ -5721,6 +5810,12 @@ export function RpgEntryHomeView({
onBuy={buyRechargeProduct}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
<RechargePaymentResultModal
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
@@ -5804,6 +5899,7 @@ export function RpgEntryHomeView({
) : null}
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
@@ -5935,6 +6031,7 @@ export function RpgEntryHomeView({
</div>
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}