Compare commits

..

5 Commits

Author SHA1 Message Date
john 80c2091baf feat(billing): add all/A/B tabs for formula user filter
Replace the formula assignment dropdown with tab buttons so admins can
quickly view all users or filter by billing formula A or B.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 11:36:13 +08:00
john f9f8b541dc fix(deploy): load vite preview config for md.tkmind.cn host
LaunchAgent web startup must use vite-preview.config.mjs so preview
allowedHosts includes md.tkmind.cn instead of blocking nginx requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 08:55:07 +08:00
john f6bb02254e feat(billing): add formula A/B admin UI and subscription renew status
Expose dual metering formula editing with batch user assignment, show
auto-renew and pending-recharge states in subscriptions, and document
billing acceptance checks for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 08:45:43 +08:00
john a503fa9cbe fix(deploy): add LaunchAgent prod runner scripts for adm api and web
Restore the missing run-memind-adm-*-prod.sh entrypoints expected by 103
LaunchAgents so admin services can restart cleanly after reboot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:57:00 +08:00
john 66dd288015 feat(admin): open SEO/GEO defaults and clarify indexable pages
Align the MindSpace config UI with public-online indexing, add a one-click enable action, and document that 可收录 means public and unexpired.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 15:51:48 +08:00
13 changed files with 562 additions and 182 deletions
+28
View File
@@ -0,0 +1,28 @@
# 计费赠金 / A-B 计量 / 包月补扣 — 业务验收清单
## 1. 新用户赠金
- [ ] 新注册用户钱包仅入账 **5 元**,流水备注为「新用户赠送」
- [ ] 余额消耗至 1 元以下时,**不再**出现「新用户低余额自动赠送」流水或站内通知
- [ ] 生产环境可设置 `H5_LOW_BALANCE_GIFT_AMOUNT_CENTS=0` 锁定关闭
## 2. 计量公式 A / B
- [ ] 管理后台「计费中心 → 计量公式」可分别编辑并保存公式 A、公式 B
- [ ] 修改公式 A 不影响已分配到公式 B 的用户扣费
- [ ] 批量分配可将用户划入 A 或 B,用户列表显示当前公式
- [ ] Portal 对话扣费在数秒内按用户所属公式生效(无需重启)
## 3. 包月自动续费补扣
- [ ] 到期且开启自动续费、余额不足:套餐过期降为免费,**自动续费标记保持开启**
- [ ] 订阅列表显示「待补扣」(已过期 + 自动续费开启)
- [ ] 用户充值后余额足够:自动扣款并恢复套餐,无需等待小时任务
- [ ] 小时任务对仍开启自动续费且已过期的订阅持续重试
## 发布顺序
1. Memind103 Portal 8081)— schema + 扣费/续费运行时
2. memind_adm5174 / 8085)— 管理后台 UI 与 API
发布前分别执行各仓库 `bash scripts/check-release-ready.sh`
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export ADM_API_PORT="${ADM_API_PORT:-8085}"
export NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
cd "${ROOT}"
exec "${NODE_BIN}" server/index.mjs
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export ADM_PORT="${ADM_PORT:-5174}"
export ADM_WEB_HOST="${ADM_WEB_HOST:-0.0.0.0}"
export ADM_DEV_BACKEND="${ADM_DEV_BACKEND:-http://127.0.0.1:8085}"
export NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
cd "${ROOT}"
if [[ ! -d dist ]]; then
echo "dist/ missing; run npm run build first" >&2
exit 1
fi
exec "${NODE_BIN}" ./node_modules/vite/bin/vite.js preview \
--config scripts/vite-preview.config.mjs \
--host 0.0.0.0 \
--port "${ADM_PORT}" \
--strictPort
+10 -1
View File
@@ -12,7 +12,16 @@ export default defineConfig(({ mode }) => {
host: env.ADM_WEB_HOST ?? env.ADM_API_HOST ?? '127.0.0.1', host: env.ADM_WEB_HOST ?? env.ADM_API_HOST ?? '127.0.0.1',
port: Number(env.ADM_PORT ?? 5174), port: Number(env.ADM_PORT ?? 5174),
strictPort: true, strictPort: true,
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'], // Behind nginx/Caddy on md.tkmind.cn; allow production hostnames explicitly.
allowedHosts: [
'md.tkmind.cn',
'gadm.tkmind.cn',
'localhost',
'127.0.0.1',
'10.10.0.2',
'58.38.22.103',
'.tkmind.cn',
],
proxy: { proxy: {
'/api': backend, '/api': backend,
'/auth': backend, '/auth': backend,
+21 -5
View File
@@ -660,11 +660,12 @@ export function createAdminApp(services) {
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig()); res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
}); });
adminApi.get('/billing/config', requireAdmin, async (_req, res) => { adminApi.get('/billing/config', requireAdmin, async (req, res) => {
if (!billingConfigService?.getAdminConfig) { if (!billingConfigService?.getAdminConfig) {
return res.status(503).json({ message: '计费公式配置服务未启用' }); return res.status(503).json({ message: '计费公式配置服务未启用' });
} }
return res.json(await billingConfigService.getAdminConfig()); const formula = req.query.formula ? String(req.query.formula) : undefined;
return res.json(await billingConfigService.getAdminConfig({ formula }));
}); });
const updateBillingConfig = async (req, res) => { const updateBillingConfig = async (req, res) => {
@@ -672,9 +673,10 @@ export function createAdminApp(services) {
return res.status(503).json({ message: '计费公式配置服务未启用' }); return res.status(503).json({ message: '计费公式配置服务未启用' });
} }
try { try {
const formula = req.body?.formula ? String(req.body.formula) : undefined;
return res.json(await billingConfigService.updateAdminConfig( return res.json(await billingConfigService.updateAdminConfig(
req.body?.config ?? req.body ?? {}, req.body?.config ?? req.body ?? {},
{ updatedBy: req.currentUser.id }, { updatedBy: req.currentUser.id, formula },
)); ));
} catch (error) { } catch (error) {
if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') { if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') {
@@ -687,11 +689,25 @@ export function createAdminApp(services) {
adminApi.put('/billing/config', requireAdmin, updateBillingConfig); adminApi.put('/billing/config', requireAdmin, updateBillingConfig);
adminApi.patch('/billing/config', requireAdmin, updateBillingConfig); adminApi.patch('/billing/config', requireAdmin, updateBillingConfig);
adminApi.get('/billing/runtime', requireAdmin, async (_req, res) => { adminApi.post('/billing/formula-assignments', requireAdmin, async (req, res) => {
if (!billingConfigService?.assignBillingFormula) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
const { userIds, formula } = req.body ?? {};
const result = await billingConfigService.assignBillingFormula(userIds, formula);
if (!result.ok) {
return res.status(400).json(result);
}
return res.json(result);
});
adminApi.get('/billing/runtime', requireAdmin, async (req, res) => {
if (!billingConfigService?.getRuntimeState) { if (!billingConfigService?.getRuntimeState) {
return res.status(503).json({ message: '计费公式配置服务未启用' }); return res.status(503).json({ message: '计费公式配置服务未启用' });
} }
return res.json(await billingConfigService.getRuntimeState()); const formula = req.query.formula ? String(req.query.formula) : undefined;
const userId = req.query.userId ? String(req.query.userId) : undefined;
return res.json(await billingConfigService.getRuntimeState({ formula, userId }));
}); });
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => { adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
+20 -1
View File
@@ -28,8 +28,15 @@ function createServices({ role = 'admin' } = {}) {
getMe: async () => ({ id: 'admin-id', username: 'admin', role }), getMe: async () => ({ id: 'admin-id', username: 'admin', role }),
}, },
billingConfigService: { billingConfigService: {
getAdminConfig: async () => ({ getAdminConfig: async ({ formula = 'A' } = {}) => ({
config: stored, config: stored,
formulas: { A: stored, B: stored },
formulaMeta: {
A: { source: 'env', updatedAt: null, updatedBy: null },
B: { source: 'env', updatedAt: null, updatedBy: null },
},
activeFormula: formula,
defaultFormula: 'A',
source: 'env', source: 'env',
updatedAt: null, updatedAt: null,
updatedBy: null, updatedBy: null,
@@ -39,12 +46,24 @@ function createServices({ role = 'admin' } = {}) {
stored = { ...stored, ...(patch.config ?? patch) }; stored = { ...stored, ...(patch.config ?? patch) };
return { return {
config: stored, config: stored,
formulas: { A: stored, B: stored },
formulaMeta: {
A: { source: 'admin-db', updatedAt: Date.now(), updatedBy: context?.updatedBy ?? null },
B: { source: 'admin-db', updatedAt: null, updatedBy: null },
},
activeFormula: context?.formula ?? 'A',
defaultFormula: 'A',
source: 'admin-db', source: 'admin-db',
updatedAt: Date.now(), updatedAt: Date.now(),
updatedBy: context?.updatedBy ?? null, updatedBy: context?.updatedBy ?? null,
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数', formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数',
}; };
}, },
assignBillingFormula: async (userIds, formula) => ({
ok: true,
updated: userIds.length,
formula,
}),
getRuntimeState: async () => ({ getRuntimeState: async () => ({
source: 'admin-db', source: 'admin-db',
config: stored, config: stored,
+1
View File
@@ -165,6 +165,7 @@ export async function bootstrapAdminServices() {
getPlanAsync: (planType) => planCatalogService.getPlan(planType), getPlanAsync: (planType) => planCatalogService.getPlan(planType),
}); });
subscriptionService._planCatalogService = planCatalogService; subscriptionService._planCatalogService = planCatalogService;
userAuth.setSubscriptionService(subscriptionService);
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs'); const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
const templateCatalogService = createPageTemplateCatalogService(pool, { const templateCatalogService = createPageTemplateCatalogService(pool, {
userAuth, userAuth,
+310 -88
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { import {
assignBillingFormula,
cancelUserSubscription, cancelUserSubscription,
createSubscriptionPlan, createSubscriptionPlan,
deleteSubscriptionPlan, deleteSubscriptionPlan,
@@ -22,6 +23,7 @@ import type {
AdminSubscription, AdminSubscription,
AdminUserRow, AdminUserRow,
BillingAdminConfig, BillingAdminConfig,
BillingFormulaKey,
LedgerEntry, LedgerEntry,
PlanDefinition, PlanDefinition,
UsageRecord, UsageRecord,
@@ -175,107 +177,50 @@ const DEFAULT_BILLING_FORMULA: BillingAdminConfig = {
costEstimateOutputUsdPer1M: 1.1, costEstimateOutputUsdPer1M: 1.1,
}; };
function FormulaTab() { const FORMULA_TABS: BillingFormulaKey[] = ['A', 'B'];
const [draft, setDraft] = useState<BillingAdminConfig>(DEFAULT_BILLING_FORMULA);
const [source, setSource] = useState('default');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [formula, setFormula] = useState<string>('');
const [envLocked, setEnvLocked] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getBillingConfig();
setDraft(result.config);
setSource(result.source ?? 'default');
setUpdatedAt(result.updatedAt ?? null);
setFormula(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
} catch (err) {
setError(err instanceof Error ? err.message : '加载计量公式失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function FormulaConfigForm({
formulaKey,
draft,
source,
updatedAt,
envLocked,
saving,
onDraftChange,
onSave,
}: {
formulaKey: BillingFormulaKey;
draft: BillingAdminConfig;
source: string;
updatedAt: number | null;
envLocked: boolean;
saving: boolean;
onDraftChange: (next: BillingAdminConfig) => void;
onSave: (event: React.FormEvent) => void;
}) {
const patchNumber = (key: keyof BillingAdminConfig, value: string) => { const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
const num = Number(value); const num = Number(value);
setDraft((prev) => ({ onDraftChange({
...prev, ...draft,
[key]: Number.isFinite(num) ? num : prev[key], [key]: Number.isFinite(num) ? num : draft[key],
})); });
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (envLocked) {
setError('当前环境已锁定为仅读 envH5_BILLING_CONFIG_SOURCE=env),无法保存。');
return;
}
if (draft.marginMultiplier <= 0 || draft.usdCnyRate <= 0) {
setError('汇率与毛利倍数必须大于 0。');
return;
}
if (
draft.useBackendCost
&& !window.confirm(
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
)
) {
return;
}
setSaving(true);
setError(null);
setMessage(null);
try {
const result = await updateBillingConfig(draft);
setDraft(result.config);
setSource(result.source ?? 'admin-db');
setUpdatedAt(result.updatedAt ?? null);
setFormula(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
setMessage('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
}; };
return ( return (
<section className="admin-card"> <form className="admin-form" onSubmit={onSave}>
<h2></h2>
<p className="muted"> <p className="muted">
= (USD) × × 退 Token {formulaKey} · {SOURCE_LABELS[source] ?? source}
</p>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<form className="admin-form" onSubmit={handleSave}>
<p className="muted">
{SOURCE_LABELS[source] ?? source}
{updatedAt {updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}` ? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''} : ''}
</p> </p>
{formula ? <p className="muted">{formula}</p> : null}
<label className="inline-check"> <label className="inline-check">
<input <input
type="checkbox" type="checkbox"
checked={draft.useBackendCost} checked={draft.useBackendCost}
disabled={envLocked} disabled={envLocked}
onChange={(event) => setDraft((prev) => ({ ...prev, useBackendCost: event.target.checked }))} onChange={(event) => onDraftChange({ ...draft, useBackendCost: event.target.checked })}
/> />
<span> <span>
<strong></strong> <strong></strong>
@@ -347,10 +292,10 @@ function FormulaTab() {
type="checkbox" type="checkbox"
checked={draft.costEstimateFromTokens} checked={draft.costEstimateFromTokens}
disabled={envLocked || !draft.useBackendCost} disabled={envLocked || !draft.useBackendCost}
onChange={(event) => setDraft((prev) => ({ onChange={(event) => onDraftChange({
...prev, ...draft,
costEstimateFromTokens: event.target.checked, costEstimateFromTokens: event.target.checked,
}))} })}
/> />
<span> <span>
<strong> Token </strong> <strong> Token </strong>
@@ -385,11 +330,276 @@ function FormulaTab() {
</p> </p>
<button type="submit" className="send-btn" disabled={saving || envLocked}> <button type="submit" className="send-btn" disabled={saving || envLocked}>
{saving ? '保存中…' : '保存计量公式'} {saving ? '保存中…' : `保存计量公式 ${formulaKey}`}
</button> </button>
</form> </form>
);
}
function FormulaAssignmentSection() {
const { users, reload, error, setError } = useAdminUsers();
const [selected, setSelected] = useState<string[]>([]);
const [formulaFilter, setFormulaFilter] = useState<'all' | BillingFormulaKey>('all');
const [search, setSearch] = useState('');
const [assigning, setAssigning] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const filteredUsers = users.filter((user) => {
if (user.role !== 'user') return false;
const formula = user.billingFormula ?? 'A';
if (formulaFilter !== 'all' && formula !== formulaFilter) return false;
if (!search.trim()) return true;
const q = search.toLowerCase();
return user.username.toLowerCase().includes(q) || user.displayName.toLowerCase().includes(q);
});
const toggleUser = (userId: string) => {
setSelected((prev) => (
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
));
};
const toggleAll = () => {
const ids = filteredUsers.map((user) => user.id);
const allSelected = ids.length > 0 && ids.every((id) => selected.includes(id));
setSelected((prev) => (
allSelected ? prev.filter((id) => !ids.includes(id)) : [...new Set([...prev, ...ids])]
));
};
const handleAssign = async (formula: BillingFormulaKey) => {
if (!selected.length) return;
setAssigning(true);
setMessage(null);
setError(null);
try {
const result = await assignBillingFormula(selected, formula);
setMessage(`已将 ${result.updated} 位用户分配到计量公式 ${formula}`);
setSelected([]);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '批量分配失败');
} finally {
setAssigning(false);
}
};
return (
<section className="admin-card">
<h2></h2>
<p className="muted"> A B使 A</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<div className="admin-tabs billing-formula-filter-tabs" role="tablist" aria-label="按计量公式筛选用户">
{(['all', 'A', 'B'] as const).map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={formulaFilter === key}
className={`admin-tab${formulaFilter === key ? ' active' : ''}`}
onClick={() => setFormulaFilter(key)}
>
{key === 'all' ? '全部' : `公式 ${key}`}
</button>
))}
</div>
<div className="billing-toolbar">
<input
className="users-search-input"
placeholder="搜索用户名 / 显示名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<button type="button" className="ghost-btn" onClick={() => void reload()} disabled={assigning}>
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('A')}>
A
</button>
<button type="button" className="send-btn" disabled={!selected.length || assigning} onClick={() => void handleAssign('B')}>
B
</button>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={filteredUsers.length > 0 && filteredUsers.every((user) => selected.includes(user.id))}
onChange={toggleAll}
/>
</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td>
<input
type="checkbox"
checked={selected.includes(user.id)}
onChange={() => toggleUser(user.id)}
/>
</td>
<td>
<span>{user.displayName}</span>
<span className="muted"> @{user.username}</span>
</td>
<td><strong>{user.billingFormula ?? 'A'}</strong></td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 ? <p className="muted billing-empty"></p> : null}
</section>
);
}
function FormulaTab() {
const [activeFormula, setActiveFormula] = useState<BillingFormulaKey>('A');
const [drafts, setDrafts] = useState<Record<BillingFormulaKey, BillingAdminConfig>>({
A: DEFAULT_BILLING_FORMULA,
B: DEFAULT_BILLING_FORMULA,
});
const [meta, setMeta] = useState<Record<BillingFormulaKey, { source: string; updatedAt: number | null }>>({
A: { source: 'default', updatedAt: null },
B: { source: 'default', updatedAt: null },
});
const [formulaText, setFormulaText] = useState('');
const [envLocked, setEnvLocked] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getBillingConfig('A');
setDrafts({
A: result.formulas?.A ?? result.config,
B: result.formulas?.B ?? result.config,
});
setMeta({
A: {
source: result.formulaMeta?.A?.source ?? result.source ?? 'default',
updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null,
},
B: {
source: result.formulaMeta?.B?.source ?? result.source ?? 'default',
updatedAt: result.formulaMeta?.B?.updatedAt ?? null,
},
});
setFormulaText(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
} catch (err) {
setError(err instanceof Error ? err.message : '加载计量公式失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const draft = drafts[activeFormula];
if (envLocked) {
setError('当前环境已锁定为仅读 envH5_BILLING_CONFIG_SOURCE=env),无法保存。');
return;
}
if (draft.marginMultiplier <= 0 || draft.usdCnyRate <= 0) {
setError('汇率与毛利倍数必须大于 0。');
return;
}
if (
draft.useBackendCost
&& !window.confirm(
`确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
)
) {
return;
}
setSaving(true);
setError(null);
setMessage(null);
try {
const result = await updateBillingConfig(draft, activeFormula);
setDrafts({
A: result.formulas?.A ?? drafts.A,
B: result.formulas?.B ?? drafts.B,
});
setMeta({
A: {
source: result.formulaMeta?.A?.source ?? result.source ?? 'admin-db',
updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null,
},
B: {
source: result.formulaMeta?.B?.source ?? result.source ?? 'admin-db',
updatedAt: result.formulaMeta?.B?.updatedAt ?? result.updatedAt ?? null,
},
});
setFormulaText(result.formula ?? '');
setEnvLocked(Boolean(result.envOverrideActive));
setMessage(`计量公式 ${activeFormula} 已保存。Portal 扣费会在数秒内读取新配置,无需重启。`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
return (
<>
<section className="admin-card">
<h2></h2>
<p className="muted">
A / B A B
</p>
{formulaText ? <p className="muted">{formulaText}</p> : null}
<div className="admin-tabs" role="tablist">
{FORMULA_TABS.map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={activeFormula === key}
className={`admin-tab${activeFormula === key ? ' active' : ''}`}
onClick={() => setActiveFormula(key)}
>
{key}
</button>
))}
</div>
{loading ? <p className="muted"></p> : null}
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{!loading ? (
<FormulaConfigForm
formulaKey={activeFormula}
draft={drafts[activeFormula]}
source={meta[activeFormula].source}
updatedAt={meta[activeFormula].updatedAt}
envLocked={envLocked}
saving={saving}
onDraftChange={(next) => setDrafts((prev) => ({ ...prev, [activeFormula]: next }))}
onSave={handleSave}
/>
) : null} ) : null}
</section> </section>
<FormulaAssignmentSection />
</>
); );
} }
@@ -1545,6 +1755,12 @@ function SubscriptionsTab() {
}; };
const STATUS_LABEL: Record<string, string> = { active: '有效', expired: '已到期', cancelled: '已取消' }; const STATUS_LABEL: Record<string, string> = { active: '有效', expired: '已到期', cancelled: '已取消' };
const autoRenewLabel = (row: AdminSubscription) => {
if (!row.autoRenew) return '未开启';
if (row.status === 'active') return '已开启';
if (row.status === 'expired') return '待补扣';
return '—';
};
return ( return (
<> <>
@@ -1637,6 +1853,7 @@ function SubscriptionsTab() {
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th style={{ textAlign: 'right' }}>Token </th> <th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th> <th style={{ textAlign: 'right' }}></th>
<th></th> <th></th>
@@ -1657,6 +1874,11 @@ function SubscriptionsTab() {
{STATUS_LABEL[row.status] ?? row.status} {STATUS_LABEL[row.status] ?? row.status}
</span> </span>
</td> </td>
<td>
<span className={row.autoRenew && row.status === 'expired' ? 'text-error' : undefined}>
{autoRenewLabel(row)}
</span>
</td>
<td className="billing-num"> <td className="billing-num">
{row.periodTokensUsed.toLocaleString()} {row.periodTokensUsed.toLocaleString()}
{row.periodTokensLimit > 0 && ( {row.periodTokensLimit > 0 && (
+17 -9
View File
@@ -4,18 +4,18 @@ import type { MindSpaceAdminConfig, MindSpaceSeoGeoConfig } from '../../types';
function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig { function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig {
return { return {
enabled: false, enabled: true,
seo: { seo: {
enabled: false, enabled: true,
canonical: true, canonical: true,
sitemap: false, sitemap: true,
robotsTxt: false, robotsTxt: true,
baiduPush: false, baiduPush: true,
}, },
geo: { geo: {
enabled: false, enabled: true,
jsonLd: false, jsonLd: true,
llmsTxt: false, llmsTxt: true,
}, },
}; };
} }
@@ -122,7 +122,7 @@ export function MindSpacePage() {
<section className="admin-card"> <section className="admin-card">
<h2>SEO / GEO</h2> <h2>SEO / GEO</h2>
<p className="muted"> <p className="muted">
access_mode=publicstatus=online noindex sitemap / llms.txt 线 SEO/GEO noindex sitemap / llms.txt
</p> </p>
<div className="admin-form"> <div className="admin-form">
<label className="admin-form-row"> <label className="admin-form-row">
@@ -203,6 +203,14 @@ export function MindSpacePage() {
<button type="submit" className="send-btn" disabled={busy || loading}> <button type="submit" className="send-btn" disabled={busy || loading}>
{busy ? '保存中...' : '保存配置'} {busy ? '保存中...' : '保存配置'}
</button> </button>
<button
type="button"
className="ghost-btn"
onClick={() => setSeoGeo(defaultSeoGeoConfig())}
disabled={busy || loading}
>
</button>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}> <button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
</button> </button>
+1 -1
View File
@@ -127,7 +127,7 @@ export function SeoGeoAnalyticsPage() {
<div className="admin-page-head"> <div className="admin-page-head">
<h2>SEO / GEO </h2> <h2>SEO / GEO </h2>
<p className="muted"> <p className="muted">
线 URL访SEO / GEO referrer User-Agent 线 URL访 = SEO / GEO referrer User-Agent
</p> </p>
</div> </div>
+23
View File
@@ -273,6 +273,29 @@ export function UserDetailPage() {
)} )}
</dd> </dd>
</div> </div>
<div>
<dt></dt>
<dd>
<select
className="admin-select"
value={user.billingFormula ?? 'A'}
onChange={(event) => {
const billingFormula = event.target.value as 'A' | 'B';
setLocalError(null);
void updateAdminUser(user.id, { billingFormula }).then((nextUser) => {
setUser(nextUser);
setMessage(`已切换到计量公式 ${billingFormula}`);
void reload();
}).catch((err) => {
setLocalError(err instanceof Error ? err.message : '计量公式更新失败');
});
}}
>
<option value="A"> A</option>
<option value="B"> B</option>
</select>
</dd>
</div>
<div> <div>
<dt></dt> <dt></dt>
<dd className="mono">{user.workspaceRoot}</dd> <dd className="mono">{user.workspaceRoot}</dd>
+16 -3
View File
@@ -887,6 +887,7 @@ export async function updateAdminUser(
balanceCents: number; balanceCents: number;
spaceQuotaBytes: number; spaceQuotaBytes: number;
role: 'user' | 'admin'; role: 'user' | 'admin';
billingFormula: 'A' | 'B';
}>, }>,
): Promise<PortalUser> { ): Promise<PortalUser> {
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, { const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
@@ -1409,16 +1410,28 @@ export async function syncSubscriptionPlansToProduction(): Promise<PlanSyncResul
return result.sync; return result.sync;
} }
export async function getBillingConfig(): Promise<BillingAdminConfigResponse> { export async function getBillingConfig(formula: 'A' | 'B' = 'A'): Promise<BillingAdminConfigResponse> {
return portalFetch('/admin-api/billing/config'); const q = formula === 'B' ? '?formula=B' : '';
return portalFetch(`/admin-api/billing/config${q}`);
} }
export async function updateBillingConfig( export async function updateBillingConfig(
config: BillingAdminConfig, config: BillingAdminConfig,
formula: 'A' | 'B' = 'A',
): Promise<BillingAdminConfigResponse> { ): Promise<BillingAdminConfigResponse> {
return portalFetch('/admin-api/billing/config', { return portalFetch('/admin-api/billing/config', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ config }), body: JSON.stringify({ config, formula }),
});
}
export async function assignBillingFormula(
userIds: string[],
formula: 'A' | 'B',
): Promise<{ ok: boolean; updated: number; formula: 'A' | 'B'; message?: string }> {
return portalFetch('/admin-api/billing/formula-assignments', {
method: 'POST',
body: JSON.stringify({ userIds, formula }),
}); });
} }
+15
View File
@@ -18,6 +18,7 @@ export type PortalUser = {
balanceCents: number; balanceCents: number;
totalCreditCents?: number; totalCreditCents?: number;
tokensUsed: number; tokensUsed: number;
billingFormula?: 'A' | 'B';
spaceQuotaBytes?: number; spaceQuotaBytes?: number;
spaceUsedBytes?: number; spaceUsedBytes?: number;
spaceReservedBytes?: number; spaceReservedBytes?: number;
@@ -44,6 +45,7 @@ export type AuthStatus = {
}; };
export type AdminUserRow = PortalUser & { export type AdminUserRow = PortalUser & {
billingFormula?: 'A' | 'B';
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
}; };
@@ -676,8 +678,20 @@ export type BillingAdminConfig = {
costEstimateOutputUsdPer1M: number; costEstimateOutputUsdPer1M: number;
}; };
export type BillingFormulaKey = 'A' | 'B';
export type BillingFormulaMeta = {
updatedAt: number | null;
updatedBy: string | null;
source?: string;
};
export type BillingAdminConfigResponse = { export type BillingAdminConfigResponse = {
config: BillingAdminConfig; config: BillingAdminConfig;
formulas?: Record<BillingFormulaKey, BillingAdminConfig>;
formulaMeta?: Record<BillingFormulaKey, BillingFormulaMeta>;
activeFormula?: BillingFormulaKey;
defaultFormula?: BillingFormulaKey;
updatedAt: number | null; updatedAt: number | null;
updatedBy: string | null; updatedBy: string | null;
source?: string; source?: string;
@@ -722,6 +736,7 @@ export type AdminSubscription = {
periodImagesLimit: number; periodImagesLimit: number;
periodImagesUsed: number; periodImagesUsed: number;
periodImagesBonus?: number; periodImagesBonus?: number;
autoRenew?: boolean;
note: string | null; note: string | null;
createdAt: number; createdAt: number;
}; };