From a4d46f2b1e03ff8515cffc11bebe4e15f278c025 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 3 Aug 2026 14:58:38 +0800 Subject: [PATCH] feat(admin): add image quota management UI on memind_adm 5174 Add image-quota admin pages and API wiring for plan defaults, ledger, and per-user grants, plus local verify scripts and AGENTS.md note that this repo is the sole admin UI surface. Co-authored-by: Cursor --- AGENTS.md | 13 ++ scripts/verify-image-quota-local.mjs | 122 +++++++++++ scripts/verify-image-quota-portal.mjs | 92 +++++++++ server/app.mjs | 80 ++++++++ server/bootstrap.mjs | 1 + src/App.tsx | 3 + src/admin/AdminNav.tsx | 5 +- src/admin/pages/ImageQuotaPage.tsx | 285 ++++++++++++++++++++++++++ src/admin/pages/UserDetailPage.tsx | 82 +++++++- src/api/client.ts | 50 +++++ src/types.ts | 26 +++ 11 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 scripts/verify-image-quota-local.mjs create mode 100644 scripts/verify-image-quota-portal.mjs create mode 100644 src/admin/pages/ImageQuotaPage.tsx diff --git a/AGENTS.md b/AGENTS.md index fb64db5..116b14b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,3 +19,16 @@ bash scripts/check-release-ready.sh - `memind_adm` 可以独立开发,但共享用户、权限、策略、技能、计费体系必须继续复用 `Memind` 主实现。 - 发版须 Git commit,禁止本机直 `rsync` 到 `103/105`。 - 共享用户、计费、空间额度、策略同步相关改动必须保留业务验收记录。 + +## 必读:本仓库是唯一合法的管理后台 UI(5174) + +**所有平台管理后台的前端功能只能在本仓库(memind_adm)开发,本地端口 5174,生产 gadm。禁止在 Memind 仓库的 `ops/`(约 3002)新增任何管理页面、导航或 API 客户端。** + +| 组件 | 端口 | 职责 | +|------|------|------| +| **memind_adm 前端(本仓库 `src/`)** | **5174** | 管理后台 UI:用户、计费、图片额度、策略、模型中心等 | +| memind_adm API(`server/`) | 8085 | 挂载 `/admin-api/*`,复用 Memind 共享模块 | +| Memind `ops/` | ~3002 | Plaza 运营 + 遗留 admin;**只读维护,禁止扩展** | +| Memind 后端 | 8081 / 8082 | 业务逻辑与 Portal;UI 不在此仓库 | + +新增管理功能时:在本仓库添加 `src/admin/pages/*`、更新 `AdminNav.tsx` 与 `App.tsx`;若需新 API,在 `server/app.mjs` 挂载并复用 Memind 模块。Memind 侧仅实现共享业务,不在 `ops/` 做 UI。 diff --git a/scripts/verify-image-quota-local.mjs b/scripts/verify-image-quota-local.mjs new file mode 100644 index 0000000..4cd3acd --- /dev/null +++ b/scripts/verify-image-quota-local.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Local smoke test for image-quota admin API (memind_adm 8085). + * Usage: node scripts/verify-image-quota-local.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const base = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085'; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const username = process.env.H5_ADMIN_USERNAME?.trim() || 'admin'; +const password = process.env.H5_ADMIN_PASSWORD?.trim() || process.env.ADMIN_PASSWORD?.trim() || ''; + +async function request(method, urlPath, { body, cookie } = {}) { + const res = await fetch(`${base}${urlPath}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text.slice(0, 200) }; + } + return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] }; +} + +function cookieFromSetCookie(setCookie) { + return setCookie.map((c) => c.split(';')[0]).join('; '); +} + +function assertOk(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function main() { + console.log(`==> Admin API base: ${base}`); + + if (!password) { + console.error('缺少 H5_ADMIN_PASSWORD(请在 memind_adm/.env 配置)'); + process.exit(1); + } + + const login = await request('POST', '/auth/login', { + body: { username, password }, + }); + assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`); + const cookie = cookieFromSetCookie(login.setCookie); + assertOk(cookie, '登录未返回 session cookie'); + console.log(`✓ 管理员登录 (${username})`); + + const config = await request('GET', '/admin-api/image-quota/config', { cookie }); + assertOk(config.status === 200, `config HTTP ${config.status}`); + assertOk(Array.isArray(config.json?.plans), 'config.plans 应为数组'); + assertOk(config.json.plans.length > 0, 'config.plans 不应为空'); + console.log(`✓ GET /admin-api/image-quota/config (${config.json.plans.length} 套餐)`); + + const users = await request('GET', '/admin-api/users?page=1&pageSize=5&role=user', { cookie }); + assertOk(users.status === 200, `users HTTP ${users.status}`); + const sampleUser = users.json?.users?.[0]; + assertOk(sampleUser?.id, '需要至少一个 user 账号做用户级测试'); + console.log(`✓ 样本用户: ${sampleUser.username} (${sampleUser.id})`); + + const userQuota = await request('GET', `/admin-api/users/${sampleUser.id}/image-quota`, { cookie }); + assertOk(userQuota.status === 200, `user image-quota HTTP ${userQuota.status}`); + assertOk(userQuota.json?.quota, '应返回 quota 对象'); + console.log( + `✓ GET user image-quota: remaining=${userQuota.json.quota.remaining ?? '∞'} unlimited=${userQuota.json.quota.unlimited}`, + ); + + const ledger = await request('GET', '/admin-api/image-quota/ledger?page=1&pageSize=5', { cookie }); + assertOk(ledger.status === 200, `ledger HTTP ${ledger.status}`); + assertOk(Array.isArray(ledger.json?.entries), 'ledger.entries 应为数组'); + console.log(`✓ GET /admin-api/image-quota/ledger (${ledger.json.total} 条)`); + + const grant = await request('POST', `/admin-api/users/${sampleUser.id}/image-quota/grant`, { + cookie, + body: { delta: 1, note: 'local-verify-image-quota' }, + }); + assertOk(grant.status === 200, `grant HTTP ${grant.status}: ${JSON.stringify(grant.json)}`); + assertOk(grant.json?.quota, 'grant 应返回更新后的 quota'); + console.log(`✓ POST grant +1 → remaining=${grant.json.quota.remaining ?? '∞'}`); + + const revoke = await request('POST', `/admin-api/users/${sampleUser.id}/image-quota/grant`, { + cookie, + body: { delta: -1, note: 'local-verify-image-quota-rollback' }, + }); + assertOk(revoke.status === 200, `rollback grant HTTP ${revoke.status}`); + console.log('✓ POST grant -1 回滚测试额度'); + + console.log('\n全部 image-quota Admin API 联调通过。'); +} + +main().catch((err) => { + console.error('\n联调失败:', err.message); + process.exit(1); +}); diff --git a/scripts/verify-image-quota-portal.mjs b/scripts/verify-image-quota-portal.mjs new file mode 100644 index 0000000..b85c993 --- /dev/null +++ b/scripts/verify-image-quota-portal.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** Portal smoke: /auth/me subscription includes image quota fields */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..', 'Memind'); +const portalBase = process.env.PORTAL_BASE ?? 'http://127.0.0.1:8081'; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} + +loadEnvFile(path.join(memindRoot, '.env')); + +const username = process.env.H5_TEST_USERNAME?.trim() || process.env.H5_ADMIN_USERNAME?.trim() || 'admin'; +const password = process.env.H5_TEST_PASSWORD?.trim() || process.env.H5_ADMIN_PASSWORD?.trim() || ''; + +async function request(method, urlPath, { body, cookie } = {}) { + const res = await fetch(`${portalBase}${urlPath}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch { json = { raw: text.slice(0, 200) }; } + return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] }; +} + +function cookieFromSetCookie(setCookie) { + return setCookie.map((c) => c.split(';')[0]).join('; '); +} + +async function main() { + console.log(`==> Portal base: ${portalBase}`); + if (!password) { + console.error('缺少登录密码(Memind/.env 中 H5_ADMIN_PASSWORD 或 H5_TEST_PASSWORD)'); + process.exit(1); + } + + const login = await request('POST', '/auth/login', { body: { username, password } }); + if (login.status !== 200) { + console.error(`登录失败 HTTP ${login.status}`); + process.exit(1); + } + const cookie = cookieFromSetCookie(login.setCookie); + console.log(`✓ Portal 登录 (${username})`); + + const me = await request('GET', '/auth/me', { cookie }); + if (me.status !== 200) { + console.error(`/auth/me HTTP ${me.status}`); + process.exit(1); + } + const sub = me.json?.user?.subscription; + if (!sub) { + console.log('⚠ 当前用户无 active subscription(免费用户可能无订阅记录)'); + process.exit(0); + } + + const fields = ['periodImagesLimit', 'periodImagesUsed', 'periodImagesBonus']; + for (const f of fields) { + if (!(f in sub)) { + console.error(`subscription 缺少字段: ${f}`); + process.exit(1); + } + } + console.log( + `✓ /auth/me subscription 图片额度: limit=${sub.periodImagesLimit} bonus=${sub.periodImagesBonus ?? 0} used=${sub.periodImagesUsed}`, + ); + console.log('\nPortal 用户侧 subscription 字段联调通过。'); +} + +main().catch((err) => { + console.error('联调失败:', err.message); + process.exit(1); +}); diff --git a/server/app.mjs b/server/app.mjs index 768ef3d..6687479 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1183,6 +1183,86 @@ export function createAdminApp(services) { res.json(result); }); + // ── Image generation quota ──────────────────────────────────────────────── + + adminApi.get('/image-quota/config', requireAdmin, async (_req, res) => { + const catalogService = subscriptionService?._planCatalogService ?? planCatalogService; + if (!catalogService?.listPlans) { + return res.status(503).json({ message: '套餐目录服务未启用' }); + } + const plans = await catalogService.listPlans(); + res.json({ plans }); + }); + + adminApi.patch('/image-quota/config/:planType', requireAdmin, async (req, res) => { + const catalogService = subscriptionService?._planCatalogService ?? planCatalogService; + if (!catalogService?.upsertPlan) { + return res.status(503).json({ message: '套餐目录服务未启用' }); + } + const periodImages = Number(req.body?.periodImages); + if (!Number.isFinite(periodImages) || periodImages < 0) { + return res.status(400).json({ message: 'periodImages 必须是非负整数;0 表示无限' }); + } + const current = await catalogService.getPlan(req.params.planType); + if (!current) return res.status(404).json({ message: '套餐不存在' }); + const result = await catalogService.upsertPlan(req.params.planType, { + ...current, + periodImages: Math.floor(periodImages), + }); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/image-quota', requireAdmin, async (req, res) => { + if (!subscriptionService?.getImageQuota) { + return res.status(503).json({ message: '图片额度服务未启用' }); + } + const quotaResult = await subscriptionService.getImageQuota(req.params.userId); + if (!quotaResult.ok) return res.status(404).json({ message: quotaResult.message }); + const ledger = subscriptionService.listImageQuotaLedger + ? await subscriptionService.listImageQuotaLedger({ + userId: req.params.userId, + page: 1, + pageSize: 20, + }) + : { entries: [] }; + res.json({ + subscription: quotaResult.subscription, + quota: quotaResult.quota, + ledger: ledger.entries, + }); + }); + + adminApi.post('/users/:userId/image-quota/grant', requireAdmin, async (req, res) => { + if (!subscriptionService?.grantImageQuota) { + return res.status(503).json({ message: '图片额度服务未启用' }); + } + const delta = Number(req.body?.delta); + if (!Number.isFinite(delta) || delta === 0) { + return res.status(400).json({ message: 'delta 必须是非零整数' }); + } + const note = String(req.body?.note ?? '').trim(); + const result = await subscriptionService.grantImageQuota( + req.params.userId, + Math.floor(delta), + { operatorId: req.currentUser.id, note }, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/image-quota/ledger', requireAdmin, async (req, res) => { + if (!subscriptionService?.listImageQuotaLedger) { + return res.status(503).json({ message: '图片额度服务未启用' }); + } + const result = await subscriptionService.listImageQuotaLedger({ + userId: req.query.userId ? String(req.query.userId) : null, + page: req.query.page, + pageSize: req.query.pageSize, + }); + res.json(result); + }); + app.use('/admin-api', adminApi); if (services.createOpsApi) { diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index bd74dcc..bb73016 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -159,6 +159,7 @@ export async function bootstrapAdminServices() { const subscriptionService = createSubscriptionService(pool, { getPlanAsync: (planType) => planCatalogService.getPlan(planType), }); + subscriptionService._planCatalogService = planCatalogService; await ensureSystemTestAccountSchema(pool); const systemTestAccountService = createSystemTestAccountService(pool); diff --git a/src/App.tsx b/src/App.tsx index 188ee53..4401813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AdminLayout } from './admin/AdminLayout'; import { BillingPage } from './admin/pages/BillingPage'; import { CapabilitiesPage } from './admin/pages/CapabilitiesPage'; import { DashboardPage } from './admin/pages/DashboardPage'; +import { ImageQuotaPage } from './admin/pages/ImageQuotaPage'; import { PoliciesPage } from './admin/pages/PoliciesPage'; import { MindSpacePage } from './admin/pages/MindSpacePage'; import { MemoryV2Page } from './admin/pages/MemoryV2Page'; @@ -119,6 +120,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> @@ -170,6 +172,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) { if ( pathname.startsWith('/users') || pathname.startsWith('/billing') + || pathname.startsWith('/image-quota') || pathname.startsWith('/capabilities') || pathname.startsWith('/skills') || pathname.startsWith('/system-tests') diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 3b15503..b6ef962 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -21,7 +21,10 @@ const NAV_SECTIONS: NavSection[] = [ }, { label: '计费', - items: [{ to: '/billing', label: '计费中心', end: false }], + items: [ + { to: '/billing', label: '计费中心', end: false }, + { to: '/image-quota', label: '图片额度' }, + ], }, { label: '平台配置', diff --git a/src/admin/pages/ImageQuotaPage.tsx b/src/admin/pages/ImageQuotaPage.tsx new file mode 100644 index 0000000..e1721fa --- /dev/null +++ b/src/admin/pages/ImageQuotaPage.tsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from 'react'; +import { + fetchImageQuotaConfig, + fetchImageQuotaLedger, + patchImageQuotaPlan, +} from '../../api/client'; +import type { ImageQuotaLedgerEntry, PlanDefinition } from '../../types'; +import { Pagination } from '../../components/Pagination'; +import { formatTime } from '../utils/format'; + +type Tab = 'plans' | 'ledger'; + +function fmtQuota(value: number | null | undefined, unlimited = false) { + if (unlimited || value == null) return '无限'; + return String(value); +} + +const REASON_LABELS: Record = { + admin_grant: '管理员充值', + admin_adjust: '管理员调整', + consume: '生图消费', + period_reset: '周期重置', + plan_change: '套餐变更', +}; + +export function ImageQuotaPage() { + const [tab, setTab] = useState('plans'); + + return ( +
+
+

图片生成额度

+

+ 用户有剩余额度时才能调用 image_make 生图;套餐默认额度中 0 表示无限。用户级充值请在「用户管理 → 用户详情」中操作。 +

+
+ +
+ + +
+ + {tab === 'plans' ? : } +
+ ); +} + +function PlansTab() { + const [plans, setPlans] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [loading, setLoading] = useState(false); + const [busyPlan, setBusyPlan] = useState(null); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const result = await fetchImageQuotaConfig(); + setPlans(result.plans); + setDrafts(Object.fromEntries(result.plans.map((plan) => [plan.planType, String(plan.periodImages)]))); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const savePlan = async (planType: string) => { + const raw = drafts[planType]; + const periodImages = Math.floor(Number(raw)); + if (!Number.isFinite(periodImages) || periodImages < 0) { + setError('额度必须是非负整数'); + return; + } + setBusyPlan(planType); + setError(null); + setMessage(null); + try { + await patchImageQuotaPlan(planType, periodImages); + setMessage(`已更新 ${planType} 默认图片额度`); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setBusyPlan(null); + } + }; + + return ( + <> + {error &&

{error}

} + {message &&

{message}

} + +
+ {loading && plans.length === 0 ? ( +

加载中…

+ ) : ( +
+ + + + + + + + + + + + {plans.map((plan) => ( + + + + + + + + ))} + {plans.length === 0 && !loading ? ( + + + + ) : null} + +
套餐标识月图片额度月 Token操作
{plan.name} + {plan.planType} + + setDrafts((prev) => ({ ...prev, [plan.planType]: e.target.value }))} + style={{ width: 120 }} + /> + + {plan.periodTokens === 0 ? '无限' : plan.periodTokens.toLocaleString('zh-CN')} + + +
+ 暂无套餐配置 +
+
+ )} +
+ + ); +} + +function LedgerTab() { + const [entries, setEntries] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [userId, setUserId] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async (p = 1) => { + setLoading(true); + setError(null); + try { + const result = await fetchImageQuotaLedger({ + page: p, + pageSize: 30, + userId: userId.trim() || undefined, + }); + setEntries(result.entries); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(1); + }, []); + + return ( + <> +
+
{ + e.preventDefault(); + void load(1); + }} + > + setUserId(e.target.value)} + /> + +
+
+ + {error &&

{error}

} + +
+ {loading && entries.length === 0 ? ( +

加载中…

+ ) : ( +
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + {entries.length === 0 && !loading ? ( + + + + ) : null} + +
时间用户变动剩余原因备注
{formatTime(entry.createdAt)} +
{entry.displayName || entry.username || '—'}
+ {entry.userId} +
= 0 ? 'var(--ok)' : 'var(--danger)' }}> + {entry.delta >= 0 ? `+${entry.delta}` : entry.delta} + {fmtQuota(entry.balanceAfter)}{REASON_LABELS[entry.reason] ?? entry.reason}{entry.note || entry.refId || '—'}
+ 暂无流水 +
+
+ )} + void load(p)} + /> +
+ + ); +} diff --git a/src/admin/pages/UserDetailPage.tsx b/src/admin/pages/UserDetailPage.tsx index f37dcc7..2802852 100644 --- a/src/admin/pages/UserDetailPage.tsx +++ b/src/admin/pages/UserDetailPage.tsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react'; import { Link, Navigate, useParams } from 'react-router-dom'; -import { getAdminUser, rechargeUser, updateAdminUser } from '../../api/client'; +import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, grantUserImageQuota } from '../../api/client'; import { CapabilitySettings } from '../../components/CapabilitySettings'; import { PolicySettings } from '../../components/PolicySettings'; import { SkillSettings } from '../../components/SkillSettings'; import { useAdminUsers } from '../hooks/useAdminUsers'; import { formatYuan } from '../utils/format'; -import type { PortalUser } from '../../types'; +import type { ImageQuotaView, PortalUser } from '../../types'; function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; @@ -23,6 +23,9 @@ export function UserDetailPage() { const [message, setMessage] = useState(null); const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' }); const [spaceQuotaMb, setSpaceQuotaMb] = useState('5'); + const [imageQuota, setImageQuota] = useState(null); + const [imageQuotaLoading, setImageQuotaLoading] = useState(false); + const [imageGrant, setImageGrant] = useState({ delta: '', note: '' }); useEffect(() => { if (!user?.spaceQuotaBytes) return; @@ -57,6 +60,28 @@ export function UserDetailPage() { }; }, [userId, users]); + useEffect(() => { + if (!user || user.role !== 'user') { + setImageQuota(null); + return; + } + let cancelled = false; + setImageQuotaLoading(true); + void fetchUserImageQuota(user.id) + .then((result) => { + if (!cancelled) setImageQuota(result.quota); + }) + .catch(() => { + if (!cancelled) setImageQuota(null); + }) + .finally(() => { + if (!cancelled) setImageQuotaLoading(false); + }); + return () => { + cancelled = true; + }; + }, [user?.id, user?.role]); + const handleRecharge = async (e: React.FormEvent) => { e.preventDefault(); if (!user) return; @@ -75,6 +100,33 @@ export function UserDetailPage() { } }; + const handleImageGrant = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + setMessage(null); + setLocalError(null); + setError(null); + const delta = Math.floor(Number(imageGrant.delta)); + if (!Number.isFinite(delta) || delta === 0) { + setLocalError('请输入非零整数额度'); + return; + } + try { + const result = await grantUserImageQuota(user.id, delta, imageGrant.note.trim()); + setImageQuota(result.quota); + setMessage('图片额度已更新'); + setImageGrant({ delta: '', note: '' }); + } catch (err) { + setLocalError(err instanceof Error ? err.message : '图片额度调整失败'); + } + }; + + const imageQuotaSummary = imageQuota + ? imageQuota.unlimited + ? '无限' + : `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})` + : ''; + if (!loading && !user && !error) { return ; } @@ -186,6 +238,32 @@ export function UserDetailPage() { +
+

图片生成额度

+ {imageQuotaLoading ? ( +

加载中…

+ ) : ( +

{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}

+ )} +
+ setImageGrant((s) => ({ ...s, delta: e.target.value }))} + /> + setImageGrant((s) => ({ ...s, note: e.target.value }))} + /> + +
+
+

调整空间

diff --git a/src/api/client.ts b/src/api/client.ts index b8cc610..4563396 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -12,6 +12,8 @@ import type { CapabilityDefinition, CapabilityMap, InsufficientBalanceDetails, + ImageQuotaLedgerEntry, + ImageQuotaView, LedgerEntry, LlmConnectionTestResult, LlmExecutorBinding, @@ -1351,3 +1353,51 @@ export async function getUserSubscription(userId: string): Promise('/admin-api/image-quota/config'); +} + +export async function patchImageQuotaPlan(planType: string, periodImages: number) { + return portalFetch<{ ok: boolean; plan: PlanDefinition }>(`/admin-api/image-quota/config/${planType}`, { + method: 'PATCH', + body: JSON.stringify({ periodImages }), + }); +} + +export async function fetchUserImageQuota(userId: string) { + return portalFetch<{ + subscription: AdminSubscription; + quota: ImageQuotaView; + ledger: ImageQuotaLedgerEntry[]; + }>(`/admin-api/users/${userId}/image-quota`); +} + +export async function grantUserImageQuota(userId: string, delta: number, note = '') { + return portalFetch<{ + ok: boolean; + subscription: AdminSubscription; + quota: ImageQuotaView; + }>(`/admin-api/users/${userId}/image-quota/grant`, { + method: 'POST', + body: JSON.stringify({ delta, note }), + }); +} + +export async function fetchImageQuotaLedger(params: { + userId?: string; + page?: number; + pageSize?: number; +} = {}) { + const q = new URLSearchParams(); + if (params.userId) q.set('userId', params.userId); + if (params.page) q.set('page', String(params.page)); + if (params.pageSize) q.set('pageSize', String(params.pageSize)); + return portalFetch<{ + entries: ImageQuotaLedgerEntry[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }>(`/admin-api/image-quota/ledger${q.toString() ? `?${q}` : ''}`); +} diff --git a/src/types.ts b/src/types.ts index 000a2f8..7752159 100644 --- a/src/types.ts +++ b/src/types.ts @@ -681,6 +681,32 @@ export type AdminSubscription = { periodTokensUsed: number; periodImagesLimit: number; periodImagesUsed: number; + periodImagesBonus?: number; + note: string | null; + createdAt: number; +}; + +export type ImageQuotaView = { + limit: number; + bonus: number; + used: number; + total: number | null; + remaining: number | null; + unlimited: boolean; + periodEnd: number | null; + planType: string | null; +}; + +export type ImageQuotaLedgerEntry = { + id: string; + userId: string; + username?: string; + displayName?: string; + delta: number; + balanceAfter: number | null; + reason: string; + refId: string | null; + operatorId: string | null; note: string | null; createdAt: number; };