Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec4dda6af | |||
| eba37640e4 | |||
| 799afd1ea5 | |||
| 04d68d0b2c | |||
| 7c6427e123 | |||
| 8651a50cbe | |||
| b414b732c8 | |||
| d4127bd24b | |||
| e07686f29d | |||
| c1aa1ba459 | |||
| 86c7c5fb08 | |||
| ec38ee086d | |||
| 70433a3dcd | |||
| 80c2091baf | |||
| f9f8b541dc | |||
| f6bb02254e | |||
| a503fa9cbe | |||
| 66dd288015 |
@@ -35,6 +35,11 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
|
||||
# Memind 业务模块路径(user-auth / llm-providers 等,默认 ../Memind)
|
||||
# MEMIND_LIB_ROOT=/Users/john/Project/Memind
|
||||
|
||||
# Memind News Engine(早报采集/排序服务,默认本地 8092)
|
||||
# MEMIND_NEWS_ENGINE_URL=http://127.0.0.1:8092
|
||||
# MEMIND_NEWS_ENGINE_API_TOKEN=
|
||||
# MEMIND_NEWS_ENGINE_TIMEOUT_MS=120000
|
||||
|
||||
# 超管页「返回对话」跳转主 H5(可选)
|
||||
VITE_MAIN_APP_URL=https://h5.tkmind.cn
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -168,6 +168,7 @@ rsync -a \
|
||||
--include '*.mjs' \
|
||||
--include '*.json' \
|
||||
--include '*.js' \
|
||||
--include 'wechat/assets/*.png' \
|
||||
--exclude '*' \
|
||||
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
|
||||
|
||||
|
||||
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',
|
||||
port: Number(env.ADM_PORT ?? 5174),
|
||||
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: {
|
||||
'/api': backend,
|
||||
'/auth': backend,
|
||||
|
||||
+331
-6
@@ -7,6 +7,7 @@ import express from 'express';
|
||||
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
|
||||
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
|
||||
import { importMemind } from './lib-path.mjs';
|
||||
import { fetchNewsEngine } from './news-engine-admin.mjs';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -95,6 +96,7 @@ export function createAdminApp(services) {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
pool,
|
||||
ready,
|
||||
wechatAdmin,
|
||||
@@ -108,6 +110,9 @@ export function createAdminApp(services) {
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
@@ -258,6 +263,7 @@ export function createAdminApp(services) {
|
||||
const { listAdminSeoGeoPublicationCatalog } = await importMemind('mindspace-seo-geo-admin-catalog.mjs');
|
||||
const startAt = Number(req.query.startAt);
|
||||
const endAt = Number(req.query.endAt);
|
||||
const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null;
|
||||
const result = await listAdminSeoGeoPublicationCatalog(pool, {
|
||||
page: Number(req.query.page) || 1,
|
||||
pageSize: Number(req.query.pageSize) || 20,
|
||||
@@ -267,6 +273,8 @@ export function createAdminApp(services) {
|
||||
endAt: Number.isFinite(endAt) ? endAt : null,
|
||||
search: typeof req.query.search === 'string' ? req.query.search.trim() : '',
|
||||
publicHost: process.env.H5_PUBLIC_BASE_URL?.replace(/^https?:\/\//, '').replace(/\/$/, '') || 'm.tkmind.cn',
|
||||
env: process.env,
|
||||
analyticsConfig: config?.analytics ?? {},
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
@@ -288,13 +296,36 @@ export function createAdminApp(services) {
|
||||
role: typeof req.query.role === 'string' ? req.query.role.trim() : '',
|
||||
status: typeof req.query.status === 'string' ? req.query.status.trim() : '',
|
||||
});
|
||||
if (subscriptionService?.getActiveSubscription && Array.isArray(result.users) && result.users.length) {
|
||||
result.users = await Promise.all(
|
||||
result.users.map(async (user) => {
|
||||
if (user.role !== 'user') return user;
|
||||
const sub = await subscriptionService.getActiveSubscription(user.id);
|
||||
if (!sub) return { ...user, subscription: null };
|
||||
return {
|
||||
...user,
|
||||
subscription: {
|
||||
planType: sub.planType,
|
||||
status: sub.status,
|
||||
periodTokensLimit: sub.periodTokensLimit,
|
||||
periodTokensUsed: sub.periodTokensUsed,
|
||||
expiresAt: sub.expiresAt,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId', requireAdmin, async (req, res) => {
|
||||
const user = await userAuth.getUserPublic(req.params.userId);
|
||||
if (!user) return res.status(404).json({ message: '用户不存在' });
|
||||
res.json({ user });
|
||||
let subscription = null;
|
||||
if (subscriptionService?.getActiveSubscription && user.role === 'user') {
|
||||
subscription = await subscriptionService.getActiveSubscription(req.params.userId);
|
||||
}
|
||||
res.json({ user, subscription });
|
||||
});
|
||||
|
||||
adminApi.get('/summary', requireAdmin, async (_req, res) => {
|
||||
@@ -395,6 +426,161 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/config', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
const [config, template] = await Promise.all([
|
||||
wechatNewsMorningDraftService.getConfig(),
|
||||
Promise.resolve(wechatNewsMorningDraftService.getTemplate()),
|
||||
]);
|
||||
return res.json({ config, template });
|
||||
});
|
||||
|
||||
adminApi.patch('/wechat/news-morning-draft/config', requireAdmin, async (req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
const config = await wechatNewsMorningDraftService.updateConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
return res.json({ config });
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/preview', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
try {
|
||||
return res.json(await wechatNewsMorningDraftService.preview());
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : '预览失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/today', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
return res.json(await wechatNewsMorningDraftService.getTodayStatus());
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/preview/today', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
try {
|
||||
return res.json(await wechatNewsMorningDraftService.previewToday());
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : '预览失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.post('/wechat/news-morning-draft/push', requireAdmin, async (req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
try {
|
||||
const result = await wechatNewsMorningDraftService.pushDraft({
|
||||
triggeredBy: req.currentUser.id,
|
||||
dryRun: req.body?.dryRun === true,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : '推送失败',
|
||||
run: error?.run ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/runs', requireAdmin, async (req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
const runs = await wechatNewsMorningDraftService.listRuns({
|
||||
limit: req.query.limit,
|
||||
});
|
||||
return res.json({ runs });
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
||||
const config = await loadMindSpaceConfig(pool);
|
||||
@@ -440,6 +626,64 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/image-make/config', requireAdmin, async (_req, res) => {
|
||||
if (!imageMakeAdminConfigService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'image_make 配置服务未启用' });
|
||||
}
|
||||
return res.json(await imageMakeAdminConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
const updateImageMakeConfig = async (req, res) => {
|
||||
if (!imageMakeAdminConfigService?.updateAdminConfig) {
|
||||
return res.status(503).json({ message: 'image_make 配置服务未启用' });
|
||||
}
|
||||
const result = await imageMakeAdminConfigService.updateAdminConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (result.ok === false) return res.status(400).json({ message: result.message });
|
||||
return res.json(result);
|
||||
};
|
||||
|
||||
adminApi.put('/image-make/config', requireAdmin, updateImageMakeConfig);
|
||||
adminApi.patch('/image-make/config', requireAdmin, updateImageMakeConfig);
|
||||
|
||||
adminApi.get('/image-make/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!imageMakeAdminConfigService?.getRuntimeConfig) {
|
||||
return res.status(503).json({ message: 'image_make 配置服务未启用' });
|
||||
}
|
||||
const runtime = await imageMakeAdminConfigService.getRuntimeConfig();
|
||||
if (!runtime.ok) {
|
||||
return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' });
|
||||
}
|
||||
const { providers, ...rest } = runtime;
|
||||
return res.json({
|
||||
...rest,
|
||||
providers: {
|
||||
mock: { enabled: providers.mock.enabled },
|
||||
aliyun_bailian: providers.aliyun_bailian.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
model: providers.aliyun_bailian.model,
|
||||
apiBase: providers.aliyun_bailian.apiBase,
|
||||
apiKeyConfigured: Boolean(providers.aliyun_bailian.apiKey),
|
||||
}
|
||||
: { enabled: false },
|
||||
comfyui: providers.comfyui.enabled
|
||||
? { enabled: true, ...providers.comfyui }
|
||||
: { enabled: false },
|
||||
fal_ai: providers.fal_ai.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
model: providers.fal_ai.model,
|
||||
apiBase: providers.fal_ai.apiBase,
|
||||
numInferenceSteps: providers.fal_ai.numInferenceSteps,
|
||||
apiKeyConfigured: Boolean(providers.fal_ai.apiKey),
|
||||
}
|
||||
: { enabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
|
||||
res.json(await memoryV2ConfigService.getAdminConfig());
|
||||
@@ -490,6 +734,71 @@ export function createAdminApp(services) {
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/health', requireAdmin, async (_req, res) => {
|
||||
const result = await fetchNewsEngine('/health');
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/config', requireAdmin, async (_req, res) => {
|
||||
const result = await fetchNewsEngine('/v1/config');
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/collections', requireAdmin, async (req, res) => {
|
||||
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
|
||||
const result = await fetchNewsEngine(`/v1/collections?limit=${limit}`);
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/collections/latest', requireAdmin, async (_req, res) => {
|
||||
const result = await fetchNewsEngine('/v1/collections/latest');
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/collections/:id', requireAdmin, async (req, res) => {
|
||||
const result = await fetchNewsEngine(`/v1/collections/${encodeURIComponent(req.params.id)}`);
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.post('/news-engine/collect', requireAdmin, async (req, res) => {
|
||||
const result = await fetchNewsEngine('/v1/collect', { method: 'POST', body: req.body ?? {} });
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 采集失败' });
|
||||
});
|
||||
|
||||
adminApi.post('/news-engine/collections/:id/score', requireAdmin, async (req, res) => {
|
||||
const result = await fetchNewsEngine(`/v1/collections/${encodeURIComponent(req.params.id)}/score`, {
|
||||
method: 'POST',
|
||||
body: req.body ?? {},
|
||||
});
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 评分失败' });
|
||||
});
|
||||
|
||||
adminApi.post('/news-engine/rank', requireAdmin, async (req, res) => {
|
||||
const result = await fetchNewsEngine('/v1/rank', { method: 'POST', body: req.body ?? {} });
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 排序失败' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/runs', requireAdmin, async (req, res) => {
|
||||
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 30));
|
||||
const result = await fetchNewsEngine(`/v1/runs?limit=${limit}`);
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/runs/:id', requireAdmin, async (req, res) => {
|
||||
const result = await fetchNewsEngine(`/v1/runs/${encodeURIComponent(req.params.id)}`);
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.get('/news-engine/scheduler/status', requireAdmin, async (_req, res) => {
|
||||
const result = await fetchNewsEngine('/v1/scheduler/status');
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
|
||||
});
|
||||
|
||||
adminApi.post('/news-engine/scheduler/trigger', requireAdmin, async (req, res) => {
|
||||
const result = await fetchNewsEngine('/v1/scheduler/trigger', { method: 'POST', body: req.body ?? {} });
|
||||
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 采集触发失败' });
|
||||
});
|
||||
|
||||
adminApi.get('/orchestrator/config', requireAdmin, async (_req, res) => {
|
||||
if (!orchestratorConfigService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||
@@ -660,11 +969,12 @@ export function createAdminApp(services) {
|
||||
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
|
||||
});
|
||||
|
||||
adminApi.get('/billing/config', requireAdmin, async (_req, res) => {
|
||||
adminApi.get('/billing/config', requireAdmin, async (req, res) => {
|
||||
if (!billingConfigService?.getAdminConfig) {
|
||||
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) => {
|
||||
@@ -672,9 +982,10 @@ export function createAdminApp(services) {
|
||||
return res.status(503).json({ message: '计费公式配置服务未启用' });
|
||||
}
|
||||
try {
|
||||
const formula = req.body?.formula ? String(req.body.formula) : undefined;
|
||||
return res.json(await billingConfigService.updateAdminConfig(
|
||||
req.body?.config ?? req.body ?? {},
|
||||
{ updatedBy: req.currentUser.id },
|
||||
{ updatedBy: req.currentUser.id, formula },
|
||||
));
|
||||
} catch (error) {
|
||||
if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') {
|
||||
@@ -687,11 +998,25 @@ export function createAdminApp(services) {
|
||||
adminApi.put('/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) {
|
||||
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) => {
|
||||
|
||||
@@ -28,8 +28,15 @@ function createServices({ role = 'admin' } = {}) {
|
||||
getMe: async () => ({ id: 'admin-id', username: 'admin', role }),
|
||||
},
|
||||
billingConfigService: {
|
||||
getAdminConfig: async () => ({
|
||||
getAdminConfig: async ({ formula = 'A' } = {}) => ({
|
||||
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',
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
@@ -39,12 +46,24 @@ function createServices({ role = 'admin' } = {}) {
|
||||
stored = { ...stored, ...(patch.config ?? patch) };
|
||||
return {
|
||||
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',
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: context?.updatedBy ?? null,
|
||||
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数',
|
||||
};
|
||||
},
|
||||
assignBillingFormula: async (userIds, formula) => ({
|
||||
ok: true,
|
||||
updated: userIds.length,
|
||||
formula,
|
||||
}),
|
||||
getRuntimeState: async () => ({
|
||||
source: 'admin-db',
|
||||
config: stored,
|
||||
|
||||
@@ -41,8 +41,17 @@ export async function bootstrapAdminServices() {
|
||||
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
||||
const { createBillingAdminConfigService } = await importMemind('billing-admin-config.mjs');
|
||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||
const {
|
||||
createImageMakeAdminConfigService,
|
||||
ensureImageMakeAdminConfigSchema,
|
||||
} = await importMemind('image-make-admin-config.mjs');
|
||||
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
|
||||
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
|
||||
const { createWechatNewsMorningDraftService } = await importMemind('wechat-news-morning-draft.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 { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||
@@ -100,7 +109,12 @@ export async function bootstrapAdminServices() {
|
||||
apiSecret,
|
||||
});
|
||||
await ensureAssetGatewaySchema(pool);
|
||||
await ensureImageMakeAdminConfigSchema(pool);
|
||||
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
||||
const imageMakeAdminConfigService = createImageMakeAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
llmProviderService,
|
||||
});
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
@@ -126,12 +140,20 @@ export async function bootstrapAdminServices() {
|
||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool);
|
||||
const wechatCursorExecutorPolicyService = createWechatCursorExecutorAdminConfigService(pool);
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
pool,
|
||||
userAuth,
|
||||
portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatNewsMorningDraftService = createWechatNewsMorningDraftService(pool, {
|
||||
mpConfig: wechatMpConfig,
|
||||
h5Root,
|
||||
memindLibRoot: resolveMemindLib(),
|
||||
env: process.env,
|
||||
});
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
userAuth,
|
||||
@@ -165,6 +187,7 @@ export async function bootstrapAdminServices() {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
subscriptionService._planCatalogService = planCatalogService;
|
||||
userAuth.setSubscriptionService(subscriptionService);
|
||||
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
|
||||
const templateCatalogService = createPageTemplateCatalogService(pool, {
|
||||
userAuth,
|
||||
@@ -183,6 +206,7 @@ export async function bootstrapAdminServices() {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -197,6 +221,9 @@ export async function bootstrapAdminServices() {
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
|
||||
@@ -99,6 +99,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -112,6 +113,9 @@ ready
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
@@ -129,6 +133,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -142,6 +147,9 @@ ready
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export function resolveNewsEngineBaseUrl(env = process.env) {
|
||||
return String(env.MEMIND_NEWS_ENGINE_URL ?? 'http://127.0.0.1:8092').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export async function fetchNewsEngine(path, {
|
||||
env = process.env,
|
||||
method = 'GET',
|
||||
body = null,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const baseUrl = resolveNewsEngineBaseUrl(env);
|
||||
if (!baseUrl) {
|
||||
return { ok: false, status: 503, data: { message: '未配置 MEMIND_NEWS_ENGINE_URL' } };
|
||||
}
|
||||
const token = String(env.MEMIND_NEWS_ENGINE_API_TOKEN ?? '').trim();
|
||||
const response = await fetchImpl(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body ? { 'content-type': 'application/json' } : {}),
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(Number(env.MEMIND_NEWS_ENGINE_TIMEOUT_MS ?? 120000) || 120000),
|
||||
});
|
||||
const text = await response.text().catch(() => '');
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { message: text.slice(0, 500) || `HTTP ${response.status}` };
|
||||
}
|
||||
return { ok: response.ok, status: response.status, data };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { PoliciesPage } from './admin/pages/PoliciesPage';
|
||||
import { MindSpacePage } from './admin/pages/MindSpacePage';
|
||||
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
|
||||
import { MindSearchPage } from './admin/pages/MindSearchPage';
|
||||
import { NewsEnginePage } from './admin/pages/NewsEnginePage';
|
||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
||||
@@ -17,7 +18,9 @@ import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage';
|
||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||
import { UsersPage } from './admin/pages/UsersPage';
|
||||
import { WechatPage } from './admin/pages/WechatPage';
|
||||
import { CursorChannelPage } from './admin/pages/CursorChannelPage';
|
||||
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
||||
import { ImageMakeConfigPage } from './admin/pages/ImageMakeConfigPage';
|
||||
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
||||
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
||||
import { SeoGeoAnalyticsPage } from './admin/pages/SeoGeoAnalyticsPage';
|
||||
@@ -133,11 +136,14 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
<Route path="analytics/seo-geo" element={<SeoGeoAnalyticsPage />} />
|
||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||
<Route path="mindsearch" element={<MindSearchPage />} />
|
||||
<Route path="news-engine" element={<NewsEnginePage />} />
|
||||
<Route path="skill-runtime" element={<SkillRuntimePage />} />
|
||||
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="cursor-channel" element={<CursorChannelPage />} />
|
||||
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
||||
<Route path="image-make" element={<ImageMakeConfigPage />} />
|
||||
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
@@ -185,11 +191,14 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
||||
|| pathname.startsWith('/mindspace')
|
||||
|| pathname.startsWith('/analytics')
|
||||
|| pathname.startsWith('/memory-v2')
|
||||
|| pathname.startsWith('/mindsearch')
|
||||
|| pathname.startsWith('/news-engine')
|
||||
|| pathname.startsWith('/skill-runtime')
|
||||
|| pathname.startsWith('/orchestrator')
|
||||
|| pathname.startsWith('/providers')
|
||||
|| pathname.startsWith('/wechat')
|
||||
|| pathname.startsWith('/asset-gateway')
|
||||
|| pathname.startsWith('/image-make')
|
||||
) {
|
||||
return role === 'admin' || role === undefined ? pathname : '/ops';
|
||||
}
|
||||
|
||||
@@ -30,12 +30,14 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: '平台配置',
|
||||
items: [
|
||||
{ to: '/cursor-channel', label: '智趣体验通道' },
|
||||
{ to: '/wechat', label: '服务号' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||
{ to: '/analytics', label: 'Analytics 配置' },
|
||||
{ to: '/analytics/seo-geo', label: 'SEO / GEO 流量' },
|
||||
{ to: '/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/mindsearch', label: 'MindSearch' },
|
||||
{ to: '/news-engine', label: 'News Engine' },
|
||||
{ to: '/skill-runtime', label: 'Skill Runtime' },
|
||||
{ to: '/orchestrator', label: '任务编排' },
|
||||
{ to: '/system-tests', label: '系统测试验证' },
|
||||
@@ -44,6 +46,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/policies', label: '策略' },
|
||||
{ to: '/providers', label: '统一模型中心' },
|
||||
{ to: '/asset-gateway', label: '资产能力' },
|
||||
{ to: '/image-make', label: '生图大模型' },
|
||||
{ to: '/blocked-words', label: '违禁词管理' },
|
||||
],
|
||||
},
|
||||
|
||||
+385
-162
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
assignBillingFormula,
|
||||
cancelUserSubscription,
|
||||
createSubscriptionPlan,
|
||||
deleteSubscriptionPlan,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
BillingAdminConfig,
|
||||
BillingFormulaKey,
|
||||
LedgerEntry,
|
||||
PlanDefinition,
|
||||
UsageRecord,
|
||||
@@ -175,11 +177,304 @@ const DEFAULT_BILLING_FORMULA: BillingAdminConfig = {
|
||||
costEstimateOutputUsdPer1M: 1.1,
|
||||
};
|
||||
|
||||
const FORMULA_TABS: BillingFormulaKey[] = ['A', 'B'];
|
||||
|
||||
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 num = Number(value);
|
||||
onDraftChange({
|
||||
...draft,
|
||||
[key]: Number.isFinite(num) ? num : draft[key],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="admin-form" onSubmit={onSave}>
|
||||
<p className="muted">
|
||||
计量公式 {formulaKey} · 来源:{SOURCE_LABELS[source] ?? source}
|
||||
{updatedAt
|
||||
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.useBackendCost}
|
||||
disabled={envLocked}
|
||||
onChange={(event) => onDraftChange({ ...draft, useBackendCost: event.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
<strong>启用成本模式</strong>
|
||||
<span className="muted"> 按上游真实 USD 成本扣费(推荐)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>汇率(USD→CNY)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.usdCnyRate}
|
||||
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>毛利倍数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.marginMultiplier}
|
||||
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>最低扣费(分)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
disabled={envLocked}
|
||||
value={draft.minBillCents}
|
||||
onChange={(e) => patchNumber('minBillCents', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h3>Token 回退单价(成本缺失时)</h3>
|
||||
<label>
|
||||
<span>输入(分 / 1k tokens)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.inputCentsPer1k}
|
||||
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>输出(分 / 1k tokens)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.outputCentsPer1k}
|
||||
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h3>上游成本估算(Finish 无 cost 时)</h3>
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.costEstimateFromTokens}
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
onChange={(event) => onDraftChange({
|
||||
...draft,
|
||||
costEstimateFromTokens: event.target.checked,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
<strong>按 Token 估算上游成本</strong>
|
||||
<span className="muted"> 仅成本模式生效</span>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>估算输入(USD / 1M)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
value={draft.costEstimateInputUsdPer1M}
|
||||
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>估算输出(USD / 1M)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
value={draft.costEstimateOutputUsdPer1M}
|
||||
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="muted">
|
||||
预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier};
|
||||
套餐额度扣减 ≈ 实际上游 Token × {draft.marginMultiplier}
|
||||
</p>
|
||||
|
||||
<button type="submit" className="send-btn" disabled={saving || envLocked}>
|
||||
{saving ? '保存中…' : `保存计量公式 ${formulaKey}`}
|
||||
</button>
|
||||
</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 [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 [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);
|
||||
@@ -190,11 +485,22 @@ function FormulaTab() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getBillingConfig();
|
||||
setDraft(result.config);
|
||||
setSource(result.source ?? 'default');
|
||||
setUpdatedAt(result.updatedAt ?? null);
|
||||
setFormula(result.formula ?? '');
|
||||
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 : '加载计量公式失败');
|
||||
@@ -207,16 +513,9 @@ function FormulaTab() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
|
||||
const num = Number(value);
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[key]: Number.isFinite(num) ? num : prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const draft = drafts[activeFormula];
|
||||
if (envLocked) {
|
||||
setError('当前环境已锁定为仅读 env(H5_BILLING_CONFIG_SOURCE=env),无法保存。');
|
||||
return;
|
||||
@@ -228,7 +527,7 @@ function FormulaTab() {
|
||||
if (
|
||||
draft.useBackendCost
|
||||
&& !window.confirm(
|
||||
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
|
||||
`确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -237,13 +536,24 @@ function FormulaTab() {
|
||||
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 ?? '');
|
||||
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('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。');
|
||||
setMessage(`计量公式 ${activeFormula} 已保存。Portal 扣费会在数秒内读取新配置,无需重启。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
@@ -252,144 +562,45 @@ function FormulaTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<h2>计量公式</h2>
|
||||
<p className="muted">
|
||||
成本模式:最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数。无上游成本时回退 Token 单价。
|
||||
</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
|
||||
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
|
||||
: ''}
|
||||
</p>
|
||||
{formula ? <p className="muted">{formula}</p> : null}
|
||||
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.useBackendCost}
|
||||
disabled={envLocked}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, useBackendCost: event.target.checked }))}
|
||||
/>
|
||||
<span>
|
||||
<strong>启用成本模式</strong>
|
||||
<span className="muted"> 按上游真实 USD 成本扣费(推荐)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>汇率(USD→CNY)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.usdCnyRate}
|
||||
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>毛利倍数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.marginMultiplier}
|
||||
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>最低扣费(分)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
disabled={envLocked}
|
||||
value={draft.minBillCents}
|
||||
onChange={(e) => patchNumber('minBillCents', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h3>Token 回退单价(成本缺失时)</h3>
|
||||
<label>
|
||||
<span>输入(分 / 1k tokens)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.inputCentsPer1k}
|
||||
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>输出(分 / 1k tokens)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.outputCentsPer1k}
|
||||
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h3>上游成本估算(Finish 无 cost 时)</h3>
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.costEstimateFromTokens}
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
onChange={(event) => setDraft((prev) => ({
|
||||
...prev,
|
||||
costEstimateFromTokens: event.target.checked,
|
||||
}))}
|
||||
/>
|
||||
<span>
|
||||
<strong>按 Token 估算上游成本</strong>
|
||||
<span className="muted"> 仅成本模式生效</span>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>估算输入(USD / 1M)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
value={draft.costEstimateInputUsdPer1M}
|
||||
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>估算输出(USD / 1M)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
value={draft.costEstimateOutputUsdPer1M}
|
||||
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="muted">
|
||||
预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier}
|
||||
</p>
|
||||
|
||||
<button type="submit" className="send-btn" disabled={saving || envLocked}>
|
||||
{saving ? '保存中…' : '保存计量公式'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
<>
|
||||
<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}
|
||||
</section>
|
||||
<FormulaAssignmentSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1545,6 +1756,12 @@ function SubscriptionsTab() {
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -1637,6 +1854,7 @@ function SubscriptionsTab() {
|
||||
<th>用户</th>
|
||||
<th>套餐</th>
|
||||
<th>状态</th>
|
||||
<th>自动续费</th>
|
||||
<th style={{ textAlign: 'right' }}>Token 用量</th>
|
||||
<th style={{ textAlign: 'right' }}>图片用量</th>
|
||||
<th>到期时间</th>
|
||||
@@ -1657,6 +1875,11 @@ function SubscriptionsTab() {
|
||||
{STATUS_LABEL[row.status] ?? row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={row.autoRenew && row.status === 'expired' ? 'text-error' : undefined}>
|
||||
{autoRenewLabel(row)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="billing-num">
|
||||
{row.periodTokensUsed.toLocaleString()}
|
||||
{row.periodTokensLimit > 0 && (
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
getWechatCursorExecutorConfig,
|
||||
getWechatCursorExecutorRuntime,
|
||||
listAdminUsers,
|
||||
patchWechatCursorExecutorConfig,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
AdminUserRow,
|
||||
CursorChannelFeatureKey,
|
||||
CursorChannelFeatures,
|
||||
WechatCursorExecutorAdminConfig,
|
||||
WechatCursorExecutorRuntimeState,
|
||||
} from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
type CursorChannelForm = {
|
||||
enabled: boolean;
|
||||
h5Enabled: boolean;
|
||||
wechatEnabled: boolean;
|
||||
selectedUserIds: string[];
|
||||
manualAllowlistEntries: string[];
|
||||
features: CursorChannelFeatures;
|
||||
fallbackToDeepseek: boolean;
|
||||
};
|
||||
|
||||
const FEATURE_OPTIONS: Array<{
|
||||
key: CursorChannelFeatureKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'pageGenerate',
|
||||
label: '页面生成',
|
||||
description: 'H5 / 服务号 page.generate 与页面落盘任务走 Cursor。',
|
||||
},
|
||||
{
|
||||
key: 'pageData',
|
||||
label: '问卷 Page Data',
|
||||
description: 'H5 问卷收集、PG 持久化与相关分析页走 Cursor。',
|
||||
},
|
||||
{
|
||||
key: 'excelAnalysis',
|
||||
label: 'Excel 分析',
|
||||
description: 'H5 表格分析与结果页走 Cursor。',
|
||||
},
|
||||
{
|
||||
key: 'chatBridge',
|
||||
label: '普通聊天(Chat Bridge)',
|
||||
description: '白名单用户的 Goose 会话 LLM 走 Cursor Chat Bridge;需服务端 MEMIND_CURSOR_CHAT_BRIDGE_ENABLED=1。',
|
||||
},
|
||||
{
|
||||
key: 'scheduledTasks',
|
||||
label: '定时任务执行',
|
||||
description: '到点自动任务(新闻/天气等)走 Cursor 而非 Goose Session。',
|
||||
},
|
||||
];
|
||||
|
||||
function defaultFeatures(): CursorChannelFeatures {
|
||||
return {
|
||||
pageGenerate: { enabled: true },
|
||||
pageData: { enabled: false },
|
||||
excelAnalysis: { enabled: false },
|
||||
chatBridge: { enabled: false },
|
||||
scheduledTasks: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFeatures(
|
||||
config: WechatCursorExecutorAdminConfig | undefined,
|
||||
): CursorChannelFeatures {
|
||||
const base = defaultFeatures();
|
||||
const raw = config?.features;
|
||||
if (raw) {
|
||||
for (const option of FEATURE_OPTIONS) {
|
||||
base[option.key] = {
|
||||
enabled: Boolean(raw[option.key]?.enabled),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
const intents = Array.isArray(config?.intentAllowlist) ? config.intentAllowlist : ['page.generate'];
|
||||
base.pageGenerate.enabled = intents.includes('page.generate');
|
||||
return base;
|
||||
}
|
||||
|
||||
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,
|
||||
features: normalizeFeatures(config),
|
||||
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,
|
||||
features: form.features,
|
||||
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>总开关</strong>:开启后,仅白名单用户可进入下方已勾选的能力通道;其余用户完全不受影响。
|
||||
</li>
|
||||
<li>
|
||||
<strong>H5 / 服务号渠道</strong>:可分别控制接入端;页面类能力主要作用于 H5,服务号目前以 page.generate 为主。
|
||||
</li>
|
||||
<li>
|
||||
<strong>独立能力开关</strong>:页面生成、问卷、Excel、普通聊天 Bridge、定时任务可分别启用;未勾选的能力仍走 DeepSeek/Goose。
|
||||
</li>
|
||||
<li>非白名单用户:所有通道保持原有 DeepSeek/Goose 行为。</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 / Goose
|
||||
</label>
|
||||
<fieldset style={{ border: 'none', padding: 0, margin: '12px 0' }}>
|
||||
<legend className="muted" style={{ marginBottom: 8 }}>
|
||||
能力通道(需总开关与白名单同时满足)
|
||||
</legend>
|
||||
{FEATURE_OPTIONS.map((option) => (
|
||||
<label key={option.key} style={{ display: 'block', marginBottom: 10 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.features[option.key]?.enabled ?? false}
|
||||
disabled={loading || saving || !form.enabled}
|
||||
onChange={(event) =>
|
||||
setForm((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
features: {
|
||||
...current.features,
|
||||
[option.key]: { enabled: event.target.checked },
|
||||
},
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
<strong>{option.label}</strong>
|
||||
<span className="muted" style={{ display: 'block', marginLeft: 22, fontSize: 13 }}>
|
||||
{option.description}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<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>
|
||||
|
||||
{runtime ? (
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
运行时:{runtime.policy.enabled ? '已开启' : '已关闭'}
|
||||
{' · '}
|
||||
白名单 {runtime.policy.userAllowlist.length} 人
|
||||
{' · '}
|
||||
渠道 {(runtime.policy.channelAllowlist ?? []).join('、') || '无'}
|
||||
{' · '}
|
||||
能力{' '}
|
||||
{FEATURE_OPTIONS.filter((item) => runtime.policy.features?.[item.key]?.enabled)
|
||||
.map((item) => item.label)
|
||||
.join('、') || '无'}
|
||||
{' · '}
|
||||
来源 {runtime.source}
|
||||
{runtime.updatedAt ? ` · 更新 ${formatTime(runtime.updatedAt)}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getImageMakeAdminConfig,
|
||||
getImageMakeRuntimeStatus,
|
||||
updateImageMakeAdminConfig,
|
||||
} from '../../api/client';
|
||||
import type { ImageMakeAdminConfig } from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
const FAL_MODELS = ['fal-ai/flux/schnell', 'fal-ai/flux/dev'] as const;
|
||||
|
||||
export function ImageMakeConfigPage() {
|
||||
const [config, setConfig] = useState<ImageMakeAdminConfig | null>(null);
|
||||
const [source, setSource] = useState('');
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const [runtimeOk, setRuntimeOk] = useState<boolean | null>(null);
|
||||
const [falApiKey, setFalApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = 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 [admin, runtime] = await Promise.all([
|
||||
getImageMakeAdminConfig(),
|
||||
getImageMakeRuntimeStatus().catch(() => null),
|
||||
]);
|
||||
setConfig(admin.config);
|
||||
setSource(admin.source);
|
||||
setUpdatedAt(admin.updatedAt);
|
||||
setRuntimeOk(runtime ? true : false);
|
||||
setFalApiKey('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const patchConfig = (patch: Partial<ImageMakeAdminConfig>) => {
|
||||
setConfig((current) => (current ? { ...current, ...patch } : current));
|
||||
};
|
||||
|
||||
const patchFal = (patch: Partial<ImageMakeAdminConfig['providers']['fal_ai']>) => {
|
||||
setConfig((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
fal_ai: { ...current.providers.fal_ai, ...patch },
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!config) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const payload: Parameters<typeof updateImageMakeAdminConfig>[0] = {
|
||||
defaultProvider: config.defaultProvider,
|
||||
jobDefaultTimeoutSeconds: config.jobDefaultTimeoutSeconds,
|
||||
providers: {
|
||||
mock: { enabled: config.providers.mock.enabled },
|
||||
aliyun_bailian: { enabled: config.providers.aliyun_bailian.enabled },
|
||||
comfyui: { enabled: config.providers.comfyui.enabled },
|
||||
fal_ai: {
|
||||
enabled: config.providers.fal_ai.enabled,
|
||||
model: config.providers.fal_ai.model,
|
||||
apiBase: config.providers.fal_ai.apiBase,
|
||||
numInferenceSteps: config.providers.fal_ai.numInferenceSteps,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (falApiKey.trim()) {
|
||||
payload.providers = {
|
||||
...payload.providers,
|
||||
fal_ai: {
|
||||
...payload.providers?.fal_ai,
|
||||
apiKey: falApiKey.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
const saved = await updateImageMakeAdminConfig(payload);
|
||||
setConfig(saved.config);
|
||||
setSource(saved.source);
|
||||
setUpdatedAt(saved.updatedAt);
|
||||
setFalApiKey('');
|
||||
setMessage('生图 Provider 配置已保存');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enableFalOnly = () => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
defaultProvider: 'fal_ai',
|
||||
providers: {
|
||||
...config.providers,
|
||||
mock: { ...config.providers.mock, enabled: false },
|
||||
aliyun_bailian: { ...config.providers.aliyun_bailian, enabled: false },
|
||||
comfyui: { ...config.providers.comfyui, enabled: false },
|
||||
fal_ai: {
|
||||
...config.providers.fal_ai,
|
||||
enabled: true,
|
||||
model: config.providers.fal_ai.model || 'fal-ai/flux/schnell',
|
||||
apiBase: config.providers.fal_ai.apiBase || 'https://queue.fal.run',
|
||||
numInferenceSteps: config.providers.fal_ai.numInferenceSteps ?? 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<p className="muted">加载生图配置…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<p className="error-text">{error ?? '无法加载配置'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>生图大模型</h2>
|
||||
<p className="muted">
|
||||
配置 image_make 服务的 Provider。fal.ai 使用 Queue API;保存后 image_make 会通过 Portal 运行时配置拉取。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="admin-alert error">{error}</div>}
|
||||
{message && <div className="admin-alert success">{message}</div>}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h3>运行状态</h3>
|
||||
</div>
|
||||
<p className="muted">
|
||||
配置来源:{source || 'unknown'}
|
||||
{updatedAt ? ` · 更新于 ${formatTime(updatedAt)}` : ''}
|
||||
{runtimeOk === false ? ' · 运行时校验未通过(请检查 Key 与默认 Provider)' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h3>fal.ai(Flux Schnell / Dev)</h3>
|
||||
<button type="button" className="ghost-btn" onClick={enableFalOnly}>
|
||||
一键启用 fal.ai
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>启用 fal.ai</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.providers.fal_ai.enabled}
|
||||
onChange={(e) => patchFal({ enabled: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>默认 Provider</span>
|
||||
<select
|
||||
value={config.defaultProvider}
|
||||
onChange={(e) => patchConfig({ defaultProvider: e.target.value })}
|
||||
>
|
||||
<option value="fal_ai">fal_ai</option>
|
||||
<option value="mock">mock</option>
|
||||
<option value="aliyun_bailian">aliyun_bailian</option>
|
||||
<option value="comfyui">comfyui</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>模型</span>
|
||||
<select
|
||||
value={config.providers.fal_ai.model ?? 'fal-ai/flux/schnell'}
|
||||
onChange={(e) => patchFal({ model: e.target.value })}
|
||||
>
|
||||
{FAL_MODELS.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>API Base</span>
|
||||
<input
|
||||
type="text"
|
||||
value={config.providers.fal_ai.apiBase ?? 'https://queue.fal.run'}
|
||||
onChange={(e) => patchFal({ apiBase: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>FAL API Key</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={config.providers.fal_ai.apiKeyConfigured
|
||||
? `已配置 ${config.providers.fal_ai.apiKeyMasked ?? ''}`
|
||||
: 'id:secret 格式'}
|
||||
value={falApiKey}
|
||||
onChange={(e) => setFalApiKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>推理步数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={12}
|
||||
value={config.providers.fal_ai.numInferenceSteps ?? 4}
|
||||
onChange={(e) => patchFal({ numInferenceSteps: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>任务超时(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
max={1800}
|
||||
value={config.jobDefaultTimeoutSeconds}
|
||||
onChange={(e) => patchConfig({ jobDefaultTimeoutSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<button type="button" className="send-btn" disabled={busy} onClick={() => void save()}>
|
||||
{busy ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h3>其它 Provider(开关)</h3>
|
||||
</div>
|
||||
<p className="muted">生产环境若仅使用 fal.ai,建议关闭 mock / 百炼 / ComfyUI。</p>
|
||||
<label className="admin-field">
|
||||
<span>mock</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.providers.mock.enabled}
|
||||
onChange={(e) => patchConfig({
|
||||
providers: { ...config.providers, mock: { ...config.providers.mock, enabled: e.target.checked } },
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>百炼 Qwen-Image</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.providers.aliyun_bailian.enabled}
|
||||
onChange={(e) => patchConfig({
|
||||
providers: {
|
||||
...config.providers,
|
||||
aliyun_bailian: { ...config.providers.aliyun_bailian, enabled: e.target.checked },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>ComfyUI 本地</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.providers.comfyui.enabled}
|
||||
onChange={(e) => patchConfig({
|
||||
providers: {
|
||||
...config.providers,
|
||||
comfyui: { ...config.providers.comfyui, enabled: e.target.checked },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,18 @@ import type { MindSpaceAdminConfig, MindSpaceSeoGeoConfig } from '../../types';
|
||||
|
||||
function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
seo: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
canonical: true,
|
||||
sitemap: false,
|
||||
robotsTxt: false,
|
||||
baiduPush: false,
|
||||
sitemap: true,
|
||||
robotsTxt: true,
|
||||
baiduPush: true,
|
||||
},
|
||||
geo: {
|
||||
enabled: false,
|
||||
jsonLd: false,
|
||||
llmsTxt: false,
|
||||
enabled: true,
|
||||
jsonLd: true,
|
||||
llmsTxt: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export function MindSpacePage() {
|
||||
<section className="admin-card">
|
||||
<h2>SEO / GEO</h2>
|
||||
<p className="muted">
|
||||
仅对「已确认公开、access_mode=public、status=online」的发布页生效。私有、密码、登录可见或未确认页面会强制 noindex,不会进入 sitemap / llms.txt。
|
||||
对「公开、在线、未过期」的发布页注入 SEO/GEO。密码、登录可见、仅本人或已过期页面仍强制 noindex,不会进入 sitemap / llms.txt。生产库若仍是旧的全关配置,请点「全部开启」后保存。
|
||||
</p>
|
||||
<div className="admin-form">
|
||||
<label className="admin-form-row">
|
||||
@@ -203,6 +203,14 @@ export function MindSpacePage() {
|
||||
<button type="submit" className="send-btn" disabled={busy || loading}>
|
||||
{busy ? '保存中...' : '保存配置'}
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
collectNewsEngine,
|
||||
getNewsEngineCollection,
|
||||
getNewsEngineCollections,
|
||||
getNewsEngineConfig,
|
||||
getNewsEngineHealth,
|
||||
getNewsEngineRuns,
|
||||
getNewsEngineSchedulerStatus,
|
||||
getWechatNewsMorningDraftTodayStatus,
|
||||
rankNewsEngine,
|
||||
scoreNewsEngineCollection,
|
||||
triggerNewsEngineScheduler,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
NewsEngineCollectionDetail,
|
||||
NewsEngineCollectionSummary,
|
||||
NewsEngineConfig,
|
||||
NewsEngineRankResult,
|
||||
NewsEngineRunSummary,
|
||||
NewsEngineSchedulerStatus,
|
||||
WechatNewsMorningDraftTodayStatus,
|
||||
} from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
function statusBadge(ok: boolean | null) {
|
||||
if (ok === null) return <span className="muted">检测中…</span>;
|
||||
return ok
|
||||
? <span style={{ color: 'var(--color-success, #0f766e)' }}>在线</span>
|
||||
: <span style={{ color: 'var(--color-danger, #c8362f)' }}>离线</span>;
|
||||
}
|
||||
|
||||
function formatInterval(ms: number) {
|
||||
if (!ms) return '未启用';
|
||||
if (ms % 3_600_000 === 0) return `${ms / 3_600_000} 小时`;
|
||||
if (ms % 60_000 === 0) return `${ms / 60_000} 分钟`;
|
||||
return `${Math.round(ms / 1000)} 秒`;
|
||||
}
|
||||
|
||||
export function NewsEnginePage() {
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null);
|
||||
const [config, setConfig] = useState<NewsEngineConfig | null>(null);
|
||||
const [scheduler, setScheduler] = useState<NewsEngineSchedulerStatus | null>(null);
|
||||
const [runs, setRuns] = useState<NewsEngineRunSummary[]>([]);
|
||||
const [collections, setCollections] = useState<NewsEngineCollectionSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<NewsEngineCollectionDetail | null>(null);
|
||||
const [rankPreview, setRankPreview] = useState<NewsEngineRankResult | null>(null);
|
||||
const [limit, setLimit] = useState(15);
|
||||
const [minimumScore, setMinimumScore] = useState(0);
|
||||
const [useLlm, setUseLlm] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [publishStatus, setPublishStatus] = useState<WechatNewsMorningDraftTodayStatus | null>(null);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
const [health, cfg, list, runList, schedulerStatus, todayStatus] = await Promise.all([
|
||||
getNewsEngineHealth().then(() => true).catch(() => false),
|
||||
getNewsEngineConfig().catch(() => null),
|
||||
getNewsEngineCollections().catch(() => [] as NewsEngineCollectionSummary[]),
|
||||
getNewsEngineRuns().catch(() => [] as NewsEngineRunSummary[]),
|
||||
getNewsEngineSchedulerStatus().catch(() => null),
|
||||
getWechatNewsMorningDraftTodayStatus().catch(() => null),
|
||||
]);
|
||||
setHealthOk(health);
|
||||
setConfig(cfg);
|
||||
setCollections(Array.isArray(list) ? list : []);
|
||||
setRuns(Array.isArray(runList) ? runList : []);
|
||||
setScheduler(schedulerStatus);
|
||||
setPublishStatus(todayStatus);
|
||||
if (cfg?.ranking) {
|
||||
setLimit(cfg.ranking.dailyLimit);
|
||||
setMinimumScore(cfg.ranking.minimumScore);
|
||||
}
|
||||
if (!selectedId && Array.isArray(list) && list.length) {
|
||||
setSelectedId(list[0].id);
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
const loadDetail = useCallback(async (id: string) => {
|
||||
const next = await getNewsEngineCollection(id);
|
||||
setDetail(next);
|
||||
setRankPreview(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview().catch((error) => {
|
||||
setMessage(error instanceof Error ? error.message : '加载 News Engine 失败');
|
||||
});
|
||||
}, [loadOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
void loadDetail(selectedId).catch((error) => {
|
||||
setMessage(error instanceof Error ? error.message : '加载采集详情失败');
|
||||
});
|
||||
}, [selectedId, loadDetail]);
|
||||
|
||||
const runCollect = async () => {
|
||||
setBusy('collect');
|
||||
setMessage('采集中,可能需要 1–2 分钟…');
|
||||
try {
|
||||
const bundle = await collectNewsEngine({ useLlm });
|
||||
setMessage(`采集完成:${bundle.ranking?.selected?.length ?? 0} 条入选`);
|
||||
await loadOverview();
|
||||
if (bundle.collectionId) setSelectedId(bundle.collectionId);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '采集失败');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runScheduler = async () => {
|
||||
setBusy('scheduler');
|
||||
setMessage('调度采集中…');
|
||||
try {
|
||||
const bundle = await triggerNewsEngineScheduler({ useLlm });
|
||||
setMessage(`调度采集完成:${bundle.ranking?.selected?.length ?? 0} 条入选`);
|
||||
await loadOverview();
|
||||
if (bundle.collectionId) setSelectedId(bundle.collectionId);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '调度采集失败');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runScore = async () => {
|
||||
if (!selectedId) return;
|
||||
setBusy('score');
|
||||
setMessage('LLM 评分中…');
|
||||
try {
|
||||
const bundle = await scoreNewsEngineCollection(selectedId);
|
||||
setDetail(bundle);
|
||||
setMessage(`评分完成:${bundle.scoring?.assessedCount ?? 0} 条已评估`);
|
||||
await loadOverview();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '评分失败');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runRankPreview = async () => {
|
||||
if (!detail?.groups?.length) return;
|
||||
setBusy('rank');
|
||||
setMessage('排序预览中…');
|
||||
try {
|
||||
const result = await rankNewsEngine({
|
||||
groups: detail.groups,
|
||||
options: { limit, minimumScore },
|
||||
});
|
||||
setRankPreview(result);
|
||||
setMessage(`预览完成:${result.selected?.length ?? 0} 条入选`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '排序预览失败');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const previewUrl = `${config?.publicBaseUrl ?? 'http://127.0.0.1:8092'}/news`;
|
||||
const selectedArticles = detail?.ranking?.selected ?? rankPreview?.selected ?? [];
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h1>News Engine</h1>
|
||||
<p>Memind 早报采集、质量过滤、规则/LLM 排序与发布前预览。服务独立运行,LLM 复用统一模型中心默认配置。</p>
|
||||
</div>
|
||||
<div className="wechat-toolbar">
|
||||
{statusBadge(healthOk)}
|
||||
<a className="ghost-btn" href={previewUrl} target="_blank" rel="noreferrer">
|
||||
打开新闻预览页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && <p className="banner">{message}</p>}
|
||||
|
||||
<div className="admin-card">
|
||||
<h2>运行配置</h2>
|
||||
<div className="admin-form">
|
||||
<label className="plan-form-row"><span>SearXNG</span><strong>{config?.searxngConfigured ? '已配置' : '未配置'}</strong></label>
|
||||
<label className="plan-form-row"><span>默认入选上限</span><strong>{config?.ranking?.dailyLimit ?? limit}</strong></label>
|
||||
<label className="plan-form-row"><span>默认最低分</span><strong>{config?.ranking?.minimumScore ?? minimumScore}</strong></label>
|
||||
<label className="plan-form-row"><span>LLM</span><strong>{config?.llm?.enabled ? `${config.llm.providerLabel ?? ''} · ${config.llm.model ?? ''}` : (config?.llm?.reason ?? '未启用')}</strong></label>
|
||||
<label className="plan-form-row"><span>LLM 来源</span><strong>{config?.llm?.source ?? '—'}</strong></label>
|
||||
<label className="plan-form-row"><span>评分上限</span><strong>{config?.llm?.maxEvents ?? '—'}</strong></label>
|
||||
<label className="plan-form-row"><span>本地配图</span><strong>{config?.media?.enabled ? `已启用 · ${config.media.publicBaseUrl}/media/` : '未启用'}</strong></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h2>发布联动</h2>
|
||||
<div className="admin-form">
|
||||
<label className="plan-form-row"><span>今日早报</span><strong>{publishStatus?.todayPageReady ? '页面已就绪' : '尚未生成'}</strong></label>
|
||||
<label className="plan-form-row"><span>日期</span><strong>{publishStatus?.dateKey ?? '—'}</strong></label>
|
||||
<label className="plan-form-row"><span>预期 slug</span><strong>{publishStatus?.expectedSlug ?? '—'}</strong></label>
|
||||
<label className="plan-form-row"><span>自动生成</span><strong>{publishStatus?.autoGenerateEnabled ? '已开启' : '未开启'}</strong></label>
|
||||
<label className="plan-form-row"><span>自动推送</span><strong>{publishStatus?.autoPushEnabled ? '已开启' : '未开启'}</strong></label>
|
||||
</div>
|
||||
<div className="wechat-toolbar">
|
||||
<Link className="ghost-btn" to="/wechat">前往服务号早报配置</Link>
|
||||
{publishStatus?.page?.url ? (
|
||||
<a className="ghost-btn" href={publishStatus.page.url} target="_blank" rel="noreferrer">打开今日页面</a>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
Memind 早报 worker 会同时合并 SearXNG 全栏目预取与 News Engine 今日批次,再统一排序并优先使用本地 `/media/` 配图。发布前请确认今日采集批次与排序结果符合预期。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h2>定时采集</h2>
|
||||
<div className="admin-form">
|
||||
<label className="plan-form-row"><span>调度状态</span><strong>{scheduler?.enabled ? '已启用' : '未启用(需设置 MEMIND_NEWS_ENGINE_COLLECT_INTERVAL_MS ≥ 60000)'}</strong></label>
|
||||
<label className="plan-form-row"><span>采集间隔</span><strong>{formatInterval(scheduler?.intervalMs ?? 0)}</strong></label>
|
||||
<label className="plan-form-row"><span>调度时 LLM</span><strong>{scheduler?.useLlm ? '是' : '否'}</strong></label>
|
||||
<label className="plan-form-row"><span>上次运行</span><strong>{scheduler?.lastRunAt ? formatTime(scheduler.lastRunAt) : '—'}</strong></label>
|
||||
<label className="plan-form-row"><span>上次状态</span><strong>{scheduler?.lastStatus ?? '—'}{scheduler?.lastError ? `(${scheduler.lastError})` : ''}</strong></label>
|
||||
<label className="plan-form-row"><span>下次预计</span><strong>{scheduler?.nextRunAt ? formatTime(scheduler.nextRunAt) : '—'}</strong></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h2>采集与排序</h2>
|
||||
<div className="admin-form" style={{ marginBottom: 16 }}>
|
||||
<label className="plan-form-row">
|
||||
<span>入选上限</span>
|
||||
<input type="number" min={1} max={30} value={limit} onChange={(e) => setLimit(Number(e.target.value) || 15)} />
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>最低分</span>
|
||||
<input type="number" min={0} max={100} value={minimumScore} onChange={(e) => setMinimumScore(Number(e.target.value) || 0)} />
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>采集时 LLM 评分</span>
|
||||
<input type="checkbox" checked={useLlm} onChange={(e) => setUseLlm(e.target.checked)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="wechat-toolbar">
|
||||
<button type="button" className="send-btn" disabled={!!busy} onClick={() => void runCollect()}>
|
||||
{busy === 'collect' ? '采集中…' : '立即采集'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" disabled={!!busy || scheduler?.running} onClick={() => void runScheduler()}>
|
||||
{busy === 'scheduler' ? '调度中…' : '触发调度采集'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" disabled={!!busy || !selectedId} onClick={() => void runScore()}>
|
||||
{busy === 'score' ? '评分中…' : 'LLM 评分当前批次'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" disabled={!!busy || !detail?.groups?.length} onClick={() => void runRankPreview()}>
|
||||
{busy === 'rank' ? '预览中…' : '规则排序预览'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" disabled={!!busy} onClick={() => void loadOverview()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h2>排序运行记录</h2>
|
||||
{!runs.length ? (
|
||||
<p className="muted">暂无排序运行记录。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>候选</th>
|
||||
<th>事件</th>
|
||||
<th>入选</th>
|
||||
<th>来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatTime(row.createdAt)}</td>
|
||||
<td>{row.candidateCount}</td>
|
||||
<td>{row.eventCount}</td>
|
||||
<td>{row.selectedCount}</td>
|
||||
<td>{String(row.options?.source ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h2>采集历史</h2>
|
||||
{!collections.length ? (
|
||||
<p className="muted">暂无采集记录。点击「立即采集」开始。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>日期标签</th>
|
||||
<th>状态</th>
|
||||
<th>候选</th>
|
||||
<th>事件</th>
|
||||
<th>入选</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collections.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatTime(row.createdAt)}</td>
|
||||
<td>{row.dateLabel || '—'}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{row.candidateCount}</td>
|
||||
<td>{row.eventCount}</td>
|
||||
<td>{row.selectedCount}</td>
|
||||
<td>
|
||||
<button type="button" className="ghost-btn" onClick={() => setSelectedId(row.id)}>
|
||||
查看
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<div className="admin-card">
|
||||
<h2>当前批次 · {detail.dateLabel || detail.id.slice(0, 8)}</h2>
|
||||
<p className="muted">
|
||||
候选 {detail.candidateCount} · 事件 {detail.eventCount} · 入选 {detail.selectedCount}
|
||||
{detail.scoring?.assessedCount != null ? ` · LLM 已评 ${detail.scoring.assessedCount}` : ''}
|
||||
</p>
|
||||
{!selectedArticles.length ? (
|
||||
<p className="muted">暂无入选条目。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>栏目</th>
|
||||
<th>分数</th>
|
||||
<th>链接</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedArticles.map((item) => (
|
||||
<tr key={item.id ?? item.url}>
|
||||
<td>{item.canonicalTitle ?? item.title ?? item.primaryArticle?.title ?? '—'}</td>
|
||||
<td>{item.category ?? item.groupTitle ?? '—'}</td>
|
||||
<td>{item.finalScore != null ? item.finalScore.toFixed(1) : (item.score != null ? item.score.toFixed(1) : '—')}</td>
|
||||
<td>
|
||||
{item.primaryArticle?.image ? (
|
||||
<a href={item.primaryArticle.image} target="_blank" rel="noreferrer">配图</a>
|
||||
) : item.url ? (
|
||||
<a href={item.url} target="_blank" rel="noreferrer">打开</a>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,8 @@ export function SeoGeoAnalyticsPage() {
|
||||
const [startDate, setStartDate] = useState(initialRange.startDate);
|
||||
const [endDate, setEndDate] = useState(initialRange.endDate);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('totalViews');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('publishedAt');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rows, setRows] = useState<SeoGeoCatalogRow[]>([]);
|
||||
@@ -68,6 +69,7 @@ export function SeoGeoAnalyticsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openingUmami, setOpeningUmami] = useState(false);
|
||||
const [umamiStatus, setUmamiStatus] = useState<{ available: boolean; reason: string } | null>(null);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / 20));
|
||||
|
||||
@@ -93,11 +95,13 @@ export function SeoGeoAnalyticsPage() {
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
});
|
||||
setUmamiStatus(result.umami ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面目录失败');
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setTotals({ totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0 });
|
||||
setUmamiStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -107,6 +111,13 @@ export function SeoGeoAnalyticsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setSearch(searchInput.trim());
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [startDate, endDate, search, sortBy, sortOrder]);
|
||||
@@ -127,7 +138,12 @@ export function SeoGeoAnalyticsPage() {
|
||||
<div className="admin-page-head">
|
||||
<h2>SEO / GEO 流量</h2>
|
||||
<p className="muted">
|
||||
列出全部在线公开页(含零流量页面),仅展示统计与 URL,不提供跳转访问。SEO / GEO 来源按 referrer 域名归类;爬虫按 User-Agent 识别。
|
||||
列出全部在线页(含零流量页面),仅展示统计与 URL,不提供跳转访问。可收录 = 公开且未过期。筛选期内 SEO / GEO 优先合并 Umami 埋点(MindSpace 直链);/u/ 发布链路与爬虫仍保留 MySQL 计数。
|
||||
{umamiStatus && !umamiStatus.available && umamiStatus.reason
|
||||
? ` 当前 Umami 未接入(${umamiStatus.reason}),MindSpace 直链流量可能偏低。`
|
||||
: umamiStatus?.available
|
||||
? ' Umami 已接入。'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -156,20 +172,20 @@ export function SeoGeoAnalyticsPage() {
|
||||
<span>搜索标题 / 用户 / URL</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="例如 john、sitemap、问卷"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
value={searchInput}
|
||||
placeholder="例如 john、daily-news、0917"
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>排序字段</span>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as PageSort)}>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="totalViews">筛选期总访问</option>
|
||||
<option value="seoViews">SEO 搜索来源</option>
|
||||
<option value="geoViews">GEO 来源</option>
|
||||
<option value="crawlerViews">爬虫访问</option>
|
||||
<option value="lifetimeViewCount">累计访问</option>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="pageTitle">标题</option>
|
||||
<option value="ownerLabel">创建用户</option>
|
||||
</select>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota, fetchAdminTemplateCatalog, grantUserPageTemplate } from '../../api/client';
|
||||
import {
|
||||
getAdminUser,
|
||||
listSubscriptionPlans,
|
||||
rechargeUser,
|
||||
updateAdminUser,
|
||||
fetchUserImageQuota,
|
||||
setUserImageQuota,
|
||||
fetchAdminTemplateCatalog,
|
||||
grantUserPageTemplate,
|
||||
} from '../../api/client';
|
||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||
import { PolicySettings } from '../../components/PolicySettings';
|
||||
import { SkillSettings } from '../../components/SkillSettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
import { formatYuan } from '../utils/format';
|
||||
import type { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types';
|
||||
import { formatTime, formatYuan } from '../utils/format';
|
||||
import {
|
||||
formatTokenCount,
|
||||
formatTokenQuota,
|
||||
resolvePlanLabel,
|
||||
subscriptionStatusLabel,
|
||||
} from '../utils/subscription';
|
||||
import type { AdminSubscription, AdminTemplateCatalogItem, ImageQuotaView, PlanDefinition, PortalUser } from '../../types';
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -18,6 +33,8 @@ export function UserDetailPage() {
|
||||
const { userId = '' } = useParams();
|
||||
const { users, reload, setError } = useAdminUsers();
|
||||
const [user, setUser] = useState<PortalUser | null>(null);
|
||||
const [subscription, setSubscription] = useState<AdminSubscription | null>(null);
|
||||
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setLocalError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -42,9 +59,10 @@ export function UserDetailPage() {
|
||||
setLoading(true);
|
||||
setLocalError(null);
|
||||
void getAdminUser(userId)
|
||||
.then((nextUser) => {
|
||||
.then(({ user: nextUser, subscription: nextSubscription }) => {
|
||||
if (cancelled) return;
|
||||
setUser(nextUser);
|
||||
setSubscription(nextSubscription);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
@@ -64,6 +82,20 @@ export function UserDetailPage() {
|
||||
};
|
||||
}, [userId, users]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void listSubscriptionPlans()
|
||||
.then((nextPlans) => {
|
||||
if (!cancelled) setPlans(nextPlans);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPlans([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'user') {
|
||||
setImageQuota(null);
|
||||
@@ -173,6 +205,17 @@ export function UserDetailPage() {
|
||||
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})`
|
||||
: '';
|
||||
|
||||
const activePlanType = subscription?.planType ?? user?.planType ?? 'free';
|
||||
const planLabel = resolvePlanLabel(activePlanType, plans);
|
||||
const tokenLimit = subscription?.periodTokensLimit ?? 0;
|
||||
const tokenUsed = subscription?.periodTokensUsed ?? 0;
|
||||
const tokenRemaining = tokenLimit === 0 ? null : Math.max(0, tokenLimit - tokenUsed);
|
||||
const tokenQuotaSummary = subscription
|
||||
? formatTokenQuota(tokenLimit, tokenUsed)
|
||||
: activePlanType !== 'free'
|
||||
? '无有效订阅记录'
|
||||
: '免费用户,无套餐额度';
|
||||
|
||||
const handleTemplateGrant = async (skillName: string) => {
|
||||
if (!user) return;
|
||||
setMessage(null);
|
||||
@@ -273,6 +316,46 @@ export function UserDetailPage() {
|
||||
)}
|
||||
</dd>
|
||||
</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>
|
||||
<dt>当前套餐</dt>
|
||||
<dd>
|
||||
{planLabel}
|
||||
{subscription && (
|
||||
<span className="muted">
|
||||
{' '}
|
||||
· {subscriptionStatusLabel(subscription.status)}
|
||||
{subscription.expiresAt ? ` · 到期 ${formatTime(subscription.expiresAt)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Token 额度</dt>
|
||||
<dd>{tokenQuotaSummary}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>工作目录</dt>
|
||||
<dd className="mono">{user.workspaceRoot}</dd>
|
||||
@@ -282,6 +365,45 @@ export function UserDetailPage() {
|
||||
|
||||
{user.role === 'user' && (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<h2>套餐与 Token 额度</h2>
|
||||
<dl className="admin-dl">
|
||||
<div>
|
||||
<dt>套餐</dt>
|
||||
<dd>
|
||||
{planLabel}
|
||||
<span className="muted mono"> ({activePlanType})</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>订阅状态</dt>
|
||||
<dd>{subscription ? subscriptionStatusLabel(subscription.status) : '无有效订阅'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>到期时间</dt>
|
||||
<dd>{subscription?.expiresAt ? formatTime(subscription.expiresAt) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Token 总量</dt>
|
||||
<dd>{tokenLimit === 0 ? '无限' : formatTokenCount(tokenLimit)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Token 已用</dt>
|
||||
<dd>{formatTokenCount(tokenUsed)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Token 剩余</dt>
|
||||
<dd>{tokenRemaining == null ? '无限' : formatTokenCount(tokenRemaining)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="muted">
|
||||
授予或取消套餐请前往
|
||||
{' '}
|
||||
<Link to="/billing/subscriptions">计费 · 订阅记录</Link>
|
||||
。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>充值</h2>
|
||||
<form className="admin-form" onSubmit={handleRecharge}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createAdminUser, listAdminUsers, updateAdminUser } from '../../api/client';
|
||||
import { createAdminUser, listAdminUsers, listSubscriptionPlans, updateAdminUser } from '../../api/client';
|
||||
import type { PagedResult } from '../../api/client';
|
||||
import type { AdminUserRow } from '../../types';
|
||||
import type { AdminUserRow, PlanDefinition } from '../../types';
|
||||
import { Pagination } from '../../components/Pagination';
|
||||
import { formatYuan } from '../utils/format';
|
||||
import { resolvePlanLabel, summarizeSubscription } from '../utils/subscription';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -25,6 +26,7 @@ export function UsersPage() {
|
||||
const [pending, setPending] = useState({ search: '', role: '', status: '' });
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
@@ -46,6 +48,12 @@ export function UsersPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void listSubscriptionPlans()
|
||||
.then(setPlans)
|
||||
.catch(() => setPlans([]));
|
||||
}, []);
|
||||
|
||||
const handleQuery = () => {
|
||||
setCommitted(pending);
|
||||
void load(1, pending);
|
||||
@@ -171,6 +179,8 @@ export function UsersPage() {
|
||||
<th>用户</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>套餐</th>
|
||||
<th>Token 额度</th>
|
||||
<th>余额</th>
|
||||
<th>空间</th>
|
||||
<th>工作目录</th>
|
||||
@@ -190,6 +200,10 @@ export function UsersPage() {
|
||||
{user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{resolvePlanLabel(user.subscription?.planType ?? user.planType ?? 'free', plans)}</td>
|
||||
<td className="billing-num">
|
||||
{summarizeSubscription(user.subscription, user.planType)}
|
||||
</td>
|
||||
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
|
||||
<td className="billing-num">
|
||||
{user.spaceQuotaBytes
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
cancelWechatDigest,
|
||||
clearWechatRoute,
|
||||
@@ -6,14 +7,20 @@ import {
|
||||
getWechatAdminSummary,
|
||||
getWechatIntentRouterConfig,
|
||||
getWechatIntentRouterRuntime,
|
||||
getWechatNewsMorningDraftConfig,
|
||||
getWechatNewsMorningDraftTodayStatus,
|
||||
listAdminUsers,
|
||||
listLlmProviderKeys,
|
||||
listWechatBindings,
|
||||
listWechatDeliveries,
|
||||
listWechatDigests,
|
||||
listWechatMessages,
|
||||
listWechatNewsMorningDraftRuns,
|
||||
listWechatWebNotifications,
|
||||
patchWechatIntentRouterConfig,
|
||||
patchWechatNewsMorningDraftConfig,
|
||||
previewWechatNewsMorningDraftToday,
|
||||
pushWechatNewsMorningDraft,
|
||||
resumeWechatDigest,
|
||||
updateWechatScheduleLlmConfig,
|
||||
} from '../../api/client';
|
||||
@@ -27,6 +34,10 @@ import type {
|
||||
WechatIntentRouterAdminConfig,
|
||||
WechatIntentRouterRuntimeState,
|
||||
WechatMessage,
|
||||
WechatNewsMorningDraftPreview,
|
||||
WechatNewsMorningDraftRun,
|
||||
WechatNewsMorningDraftTemplate,
|
||||
WechatNewsMorningDraftTodayStatus,
|
||||
WechatWebNotification,
|
||||
} from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
@@ -87,6 +98,35 @@ type IntentRouterForm = {
|
||||
canaryOpenids: string;
|
||||
};
|
||||
|
||||
type NewsMorningDraftForm = {
|
||||
enabled: boolean;
|
||||
autoPushEnabled: boolean;
|
||||
pushHour: string;
|
||||
pushMinute: string;
|
||||
timezone: string;
|
||||
sourceUserId: string;
|
||||
pageSlugPattern: string;
|
||||
author: string;
|
||||
templateVersion: string;
|
||||
};
|
||||
|
||||
function newsMorningDraftToForm(
|
||||
config: Partial<NewsMorningDraftForm> | undefined,
|
||||
template: WechatNewsMorningDraftTemplate | undefined,
|
||||
): NewsMorningDraftForm {
|
||||
return {
|
||||
enabled: Boolean(config?.enabled),
|
||||
autoPushEnabled: Boolean(config?.autoPushEnabled),
|
||||
pushHour: String(config?.pushHour ?? 6),
|
||||
pushMinute: String(config?.pushMinute ?? 0),
|
||||
timezone: String(config?.timezone ?? 'Asia/Shanghai'),
|
||||
sourceUserId: String(config?.sourceUserId ?? ''),
|
||||
pageSlugPattern: String(config?.pageSlugPattern ?? 'daily-news-*'),
|
||||
author: String(config?.author ?? 'TKMind'),
|
||||
templateVersion: String(config?.templateVersion ?? template?.version ?? '2026-09-10'),
|
||||
};
|
||||
}
|
||||
|
||||
function intentRouterToForm(
|
||||
config: WechatIntentRouterAdminConfig | undefined,
|
||||
keys: LlmProviderKeyRow[],
|
||||
@@ -146,6 +186,14 @@ export function WechatPage() {
|
||||
const [savedIntentForm, setSavedIntentForm] = useState<IntentRouterForm | null>(null);
|
||||
const [intentRuntime, setIntentRuntime] = useState<WechatIntentRouterRuntimeState | null>(null);
|
||||
const [intentSaving, setIntentSaving] = useState(false);
|
||||
const [newsDraftForm, setNewsDraftForm] = useState<NewsMorningDraftForm | null>(null);
|
||||
const [savedNewsDraftForm, setSavedNewsDraftForm] = useState<NewsMorningDraftForm | null>(null);
|
||||
const [newsDraftTemplate, setNewsDraftTemplate] = useState<WechatNewsMorningDraftTemplate | null>(null);
|
||||
const [newsDraftPreview, setNewsDraftPreview] = useState<WechatNewsMorningDraftPreview | null>(null);
|
||||
const [newsDraftTodayStatus, setNewsDraftTodayStatus] = useState<WechatNewsMorningDraftTodayStatus | null>(null);
|
||||
const [newsDraftRuns, setNewsDraftRuns] = useState<WechatNewsMorningDraftRun[]>([]);
|
||||
const [newsDraftSaving, setNewsDraftSaving] = useState(false);
|
||||
const [newsDraftWorking, setNewsDraftWorking] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -157,6 +205,11 @@ export function WechatPage() {
|
||||
return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm);
|
||||
}, [intentForm, savedIntentForm]);
|
||||
|
||||
const newsDraftDirty = useMemo(() => {
|
||||
if (!newsDraftForm || !savedNewsDraftForm) return false;
|
||||
return JSON.stringify(newsDraftForm) !== JSON.stringify(savedNewsDraftForm);
|
||||
}, [newsDraftForm, savedNewsDraftForm]);
|
||||
|
||||
const selectedIntentKey = useMemo(
|
||||
() => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null,
|
||||
[llmKeys, intentForm?.modelProviderKeyId],
|
||||
@@ -177,6 +230,8 @@ export function WechatPage() {
|
||||
nextLlmKeys,
|
||||
nextIntentConfig,
|
||||
nextIntentRuntime,
|
||||
nextNewsDraftConfig,
|
||||
nextNewsDraftRuns,
|
||||
] =
|
||||
await Promise.all([
|
||||
getWechatAdminSummary(),
|
||||
@@ -189,6 +244,8 @@ export function WechatPage() {
|
||||
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
|
||||
getWechatIntentRouterConfig().catch(() => null),
|
||||
getWechatIntentRouterRuntime().catch(() => null),
|
||||
getWechatNewsMorningDraftConfig().catch(() => null),
|
||||
listWechatNewsMorningDraftRuns(10).catch(() => [] as WechatNewsMorningDraftRun[]),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false);
|
||||
@@ -206,6 +263,13 @@ export function WechatPage() {
|
||||
setSavedIntentForm(nextForm);
|
||||
}
|
||||
setIntentRuntime(nextIntentRuntime);
|
||||
if (nextNewsDraftConfig) {
|
||||
const nextForm = newsMorningDraftToForm(nextNewsDraftConfig.config, nextNewsDraftConfig.template);
|
||||
setNewsDraftForm(nextForm);
|
||||
setSavedNewsDraftForm(nextForm);
|
||||
setNewsDraftTemplate(nextNewsDraftConfig.template);
|
||||
}
|
||||
setNewsDraftRuns(nextNewsDraftRuns);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载服务号管理失败');
|
||||
} finally {
|
||||
@@ -213,10 +277,40 @@ export function WechatPage() {
|
||||
}
|
||||
}, [deliveryStatus, digestStatus, messageStatus, notificationStatus, search]);
|
||||
|
||||
const refreshTodayNewsMorningDraft = useCallback(async (sourceUserId?: string) => {
|
||||
if (!sourceUserId?.trim()) {
|
||||
setNewsDraftTodayStatus(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getWechatNewsMorningDraftTodayStatus();
|
||||
setNewsDraftTodayStatus(status);
|
||||
if (status.todayPageReady) {
|
||||
setNewsDraftPreview(await previewWechatNewsMorningDraftToday());
|
||||
} else {
|
||||
setNewsDraftPreview(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setNewsDraftTodayStatus(null);
|
||||
if (!(err instanceof Error && /404|503/u.test(err.message))) {
|
||||
setError(err instanceof Error ? err.message : '加载今日早报失败');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newsDraftForm?.sourceUserId.trim()) {
|
||||
setNewsDraftTodayStatus(null);
|
||||
setNewsDraftPreview(null);
|
||||
return;
|
||||
}
|
||||
void refreshTodayNewsMorningDraft(newsDraftForm.sourceUserId);
|
||||
}, [newsDraftForm?.sourceUserId, refreshTodayNewsMorningDraft]);
|
||||
|
||||
const runAction = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -313,6 +407,90 @@ export function WechatPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveNewsMorningDraft = async () => {
|
||||
if (!newsDraftForm) return;
|
||||
setNewsDraftSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await patchWechatNewsMorningDraftConfig({
|
||||
enabled: newsDraftForm.enabled,
|
||||
autoPushEnabled: newsDraftForm.autoPushEnabled,
|
||||
pushHour: Number(newsDraftForm.pushHour) || 6,
|
||||
pushMinute: Number(newsDraftForm.pushMinute) || 0,
|
||||
timezone: newsDraftForm.timezone.trim() || 'Asia/Shanghai',
|
||||
sourceUserId: newsDraftForm.sourceUserId.trim() || null,
|
||||
pageSlugPattern: newsDraftForm.pageSlugPattern.trim() || 'daily-news-*',
|
||||
author: newsDraftForm.author.trim() || 'TKMind',
|
||||
});
|
||||
const nextForm = newsMorningDraftToForm(result, newsDraftTemplate ?? undefined);
|
||||
setNewsDraftForm(nextForm);
|
||||
setSavedNewsDraftForm(nextForm);
|
||||
setNotice('新闻早报草稿推送配置已保存。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setNewsDraftSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewNewsMorningDraft = async () => {
|
||||
setNewsDraftWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await getWechatNewsMorningDraftTodayStatus();
|
||||
setNewsDraftTodayStatus(status);
|
||||
if (status.todayPageReady) {
|
||||
setNewsDraftPreview(await previewWechatNewsMorningDraftToday());
|
||||
} else {
|
||||
setNewsDraftPreview(null);
|
||||
setError(status.message ?? '今日新闻早报尚未生成');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '预览失败');
|
||||
} finally {
|
||||
setNewsDraftWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushNewsMorningDraft = async () => {
|
||||
if (!newsDraftForm) return;
|
||||
if (!newsDraftForm.sourceUserId.trim()) {
|
||||
setError('请先填写来源用户 ID');
|
||||
return;
|
||||
}
|
||||
setNewsDraftWorking(true);
|
||||
setError(null);
|
||||
setNotice('正在推送到微信草稿箱,请稍候(约 5–15 秒)…');
|
||||
try {
|
||||
if (newsDraftDirty || !newsDraftForm.enabled) {
|
||||
const saved = await patchWechatNewsMorningDraftConfig({
|
||||
enabled: true,
|
||||
autoPushEnabled: newsDraftForm.autoPushEnabled,
|
||||
pushHour: Number(newsDraftForm.pushHour) || 6,
|
||||
pushMinute: Number(newsDraftForm.pushMinute) || 0,
|
||||
timezone: newsDraftForm.timezone.trim() || 'Asia/Shanghai',
|
||||
sourceUserId: newsDraftForm.sourceUserId.trim() || null,
|
||||
pageSlugPattern: newsDraftForm.pageSlugPattern.trim() || 'daily-news-*',
|
||||
author: newsDraftForm.author.trim() || 'TKMind',
|
||||
});
|
||||
const nextForm = newsMorningDraftToForm(saved, newsDraftTemplate ?? undefined);
|
||||
setNewsDraftForm(nextForm);
|
||||
setSavedNewsDraftForm(nextForm);
|
||||
}
|
||||
const result = await pushWechatNewsMorningDraft();
|
||||
setNotice(`草稿已写入微信后台,media_id:${result.draftMediaId ?? '—'}`);
|
||||
setNewsDraftPreview(result.preview ?? null);
|
||||
setNewsDraftRuns(await listWechatNewsMorningDraftRuns(10));
|
||||
await refreshTodayNewsMorningDraft(newsDraftForm.sourceUserId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '推送失败');
|
||||
setNotice(null);
|
||||
} finally {
|
||||
setNewsDraftWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveIntentRouter = async () => {
|
||||
if (!intentForm) return;
|
||||
setIntentSaving(true);
|
||||
@@ -424,6 +602,291 @@ export function WechatPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{newsDraftForm ? (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>新闻早报 · 微信草稿箱</h2>
|
||||
<p className="muted" style={{ marginTop: 6 }}>
|
||||
将 MindSpace 生成的新闻早报 HTML 转为服务号图文草稿。模板版本 {newsDraftForm.templateVersion}(0910 版式)。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handleSaveNewsMorningDraft()}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking || !newsDraftDirty}
|
||||
>
|
||||
{newsDraftSaving ? '保存中…' : newsDraftDirty ? '保存配置' : '已保存'}
|
||||
</button>
|
||||
</div>
|
||||
{newsDraftTodayStatus ? (
|
||||
<div
|
||||
className="admin-card"
|
||||
style={{ marginTop: 16, background: 'var(--panel-2, rgba(255,255,255,.03))' }}
|
||||
>
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 4 }}>今日早报 · {newsDraftTodayStatus.dateKey}</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
期望文件名:<code>{newsDraftTodayStatus.expectedSlug}.html</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void refreshTodayNewsMorningDraft(newsDraftForm.sourceUserId)}
|
||||
disabled={busy || newsDraftWorking || !newsDraftForm.sourceUserId.trim()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<dl className="admin-dl">
|
||||
<div>
|
||||
<dt>生成状态</dt>
|
||||
<dd className={newsDraftTodayStatus.todayPageReady ? 'wechat-ok' : 'text-error'}>
|
||||
{newsDraftTodayStatus.todayPageReady ? '今日页面已生成' : newsDraftTodayStatus.message ?? '待生成'}
|
||||
</dd>
|
||||
</div>
|
||||
{newsDraftTodayStatus.page ? (
|
||||
<>
|
||||
<div>
|
||||
<dt>页面</dt>
|
||||
<dd>
|
||||
<a href={newsDraftTodayStatus.page.publicUrl} target="_blank" rel="noreferrer">
|
||||
{newsDraftTodayStatus.page.slug}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新时间</dt>
|
||||
<dd>{dateLabel(newsDraftTodayStatus.page.modifiedAt)}</dd>
|
||||
</div>
|
||||
</>
|
||||
) : newsDraftTodayStatus.latestPageSlug ? (
|
||||
<div>
|
||||
<dt>最近页面</dt>
|
||||
<dd className="muted">{newsDraftTodayStatus.latestPageSlug}(非今日)</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{newsDraftTodayStatus.autoPushEnabled ? (
|
||||
<div>
|
||||
<dt>自动推送</dt>
|
||||
<dd>
|
||||
计划 {String(newsDraftTodayStatus.pushHour ?? 6).padStart(2, '0')}:
|
||||
{String(newsDraftTodayStatus.pushMinute ?? 0).padStart(2, '0')}
|
||||
{newsDraftTodayStatus.runs.push?.status === 'success'
|
||||
? ' · 今日已推送草稿'
|
||||
: newsDraftTodayStatus.runs.push?.status === 'failed'
|
||||
? ` · 推送失败:${newsDraftTodayStatus.runs.push.errorMessage ?? '—'}`
|
||||
: ' · 待推送'}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-form" style={{ marginTop: 16 }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newsDraftForm.enabled}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, enabled: event.target.checked })
|
||||
}
|
||||
/>{' '}
|
||||
启用新闻早报草稿推送
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newsDraftForm.autoPushEnabled}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking || !newsDraftForm.enabled}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, autoPushEnabled: event.target.checked })
|
||||
}
|
||||
/>{' '}
|
||||
定时自动推送(Portal 在推送时间前约 1 小时自动生成当日页面,到点写入草稿箱)
|
||||
</label>
|
||||
<label>
|
||||
推送时间(北京时间)
|
||||
<div className="wechat-toolbar" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={newsDraftForm.pushHour}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, pushHour: event.target.value })
|
||||
}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={newsDraftForm.pushMinute}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, pushMinute: event.target.value })
|
||||
}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
来源用户 ID
|
||||
<input
|
||||
value={newsDraftForm.sourceUserId}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
placeholder="a70ff537-8908-486e-9b6c-042e07cc25db"
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, sourceUserId: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
页面文件名匹配
|
||||
<input
|
||||
value={newsDraftForm.pageSlugPattern}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
placeholder="daily-news-*"
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, pageSlugPattern: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
草稿作者名(最多 8 字)
|
||||
<input
|
||||
value={newsDraftForm.author}
|
||||
maxLength={8}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, author: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{newsDraftTemplate ? (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary className="muted">0910 模板说明(taskSpec 参考)</summary>
|
||||
<pre className="admin-code-block" style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{newsDraftTemplate.spec}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<div className="wechat-toolbar" style={{ marginTop: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handlePreviewNewsMorningDraft()}
|
||||
disabled={busy || newsDraftWorking || !newsDraftForm.sourceUserId.trim()}
|
||||
>
|
||||
{newsDraftWorking ? '处理中…' : '预览今日草稿'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-btn"
|
||||
onClick={() => void handlePushNewsMorningDraft()}
|
||||
disabled={busy || newsDraftWorking || !newsDraftForm.sourceUserId.trim()}
|
||||
title={
|
||||
!newsDraftForm.sourceUserId.trim()
|
||||
? '请先填写来源用户 ID'
|
||||
: !newsDraftForm.enabled
|
||||
? '建议勾选「启用新闻早报草稿推送」后再推送'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{newsDraftWorking ? '推送中…' : '立即推送到草稿箱'}
|
||||
</button>
|
||||
</div>
|
||||
{!newsDraftForm.enabled ? (
|
||||
<p className="muted" style={{ marginTop: 8 }}>
|
||||
提示:未勾选「启用」也可尝试推送;若失败请先启用并保存配置。
|
||||
</p>
|
||||
) : null}
|
||||
{newsDraftDirty ? (
|
||||
<p className="muted" style={{ marginTop: 8 }}>
|
||||
配置有未保存改动,点击推送时会自动保存后再推送到微信。
|
||||
</p>
|
||||
) : null}
|
||||
{newsDraftPreview ? (
|
||||
<div className="admin-card" style={{ marginTop: 16, background: 'var(--panel-2, rgba(255,255,255,.03))' }}>
|
||||
<h3 style={{ marginBottom: 8 }}>草稿预览</h3>
|
||||
<dl className="admin-dl">
|
||||
<div>
|
||||
<dt>页面</dt>
|
||||
<dd>
|
||||
<a href={newsDraftPreview.page.publicUrl} target="_blank" rel="noreferrer">
|
||||
{newsDraftPreview.page.slug}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>标题</dt>
|
||||
<dd>{newsDraftPreview.article.title}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>摘要</dt>
|
||||
<dd>{newsDraftPreview.article.digest}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>正文模式</dt>
|
||||
<dd>
|
||||
{'contentMode' in newsDraftPreview.article && newsDraftPreview.article.contentMode === 'inline_html'
|
||||
? `内联 HTML(公众号排版,非长图${newsDraftPreview.article.trimmed ? ',已自动裁剪' : ''})`
|
||||
: 'HTML 正文'}
|
||||
</dd>
|
||||
</div>
|
||||
{'contentLength' in newsDraftPreview.article && newsDraftPreview.article.contentLength ? (
|
||||
<div>
|
||||
<dt>正文字符数</dt>
|
||||
<dd>{newsDraftPreview.article.contentLength.toLocaleString()} / 20,000</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{'cardCount' in newsDraftPreview.article ? (
|
||||
<div>
|
||||
<dt>卡片数</dt>
|
||||
<dd>{newsDraftPreview.article.cardCount}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
{newsDraftRuns.length > 0 ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3>最近推送记录</h3>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>状态</th>
|
||||
<th>页面</th>
|
||||
<th>draft media_id</th>
|
||||
<th>错误</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{newsDraftRuns.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td>{dateLabel(run.createdAt)}</td>
|
||||
<td className={statusClass(run.status)}>{statusLabel(run.status)}</td>
|
||||
<td>{run.pageSlug ?? '—'}</td>
|
||||
<td>{run.draftMediaId ?? '—'}</td>
|
||||
<td className={run.errorMessage ? 'text-error' : 'muted'}>{run.errorMessage ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{intentForm ? (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
@@ -559,6 +1022,18 @@ export function WechatPage() {
|
||||
</section>
|
||||
) : 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">
|
||||
<div className="admin-card-head">
|
||||
<h2>通知平台</h2>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AdminSubscription, PlanDefinition, UserSubscriptionSummary } from '../../types';
|
||||
|
||||
export function formatTokenCount(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function formatTokenQuota(limit: number, used: number) {
|
||||
if (limit === 0) {
|
||||
return `已用 ${formatTokenCount(used)} / 无限`;
|
||||
}
|
||||
const remaining = Math.max(0, limit - used);
|
||||
return `${formatTokenCount(remaining)} / ${formatTokenCount(limit)}(已用 ${formatTokenCount(used)})`;
|
||||
}
|
||||
|
||||
export function resolvePlanLabel(planType: string, plans: PlanDefinition[]) {
|
||||
return plans.find((plan) => plan.planType === planType)?.name ?? planType;
|
||||
}
|
||||
|
||||
export function subscriptionStatusLabel(status: AdminSubscription['status']) {
|
||||
if (status === 'active') return '有效';
|
||||
if (status === 'expired') return '已到期';
|
||||
if (status === 'cancelled') return '已取消';
|
||||
return status;
|
||||
}
|
||||
|
||||
export function summarizeSubscription(
|
||||
subscription: Pick<UserSubscriptionSummary, 'planType' | 'periodTokensLimit' | 'periodTokensUsed'> | null | undefined,
|
||||
fallbackPlanType?: string,
|
||||
) {
|
||||
if (!subscription) {
|
||||
return fallbackPlanType && fallbackPlanType !== 'free'
|
||||
? `${fallbackPlanType}(无有效订阅)`
|
||||
: '免费 / 无订阅';
|
||||
}
|
||||
return formatTokenQuota(subscription.periodTokensLimit, subscription.periodTokensUsed);
|
||||
}
|
||||
+189
-6
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AssetGatewayConfig,
|
||||
ImageMakeAdminConfig,
|
||||
ImageMakeAdminConfigResponse,
|
||||
AdminDashboardSummary,
|
||||
AdminServiceRestartAction,
|
||||
AdminServiceRestartResult,
|
||||
@@ -55,13 +57,28 @@ import type {
|
||||
WechatDeliveryLog,
|
||||
WechatDigestSubscription,
|
||||
WechatScheduleLlmConfig,
|
||||
WechatNewsMorningDraftConfig,
|
||||
WechatNewsMorningDraftPreview,
|
||||
WechatNewsMorningDraftRun,
|
||||
WechatNewsMorningDraftTemplate,
|
||||
WechatNewsMorningDraftTodayStatus,
|
||||
WechatIntentRouterAdminConfig,
|
||||
WechatIntentRouterConfigState,
|
||||
WechatIntentRouterRuntimeState,
|
||||
WechatCursorExecutorAdminConfig,
|
||||
WechatCursorExecutorAdminConfigState,
|
||||
WechatCursorExecutorRuntimeState,
|
||||
WechatMessage,
|
||||
WechatWebNotification,
|
||||
MindSearchConfig,
|
||||
MindSearchServiceTestResult,
|
||||
NewsEngineCollectionDetail,
|
||||
NewsEngineCollectionSummary,
|
||||
NewsEngineConfig,
|
||||
NewsEngineHealth,
|
||||
NewsEngineRankResult,
|
||||
NewsEngineRunSummary,
|
||||
NewsEngineSchedulerStatus,
|
||||
AdminTemplateCatalogItem,
|
||||
AdminTemplateCatalogPatch,
|
||||
} from '../types';
|
||||
@@ -455,6 +472,58 @@ export async function updateWechatScheduleLlmConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWechatNewsMorningDraftConfig(): Promise<{
|
||||
config: WechatNewsMorningDraftConfig;
|
||||
template: WechatNewsMorningDraftTemplate;
|
||||
}> {
|
||||
return portalFetch('/admin-api/wechat/news-morning-draft/config');
|
||||
}
|
||||
|
||||
export async function patchWechatNewsMorningDraftConfig(
|
||||
patch: Partial<Omit<WechatNewsMorningDraftConfig, 'updatedAt' | 'updatedBy' | 'templateVersion'>>,
|
||||
): Promise<WechatNewsMorningDraftConfig> {
|
||||
const result = await portalFetch<{ config: WechatNewsMorningDraftConfig }>(
|
||||
'/admin-api/wechat/news-morning-draft/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
);
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export async function previewWechatNewsMorningDraft(): Promise<WechatNewsMorningDraftPreview> {
|
||||
return portalFetch<WechatNewsMorningDraftPreview>('/admin-api/wechat/news-morning-draft/preview');
|
||||
}
|
||||
|
||||
export async function getWechatNewsMorningDraftTodayStatus(): Promise<WechatNewsMorningDraftTodayStatus> {
|
||||
return portalFetch<WechatNewsMorningDraftTodayStatus>('/admin-api/wechat/news-morning-draft/today');
|
||||
}
|
||||
|
||||
export async function previewWechatNewsMorningDraftToday(): Promise<WechatNewsMorningDraftPreview> {
|
||||
return portalFetch<WechatNewsMorningDraftPreview>('/admin-api/wechat/news-morning-draft/preview/today');
|
||||
}
|
||||
|
||||
export async function pushWechatNewsMorningDraft(options?: { dryRun?: boolean }): Promise<{
|
||||
ok?: boolean;
|
||||
dryRun?: boolean;
|
||||
draftMediaId?: string;
|
||||
preview?: WechatNewsMorningDraftPreview;
|
||||
run?: WechatNewsMorningDraftRun;
|
||||
}> {
|
||||
return portalFetch('/admin-api/wechat/news-morning-draft/push', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWechatNewsMorningDraftRuns(limit = 20): Promise<WechatNewsMorningDraftRun[]> {
|
||||
const result = await portalFetch<{ runs: WechatNewsMorningDraftRun[] }>(
|
||||
`/admin-api/wechat/news-morning-draft/runs?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
return result.runs ?? [];
|
||||
}
|
||||
|
||||
export async function getWechatIntentRouterConfig(): Promise<WechatIntentRouterAdminConfig> {
|
||||
const result = await portalFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config');
|
||||
return result.config;
|
||||
@@ -474,10 +543,56 @@ export async function getWechatIntentRouterRuntime(): Promise<WechatIntentRouter
|
||||
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> {
|
||||
return portalFetch('/admin-api/asset-gateway/config');
|
||||
}
|
||||
|
||||
export async function getImageMakeAdminConfig(): Promise<ImageMakeAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/image-make/config');
|
||||
}
|
||||
|
||||
export async function updateImageMakeAdminConfig(
|
||||
payload: Partial<ImageMakeAdminConfig> & {
|
||||
providers?: Partial<ImageMakeAdminConfig['providers']> & {
|
||||
fal_ai?: Partial<ImageMakeAdminConfig['providers']['fal_ai']> & { apiKey?: string };
|
||||
aliyun_bailian?: Partial<ImageMakeAdminConfig['providers']['aliyun_bailian']> & { apiKey?: string };
|
||||
};
|
||||
},
|
||||
): Promise<ImageMakeAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/image-make/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getImageMakeRuntimeStatus(): Promise<{
|
||||
ok: boolean;
|
||||
defaultProvider: string;
|
||||
fingerprint?: string;
|
||||
source?: string;
|
||||
updatedAt?: number | null;
|
||||
providers: ImageMakeAdminConfig['providers'];
|
||||
}> {
|
||||
return portalFetch('/admin-api/image-make/runtime');
|
||||
}
|
||||
|
||||
export async function updateAssetGatewayConfig(
|
||||
payload: Pick<AssetGatewayConfig, 'enabled'>,
|
||||
): Promise<AssetGatewayConfig> {
|
||||
@@ -589,6 +704,10 @@ export type SeoGeoCatalogResult = {
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
umami?: {
|
||||
available: boolean;
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getSeoGeoPublicationCatalog(
|
||||
@@ -857,9 +976,11 @@ export async function listAdminUsers(params?: {
|
||||
return { items: result.users ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
export async function getAdminUser(userId: string): Promise<PortalUser> {
|
||||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`);
|
||||
return result.user;
|
||||
export async function getAdminUser(userId: string): Promise<{ user: PortalUser; subscription: AdminSubscription | null }> {
|
||||
const result = await portalFetch<{ user: PortalUser; subscription?: AdminSubscription | null }>(
|
||||
`/admin-api/users/${userId}`,
|
||||
);
|
||||
return { user: result.user, subscription: result.subscription ?? null };
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: {
|
||||
@@ -887,6 +1008,7 @@ export async function updateAdminUser(
|
||||
balanceCents: number;
|
||||
spaceQuotaBytes: number;
|
||||
role: 'user' | 'admin';
|
||||
billingFormula: 'A' | 'B';
|
||||
}>,
|
||||
): Promise<PortalUser> {
|
||||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
|
||||
@@ -970,6 +1092,55 @@ export async function testMindSearchService(serviceId: string): Promise<MindSear
|
||||
return portalFetch(`/admin-api/mindsearch/services/${encodeURIComponent(serviceId)}/test`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getNewsEngineHealth(): Promise<NewsEngineHealth> {
|
||||
return portalFetch('/admin-api/news-engine/health');
|
||||
}
|
||||
|
||||
export async function getNewsEngineConfig(): Promise<NewsEngineConfig> {
|
||||
return portalFetch('/admin-api/news-engine/config');
|
||||
}
|
||||
|
||||
export async function getNewsEngineCollections(limit = 20): Promise<NewsEngineCollectionSummary[]> {
|
||||
return portalFetch(`/admin-api/news-engine/collections?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function getNewsEngineCollection(id: string): Promise<NewsEngineCollectionDetail> {
|
||||
return portalFetch(`/admin-api/news-engine/collections/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function collectNewsEngine(body: { useLlm?: boolean; groupIds?: string[] } = {}): Promise<NewsEngineCollectionDetail> {
|
||||
return portalFetch('/admin-api/news-engine/collect', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function scoreNewsEngineCollection(id: string, body: Record<string, unknown> = {}): Promise<NewsEngineCollectionDetail> {
|
||||
return portalFetch(`/admin-api/news-engine/collections/${encodeURIComponent(id)}/score`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rankNewsEngine(body: {
|
||||
groups: unknown[];
|
||||
options?: { limit?: number; minimumScore?: number };
|
||||
}): Promise<NewsEngineRankResult> {
|
||||
return portalFetch('/admin-api/news-engine/rank', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function getNewsEngineRuns(limit = 30): Promise<NewsEngineRunSummary[]> {
|
||||
return portalFetch(`/admin-api/news-engine/runs?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function getNewsEngineSchedulerStatus(): Promise<NewsEngineSchedulerStatus> {
|
||||
return portalFetch('/admin-api/news-engine/scheduler/status');
|
||||
}
|
||||
|
||||
export async function triggerNewsEngineScheduler(body: { useLlm?: boolean } = {}): Promise<NewsEngineCollectionDetail> {
|
||||
return portalFetch('/admin-api/news-engine/scheduler/trigger', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workflow Orchestrator ─────────────────────────────
|
||||
|
||||
export async function getOrchestratorConfig() {
|
||||
@@ -1409,16 +1580,28 @@ export async function syncSubscriptionPlansToProduction(): Promise<PlanSyncResul
|
||||
return result.sync;
|
||||
}
|
||||
|
||||
export async function getBillingConfig(): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config');
|
||||
export async function getBillingConfig(formula: 'A' | 'B' = 'A'): Promise<BillingAdminConfigResponse> {
|
||||
const q = formula === 'B' ? '?formula=B' : '';
|
||||
return portalFetch(`/admin-api/billing/config${q}`);
|
||||
}
|
||||
|
||||
export async function updateBillingConfig(
|
||||
config: BillingAdminConfig,
|
||||
formula: 'A' | 'B' = 'A',
|
||||
): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config', {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
|
||||
+286
@@ -18,6 +18,7 @@ export type PortalUser = {
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
tokensUsed: number;
|
||||
billingFormula?: 'A' | 'B';
|
||||
spaceQuotaBytes?: number;
|
||||
spaceUsedBytes?: number;
|
||||
spaceReservedBytes?: number;
|
||||
@@ -43,7 +44,17 @@ export type AuthStatus = {
|
||||
unrestricted?: boolean;
|
||||
};
|
||||
|
||||
export type UserSubscriptionSummary = {
|
||||
planType: string;
|
||||
status: 'active' | 'expired' | 'cancelled';
|
||||
periodTokensLimit: number;
|
||||
periodTokensUsed: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type AdminUserRow = PortalUser & {
|
||||
billingFormula?: 'A' | 'B';
|
||||
subscription?: UserSubscriptionSummary | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -222,6 +233,38 @@ export type AssetGatewayConfig = {
|
||||
plugins: AssetPluginConfig[];
|
||||
};
|
||||
|
||||
export type ImageMakeProviderConfig = {
|
||||
enabled: boolean;
|
||||
model?: string;
|
||||
apiBase?: string;
|
||||
apiKeyConfigured?: boolean;
|
||||
apiKeyMasked?: string;
|
||||
numInferenceSteps?: number;
|
||||
checkpoint?: string;
|
||||
workflowPath?: string;
|
||||
};
|
||||
|
||||
export type ImageMakeAdminConfig = {
|
||||
defaultProvider: string;
|
||||
jobDefaultTimeoutSeconds: number;
|
||||
providers: {
|
||||
mock: ImageMakeProviderConfig;
|
||||
aliyun_bailian: ImageMakeProviderConfig & {
|
||||
dashScopeSource?: string | null;
|
||||
dashScopeKeyName?: string | null;
|
||||
};
|
||||
comfyui: ImageMakeProviderConfig;
|
||||
fal_ai: ImageMakeProviderConfig;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageMakeAdminConfigResponse = {
|
||||
config: ImageMakeAdminConfig;
|
||||
source: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
|
||||
|
||||
export type AdminServiceRestartResult = {
|
||||
@@ -319,6 +362,82 @@ export type WechatScheduleLlmConfig = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftConfig = {
|
||||
enabled: boolean;
|
||||
autoPushEnabled: boolean;
|
||||
pushHour: number;
|
||||
pushMinute: number;
|
||||
timezone: string;
|
||||
sourceUserId: string | null;
|
||||
pageSlugPattern: string;
|
||||
author: string;
|
||||
templateVersion: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftTemplate = {
|
||||
version: string;
|
||||
spec: string;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftPreview = {
|
||||
config: WechatNewsMorningDraftConfig;
|
||||
page: {
|
||||
slug: string;
|
||||
relativePath: string;
|
||||
publicUrl: string;
|
||||
modifiedAt: number;
|
||||
hasThumb: boolean;
|
||||
isTodayPage?: boolean;
|
||||
dateKey?: string;
|
||||
expectedSlug?: string;
|
||||
};
|
||||
article: {
|
||||
title: string;
|
||||
digest: string;
|
||||
content: string;
|
||||
contentSourceUrl?: string;
|
||||
contentMode?: 'inline_html' | 'html' | string;
|
||||
contentLength?: number;
|
||||
cardCount?: number;
|
||||
statsCount?: number;
|
||||
trimmed?: boolean;
|
||||
trimLevel?: number;
|
||||
};
|
||||
template: WechatNewsMorningDraftTemplate;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftRun = {
|
||||
id: string;
|
||||
status: string;
|
||||
pageSlug: string | null;
|
||||
pageUrl: string | null;
|
||||
draftMediaId: string | null;
|
||||
errorMessage: string | null;
|
||||
triggeredBy: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftTodayStatus = {
|
||||
configured: boolean;
|
||||
dateKey: string;
|
||||
expectedSlug: string;
|
||||
todayPageReady: boolean;
|
||||
page: WechatNewsMorningDraftPreview['page'] | null;
|
||||
latestPageSlug?: string | null;
|
||||
autoGenerateEnabled?: boolean;
|
||||
autoPushEnabled?: boolean;
|
||||
pushHour?: number;
|
||||
pushMinute?: number;
|
||||
generateLeadMinutes?: number;
|
||||
message?: string | null;
|
||||
runs: {
|
||||
generate: WechatNewsMorningDraftRun | null;
|
||||
push: WechatNewsMorningDraftRun | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type WechatIntentRouterAdminConfig = {
|
||||
enabled: boolean;
|
||||
shadowMode: boolean;
|
||||
@@ -344,6 +463,53 @@ export type WechatIntentRouterRuntimeState = {
|
||||
config: WechatIntentRouterAdminConfig;
|
||||
};
|
||||
|
||||
export type CursorChannelFeatureKey =
|
||||
| 'pageGenerate'
|
||||
| 'pageData'
|
||||
| 'excelAnalysis'
|
||||
| 'chatBridge'
|
||||
| 'scheduledTasks';
|
||||
|
||||
export type CursorChannelFeatures = Record<
|
||||
CursorChannelFeatureKey,
|
||||
{ enabled: boolean }
|
||||
>;
|
||||
|
||||
export type WechatCursorExecutorAdminConfig = {
|
||||
enabled: boolean;
|
||||
userAllowlist: string[];
|
||||
channelAllowlist: string[];
|
||||
intentAllowlist: string[];
|
||||
features?: CursorChannelFeatures;
|
||||
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[];
|
||||
features?: CursorChannelFeatures;
|
||||
fallbackToDeepseek: boolean;
|
||||
source?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MindSpaceSeoGeoConfig = {
|
||||
enabled: boolean;
|
||||
seo: {
|
||||
@@ -676,8 +842,20 @@ export type BillingAdminConfig = {
|
||||
costEstimateOutputUsdPer1M: number;
|
||||
};
|
||||
|
||||
export type BillingFormulaKey = 'A' | 'B';
|
||||
|
||||
export type BillingFormulaMeta = {
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type BillingAdminConfigResponse = {
|
||||
config: BillingAdminConfig;
|
||||
formulas?: Record<BillingFormulaKey, BillingAdminConfig>;
|
||||
formulaMeta?: Record<BillingFormulaKey, BillingFormulaMeta>;
|
||||
activeFormula?: BillingFormulaKey;
|
||||
defaultFormula?: BillingFormulaKey;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
@@ -722,6 +900,7 @@ export type AdminSubscription = {
|
||||
periodImagesLimit: number;
|
||||
periodImagesUsed: number;
|
||||
periodImagesBonus?: number;
|
||||
autoRenew?: boolean;
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
@@ -783,6 +962,113 @@ export type MindSearchRoutes = {
|
||||
research: string;
|
||||
};
|
||||
|
||||
export type NewsEngineHealth = {
|
||||
ok: boolean;
|
||||
service?: string;
|
||||
};
|
||||
|
||||
export type NewsEngineSchedulerStatus = {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
useLlm: boolean;
|
||||
running: boolean;
|
||||
lastRunAt: number | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
lastCollectionId: string | null;
|
||||
lastSelectedCount: number | null;
|
||||
lastSource: string | null;
|
||||
nextRunAt: number | null;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type NewsEngineConfig = {
|
||||
service?: string;
|
||||
publicBaseUrl?: string;
|
||||
ranking?: {
|
||||
dailyLimit: number;
|
||||
minimumScore: number;
|
||||
};
|
||||
collect?: {
|
||||
limit: number;
|
||||
concurrency: number;
|
||||
timezone: string;
|
||||
engines: string;
|
||||
};
|
||||
scheduler?: Pick<NewsEngineSchedulerStatus, 'enabled' | 'intervalMs' | 'useLlm'>;
|
||||
media?: {
|
||||
enabled: boolean;
|
||||
root: string;
|
||||
publicBaseUrl: string;
|
||||
};
|
||||
llm: {
|
||||
enabled: boolean;
|
||||
model: string | null;
|
||||
keyName: string | null;
|
||||
providerLabel: string | null;
|
||||
source: string | null;
|
||||
maxEvents: number | null;
|
||||
reason?: string | null;
|
||||
};
|
||||
searxngConfigured: boolean;
|
||||
searxngEndpoint?: string | null;
|
||||
};
|
||||
|
||||
export type NewsEngineRunSummary = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
candidateCount: number;
|
||||
eventCount: number;
|
||||
selectedCount: number;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type NewsEngineCollectionSummary = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
dateLabel: string;
|
||||
endpoint: string;
|
||||
status: string;
|
||||
candidateCount: number;
|
||||
eventCount: number;
|
||||
selectedCount: number;
|
||||
articleCount: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type NewsEngineSelectedItem = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
canonicalTitle?: string;
|
||||
url?: string;
|
||||
score?: number;
|
||||
finalScore?: number;
|
||||
category?: string;
|
||||
groupTitle?: string;
|
||||
primaryArticle?: {
|
||||
title?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
sourceDomain?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type NewsEngineRankResult = {
|
||||
candidateCount?: number;
|
||||
eventCount?: number;
|
||||
selected?: NewsEngineSelectedItem[];
|
||||
runId?: string | null;
|
||||
};
|
||||
|
||||
export type NewsEngineCollectionDetail = NewsEngineCollectionSummary & {
|
||||
groups?: Array<{ id?: string; title?: string; results?: unknown[] }>;
|
||||
ranking?: NewsEngineRankResult;
|
||||
scoring?: { assessedCount?: number; skipped?: boolean; reason?: string | null };
|
||||
collectionId?: string;
|
||||
articles?: unknown[];
|
||||
assessments?: unknown[];
|
||||
};
|
||||
|
||||
export type MindSearchServiceTestResult = {
|
||||
ok: boolean;
|
||||
serviceId: string;
|
||||
|
||||
Reference in New Issue
Block a user