feat(admin): 计费页长出「充值渠道」块 —— 兑换码生成/台账 + 积分包定价
P5.1 收尾:此前生成码只有 API。计费页现在从上到下 = 计费规则(积分→token 汇率) → 充值渠道(钱→积分:兑换码 + 微信定价用的积分包) → 用量观测, 两层汇率在同一页可见、各管各的。 - 兑换码:面额/张数/备注生成;**明文码只在生成响应显示一次**(等同现金, 台账 GET /admin/redeem-codes 服务端脱敏只露首尾,丢码重生成、不提供找回 ——顺手把接口这个第二明文出口堵了);台账含核销状态。 - 积分包:新增/上下架(微信 P5.2 上线前把定价面备好);admin api.ts 补 packs/redeem-codes 四个函数。 - launch.json 加 admin-console-alt(:5176)——5174 被用户自己的 sundynix-site 占着,不动别人端口。 live:5176 登录→生成 5 张(绿色一次性面板+复制全部)→配「入门包 1000分/¥9.9」 在售可下架→台账脱敏 curl 复核;tsc+41 vitest 全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,13 @@
|
|||||||
"cwd": "sundynix-admin",
|
"cwd": "sundynix-admin",
|
||||||
"port": 5174
|
"port": 5174
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "admin-console-alt",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5176"],
|
||||||
|
"cwd": "sundynix-admin",
|
||||||
|
"port": 5176
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "web-face",
|
"name": "web-face",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
|
|||||||
@@ -175,6 +175,57 @@ export async function grantCredits(tenantId: string, credits: number, memo: stri
|
|||||||
return d.balance_micro ?? 0;
|
return d.balance_micro ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 充值渠道(P5.1):积分包配置 + 兑换码 ----
|
||||||
|
export interface CreditPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
credits_micro: number;
|
||||||
|
price_fen: number;
|
||||||
|
active: boolean;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminPacks(): Promise<CreditPack[]> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/packs`, { headers: authHeaders() }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { packs?: CreditPack[]; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `packs failed: ${res.status}`);
|
||||||
|
return d.packs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// savePack:id 空 = 新建。credits 单位为「积分」(面向人,服务端转 micro)。
|
||||||
|
export async function savePack(p: { id?: string; name: string; credits: number; price_fen: number; active: boolean; sort: number }): Promise<void> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/packs`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemCodeRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
credits_micro: number;
|
||||||
|
status: string; // unused / used
|
||||||
|
used_tenant: string;
|
||||||
|
used_at: string | null;
|
||||||
|
memo: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function genRedeemCodes(credits: number, count: number, memo: string): Promise<string[]> {
|
||||||
|
const res = guard(
|
||||||
|
await fetch(`${ADMIN}/redeem-codes`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ credits, count, memo }) }),
|
||||||
|
);
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { codes?: string[]; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `生成失败: ${res.status}`);
|
||||||
|
return d.codes ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/redeem-codes`, { headers: authHeaders() }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { codes?: RedeemCodeRow[]; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `codes failed: ${res.status}`);
|
||||||
|
return d.codes ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
||||||
export async function gatewayOnline(): Promise<boolean> {
|
export async function gatewayOnline(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { adminPacks, savePack, genRedeemCodes, listRedeemCodes, type CreditPack, type RedeemCodeRow } from "../api";
|
||||||
|
|
||||||
|
// 充值渠道(P5.1,设计见 PAYMENT_DESIGN.md):
|
||||||
|
// - 积分包:钱→积分的第一层汇率(第二层 积分→token 在上方「计费规则」里,两层解耦)。
|
||||||
|
// 微信支付(P5.2)上线前包只做展示位,这里先把配置面备好。
|
||||||
|
// - 兑换码:零资质渠道 + 线下打款核销通道。生成后明文码只显示一次(列表页常驻展示
|
||||||
|
// 等于把「钱」贴在墙上,别这么干)。
|
||||||
|
const MICRO = 1_000_000;
|
||||||
|
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
export function TopupChannels() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">充值渠道</h3>
|
||||||
|
<span className="text-[11px] text-gray-400">兑换码即刻可用;微信扫码支付(P5.2)待商户号</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<RedeemBlock />
|
||||||
|
<PacksBlock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 兑换码:生成 + 台账 ----
|
||||||
|
function RedeemBlock() {
|
||||||
|
const [rows, setRows] = useState<RedeemCodeRow[]>([]);
|
||||||
|
const [creditsIn, setCreditsIn] = useState("100");
|
||||||
|
const [count, setCount] = useState("5");
|
||||||
|
const [memo, setMemo] = useState("");
|
||||||
|
const [fresh, setFresh] = useState<string[]>([]); // 刚生成的明文码(只此一屏)
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
const load = () => listRedeemCodes().then(setRows).catch((e) => setErr((e as Error).message));
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const gen = async () => {
|
||||||
|
const c = Number(creditsIn);
|
||||||
|
const n = Number(count);
|
||||||
|
if (!c || c <= 0 || !n || n <= 0 || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
setFresh(await genRedeemCodes(c, n, memo.trim()));
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAll = () => void navigator.clipboard?.writeText(fresh.join("\n"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">兑换码</h4>
|
||||||
|
<span className="text-[11px] text-gray-400">用户在 Web 面「用量与账单」输码入账</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
面额(积分)
|
||||||
|
<input value={creditsIn} onChange={(e) => setCreditsIn(e.target.value)} inputMode="numeric"
|
||||||
|
className="mt-1 block w-24 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
张数
|
||||||
|
<input value={count} onChange={(e) => setCount(e.target.value)} inputMode="numeric"
|
||||||
|
className="mt-1 block w-16 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 text-xs text-gray-500">
|
||||||
|
备注(发给谁/用途)
|
||||||
|
<input value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="如:X 公司 PoC"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<button onClick={() => void gen()} disabled={busy}
|
||||||
|
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||||
|
{busy ? "生成中…" : "生成"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||||
|
|
||||||
|
{fresh.length > 0 && (
|
||||||
|
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-emerald-700">已生成 {fresh.length} 张——明文码只显示这一次,请立即复制发放</span>
|
||||||
|
<button onClick={copyAll} className="rounded border border-emerald-300 px-2 py-0.5 text-[11px] text-emerald-700 hover:bg-emerald-100">
|
||||||
|
复制全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-0.5 font-mono text-xs text-emerald-900 md:grid-cols-2">
|
||||||
|
{fresh.map((c) => (
|
||||||
|
<span key={c}>{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 max-h-56 overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-white">
|
||||||
|
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="py-2 pr-3 font-medium">码</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">面额</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">状态</th>
|
||||||
|
<th className="py-2 font-medium">备注</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-gray-50 last:border-0">
|
||||||
|
{/* 服务端已脱敏(只露首尾):完整明文只在生成响应里给一次 */}
|
||||||
|
<td className="py-1.5 pr-3 font-mono text-xs text-gray-600">{r.code}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(r.credits_micro)}</td>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
{r.status === "used" ? (
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">已核销</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">未使用</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-xs text-gray-400">{r.memo || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">还没有兑换码</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ----
|
||||||
|
function PacksBlock() {
|
||||||
|
const [rows, setRows] = useState<CreditPack[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [creditsIn, setCreditsIn] = useState("");
|
||||||
|
const [yuan, setYuan] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
const load = () => adminPacks().then(setRows).catch((e) => setErr((e as Error).message));
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
const c = Number(creditsIn);
|
||||||
|
const y = Number(yuan);
|
||||||
|
if (!name.trim() || !c || c <= 0 || y < 0 || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await savePack({ name: name.trim(), credits: c, price_fen: Math.round(y * 100), active: true, sort: rows.length });
|
||||||
|
setName("");
|
||||||
|
setCreditsIn("");
|
||||||
|
setYuan("");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (p: CreditPack) => {
|
||||||
|
try {
|
||||||
|
await savePack({ id: p.id, name: p.name, credits: p.credits_micro / MICRO, price_fen: p.price_fen, active: !p.active, sort: p.sort });
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">积分包(微信支付定价)</h4>
|
||||||
|
<span className="text-[11px] text-gray-400">钱→积分;积分→token 的汇率在上方计费规则</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex-1 text-xs text-gray-500">
|
||||||
|
名称
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:入门包"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
积分
|
||||||
|
<input value={creditsIn} onChange={(e) => setCreditsIn(e.target.value)} inputMode="numeric"
|
||||||
|
className="mt-1 block w-24 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
售价(¥)
|
||||||
|
<input value={yuan} onChange={(e) => setYuan(e.target.value)} inputMode="decimal"
|
||||||
|
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<button onClick={() => void add()} disabled={busy}
|
||||||
|
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||||
|
{busy ? "…" : "新增"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="py-2 pr-3 font-medium">名称</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">积分</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">售价</th>
|
||||||
|
<th className="py-2 text-right font-medium">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b border-gray-50 last:border-0">
|
||||||
|
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(p.credits_micro)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{(p.price_fen / 100).toFixed(2)}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button onClick={() => void toggle(p)}
|
||||||
|
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
|
||||||
|
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">还没有积分包——微信支付上线前配好即可</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||||
import { BillingRules } from "../components/BillingRules";
|
import { BillingRules } from "../components/BillingRules";
|
||||||
|
import { TopupChannels } from "../components/TopupChannels";
|
||||||
|
|
||||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
||||||
@@ -97,6 +98,9 @@ export function UsagePage() {
|
|||||||
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
||||||
<BillingRules onSaved={() => void load()} />
|
<BillingRules onSaved={() => void load()} />
|
||||||
|
|
||||||
|
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价,P5.1) */}
|
||||||
|
<TopupChannels />
|
||||||
|
|
||||||
{/* 观测端:用量结果 */}
|
{/* 观测端:用量结果 */}
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
||||||
|
|||||||
@@ -84,13 +84,20 @@ func (h *Handler) AdminGenRedeemCodes(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"codes": codes})
|
c.JSON(http.StatusOK, gin.H{"codes": codes})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRedeemCodes: GET /api/v1/admin/redeem-codes —— 兑换码列表(含核销状态)。
|
// AdminRedeemCodes: GET /api/v1/admin/redeem-codes —— 兑换码台账(含核销状态)。
|
||||||
|
// 码在台账里脱敏只露首尾:完整明文只在生成响应里给一次。兑换码等同现金,
|
||||||
|
// 常驻可查的列表接口不该是第二个明文出口(丢了码就重新生成一张,不提供找回)。
|
||||||
func (h *Handler) AdminRedeemCodes(c *gin.Context) {
|
func (h *Handler) AdminRedeemCodes(c *gin.Context) {
|
||||||
rows, err := h.db.ListRedeemCodes(c.Request.Context(), 200)
|
rows, err := h.db.ListRedeemCodes(c.Request.Context(), 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for i := range rows {
|
||||||
|
if n := len(rows[i].Code); n > 12 {
|
||||||
|
rows[i].Code = rows[i].Code[:8] + "…" + rows[i].Code[n-4:]
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"codes": rows})
|
c.JSON(http.StatusOK, gin.H{"codes": rows})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user