Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86c7c5fb08 | |||
| ec38ee086d | |||
| 70433a3dcd | |||
| 80c2091baf | |||
| f9f8b541dc | |||
| f6bb02254e | |||
| a503fa9cbe | |||
| 66dd288015 |
@@ -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. Memind(103 Portal 8081)— schema + 扣费/续费运行时
|
||||||
|
2. memind_adm(5174 / 8085)— 管理后台 UI 与 API
|
||||||
|
|
||||||
|
发布前分别执行各仓库 `bash scripts/check-release-ready.sh`。
|
||||||
Executable
+8
@@ -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
|
||||||
Executable
+18
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
+96
-5
@@ -108,6 +108,8 @@ export function createAdminApp(services) {
|
|||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
billingConfigService,
|
billingConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
|
wechatCursorExecutorPolicyService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
systemTestAccountService,
|
systemTestAccountService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
@@ -395,6 +397,79 @@ export function createAdminApp(services) {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminApi.get('/wechat/intent-router/config', requireAdmin, async (_req, res) => {
|
||||||
|
if (!wechatIntentRouterConfigService?.getConfig) {
|
||||||
|
return res.status(503).json({ message: '微信意图路由配置未启用' });
|
||||||
|
}
|
||||||
|
const config = await wechatIntentRouterConfigService.getConfig();
|
||||||
|
return res.json({ config });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/wechat/intent-router/runtime', requireAdmin, async (_req, res) => {
|
||||||
|
if (!wechatIntentRouterConfigService?.getRuntimeState) {
|
||||||
|
return res.status(503).json({ message: '微信意图路由配置未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await wechatIntentRouterConfigService.getRuntimeState());
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWechatIntentRouterConfig = async (req, res) => {
|
||||||
|
if (!wechatIntentRouterConfigService?.updateConfig) {
|
||||||
|
return res.status(503).json({ message: '微信意图路由配置未启用' });
|
||||||
|
}
|
||||||
|
const config = await wechatIntentRouterConfigService.updateConfig(req.body ?? {}, {
|
||||||
|
updatedBy: req.currentUser.id,
|
||||||
|
});
|
||||||
|
return res.json({ config });
|
||||||
|
};
|
||||||
|
|
||||||
|
adminApi.put('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig);
|
||||||
|
adminApi.patch('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig);
|
||||||
|
|
||||||
|
adminApi.get('/wechat/cursor-executor/config', requireAdmin, async (_req, res) => {
|
||||||
|
if (!wechatCursorExecutorPolicyService?.getAdminConfig) {
|
||||||
|
return res.status(503).json({ message: '微信 Cursor 体验通道未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await wechatCursorExecutorPolicyService.getAdminConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/wechat/cursor-executor/runtime', requireAdmin, async (_req, res) => {
|
||||||
|
if (!wechatCursorExecutorPolicyService?.getRuntimeState) {
|
||||||
|
return res.status(503).json({ message: '微信 Cursor 体验通道未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await wechatCursorExecutorPolicyService.getRuntimeState());
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWechatCursorExecutorConfig = async (req, res) => {
|
||||||
|
if (!wechatCursorExecutorPolicyService?.updateAdminConfig) {
|
||||||
|
return res.status(503).json({ message: '微信 Cursor 体验通道未启用' });
|
||||||
|
}
|
||||||
|
const result = await wechatCursorExecutorPolicyService.updateAdminConfig(
|
||||||
|
req.body?.config ?? req.body ?? {},
|
||||||
|
{ updatedBy: req.currentUser.id },
|
||||||
|
);
|
||||||
|
return res.json(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
adminApi.put('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||||
|
adminApi.patch('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||||
|
|
||||||
|
adminApi.get('/cursor-executor-channel/config', requireAdmin, async (_req, res) => {
|
||||||
|
if (!wechatCursorExecutorPolicyService?.getAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await wechatCursorExecutorPolicyService.getAdminConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/cursor-executor-channel/runtime', requireAdmin, async (_req, res) => {
|
||||||
|
if (!wechatCursorExecutorPolicyService?.getRuntimeState) {
|
||||||
|
return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await wechatCursorExecutorPolicyService.getRuntimeState());
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.put('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||||
|
adminApi.patch('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||||
|
|
||||||
adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => {
|
adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => {
|
||||||
if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
||||||
const config = await loadMindSpaceConfig(pool);
|
const config = await loadMindSpaceConfig(pool);
|
||||||
@@ -660,11 +735,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 +748,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 +764,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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export async function bootstrapAdminServices() {
|
|||||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||||
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
|
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
|
||||||
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
|
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
|
||||||
|
const { createWechatIntentRouterConfigService } = await importMemind('wechat-intent-router-config.mjs');
|
||||||
|
const { createWechatCursorExecutorAdminConfigService } = await importMemind(
|
||||||
|
'wechat-cursor-executor-admin-config.mjs',
|
||||||
|
);
|
||||||
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
||||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||||
@@ -126,6 +130,8 @@ export async function bootstrapAdminServices() {
|
|||||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
|
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool);
|
||||||
|
const wechatCursorExecutorPolicyService = createWechatCursorExecutorAdminConfigService(pool);
|
||||||
const adminSystemTestService = createAdminSystemTestService({
|
const adminSystemTestService = createAdminSystemTestService({
|
||||||
pool,
|
pool,
|
||||||
userAuth,
|
userAuth,
|
||||||
@@ -165,6 +171,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,
|
||||||
@@ -197,6 +204,8 @@ export async function bootstrapAdminServices() {
|
|||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
billingConfigService,
|
billingConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
|
wechatCursorExecutorPolicyService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
systemTestAccountService,
|
systemTestAccountService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ ready
|
|||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
billingConfigService,
|
billingConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
|
wechatCursorExecutorPolicyService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
systemTestAccountService,
|
systemTestAccountService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
@@ -142,6 +144,8 @@ ready
|
|||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
billingConfigService,
|
billingConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
wechatIntentRouterConfigService,
|
||||||
|
wechatCursorExecutorPolicyService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
systemTestAccountService,
|
systemTestAccountService,
|
||||||
wordFilterService,
|
wordFilterService,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage';
|
|||||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||||
import { UsersPage } from './admin/pages/UsersPage';
|
import { UsersPage } from './admin/pages/UsersPage';
|
||||||
import { WechatPage } from './admin/pages/WechatPage';
|
import { WechatPage } from './admin/pages/WechatPage';
|
||||||
|
import { CursorChannelPage } from './admin/pages/CursorChannelPage';
|
||||||
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
||||||
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
||||||
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
||||||
@@ -137,6 +138,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
|||||||
<Route path="orchestrator" element={<OrchestratorPage />} />
|
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||||
<Route path="providers" element={<ProvidersPage />} />
|
<Route path="providers" element={<ProvidersPage />} />
|
||||||
<Route path="wechat" element={<WechatPage />} />
|
<Route path="wechat" element={<WechatPage />} />
|
||||||
|
<Route path="cursor-channel" element={<CursorChannelPage />} />
|
||||||
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
||||||
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: '平台配置',
|
label: '平台配置',
|
||||||
items: [
|
items: [
|
||||||
|
{ to: '/cursor-channel', label: '智趣体验通道' },
|
||||||
{ to: '/wechat', label: '服务号' },
|
{ to: '/wechat', label: '服务号' },
|
||||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||||
{ to: '/analytics', label: 'Analytics 配置' },
|
{ to: '/analytics', label: 'Analytics 配置' },
|
||||||
|
|||||||
+312
-89
@@ -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('当前环境已锁定为仅读 env(H5_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>
|
||||||
@@ -381,15 +326,281 @@ function FormulaTab() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier}
|
预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier};
|
||||||
|
套餐额度扣减 ≈ 实际上游 Token × {draft.marginMultiplier}
|
||||||
</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('当前环境已锁定为仅读 env(H5_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 +1756,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 +1854,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 +1875,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 && (
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getWechatCursorExecutorConfig,
|
||||||
|
getWechatCursorExecutorRuntime,
|
||||||
|
listAdminUsers,
|
||||||
|
patchWechatCursorExecutorConfig,
|
||||||
|
} from '../../api/client';
|
||||||
|
import type {
|
||||||
|
AdminUserRow,
|
||||||
|
WechatCursorExecutorAdminConfig,
|
||||||
|
WechatCursorExecutorRuntimeState,
|
||||||
|
} from '../../types';
|
||||||
|
import { formatTime } from '../utils/format';
|
||||||
|
|
||||||
|
type CursorChannelForm = {
|
||||||
|
enabled: boolean;
|
||||||
|
h5Enabled: boolean;
|
||||||
|
wechatEnabled: boolean;
|
||||||
|
selectedUserIds: string[];
|
||||||
|
manualAllowlistEntries: string[];
|
||||||
|
intentAllowlist: string;
|
||||||
|
fallbackToDeepseek: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_ID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function userMatchesAllowlistToken(user: AdminUserRow, token: string) {
|
||||||
|
const normalized = token.trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
const identities = [user.id, user.username, user.slug, user.publishSlug, user.displayName]
|
||||||
|
.map((value) => String(value ?? '').trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
return identities.includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAllowlistTokens(tokens: string[], users: AdminUserRow[]) {
|
||||||
|
const selectedUserIds: string[] = [];
|
||||||
|
const manualAllowlistEntries: string[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawToken of tokens) {
|
||||||
|
const token = String(rawToken ?? '').trim();
|
||||||
|
if (!token) continue;
|
||||||
|
const matchedUser = users.find((user) => userMatchesAllowlistToken(user, token));
|
||||||
|
if (matchedUser) {
|
||||||
|
if (!seenIds.has(matchedUser.id)) {
|
||||||
|
seenIds.add(matchedUser.id);
|
||||||
|
selectedUserIds.push(matchedUser.id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (USER_ID_PATTERN.test(token)) {
|
||||||
|
if (!seenIds.has(token)) {
|
||||||
|
seenIds.add(token);
|
||||||
|
selectedUserIds.push(token);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
manualAllowlistEntries.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selectedUserIds, manualAllowlistEntries };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configToForm(
|
||||||
|
config: WechatCursorExecutorAdminConfig | undefined,
|
||||||
|
users: AdminUserRow[] = [],
|
||||||
|
): CursorChannelForm {
|
||||||
|
const tokens = Array.isArray(config?.userAllowlist) ? config.userAllowlist : [];
|
||||||
|
const channels = Array.isArray(config?.channelAllowlist) ? config.channelAllowlist : ['h5', 'wechat_mp'];
|
||||||
|
const { selectedUserIds, manualAllowlistEntries } = resolveAllowlistTokens(tokens, users);
|
||||||
|
return {
|
||||||
|
enabled: Boolean(config?.enabled),
|
||||||
|
h5Enabled: channels.includes('h5'),
|
||||||
|
wechatEnabled: channels.includes('wechat_mp'),
|
||||||
|
selectedUserIds,
|
||||||
|
manualAllowlistEntries,
|
||||||
|
intentAllowlist: Array.isArray(config?.intentAllowlist)
|
||||||
|
? config.intentAllowlist.join('\n')
|
||||||
|
: 'page.generate',
|
||||||
|
fallbackToDeepseek: config?.fallbackToDeepseek !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToAllowlist(form: CursorChannelForm) {
|
||||||
|
const manual = form.manualAllowlistEntries.map((item) => item.trim()).filter(Boolean);
|
||||||
|
return [...new Set([...form.selectedUserIds, ...manual])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToChannelAllowlist(form: CursorChannelForm) {
|
||||||
|
const channels: string[] = [];
|
||||||
|
if (form.h5Enabled) channels.push('h5');
|
||||||
|
if (form.wechatEnabled) channels.push('wechat_mp');
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CursorAllowlistPickerModal({
|
||||||
|
open,
|
||||||
|
users,
|
||||||
|
selectedUserIds,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
users: AdminUserRow[];
|
||||||
|
selectedUserIds: string[];
|
||||||
|
busy?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (nextIds: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [draftIds, setDraftIds] = useState<string[]>(selectedUserIds);
|
||||||
|
const [pickerUsers, setPickerUsers] = useState<AdminUserRow[]>(users);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraftIds(selectedUserIds);
|
||||||
|
setSearch('');
|
||||||
|
setPickerUsers(users);
|
||||||
|
}, [open, selectedUserIds, users]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
setLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const result = await listAdminUsers({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
status: 'active',
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
});
|
||||||
|
setPickerUsers(result.items);
|
||||||
|
} catch {
|
||||||
|
setPickerUsers(users);
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, search.trim() ? 250 : 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [open, search, users]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const visibleIds = pickerUsers.map((user) => user.id);
|
||||||
|
const allVisibleSelected =
|
||||||
|
visibleIds.length > 0 && visibleIds.every((id) => draftIds.includes(id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-box"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cursor-allowlist-picker-title"
|
||||||
|
style={{ maxWidth: 720, width: 'min(720px, calc(100vw - 32px))' }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<h3 id="cursor-allowlist-picker-title">选择体验通道用户</h3>
|
||||||
|
<p className="muted" style={{ margin: '6px 0 0', fontSize: 13 }}>
|
||||||
|
仅勾选用户会进入 TKMind 智趣独立通道;其余用户完全不受影响,仍走原有 DeepSeek/Goose 链路。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wechat-toolbar" style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="搜索用户名、昵称或 userId"
|
||||||
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={loadingUsers || pickerUsers.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setDraftIds((current) => {
|
||||||
|
if (allVisibleSelected) {
|
||||||
|
return current.filter((id) => !visibleIds.includes(id));
|
||||||
|
}
|
||||||
|
return [...new Set([...current, ...visibleIds])];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allVisibleSelected ? '取消全选当前列表' : '全选当前列表'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cursor-allowlist-picker-list">
|
||||||
|
{loadingUsers ? <p className="muted">加载用户…</p> : null}
|
||||||
|
{!loadingUsers && pickerUsers.length === 0 ? (
|
||||||
|
<p className="muted">没有匹配的用户</p>
|
||||||
|
) : null}
|
||||||
|
{pickerUsers.map((user) => (
|
||||||
|
<label key={user.id} className="cursor-allowlist-picker-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draftIds.includes(user.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setDraftIds((current) =>
|
||||||
|
current.includes(user.id)
|
||||||
|
? current.filter((id) => id !== user.id)
|
||||||
|
: [...current, user.id],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{user.displayName || user.username}
|
||||||
|
<span className="muted"> @{user.username}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onConfirm(draftIds)}
|
||||||
|
>
|
||||||
|
确认选择 ({draftIds.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CursorChannelPage() {
|
||||||
|
const [users, setUsers] = useState<AdminUserRow[]>([]);
|
||||||
|
const [form, setForm] = useState<CursorChannelForm | null>(null);
|
||||||
|
const [savedForm, setSavedForm] = useState<CursorChannelForm | null>(null);
|
||||||
|
const [runtime, setRuntime] = useState<WechatCursorExecutorRuntimeState | null>(null);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const dirty = useMemo(() => {
|
||||||
|
if (!form || !savedForm) return false;
|
||||||
|
return JSON.stringify(form) !== JSON.stringify(savedForm);
|
||||||
|
}, [form, savedForm]);
|
||||||
|
|
||||||
|
const selectedUsers = useMemo(() => {
|
||||||
|
if (!form) return [];
|
||||||
|
const byId = new Map(users.map((user) => [user.id, user]));
|
||||||
|
return form.selectedUserIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
user: byId.get(userId) ?? null,
|
||||||
|
}));
|
||||||
|
}, [form, users]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [nextUsers, nextConfig, nextRuntime] = await Promise.all([
|
||||||
|
listAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
|
||||||
|
getWechatCursorExecutorConfig().catch(() => null),
|
||||||
|
getWechatCursorExecutorRuntime().catch(() => null),
|
||||||
|
]);
|
||||||
|
setUsers(nextUsers.items);
|
||||||
|
const nextForm = configToForm(nextConfig?.config, nextUsers.items);
|
||||||
|
setForm(nextForm);
|
||||||
|
setSavedForm(nextForm);
|
||||||
|
setRuntime(nextRuntime);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const channelAllowlist = formToChannelAllowlist(form);
|
||||||
|
if (form.enabled && channelAllowlist.length === 0) {
|
||||||
|
throw new Error('启用体验通道时,至少选择一个接入渠道(H5 或服务号)');
|
||||||
|
}
|
||||||
|
if (form.enabled && formToAllowlist(form).length === 0) {
|
||||||
|
throw new Error('启用体验通道时,至少选择一名白名单用户');
|
||||||
|
}
|
||||||
|
const result = await patchWechatCursorExecutorConfig({
|
||||||
|
enabled: form.enabled,
|
||||||
|
userAllowlist: formToAllowlist(form),
|
||||||
|
channelAllowlist,
|
||||||
|
intentAllowlist: form.intentAllowlist
|
||||||
|
.split(/[\s,]+/u)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
fallbackToDeepseek: form.fallbackToDeepseek,
|
||||||
|
});
|
||||||
|
const nextForm = configToForm(result.config, users);
|
||||||
|
setForm(nextForm);
|
||||||
|
setSavedForm(nextForm);
|
||||||
|
setRuntime(await getWechatCursorExecutorRuntime().catch(() => null));
|
||||||
|
setNotice('TKMind 智趣体验通道已保存。未在白名单内的用户不受影响。');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<div className="admin-page-head">
|
||||||
|
<h2>TKMind 智趣体验通道</h2>
|
||||||
|
<p className="muted">
|
||||||
|
独立于现有 DeepSeek/Goose 链路的 Cursor 执行通道。仅对白名单用户生效,支持 H5 与服务号;其余用户完全不受影响。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="banner banner-error">{error}</p>}
|
||||||
|
{notice && <p className="banner banner-info">{notice}</p>}
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<h3>通道说明</h3>
|
||||||
|
<ul className="muted" style={{ margin: '8px 0 0', paddingLeft: 20, lineHeight: 1.7 }}>
|
||||||
|
<li>
|
||||||
|
<strong>H5</strong>:白名单用户在页面生成、问卷(Page Data)、Excel 分析等任务时,走 Cursor 独立执行。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>服务号</strong>:白名单用户在指定意图(默认 page.generate)下走 Cursor;其余意图仍走原有链路。
|
||||||
|
</li>
|
||||||
|
<li>非白名单用户:H5 与服务号均保持原有 DeepSeek/Goose 行为,不会被 Cursor 路由影响。</li>
|
||||||
|
</ul>
|
||||||
|
<p className="muted" style={{ marginTop: 12 }}>
|
||||||
|
服务端需开启 <code>MEMIND_CURSOR_EXECUTOR_ENABLED=1</code> 与 Tool Gateway;详见 Memind{' '}
|
||||||
|
<code>.env.example</code>。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{form ? (
|
||||||
|
<section className="admin-card">
|
||||||
|
<div className="admin-card-head">
|
||||||
|
<div>
|
||||||
|
<h2>通道配置</h2>
|
||||||
|
<p className="muted" style={{ marginTop: 6 }}>
|
||||||
|
默认关闭。开启后仅白名单用户进入智趣通道,不影响其他用户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={loading || saving || !dirty}
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : dirty ? '保存配置' : '已保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form" style={{ marginTop: 16 }}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.enabled}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => current && { ...current, enabled: event.target.checked })
|
||||||
|
}
|
||||||
|
/>{' '}
|
||||||
|
启用智趣体验通道
|
||||||
|
</label>
|
||||||
|
<fieldset style={{ border: 'none', padding: 0, margin: '12px 0' }}>
|
||||||
|
<legend className="muted" style={{ marginBottom: 8 }}>
|
||||||
|
接入渠道
|
||||||
|
</legend>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.h5Enabled}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => current && { ...current, h5Enabled: event.target.checked })
|
||||||
|
}
|
||||||
|
/>{' '}
|
||||||
|
H5 聊天
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.wechatEnabled}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => current && { ...current, wechatEnabled: event.target.checked })
|
||||||
|
}
|
||||||
|
/>{' '}
|
||||||
|
微信服务号
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.fallbackToDeepseek}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) =>
|
||||||
|
current && { ...current, fallbackToDeepseek: event.target.checked },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>{' '}
|
||||||
|
Cursor 失败时自动回退 DeepSeek
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
体验用户白名单
|
||||||
|
<div className="wechat-toolbar" style={{ marginTop: 8, marginBottom: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
从用户列表选择…
|
||||||
|
</button>
|
||||||
|
{form.selectedUserIds.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() =>
|
||||||
|
setForm((current) => current && { ...current, selectedUserIds: [] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
清空已选
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedUsers.length > 0 ? (
|
||||||
|
<div className="cursor-allowlist-chips">
|
||||||
|
{selectedUsers.map(({ userId, user }) => (
|
||||||
|
<span key={userId} className="cursor-allowlist-chip">
|
||||||
|
<span>
|
||||||
|
{user ? user.displayName || user.username : userId}
|
||||||
|
{user ? <span className="muted"> @{user.username}</span> : null}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-allowlist-chip-remove"
|
||||||
|
aria-label="移出白名单"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() =>
|
||||||
|
setForm((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
selectedUserIds: current.selectedUserIds.filter((id) => id !== userId),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
尚未选择用户。可在 <Link to="/users">用户管理</Link> 中查找账号后在此勾选。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
服务号启用意图(H5 不受此限制,默认 page.generate)
|
||||||
|
<textarea
|
||||||
|
value={form.intentAllowlist}
|
||||||
|
disabled={loading || saving}
|
||||||
|
rows={2}
|
||||||
|
placeholder="page.generate"
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => current && { ...current, intentAllowlist: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runtime ? (
|
||||||
|
<p className="muted" style={{ marginTop: 12 }}>
|
||||||
|
运行时:{runtime.policy.enabled ? '已开启' : '已关闭'}
|
||||||
|
{' · '}
|
||||||
|
白名单 {runtime.policy.userAllowlist.length} 人
|
||||||
|
{' · '}
|
||||||
|
渠道 {(runtime.policy.channelAllowlist ?? []).join('、') || '无'}
|
||||||
|
{' · '}
|
||||||
|
来源 {runtime.source}
|
||||||
|
{runtime.updatedAt ? ` · 更新 ${formatTime(runtime.updatedAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{form ? (
|
||||||
|
<CursorAllowlistPickerModal
|
||||||
|
open={pickerOpen}
|
||||||
|
users={users}
|
||||||
|
selectedUserIds={form.selectedUserIds}
|
||||||
|
busy={loading || saving}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onConfirm={(nextIds) => {
|
||||||
|
setForm((current) => current && { ...current, selectedUserIds: nextIds });
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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=public、status=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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
cancelWechatDigest,
|
cancelWechatDigest,
|
||||||
clearWechatRoute,
|
clearWechatRoute,
|
||||||
@@ -559,6 +560,18 @@ export function WechatPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<h2>TKMind 智趣体验通道</h2>
|
||||||
|
<p className="muted" style={{ marginTop: 6 }}>
|
||||||
|
Cursor 独立执行通道已迁移至独立管理页,可同时配置 H5 与服务号白名单用户,不影响其他用户原有链路。
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: 12 }}>
|
||||||
|
<Link to="/cursor-channel" className="ghost-btn">
|
||||||
|
前往智趣体验通道配置 →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="admin-card">
|
<section className="admin-card">
|
||||||
<div className="admin-card-head">
|
<div className="admin-card-head">
|
||||||
<h2>通知平台</h2>
|
<h2>通知平台</h2>
|
||||||
|
|||||||
+36
-3
@@ -58,6 +58,9 @@ import type {
|
|||||||
WechatIntentRouterAdminConfig,
|
WechatIntentRouterAdminConfig,
|
||||||
WechatIntentRouterConfigState,
|
WechatIntentRouterConfigState,
|
||||||
WechatIntentRouterRuntimeState,
|
WechatIntentRouterRuntimeState,
|
||||||
|
WechatCursorExecutorAdminConfig,
|
||||||
|
WechatCursorExecutorAdminConfigState,
|
||||||
|
WechatCursorExecutorRuntimeState,
|
||||||
WechatMessage,
|
WechatMessage,
|
||||||
WechatWebNotification,
|
WechatWebNotification,
|
||||||
MindSearchConfig,
|
MindSearchConfig,
|
||||||
@@ -474,6 +477,23 @@ export async function getWechatIntentRouterRuntime(): Promise<WechatIntentRouter
|
|||||||
return portalFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
|
return portalFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWechatCursorExecutorConfig(): Promise<WechatCursorExecutorAdminConfigState> {
|
||||||
|
return portalFetch<WechatCursorExecutorAdminConfigState>('/admin-api/cursor-executor-channel/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchWechatCursorExecutorConfig(
|
||||||
|
patch: Partial<WechatCursorExecutorAdminConfig>,
|
||||||
|
): Promise<WechatCursorExecutorAdminConfigState> {
|
||||||
|
return portalFetch<WechatCursorExecutorAdminConfigState>('/admin-api/cursor-executor-channel/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ config: patch }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWechatCursorExecutorRuntime(): Promise<WechatCursorExecutorRuntimeState> {
|
||||||
|
return portalFetch<WechatCursorExecutorRuntimeState>('/admin-api/cursor-executor-channel/runtime');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
|
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
|
||||||
return portalFetch('/admin-api/asset-gateway/config');
|
return portalFetch('/admin-api/asset-gateway/config');
|
||||||
}
|
}
|
||||||
@@ -887,6 +907,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 +1430,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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -879,6 +879,37 @@ body,
|
|||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cursor-allowlist-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-allowlist-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--color-border-input, #e7dfd1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-allowlist-chip-remove {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted, #68716c);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-allowlist-chip-remove:hover {
|
||||||
|
color: var(--color-text, #1f2421);
|
||||||
|
}
|
||||||
|
|
||||||
.global-model-card {
|
.global-model-card {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -344,6 +346,39 @@ export type WechatIntentRouterRuntimeState = {
|
|||||||
config: WechatIntentRouterAdminConfig;
|
config: WechatIntentRouterAdminConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WechatCursorExecutorAdminConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
userAllowlist: string[];
|
||||||
|
channelAllowlist: string[];
|
||||||
|
intentAllowlist: string[];
|
||||||
|
fallbackToDeepseek: boolean;
|
||||||
|
meta?: {
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WechatCursorExecutorAdminConfigState = {
|
||||||
|
config: WechatCursorExecutorAdminConfig;
|
||||||
|
updatedAt?: number | null;
|
||||||
|
updatedBy?: string | null;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WechatCursorExecutorRuntimeState = {
|
||||||
|
source: string;
|
||||||
|
updatedAt: number | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
config: WechatCursorExecutorAdminConfig;
|
||||||
|
policy: {
|
||||||
|
enabled: boolean;
|
||||||
|
userAllowlist: string[];
|
||||||
|
channelAllowlist: string[];
|
||||||
|
intentAllowlist: string[];
|
||||||
|
fallbackToDeepseek: boolean;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MindSpaceSeoGeoConfig = {
|
export type MindSpaceSeoGeoConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
seo: {
|
seo: {
|
||||||
@@ -676,8 +711,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 +769,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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user