Show app logos across admin

This commit is contained in:
zhx 2026-07-14 11:29:05 +08:00
parent 6d237766f9
commit cada5d8f5e
23 changed files with 410 additions and 38 deletions

View File

@ -1618,7 +1618,7 @@
"operationId": "listApps", "operationId": "listApps",
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/AppListResponse"
} }
} }
} }
@ -3945,7 +3945,7 @@
"operationId": "listRechargeApps", "operationId": "listRechargeApps",
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/AppListResponse"
} }
}, },
"x-permission": "payment-bill:view", "x-permission": "payment-bill:view",
@ -4146,7 +4146,7 @@
"operationId": "getFinanceScope", "operationId": "getFinanceScope",
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/FinanceScopeCatalogResponse"
} }
}, },
"x-permission": "finance:view", "x-permission": "finance:view",
@ -7636,6 +7636,26 @@
} }
}, },
"responses": { "responses": {
"AppListResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseAdminAppList"
}
}
}
},
"FinanceScopeCatalogResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseFinanceScopeCatalog"
}
}
}
},
"H5LinkListResponse": { "H5LinkListResponse": {
"description": "OK", "description": "OK",
"content": { "content": {
@ -8214,6 +8234,36 @@
} }
}, },
"schemas": { "schemas": {
"ApiResponseAdminAppList": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/AdminAppList"
}
}
}
]
},
"ApiResponseFinanceScopeCatalog": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/FinanceScopeCatalog"
}
}
}
]
},
"ApiResponseH5LinkList": { "ApiResponseH5LinkList": {
"allOf": [ "allOf": [
{ {
@ -11552,6 +11602,92 @@
} }
} }
}, },
"AdminApp": {
"type": "object",
"required": ["appCode", "appName", "logoUrl"],
"properties": {
"appId": {
"type": "integer"
},
"appCode": {
"type": "string"
},
"appName": {
"type": "string"
},
"logoUrl": {
"type": "string",
"format": "uri"
},
"packageName": {
"type": "string"
},
"platform": {
"type": "string"
},
"status": {
"type": "string"
},
"createdAtMs": {
"type": "integer",
"format": "int64"
},
"updatedAtMs": {
"type": "integer",
"format": "int64"
}
}
},
"AdminAppList": {
"type": "object",
"required": ["items", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AdminApp"
}
},
"total": {
"type": "integer"
}
}
},
"FinanceScopeCatalog": {
"type": "object",
"required": ["all", "apps", "countries", "regions", "scopes"],
"properties": {
"all": {
"type": "boolean"
},
"apps": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AdminApp"
}
},
"countries": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"regions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserFinanceScope"
}
}
}
},
"Country": { "Country": {
"type": "object", "type": "object",
"required": ["countryCode", "countryId"], "required": ["countryCode", "countryId"],

View File

@ -49,6 +49,7 @@ import {
} from "./format.js"; } from "./format.js";
import { downloadCsv } from "@/shared/api/download"; import { downloadCsv } from "@/shared/api/download";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js"; import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -842,7 +843,7 @@ export function FinanceApp() {
type="button" type="button"
onClick={() => updateRechargeDetailFilters({ appCode: app.appCode, regionId: "" })} onClick={() => updateRechargeDetailFilters({ appCode: app.appCode, regionId: "" })}
> >
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</button> </button>
))} ))}
</div> </div>
@ -1005,6 +1006,7 @@ export function FinanceApp() {
</Dialog> </Dialog>
<FinanceAuditDialog <FinanceAuditDialog
application={auditTarget} application={auditTarget}
apps={optionsState.data.apps}
loading={auditLoading} loading={auditLoading}
operations={optionsState.data.operations} operations={optionsState.data.operations}
open={Boolean(auditTarget)} open={Boolean(auditTarget)}
@ -1013,6 +1015,7 @@ export function FinanceApp() {
/> />
<FinanceAuditDialog <FinanceAuditDialog
application={withdrawalAuditTarget} application={withdrawalAuditTarget}
apps={optionsState.data.apps}
loading={withdrawalAuditLoading} loading={withdrawalAuditLoading}
operations={optionsState.data.operations} operations={optionsState.data.operations}
open={Boolean(withdrawalAuditTarget)} open={Boolean(withdrawalAuditTarget)}

View File

@ -380,7 +380,8 @@ function normalizeApps(data) {
} }
return { return {
appCode, appCode,
appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode,
logoUrl: stringValue(item.logoUrl ?? item.logo_url)
}; };
}) })
.filter(Boolean); .filter(Boolean);

View File

@ -33,7 +33,13 @@ test("finance application APIs use generated endpoint paths", async () => {
"fetch", "fetch",
vi.fn(async (url) => { vi.fn(async (url) => {
if (String(url).includes("/admin/apps")) { if (String(url).includes("/admin/apps")) {
return new Response(JSON.stringify({ code: 0, data: { items: [{ appCode: "lalu", appName: "lalu" }], total: 1 } })); return new Response(JSON.stringify({
code: 0,
data: {
items: [{ appCode: "lalu", appName: "Lalu", logoUrl: "https://media.example.com/lalu.png" }],
total: 1
}
}));
} }
if (String(url).includes("/applications?")) { if (String(url).includes("/applications?")) {
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } })); return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
@ -65,7 +71,9 @@ test("finance application APIs use generated endpoint paths", async () => {
await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" }); await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" });
const calls = vi.mocked(fetch).mock.calls; const calls = vi.mocked(fetch).mock.calls;
expect(options.apps).toEqual([{ appCode: "lalu", appName: "lalu" }]); expect(options.apps).toEqual([
{ appCode: "lalu", appName: "Lalu", logoUrl: "https://media.example.com/lalu.png" }
]);
expect(options.operations.map((item) => item.label)).toEqual([ expect(options.operations.map((item) => item.label)).toEqual([
"增加用户金币", "增加用户金币",
"减少用户金币", "减少用户金币",

View File

@ -4,6 +4,7 @@ import Button from "@mui/material/Button";
import InputAdornment from "@mui/material/InputAdornment"; import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
export function FinanceApplicationForm({ export function FinanceApplicationForm({
@ -47,7 +48,7 @@ export function FinanceApplicationForm({
> >
{apps.map((app) => ( {apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>

View File

@ -11,6 +11,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { APPLICATION_STATUS } from "../constants.js"; import { APPLICATION_STATUS } from "../constants.js";
import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js"; import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js";
@ -34,7 +35,7 @@ export function FinanceApplicationList({ applications, error, filters, loading,
<MenuItem value="">全部 APP</MenuItem> <MenuItem value="">全部 APP</MenuItem>
{options.apps.map((app) => ( {options.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -93,7 +94,14 @@ export function FinanceApplicationList({ applications, error, filters, loading,
{items.length ? ( {items.length ? (
items.map((item) => ( items.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell>{item.appName || item.appCode || "-"}</TableCell> <TableCell>
<AppIdentity
app={options.apps.find((app) => app.appCode === item.appCode)}
appCode={item.appCode}
appName={item.appName}
size="small"
/>
</TableCell>
<TableCell> <TableCell>
<Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} /> <Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} />
</TableCell> </TableCell>

View File

@ -5,10 +5,11 @@ import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx"; import { UploadField } from "@/shared/ui/UploadField.jsx";
import { operationLabel } from "../format.js"; import { operationLabel } from "../format.js";
export function FinanceAuditDialog({ application, loading, onClose, onSubmit, open, operations, requireApprovalImage = false, requireRejectRemark = false }) { export function FinanceAuditDialog({ application, apps = [], loading, onClose, onSubmit, open, operations, requireApprovalImage = false, requireRejectRemark = false }) {
const [remark, setRemark] = useState(""); const [remark, setRemark] = useState("");
const [auditImageUrl, setAuditImageUrl] = useState(""); const [auditImageUrl, setAuditImageUrl] = useState("");
const decision = application?.decision || "approved"; const decision = application?.decision || "approved";
@ -32,7 +33,12 @@ export function FinanceAuditDialog({ application, loading, onClose, onSubmit, op
<DialogTitle>{decision === "approved" ? "通过申请" : "拒绝申请"}</DialogTitle> <DialogTitle>{decision === "approved" ? "通过申请" : "拒绝申请"}</DialogTitle>
<DialogContent className="finance-audit-dialog"> <DialogContent className="finance-audit-dialog">
<div className="finance-audit-summary"> <div className="finance-audit-summary">
<span>{application?.appName || application?.appCode || "-"}</span> <AppIdentity
app={apps.find((app) => app.appCode === application?.appCode)}
appCode={application?.appCode}
appName={application?.appName}
size="small"
/>
<strong>{operationLabel(application?.operation, operations)}</strong> <strong>{operationLabel(application?.operation, operations)}</strong>
<span>{application?.targetUserId || "-"}</span> <span>{application?.targetUserId || "-"}</span>
</div> </div>

View File

@ -8,6 +8,7 @@ import DialogTitle from "@mui/material/DialogTitle";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { import {
buildCoinSellerRechargeExchangeRatePayload, buildCoinSellerRechargeExchangeRatePayload,
validateCoinSellerRechargeExchangeRatePayload, validateCoinSellerRechargeExchangeRatePayload,
@ -123,7 +124,7 @@ export function FinanceCoinSellerExchangeRateDialog({ apps, initialAppCode, open
> >
{apps.map((app) => ( {apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>

View File

@ -13,6 +13,7 @@ import DialogTitle from "@mui/material/DialogTitle";
import InputAdornment from "@mui/material/InputAdornment"; import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { DateTimePicker } from "@/shared/ui/DateTimePicker.jsx"; import { DateTimePicker } from "@/shared/ui/DateTimePicker.jsx";
import { import {
COIN_SELLER_RECHARGE_CHAINS, COIN_SELLER_RECHARGE_CHAINS,
@ -40,7 +41,7 @@ export function FinanceCoinSellerRechargeCreateDialog({
const disabled = loading || receiptLoading; const disabled = loading || receiptLoading;
// + // +
const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled; const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled;
const selectedAppName = apps.find((app) => app.appCode === form.appCode)?.appName || form.appCode || "待选择"; const selectedApp = apps.find((app) => app.appCode === form.appCode);
return ( return (
<Dialog <Dialog
@ -77,7 +78,7 @@ export function FinanceCoinSellerRechargeCreateDialog({
> >
{apps.map((app) => ( {apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -242,7 +243,7 @@ export function FinanceCoinSellerRechargeCreateDialog({
</div> </div>
<OrderPreview <OrderPreview
appName={selectedAppName} app={selectedApp}
form={form} form={form}
quote={quote} quote={quote}
quoteLoading={quoteLoading} quoteLoading={quoteLoading}
@ -296,7 +297,7 @@ function CoinQuoteResult({ form, quote, quoteError, quoteLoading }) {
); );
} }
function OrderPreview({ appName, form, quote, quoteLoading, receiptVerification }) { function OrderPreview({ app, form, quote, quoteLoading, receiptVerification }) {
const usdAmount = Number(form.usdAmount || 0); const usdAmount = Number(form.usdAmount || 0);
const coinAmount = form.isMakeup ? 0 : Number(quote?.coinAmount || 0); const coinAmount = form.isMakeup ? 0 : Number(quote?.coinAmount || 0);
const providerLabel = COIN_SELLER_RECHARGE_PROVIDERS.find(([value]) => value === form.providerCode)?.[1] || "待选择"; const providerLabel = COIN_SELLER_RECHARGE_PROVIDERS.find(([value]) => value === form.providerCode)?.[1] || "待选择";
@ -316,7 +317,7 @@ function OrderPreview({ appName, form, quote, quoteLoading, receiptVerification
<strong>{quoteLoading ? "计算中" : formatAmount(coinAmount)}</strong> <strong>{quoteLoading ? "计算中" : formatAmount(coinAmount)}</strong>
</div> </div>
<dl> <dl>
<div><dt>APP</dt><dd>{appName}</dd></div> <div><dt>APP</dt><dd><AppIdentity app={app} appCode={form.appCode} size="small" /></dd></div>
<div><dt>目标用户</dt><dd>{form.targetUserId || "待填写"}</dd></div> <div><dt>目标用户</dt><dd>{form.targetUserId || "待填写"}</dd></div>
<div><dt>订单时间</dt><dd>{formatTime(form.orderDateMs) || "待选择"}</dd></div> <div><dt>订单时间</dt><dd>{formatTime(form.orderDateMs) || "待选择"}</dd></div>
<div><dt>支付渠道</dt><dd>{providerLabel}</dd></div> <div><dt>支付渠道</dt><dd>{providerLabel}</dd></div>

View File

@ -7,6 +7,7 @@ import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { import {
COIN_SELLER_RECHARGE_PROVIDERS, COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS, COIN_SELLER_RECHARGE_STATUS,
@ -237,7 +238,7 @@ export function FinanceCoinSellerRechargeOrderList({
<MenuItem value="">全部 APP</MenuItem> <MenuItem value="">全部 APP</MenuItem>
{apps.map((app) => ( {apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -323,7 +324,13 @@ export function FinanceCoinSellerRechargeOrderList({
items.map((item) => ( items.map((item) => (
<tr className="finance-flat-table__row" key={item.id}> <tr className="finance-flat-table__row" key={item.id}>
<td className="finance-order-cell">{item.id || "-"}</td> <td className="finance-order-cell">{item.id || "-"}</td>
<td>{appName(item.appCode, apps)}</td> <td>
<AppIdentity
app={apps.find((app) => app.appCode === item.appCode)}
appCode={item.appCode}
size="small"
/>
</td>
<td> <td>
<Stacked <Stacked
primary={item.targetDisplayUserId || item.targetUserId} primary={item.targetDisplayUserId || item.targetUserId}
@ -509,10 +516,6 @@ function Stacked({ primary, secondary }) {
); );
} }
function appName(appCode, apps) {
return apps.find((app) => app.appCode === appCode)?.appName || appCode || "-";
}
function usdText(item) { function usdText(item) {
if (item.usdAmount !== undefined && item.usdAmount !== null && item.usdAmount !== "") { if (item.usdAmount !== undefined && item.usdAmount !== null && item.usdAmount !== "") {
return `USD ${item.usdAmount}`; return `USD ${item.usdAmount}`;

View File

@ -9,6 +9,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { APPLICATION_STATUS } from "../constants.js"; import { APPLICATION_STATUS } from "../constants.js";
import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js"; import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js";
@ -26,7 +27,7 @@ export function FinanceMyApplicationList({ applications, error, filters, loading
<MenuItem value="">全部 APP</MenuItem> <MenuItem value="">全部 APP</MenuItem>
{options.apps.map((app) => ( {options.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -77,7 +78,14 @@ export function FinanceMyApplicationList({ applications, error, filters, loading
{items.length ? ( {items.length ? (
items.map((item) => ( items.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell>{item.appName || item.appCode || "-"}</TableCell> <TableCell>
<AppIdentity
app={options.apps.find((app) => app.appCode === item.appCode)}
appCode={item.appCode}
appName={item.appName}
size="small"
/>
</TableCell>
<TableCell> <TableCell>
<Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} /> <Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} />
</TableCell> </TableCell>

View File

@ -1,3 +1,4 @@
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js"; import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js";
const KPI_CARDS = [ const KPI_CARDS = [
@ -166,7 +167,7 @@ export function FinanceOverview({
onClick={() => onOpenApp(app.appCode)} onClick={() => onOpenApp(app.appCode)}
> >
<td> <td>
<b>{app.appName || app.appCode}</b> <AppIdentity app={app} size="small" />
</td> </td>
<td className="r num">{formatUsdMinor(appTotal, "USD")}</td> <td className="r num">{formatUsdMinor(appTotal, "USD")}</td>
<td className="r num">{formatAmount(app.summary?.total?.billCount || 0)}</td> <td className="r num">{formatAmount(app.summary?.total?.billCount || 0)}</td>

View File

@ -5,6 +5,7 @@ import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { import {
formatAmount, formatAmount,
formatMicroMoney, formatMicroMoney,
@ -60,10 +61,15 @@ export function FinanceRechargeDetailList({
.map((item) => item.transactionId) .map((item) => item.transactionId)
.filter(Boolean); .filter(Boolean);
const columnCount = showCoins ? 9 : 8; const columnCount = showCoins ? 9 : 8;
const selectedApp = (apps || []).find((app) => app.appCode === filters.appCode);
const chips = [ const chips = [
filters.appCode filters.appCode
? { key: "appCode", label: `App${appName(filters.appCode, apps)}`, reset: { appCode: "", regionId: "" } } ? {
key: "appCode",
label: <AppIdentity app={selectedApp} appCode={filters.appCode} size="small" />,
reset: { appCode: "", regionId: "" },
}
: null, : null,
filters.regionId filters.regionId
? { ? {
@ -200,7 +206,13 @@ export function FinanceRechargeDetailList({
onClick={() => setExpandedId(expanded ? "" : rowId)} onClick={() => setExpandedId(expanded ? "" : rowId)}
> >
<td className="num">{formatTime(item.createdAtMs)}</td> <td className="num">{formatTime(item.createdAtMs)}</td>
<td>{appName(item.appCode, apps)}</td> <td>
<AppIdentity
app={(apps || []).find((app) => app.appCode === item.appCode)}
appCode={item.appCode}
size="small"
/>
</td>
<td> <td>
<span <span
className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`} className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`}

View File

@ -8,6 +8,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { formatAmount, formatTime, statusLabel } from "../format.js"; import { formatAmount, formatTime, statusLabel } from "../format.js";
export function FinanceWithdrawalApplicationList({ applications, canAudit, error, filters, loading, onAudit, onFiltersChange, onReload, options }) { export function FinanceWithdrawalApplicationList({ applications, canAudit, error, filters, loading, onAudit, onFiltersChange, onReload, options }) {
@ -29,7 +30,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
<MenuItem value="">全部 APP</MenuItem> <MenuItem value="">全部 APP</MenuItem>
{options.apps.map((app) => ( {options.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -60,7 +61,14 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
{items.length ? ( {items.length ? (
items.map((item) => ( items.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell>{item.appName || item.appCode || "-"}</TableCell> <TableCell>
<AppIdentity
app={options.apps.find((app) => app.appCode === item.appCode)}
appCode={item.appCode}
appName={item.appName}
size="small"
/>
</TableCell>
<TableCell>{item.userId || "-"}</TableCell> <TableCell>{item.userId || "-"}</TableCell>
<TableCell align="right">$ {formatAmount(item.withdrawAmount)}</TableCell> <TableCell align="right">$ {formatAmount(item.withdrawAmount)}</TableCell>
<TableCell>{item.withdrawMethod || "-"}</TableCell> <TableCell>{item.withdrawMethod || "-"}</TableCell>

View File

@ -16,6 +16,7 @@ import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
import { changePassword } from "@/features/auth/api"; import { changePassword } from "@/features/auth/api";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -164,7 +165,7 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
{appScope.apps.length ? ( {appScope.apps.length ? (
appScope.apps.map((app) => ( appScope.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}> <MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode} <AppIdentity app={app} size="small" />
</MenuItem> </MenuItem>
)) ))
) : ( ) : (

View File

@ -67,7 +67,7 @@ export function exportUsers(query?: PageQuery): Promise<Response> {
export interface FinanceScopeCatalogDto { export interface FinanceScopeCatalogDto {
all?: boolean; all?: boolean;
apps?: Array<{ appCode: string; appName?: string }>; apps?: Array<{ appCode: string; appName?: string; logoUrl?: string }>;
regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: string; regionId: number }>; regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: string; regionId: number }>;
countries?: Array<{ appCode: string; countryCode: string; countryDisplayName?: string; countryName?: string; regionId?: number }>; countries?: Array<{ appCode: string; countryCode: string; countryDisplayName?: string; countryName?: string; regionId?: number }>;
scopes?: UserFinanceScopeDto[]; scopes?: UserFinanceScopeDto[];

View File

@ -3,6 +3,7 @@ import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
export function UserFormDrawer({ export function UserFormDrawer({
editingUser, editingUser,
@ -115,7 +116,7 @@ function FinanceScopeGroup({ app, checkedScopes, onChecked, regions }) {
return ( return (
<div className="permission-scope-group"> <div className="permission-scope-group">
<strong>{app.appName || appCode}</strong> <strong><AppIdentity app={app} size="small" /></strong>
<label className="permission-check" key={`${appCode}:0`}> <label className="permission-check" key={`${appCode}:0`}>
<input checked={isChecked(0)} onChange={(event) => onChecked(appCode, 0, event.target.checked)} type="checkbox" /> <input checked={isChecked(0)} onChange={(event) => onChecked(appCode, 0, event.target.checked)} type="checkbox" />
<span>全部区域</span> <span>全部区域</span>

View File

@ -13,7 +13,7 @@ const baseForm = {
}; };
const baseFinanceScopeCatalog = { const baseFinanceScopeCatalog = {
apps: [{ appCode: "Huwaa", appName: "Huwaa" }], apps: [{ appCode: "Huwaa", appName: "Huwaa", logoUrl: "https://media.example.com/huwaa.png" }],
error: null, error: null,
loading: false, loading: false,
regions: [{ appCode: "Huwaa", name: "菲律宾", regionCode: "PHILIPPINES", regionId: 8 }] regions: [{ appCode: "Huwaa", name: "菲律宾", regionCode: "PHILIPPINES", regionId: 8 }]

View File

@ -4336,6 +4336,12 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
ApiResponseAdminAppList: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AdminAppList"];
};
ApiResponseFinanceScopeCatalog: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceScopeCatalog"];
};
ApiResponseH5LinkList: components["schemas"]["Envelope"] & { ApiResponseH5LinkList: components["schemas"]["Envelope"] & {
data?: components["schemas"]["H5LinkList"]; data?: components["schemas"]["H5LinkList"];
}; };
@ -5570,6 +5576,35 @@ export interface components {
createdAtMs?: number; createdAtMs?: number;
updatedAtMs?: number; updatedAtMs?: number;
}; };
AdminApp: {
appId?: number;
appCode: string;
appName: string;
/** Format: uri */
logoUrl: string;
packageName?: string;
platform?: string;
status?: string;
/** Format: int64 */
createdAtMs?: number;
/** Format: int64 */
updatedAtMs?: number;
};
AdminAppList: {
items: components["schemas"]["AdminApp"][];
total: number;
};
FinanceScopeCatalog: {
all: boolean;
apps: components["schemas"]["AdminApp"][];
countries: {
[key: string]: unknown;
}[];
regions: {
[key: string]: unknown;
}[];
scopes: components["schemas"]["UserFinanceScope"][];
};
Country: { Country: {
countryCode: string; countryCode: string;
countryDisplayName?: string; countryDisplayName?: string;
@ -5903,6 +5938,24 @@ export interface components {
}; };
}; };
responses: { responses: {
/** @description OK */
AppListResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAdminAppList"];
};
};
/** @description OK */
FinanceScopeCatalogResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseFinanceScopeCatalog"];
};
};
/** @description OK */ /** @description OK */
H5LinkListResponse: { H5LinkListResponse: {
headers: { headers: {
@ -7996,7 +8049,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["AppListResponse"];
}; };
}; };
listBDLeaders: { listBDLeaders: {
@ -9474,7 +9527,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["AppListResponse"];
}; };
}; };
listRechargeProducts: { listRechargeProducts: {
@ -9630,7 +9683,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["FinanceScopeCatalogResponse"];
}; };
}; };
listFinanceApplications: { listFinanceApplications: {

View File

@ -1646,6 +1646,7 @@ export interface AdminAppDto {
appId: number; appId: number;
appName: string; appName: string;
createdAtMs?: number; createdAtMs?: number;
logoUrl: string;
packageName?: string; packageName?: string;
platform?: string; platform?: string;
status?: string; status?: string;

View File

@ -0,0 +1,45 @@
import { useState } from "react";
import styles from "./AppIdentity.module.css";
// AppIdentity App Logo
export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) {
const resolvedCode = String(app?.appCode || appCode || "").trim();
const resolvedName = String(app?.appName || appName || resolvedCode || "-").trim();
const resolvedLogoUrl = String(app?.logoUrl || logoUrl || "").trim();
return (
<span className={[styles.root, styles[size] || styles.medium, className].filter(Boolean).join(" ")}>
<AppLogo appCode={resolvedCode} appName={resolvedName} logoUrl={resolvedLogoUrl} />
<span className={styles.name}>{resolvedName}</span>
</span>
);
}
function AppLogo({ appCode, appName, logoUrl }) {
const [failedLogoUrl, setFailedLogoUrl] = useState("");
const canRenderImage = Boolean(logoUrl) && failedLogoUrl !== logoUrl;
if (canRenderImage) {
return (
<img
alt=""
className={styles.logo}
loading="lazy"
src={logoUrl}
onError={() => setFailedLogoUrl(logoUrl)}
/>
);
}
// Logo
const initial =
String(appName || appCode || "?")
.trim()
.slice(0, 1)
.toUpperCase() || "?";
return (
<span aria-hidden="true" className={styles.fallback}>
{initial}
</span>
);
}

View File

@ -0,0 +1,52 @@
.root {
align-items: center;
display: inline-flex;
gap: 8px;
min-width: 0;
vertical-align: middle;
}
.logo,
.fallback {
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 7px;
box-shadow: 0 1px 3px rgb(15 23 42 / 10%);
flex: 0 0 auto;
object-fit: cover;
}
.fallback {
align-items: center;
background: linear-gradient(145deg, #e8f1ff, #f7faff);
color: var(--primary);
display: inline-flex;
font-size: 11px;
font-weight: 800;
justify-content: center;
}
.small .logo,
.small .fallback {
height: 22px;
width: 22px;
}
.medium .logo,
.medium .fallback {
height: 28px;
width: 28px;
}
.large .logo,
.large .fallback {
border-radius: 9px;
height: 36px;
width: 36px;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,22 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { AppIdentity } from "./AppIdentity.jsx";
test("renders the server-provided app logo beside the app name", () => {
const { container } = render(
<AppIdentity app={{ appCode: "lalu", appName: "Lalu", logoUrl: "https://media.example.com/lalu.png" }} />,
);
expect(screen.getByText("Lalu")).toBeInTheDocument();
expect(container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/lalu.png");
});
test("falls back to a stable initial when the logo cannot load", () => {
const { container } = render(
<AppIdentity app={{ appCode: "lalu", appName: "Lalu", logoUrl: "https://media.example.com/missing.png" }} />,
);
fireEvent.error(container.querySelector("img"));
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("L", { selector: "span" })).toBeInTheDocument();
});