Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdc234e5d0 | |||
| 6542a43495 | |||
| e9ea55cf5e | |||
| 421e204711 | |||
| e9cecf8733 | |||
| dedf8833d3 | |||
| 753c1eec23 | |||
| 62fbea0936 | |||
| 871688550e | |||
| 6b586e0951 |
+3
-3
@@ -215,7 +215,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
|||||||
# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。
|
# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。
|
||||||
ADMIN_PORT=8082
|
ADMIN_PORT=8082
|
||||||
ADMIN_HOST=127.0.0.1
|
ADMIN_HOST=127.0.0.1
|
||||||
ADMIN_PUBLIC_HOST=gadm.tkmind.cn
|
ADMIN_PUBLIC_HOST=md.tkmind.cn
|
||||||
|
|
||||||
# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。
|
# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。
|
||||||
# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。
|
# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。
|
||||||
@@ -223,9 +223,9 @@ ADMIN_PUBLIC_HOST=gadm.tkmind.cn
|
|||||||
|
|
||||||
# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。
|
# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。
|
||||||
# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。
|
# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。
|
||||||
# ADMIN_API_ALLOWED_HOSTS=gadm.tkmind.cn
|
# ADMIN_API_ALLOWED_HOSTS=md.tkmind.cn
|
||||||
# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1
|
# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1
|
||||||
# OPS_API_ALLOWED_HOSTS=gadm.tkmind.cn
|
# OPS_API_ALLOWED_HOSTS=md.tkmind.cn
|
||||||
# OPS_API_IP_ALLOWLIST=
|
# OPS_API_IP_ALLOWLIST=
|
||||||
|
|
||||||
# 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD)
|
# 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc`
|
|||||||
|
|
||||||
## 必读:管理后台 UI 边界(memind_adm 5174)
|
## 必读:管理后台 UI 边界(memind_adm 5174)
|
||||||
|
|
||||||
**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 gadm)开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。**
|
**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 [md.tkmind.cn](https://md.tkmind.cn))开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。**
|
||||||
|
|
||||||
| 层级 | 位置 | 说明 |
|
| 层级 | 位置 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ test('parseList splits, trims, and drops empties', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => {
|
test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => {
|
||||||
assert.equal(hostAllowed('gadm.tkmind.cn:8082', []), true);
|
assert.equal(hostAllowed('md.tkmind.cn:8082', []), true);
|
||||||
assert.equal(hostAllowed('GADM.tkmind.cn', ['gadm.tkmind.cn']), true);
|
assert.equal(hostAllowed('MD.tkmind.cn', ['md.tkmind.cn']), true);
|
||||||
assert.equal(hostAllowed('evil.example.com', ['gadm.tkmind.cn']), false);
|
assert.equal(hostAllowed('evil.example.com', ['md.tkmind.cn']), false);
|
||||||
assert.equal(hostAllowed('gadm.tkmind.cn:443', ['gadm.tkmind.cn', 'localhost']), true);
|
assert.equal(hostAllowed('md.tkmind.cn:443', ['md.tkmind.cn', 'localhost']), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ipMatches: exact and IPv4-mapped IPv6', () => {
|
test('ipMatches: exact and IPv4-mapped IPv6', () => {
|
||||||
@@ -48,7 +48,7 @@ test('buildNetworkGuard returns null when nothing configured', () => {
|
|||||||
|
|
||||||
test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
|
test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
|
||||||
const guard = buildNetworkGuard({
|
const guard = buildNetworkGuard({
|
||||||
allowedHosts: ['gadm.tkmind.cn'],
|
allowedHosts: ['md.tkmind.cn'],
|
||||||
ipAllowlist: ['10.0.0.0/8'],
|
ipAllowlist: ['10.0.0.0/8'],
|
||||||
onDeny: (_res, _req, reason) => `deny:${reason}`,
|
onDeny: (_res, _req, reason) => `deny:${reason}`,
|
||||||
});
|
});
|
||||||
@@ -60,8 +60,8 @@ test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host');
|
assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host');
|
||||||
assert.equal(guard(mk('gadm.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
|
assert.equal(guard(mk('md.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
|
||||||
assert.equal(nexted, false);
|
assert.equal(nexted, false);
|
||||||
guard(mk('gadm.tkmind.cn', '10.1.1.1'), {}, next);
|
guard(mk('md.tkmind.cn', '10.1.1.1'), {}, next);
|
||||||
assert.equal(nexted, true);
|
assert.equal(nexted, true);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
// Serves the platform super-admin API (/admin-api/*) and the plaza operations
|
// Serves the platform super-admin API (/admin-api/*) and the plaza operations
|
||||||
// console API (/api/ops/v1/*) on its own port, isolated from the public
|
// console API (/api/ops/v1/*) on its own port, isolated from the public
|
||||||
// user-facing server (server.mjs). In production this is fronted by
|
// user-facing server (server.mjs). In production this is fronted by
|
||||||
// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
|
// md.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
|
||||||
//
|
//
|
||||||
// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login
|
// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login
|
||||||
// continues to happen on the main app domain — this process only reads the
|
// continues to happen on the main app domain — this process only reads the
|
||||||
@@ -43,7 +43,7 @@ loadEnvFile(path.join(__dirname, '.env'));
|
|||||||
|
|
||||||
const PORT = Number(process.env.ADMIN_PORT ?? 8082);
|
const PORT = Number(process.env.ADMIN_PORT ?? 8082);
|
||||||
const HOST = process.env.ADMIN_HOST ?? '127.0.0.1';
|
const HOST = process.env.ADMIN_HOST ?? '127.0.0.1';
|
||||||
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn';
|
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'md.tkmind.cn';
|
||||||
|
|
||||||
const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE];
|
const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE];
|
||||||
|
|
||||||
|
|||||||
@@ -606,6 +606,14 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) {
|
|||||||
envs[key] = String(value).trim();
|
envs[key] = String(value).trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// goosed stdio MCPs do not inherit Portal env; forward worker flags so
|
||||||
|
// scheduled_task_create can report the same enablement as server.mjs.
|
||||||
|
for (const key of ['H5_REMINDER_WORKER_ENABLED', 'H5_SCHEDULED_TASK_WORKER_ENABLED']) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value != null && String(value).trim() !== '') {
|
||||||
|
envs[key] = String(value).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (mcpTools.includes('generate_image')) {
|
if (mcpTools.includes('generate_image')) {
|
||||||
if (sandboxMcp.agentApiBaseUrl) {
|
if (sandboxMcp.agentApiBaseUrl) {
|
||||||
envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl;
|
envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl;
|
||||||
|
|||||||
@@ -507,6 +507,31 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
|
|||||||
assert.ok(!allTools.includes('shell'), 'shell should not be exposed');
|
assert.ok(!allTools.includes('shell'), 'shell should not be exposed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sandboxMcpEnvs forwards scheduled task worker flags from portal env', () => {
|
||||||
|
const previousReminder = process.env.H5_REMINDER_WORKER_ENABLED;
|
||||||
|
const previousScheduled = process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
|
||||||
|
process.env.H5_REMINDER_WORKER_ENABLED = '1';
|
||||||
|
delete process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
|
||||||
|
try {
|
||||||
|
const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true };
|
||||||
|
const policy = buildAgentExtensionPolicy(caps, {
|
||||||
|
sandboxMcp: {
|
||||||
|
serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs',
|
||||||
|
sandboxRoot: '/opt/h5/MindSpace/abc123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
|
||||||
|
assert.ok(sandboxExt);
|
||||||
|
assert.equal(sandboxExt.envs.H5_REMINDER_WORKER_ENABLED, '1');
|
||||||
|
assert.equal(sandboxExt.envs.H5_SCHEDULED_TASK_WORKER_ENABLED, undefined);
|
||||||
|
} finally {
|
||||||
|
if (previousReminder === undefined) delete process.env.H5_REMINDER_WORKER_ENABLED;
|
||||||
|
else process.env.H5_REMINDER_WORKER_ENABLED = previousReminder;
|
||||||
|
if (previousScheduled === undefined) delete process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
|
||||||
|
else process.env.H5_SCHEDULED_TASK_WORKER_ENABLED = previousScheduled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('sandboxMcp emits a scoped logical workspace token only for a bound session', () => {
|
test('sandboxMcp emits a scoped logical workspace token only for a bound session', () => {
|
||||||
const secret =
|
const secret =
|
||||||
'mindspace-mcp-policy-secret-1234';
|
'mindspace-mcp-policy-secret-1234';
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Memind 公开页与生成页的真实用户访问统计 **仅使用 Umami**(`m
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 1 | memind-analytics | `bash scripts/release-umami-105.sh --yes` |
|
| 1 | memind-analytics | `bash scripts/release-umami-105.sh --yes` |
|
||||||
| 2 | Memind(103 Portal) | `bash scripts/release-portal-runtime-prod.sh --yes` |
|
| 2 | Memind(103 Portal) | `bash scripts/release-portal-runtime-prod.sh --yes` |
|
||||||
| 3 | memind_adm(103 gadm) | `bash scripts/release-prod.sh --yes` |
|
| 3 | memind_adm(103 · md.tkmind.cn) | `bash scripts/release-prod.sh --yes` |
|
||||||
|
|
||||||
105 Umami 细节见 memind-analytics 仓库 `deploy/105-runbook.md`。
|
105 Umami 细节见 memind-analytics 仓库 `deploy/105-runbook.md`。
|
||||||
gadm Analytics 环境变量见 memind_adm `docs/analytics-deploy.md`。
|
md.tkmind.cn Analytics 环境变量见 memind_adm `docs/analytics-deploy.md`。
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ memind_adm SEO/GEO 看板
|
|||||||
| 2 | **Memind** | 改了 discovery 埋点、Umami 代理、公开页 delivery | `bash scripts/release-portal-runtime-prod.sh` |
|
| 2 | **Memind** | 改了 discovery 埋点、Umami 代理、公开页 delivery | `bash scripts/release-portal-runtime-prod.sh` |
|
||||||
| 3 | **memind_adm** | 改了 SEO/GEO 看板、Analytics 配置页、Umami 代理 | `bash scripts/release-prod.sh --yes` |
|
| 3 | **memind_adm** | 改了 SEO/GEO 看板、Analytics 配置页、Umami 代理 | `bash scripts/release-prod.sh --yes` |
|
||||||
|
|
||||||
只改 overlay / API 时,发 **105** 即可;只改 adm 看板 UI 时,发 **103 gadm** 即可。
|
只改 overlay / API 时,发 **105** 即可;只改 adm 看板 UI 时,发 **103 md.tkmind.cn(memind_adm)** 即可。
|
||||||
Memind discovery 字段(`discovery_channel` 等)未上线前,adm 看板可能暂无 SEO/GEO 分列数据。
|
Memind discovery 字段(`discovery_channel` 等)未上线前,adm 看板可能暂无 SEO/GEO 分列数据。
|
||||||
|
|
||||||
## 1. 105 Umami(memind-analytics)
|
## 1. 105 Umami(memind-analytics)
|
||||||
@@ -92,22 +92,20 @@ bash scripts/release-prod.sh --yes
|
|||||||
### 生产 `.env`(Analytics 相关)
|
### 生产 `.env`(Analytics 相关)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# SEO/GEO 看板拉 Umami memind-pages API 所需
|
# SEO/GEO 看板拉 Umami memind-pages API 所需(105 SSO-only 时用 MEMIND_UMAMI_SSO_SECRET)
|
||||||
UMAMI_ADMIN_PASSWORD=<与 Umami admin 账号一致>
|
|
||||||
UMAMI_URL=https://fxi.tkmind.cn # 可选,默认 http://127.0.0.1:3100
|
|
||||||
UMAMI_WEBSITE_ID=<Memind Generated Pages 的 Website ID>
|
|
||||||
|
|
||||||
# Umami SSO(Analytics 配置页「打开 Umami」)
|
|
||||||
MEMIND_UMAMI_SSO_SECRET=<与 105 .env 一致>
|
MEMIND_UMAMI_SSO_SECRET=<与 105 .env 一致>
|
||||||
UMAMI_SSO_USERNAME=admin
|
UMAMI_SSO_USERNAME=admin
|
||||||
|
UMAMI_URL=https://fxi.tkmind.cn # 可选,默认 http://127.0.0.1:3100
|
||||||
|
UMAMI_WEBSITE_ID=<Memind 公共页面 Website ID>
|
||||||
|
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
|
||||||
```
|
```
|
||||||
|
|
||||||
103 需能访问 105 Umami(内网或配置的 `UMAMI_URL`)。
|
103 需能访问 105 Umami(内网或配置的 `UMAMI_URL`)。
|
||||||
|
|
||||||
### 验收
|
### 验收
|
||||||
|
|
||||||
1. gadm → **Analytics 配置** → 「打开 Umami 分析后台」可 SSO 登录
|
1. [md.tkmind.cn/analytics](https://md.tkmind.cn/analytics) → **Analytics 配置** → 「打开 Umami 分析后台」可 SSO 登录
|
||||||
2. gadm → **SEO / GEO 流量** → 表格加载无 `未配置 UMAMI_ADMIN_PASSWORD`
|
2. [md.tkmind.cn/analytics/seo-geo](https://md.tkmind.cn/analytics/seo-geo) → **SEO / GEO 流量** → 表格加载无 SSO/凭证错误
|
||||||
3. 抽样对比 Umami「所有生成页面明细」与 adm 看板列口径一致
|
3. 抽样对比 Umami「所有生成页面明细」与 adm 看板列口径一致
|
||||||
|
|
||||||
详见 memind_adm [`docs/analytics-deploy.md`](../memind_adm/docs/analytics-deploy.md)。
|
详见 memind_adm [`docs/analytics-deploy.md`](../memind_adm/docs/analytics-deploy.md)。
|
||||||
@@ -130,4 +128,4 @@ UMAMI_SSO_USERNAME=admin
|
|||||||
| [local-analytics.md](local-analytics.md) | 本地 Umami 与 Memind env |
|
| [local-analytics.md](local-analytics.md) | 本地 Umami 与 Memind env |
|
||||||
| [105-server-operations.md](105-server-operations.md) | 105 运维边界(含 `/opt/memind-analytics`) |
|
| [105-server-operations.md](105-server-operations.md) | 105 运维边界(含 `/opt/memind-analytics`) |
|
||||||
| memind-analytics `README.md` | Umami 仓库运维与 overlay |
|
| memind-analytics `README.md` | Umami 仓库运维与 overlay |
|
||||||
| memind_adm `docs/analytics-deploy.md` | gadm Analytics / SEO·GEO 配置 |
|
| memind_adm `docs/analytics-deploy.md` | md.tkmind.cn Analytics / SEO·GEO 配置 |
|
||||||
|
|||||||
@@ -13,6 +13,33 @@
|
|||||||
- 远端功能分支默认在闭环后删除;发布和审计依据使用 `main` commit、tag、CI 记录或 runtime manifest,不使用功能分支名。
|
- 远端功能分支默认在闭环后删除;发布和审计依据使用 `main` commit、tag、CI 记录或 runtime manifest,不使用功能分支名。
|
||||||
- 这类保留分支必须在本文件登记清楚:分支名、最终 HEAD、进入 `origin/main` 的提交、验证摘要,以及“禁止再次引用”的状态。
|
- 这类保留分支必须在本文件登记清楚:分支名、最终 HEAD、进入 `origin/main` 的提交、验证摘要,以及“禁止再次引用”的状态。
|
||||||
|
|
||||||
|
## `feature/md-admin-domain`(Memind + memind_adm)
|
||||||
|
|
||||||
|
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||||
|
|
||||||
|
审计日期:2026-08-11
|
||||||
|
分支 HEAD:`8716885`(Memind)、`0da7f04`(memind_adm)
|
||||||
|
`origin/main` 对应提交:同上
|
||||||
|
|
||||||
|
### 原始用途
|
||||||
|
|
||||||
|
- 管理后台公网域名从 `gadm.tkmind.cn` 更正为 `md.tkmind.cn`
|
||||||
|
- 文档与 nginx 示例明确 **105 反代 → 103 memind_adm**(5174/8085)
|
||||||
|
- memind_adm 默认 `VITE_BASE_PATH=/`,使 `/analytics/seo-geo` 在根路径可访问
|
||||||
|
|
||||||
|
### 生产发布
|
||||||
|
|
||||||
|
| 仓库 | release_id |
|
||||||
|
|------|------------|
|
||||||
|
| memind_adm | `20260810-173922-0da7f04` |
|
||||||
|
| 105 nginx | `md.tkmind.cn.conf` 已 reload |
|
||||||
|
|
||||||
|
### 验证摘要
|
||||||
|
|
||||||
|
- `https://md.tkmind.cn/analytics/seo-geo` → 200
|
||||||
|
- 103 `8085/health` → ok;5174 `base=/`
|
||||||
|
- `/ops/analytics/seo-geo` → 301 到 `/analytics/seo-geo`
|
||||||
|
|
||||||
## `feature/fix-rybbit-publication-tracking`
|
## `feature/fix-rybbit-publication-tracking`
|
||||||
|
|
||||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||||
@@ -479,3 +506,24 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
|
|||||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||||
- 后续 Analytics 发布以 `docs/analytics-release-runbook.md` 为准。
|
- 后续 Analytics 发布以 `docs/analytics-release-runbook.md` 为准。
|
||||||
|
|
||||||
|
## `feature/subscription-usage-records`
|
||||||
|
|
||||||
|
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||||
|
|
||||||
|
审计日期:2026-08-12
|
||||||
|
分支 HEAD:`6b586e0`
|
||||||
|
`origin/main` 对应提交:`dedf883`(merge)
|
||||||
|
|
||||||
|
### 原始用途
|
||||||
|
|
||||||
|
订阅额度全额覆盖 token 消耗时,仍写入 usage record,便于审计与对账。
|
||||||
|
|
||||||
|
### 验证摘要
|
||||||
|
|
||||||
|
- `node --test user-auth.test.mjs`:19/19 通过
|
||||||
|
|
||||||
|
### 最终处置
|
||||||
|
|
||||||
|
- 保留本地分支名用于审计追溯。
|
||||||
|
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ function matchHostRule(hostname, rules = []) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classifyReferrerHost(hostname) {
|
||||||
|
const host = normalizeHostname(hostname);
|
||||||
|
if (!host) {
|
||||||
|
return { discovery_channel: 'direct', discovery_source: 'direct' };
|
||||||
|
}
|
||||||
|
const geoSource = matchHostRule(host, GEO_HOST_RULES);
|
||||||
|
if (geoSource) {
|
||||||
|
return { discovery_channel: 'geo', discovery_source: geoSource };
|
||||||
|
}
|
||||||
|
const seoSource = matchHostRule(host, SEO_HOST_RULES);
|
||||||
|
if (seoSource) {
|
||||||
|
return { discovery_channel: 'seo', discovery_source: seoSource };
|
||||||
|
}
|
||||||
|
return { discovery_channel: 'referral', discovery_source: 'other' };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDiscoveryChannel(value) {
|
function normalizeDiscoveryChannel(value) {
|
||||||
const channel = String(value ?? '').trim().toLowerCase();
|
const channel = String(value ?? '').trim().toLowerCase();
|
||||||
return channel === 'seo' || channel === 'geo' ? channel : '';
|
return channel === 'seo' || channel === 'geo' ? channel : '';
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { enrichPagesWithAchievementStats } from './mindspace-page-achievement-stats.mjs';
|
||||||
|
|
||||||
|
export const DEFAULT_ACHIEVEMENT_LIMIT = 20;
|
||||||
|
|
||||||
|
export function parseAchievementQuery(query = {}) {
|
||||||
|
const limit = Number.parseInt(String(query.limit ?? ''), 10);
|
||||||
|
const offset = Number.parseInt(String(query.offset ?? ''), 10);
|
||||||
|
const includeStatsRaw = String(query.include_stats ?? '0').trim().toLowerCase();
|
||||||
|
const includeStats = includeStatsRaw === '1' || includeStatsRaw === 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: typeof query.status === 'string' ? query.status : undefined,
|
||||||
|
categoryCode: typeof query.category_code === 'string' ? query.category_code : undefined,
|
||||||
|
limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 100) : DEFAULT_ACHIEVEMENT_LIMIT,
|
||||||
|
offset: Number.isFinite(offset) ? Math.max(offset, 0) : 0,
|
||||||
|
includeStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAchievementPagesForUser({
|
||||||
|
pages,
|
||||||
|
userId,
|
||||||
|
query = {},
|
||||||
|
pool = null,
|
||||||
|
logger = console,
|
||||||
|
} = {}) {
|
||||||
|
if (!pages || typeof pages.listPages !== 'function') {
|
||||||
|
throw new Error('MindSpace page service unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAchievementQuery(query);
|
||||||
|
const result = await pages.listPages(userId, {
|
||||||
|
status: parsed.status,
|
||||||
|
categoryCode: parsed.categoryCode,
|
||||||
|
limit: parsed.limit,
|
||||||
|
offset: parsed.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = await enrichPagesWithAchievementStats(result.items, {
|
||||||
|
ownerUserId: userId,
|
||||||
|
pool,
|
||||||
|
logger,
|
||||||
|
skipUmami: !parsed.includeStats,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: result.total,
|
||||||
|
limit: result.limit,
|
||||||
|
offset: result.offset,
|
||||||
|
hasMore: Boolean(result.hasMore ?? result.offset + result.items.length < result.total),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { parseAchievementQuery } from './mindspace-page-achievement-list.mjs';
|
||||||
|
|
||||||
|
test('parseAchievementQuery defaults to fast paginated listing', () => {
|
||||||
|
const parsed = parseAchievementQuery({});
|
||||||
|
assert.equal(parsed.limit, 20);
|
||||||
|
assert.equal(parsed.offset, 0);
|
||||||
|
assert.equal(parsed.includeStats, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
pseudonymizeAnalyticsId,
|
||||||
|
resolveAnalyticsIdentity,
|
||||||
|
resolveMindSpaceAnalyticsConfig,
|
||||||
|
} from './mindspace-analytics.mjs';
|
||||||
|
|
||||||
|
const TOKEN_TTL_MS = 55 * 60 * 1000;
|
||||||
|
let cachedToken = '';
|
||||||
|
let cachedTokenAt = 0;
|
||||||
|
let cachedBaseUrl = '';
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value = '') {
|
||||||
|
return String(value ?? '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUmamiCredentials(env = process.env, analyticsConfig = resolveMindSpaceAnalyticsConfig(env)) {
|
||||||
|
const baseUrl = normalizeBaseUrl(analyticsConfig?.analyticsUrl || env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100');
|
||||||
|
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
|
||||||
|
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
|
||||||
|
const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
|
||||||
|
const websiteId = String(analyticsConfig?.websiteId || env.MEMIND_ANALYTICS_WEBSITE_ID || '').trim();
|
||||||
|
return { baseUrl, username, password, sharedSecret, websiteId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
|
||||||
|
const encoded = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
username,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60,
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
}),
|
||||||
|
).toString('base64url');
|
||||||
|
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||||
|
const ticket = `${encoded}.${signature}`;
|
||||||
|
const response = await fetch(`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`, {
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
|
||||||
|
}
|
||||||
|
const token = String(body?.token ?? '').trim();
|
||||||
|
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginUmami(credentials) {
|
||||||
|
if (credentials.sharedSecret) {
|
||||||
|
return loginUmamiViaSso(credentials);
|
||||||
|
}
|
||||||
|
if (!credentials.password) {
|
||||||
|
throw new Error('未配置 Umami 凭据');
|
||||||
|
}
|
||||||
|
const response = await fetch(`${credentials.baseUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: credentials.username, password: credentials.password }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.message || `Umami 登录失败 (${response.status})`);
|
||||||
|
}
|
||||||
|
const token = String(body?.token ?? '').trim();
|
||||||
|
if (!token) throw new Error('Umami 登录响应缺少 token');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUmamiAuthToken(credentials) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedToken && cachedBaseUrl === credentials.baseUrl && now - cachedTokenAt < TOKEN_TTL_MS) {
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
cachedToken = await loginUmami(credentials);
|
||||||
|
cachedTokenAt = now;
|
||||||
|
cachedBaseUrl = credentials.baseUrl;
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathnameFromUrl(value) {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
return new URL(raw, 'https://m.tkmind.cn').pathname.replace(/\/$/, '') || '/';
|
||||||
|
} catch {
|
||||||
|
return raw.startsWith('/') ? raw.replace(/\/$/, '') || '/' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageMatchKeys(page, userId) {
|
||||||
|
const keys = new Set();
|
||||||
|
if (page?.id) keys.add(String(page.id));
|
||||||
|
const workspacePath = String(page?.workspaceRelativePath ?? '').trim().replace(/^\/+/, '');
|
||||||
|
if (workspacePath) {
|
||||||
|
keys.add(`/MindSpace/${userId}/${workspacePath}`.replace(/\/$/, ''));
|
||||||
|
}
|
||||||
|
for (const candidate of [page?.publicationUrl, page?.workspacePublicUrl]) {
|
||||||
|
const pathname = pathnameFromUrl(candidate);
|
||||||
|
if (pathname) keys.add(pathname);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAchievementStatsForPage(page, umamiByPath, userId) {
|
||||||
|
const publicationViews = Number(page?.viewCount ?? 0);
|
||||||
|
const keys = buildPageMatchKeys(page, userId);
|
||||||
|
let umamiViews = 0;
|
||||||
|
let umamiClicks = 0;
|
||||||
|
let matched = false;
|
||||||
|
for (const key of keys) {
|
||||||
|
const stats = umamiByPath.get(key);
|
||||||
|
if (!stats) continue;
|
||||||
|
matched = true;
|
||||||
|
umamiViews = Math.max(umamiViews, Number(stats.views ?? 0));
|
||||||
|
umamiClicks = Math.max(umamiClicks, Number(stats.clicks ?? 0));
|
||||||
|
}
|
||||||
|
const viewCount = matched ? Math.max(publicationViews, umamiViews) : publicationViews;
|
||||||
|
const clickCount = matched ? umamiClicks : null;
|
||||||
|
let statsSource = 'none';
|
||||||
|
if (matched && (umamiViews > 0 || umamiClicks > 0)) statsSource = 'umami';
|
||||||
|
else if (publicationViews > 0) statsSource = 'publication';
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
viewCount,
|
||||||
|
clickCount,
|
||||||
|
statsSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUmamiPageStatsByPath({
|
||||||
|
ownerUserId,
|
||||||
|
env = process.env,
|
||||||
|
analyticsConfig = resolveMindSpaceAnalyticsConfig(env),
|
||||||
|
logger = console,
|
||||||
|
} = {}) {
|
||||||
|
if (!analyticsConfig?.enabled || !ownerUserId) return new Map();
|
||||||
|
const credentials = resolveUmamiCredentials(env, analyticsConfig);
|
||||||
|
if (!credentials.websiteId) return new Map();
|
||||||
|
if (!credentials.sharedSecret && !credentials.password) return new Map();
|
||||||
|
|
||||||
|
const ownerIdentity = resolveAnalyticsIdentity(ownerUserId, analyticsConfig);
|
||||||
|
if (!ownerIdentity) return new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getUmamiAuthToken(credentials);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('pageSize', '100');
|
||||||
|
params.set('sortBy', 'generatedAt');
|
||||||
|
params.set('sortOrder', 'desc');
|
||||||
|
const endAt = Date.now();
|
||||||
|
const startAt = endAt - 365 * 24 * 60 * 60 * 1000;
|
||||||
|
params.set('startAt', String(startAt));
|
||||||
|
params.set('endAt', String(endAt));
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(body?.data) ? body.data : [];
|
||||||
|
const map = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (String(row?.ownerId ?? '') !== ownerIdentity) continue;
|
||||||
|
const urlPath = pathnameFromUrl(row?.urlPath);
|
||||||
|
const pageUrlPath = pathnameFromUrl(row?.pageUrl);
|
||||||
|
const stats = {
|
||||||
|
views: Number(row?.views ?? 0),
|
||||||
|
clicks: Number(row?.clicks ?? 0),
|
||||||
|
};
|
||||||
|
if (urlPath) map.set(urlPath, stats);
|
||||||
|
if (pageUrlPath) map.set(pageUrlPath, stats);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch (error) {
|
||||||
|
logger?.warn?.(
|
||||||
|
'[MindSpace] Umami achievement stats unavailable:',
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPublicationStatsByPageIds(pool, userId, pageIds) {
|
||||||
|
if (!pool || !userId || !Array.isArray(pageIds) || pageIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
const ids = [...new Set(pageIds.map((id) => String(id ?? '').trim()).filter(Boolean))];
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT page_id,
|
||||||
|
SUM(view_count) AS total_view_count,
|
||||||
|
SUBSTRING_INDEX(
|
||||||
|
GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC),
|
||||||
|
',',
|
||||||
|
1
|
||||||
|
) AS latest_publish_id
|
||||||
|
FROM h5_publish_records
|
||||||
|
WHERE user_id = ? AND page_id IN (${placeholders})
|
||||||
|
GROUP BY page_id`,
|
||||||
|
[userId, ...ids],
|
||||||
|
);
|
||||||
|
const map = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(String(row.page_id), {
|
||||||
|
viewCount: Number(row.total_view_count ?? 0),
|
||||||
|
publicationId: row.latest_publish_id ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPublicationStats(pages, pubStatsByPageId) {
|
||||||
|
if (!pubStatsByPageId?.size) return pages;
|
||||||
|
return pages.map((page) => {
|
||||||
|
const pubStats = pubStatsByPageId.get(String(page?.id ?? ''));
|
||||||
|
if (!pubStats) return page;
|
||||||
|
const existingViews = Number(page?.viewCount ?? 0);
|
||||||
|
const mergedViews = Math.max(existingViews, pubStats.viewCount);
|
||||||
|
if (mergedViews === existingViews && (page?.publicationId || !pubStats.publicationId)) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
viewCount: mergedViews,
|
||||||
|
...(page?.publicationId ? {} : pubStats.publicationId ? { publicationId: pubStats.publicationId } : {}),
|
||||||
|
...(mergedViews > 0 && page?.statsSource === 'none' ? { statsSource: 'publication' } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichPagesWithAchievementStats(pages, {
|
||||||
|
ownerUserId,
|
||||||
|
env = process.env,
|
||||||
|
analyticsConfig = resolveMindSpaceAnalyticsConfig(env),
|
||||||
|
pool = null,
|
||||||
|
logger = console,
|
||||||
|
skipUmami = false,
|
||||||
|
} = {}) {
|
||||||
|
const pageIds = pages.map((page) => page?.id).filter(Boolean);
|
||||||
|
const pubStatsByPageId = await loadPublicationStatsByPageIds(pool, ownerUserId, pageIds);
|
||||||
|
const pagesWithPublicationStats = applyPublicationStats(pages, pubStatsByPageId);
|
||||||
|
if (skipUmami) {
|
||||||
|
return pagesWithPublicationStats.map((page) =>
|
||||||
|
mergeAchievementStatsForPage(page, new Map(), ownerUserId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const umamiByPath = await fetchUmamiPageStatsByPath({
|
||||||
|
ownerUserId,
|
||||||
|
env,
|
||||||
|
analyticsConfig,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
return pagesWithPublicationStats.map((page) => mergeAchievementStatsForPage(page, umamiByPath, ownerUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const achievementStatsInternals = {
|
||||||
|
buildPageMatchKeys,
|
||||||
|
mergeAchievementStatsForPage,
|
||||||
|
pathnameFromUrl,
|
||||||
|
pseudonymizeAnalyticsId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
achievementStatsInternals,
|
||||||
|
mergeAchievementStatsForPage,
|
||||||
|
} from './mindspace-page-achievement-stats.mjs';
|
||||||
|
|
||||||
|
const { buildPageMatchKeys, mergeAchievementStatsForPage: mergePage, pathnameFromUrl } =
|
||||||
|
achievementStatsInternals;
|
||||||
|
|
||||||
|
test('buildPageMatchKeys includes workspace and publication paths', () => {
|
||||||
|
const keys = buildPageMatchKeys(
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
workspaceRelativePath: 'public/demo.html',
|
||||||
|
publicationUrl: 'https://m.tkmind.cn/u/john/pages/demo',
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
assert.ok(keys.has('page-1'));
|
||||||
|
assert.ok(keys.has('/MindSpace/user-1/public/demo.html'));
|
||||||
|
assert.ok(keys.has('/u/john/pages/demo'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeAchievementStatsForPage prefers umami metrics when matched', () => {
|
||||||
|
const umamiByPath = new Map([
|
||||||
|
['/MindSpace/user-1/public/demo.html', { views: 12, clicks: 3 }],
|
||||||
|
]);
|
||||||
|
const merged = mergePage(
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Demo',
|
||||||
|
workspaceRelativePath: 'public/demo.html',
|
||||||
|
viewCount: 5,
|
||||||
|
},
|
||||||
|
umamiByPath,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
assert.equal(merged.viewCount, 12);
|
||||||
|
assert.equal(merged.clickCount, 3);
|
||||||
|
assert.equal(merged.statsSource, 'umami');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeAchievementStatsForPage keeps publication views without umami match', () => {
|
||||||
|
const merged = mergeAchievementStatsForPage(
|
||||||
|
{
|
||||||
|
id: 'page-2',
|
||||||
|
title: 'Draft',
|
||||||
|
viewCount: 7,
|
||||||
|
},
|
||||||
|
new Map(),
|
||||||
|
'user-2',
|
||||||
|
);
|
||||||
|
assert.equal(merged.viewCount, 7);
|
||||||
|
assert.equal(merged.clickCount, null);
|
||||||
|
assert.equal(merged.statsSource, 'publication');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathnameFromUrl normalizes absolute and relative urls', () => {
|
||||||
|
assert.equal(pathnameFromUrl('https://m.tkmind.cn/u/john/pages/demo/'), '/u/john/pages/demo');
|
||||||
|
assert.equal(pathnameFromUrl('/MindSpace/u/public/page.html'), '/MindSpace/u/public/page.html');
|
||||||
|
});
|
||||||
+73
-8
@@ -135,6 +135,12 @@ function pageResponse(row, { includeContent = true, h5Root = null, env = process
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
visibility: row.visibility,
|
visibility: row.visibility,
|
||||||
publicationAccessMode: row.pub_access_mode ?? null,
|
publicationAccessMode: row.pub_access_mode ?? null,
|
||||||
|
publicationId: row.pub_id ?? null,
|
||||||
|
publicationStatus: row.pub_status ?? null,
|
||||||
|
viewCount: asNumber(row.total_view_count ?? row.pub_view_count ?? 0),
|
||||||
|
clickCount: null,
|
||||||
|
statsSource: asNumber(row.total_view_count ?? row.pub_view_count ?? 0) > 0 ? 'publication' : 'none',
|
||||||
|
publishedAt: row.pub_published_at != null ? asNumber(row.pub_published_at) : null,
|
||||||
publicationUrl: workspacePublicUrl ?? row.pub_public_url ?? null,
|
publicationUrl: workspacePublicUrl ?? row.pub_public_url ?? null,
|
||||||
workspaceRelativePath: workspaceRelativePath ?? null,
|
workspaceRelativePath: workspaceRelativePath ?? null,
|
||||||
workspacePublicUrl,
|
workspacePublicUrl,
|
||||||
@@ -727,21 +733,42 @@ export function createPageService(pool, options = {}) {
|
|||||||
clauses.push(`c.category_code = ?`);
|
clauses.push(`c.category_code = ?`);
|
||||||
params.push(filters.categoryCode);
|
params.push(filters.categoryCode);
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(filters.createdAfter)) {
|
||||||
|
clauses.push(`p.created_at >= ?`);
|
||||||
|
params.push(filters.createdAfter);
|
||||||
|
}
|
||||||
|
if (Number.isFinite(filters.createdBefore)) {
|
||||||
|
clauses.push(`p.created_at < ?`);
|
||||||
|
params.push(filters.createdBefore);
|
||||||
|
}
|
||||||
const where = clauses.join(' AND ');
|
const where = clauses.join(' AND ');
|
||||||
const { limit, offset } = normalizeListPageFilters(filters);
|
const { limit, offset } = normalizeListPageFilters(filters);
|
||||||
|
const queryParams = [userId, ...params];
|
||||||
const baseFrom = `FROM h5_page_records p
|
const baseFrom = `FROM h5_page_records p
|
||||||
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
||||||
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||||||
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'`;
|
LEFT JOIN (
|
||||||
|
SELECT page_id,
|
||||||
|
SUM(view_count) AS total_view_count,
|
||||||
|
MAX(published_at) AS last_published_at,
|
||||||
|
SUBSTRING_INDEX(GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC), ',', 1) AS latest_publish_id
|
||||||
|
FROM h5_publish_records
|
||||||
|
WHERE user_id = ?
|
||||||
|
GROUP BY page_id
|
||||||
|
) pub_stats ON pub_stats.page_id = p.id
|
||||||
|
LEFT JOIN h5_publish_records pr ON pr.id = COALESCE(p.current_publish_id, pub_stats.latest_publish_id)`;
|
||||||
const [[countRows], [rows]] = await Promise.all([
|
const [[countRows], [rows]] = await Promise.all([
|
||||||
pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params),
|
pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, queryParams),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
|
`SELECT p.*, c.category_code, pv.version_no,
|
||||||
|
pr.id AS pub_id, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url,
|
||||||
|
pr.view_count AS pub_view_count, pr.published_at AS pub_published_at, pr.status AS pub_status,
|
||||||
|
COALESCE(pub_stats.total_view_count, 0) AS total_view_count
|
||||||
${baseFrom}
|
${baseFrom}
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
ORDER BY p.created_at DESC, p.updated_at DESC
|
ORDER BY p.created_at DESC, p.updated_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[...params, limit, offset],
|
[...queryParams, limit, offset],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const total = asNumber(countRows[0]?.total);
|
const total = asNumber(countRows[0]?.total);
|
||||||
@@ -754,6 +781,28 @@ export function createPageService(pool, options = {}) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listPageCreatedDateBuckets = async (userId, { limit = 3 } = {}) => {
|
||||||
|
const safeLimit = Math.min(Math.max(Number(limit) || 3, 1), 31);
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT DATE(FROM_UNIXTIME(p.created_at / 1000)) AS date_key,
|
||||||
|
MIN(p.created_at) AS min_created_at,
|
||||||
|
MAX(p.created_at) AS max_created_at,
|
||||||
|
COUNT(*) AS page_count
|
||||||
|
FROM h5_page_records p
|
||||||
|
WHERE p.user_id = ? AND p.status <> 'deleted'
|
||||||
|
GROUP BY date_key
|
||||||
|
ORDER BY date_key DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[userId, safeLimit],
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
dateKey: String(row.date_key),
|
||||||
|
minCreatedAt: asNumber(row.min_created_at),
|
||||||
|
maxCreatedAt: asNumber(row.max_created_at),
|
||||||
|
pageCount: asNumber(row.page_count),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
async function getPage(userId, pageId) {
|
async function getPage(userId, pageId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json
|
`SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json
|
||||||
@@ -1241,13 +1290,27 @@ export function createPageService(pool, options = {}) {
|
|||||||
|
|
||||||
const deletePage = async (userId, pageId, options = {}) => {
|
const deletePage = async (userId, pageId, options = {}) => {
|
||||||
const removeFromPlaza = Boolean(options.removeFromPlaza);
|
const removeFromPlaza = Boolean(options.removeFromPlaza);
|
||||||
const page = await getPage(userId, pageId);
|
const [metaRows] = await pool.query(
|
||||||
|
`SELECT p.id, p.title, p.page_type, p.space_id, p.source_asset_id
|
||||||
|
FROM h5_page_records p
|
||||||
|
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
|
||||||
|
LIMIT 1`,
|
||||||
|
[pageId, userId],
|
||||||
|
);
|
||||||
|
const pageMeta = metaRows[0];
|
||||||
|
if (!pageMeta) throw pageError('页面不存在', 'page_not_found');
|
||||||
|
|
||||||
|
const pageDetail = await getPage(userId, pageId).catch(() => null);
|
||||||
|
const contentFormat =
|
||||||
|
pageDetail?.contentFormat ?? (pageMeta.page_type === 'html' ? 'html' : 'markdown');
|
||||||
|
const pageContent = pageDetail?.content ?? '';
|
||||||
|
|
||||||
let workspaceHtmlRelativePath = null;
|
let workspaceHtmlRelativePath = null;
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT pv.source_snapshot_json
|
`SELECT pv.source_snapshot_json
|
||||||
FROM h5_page_records p
|
FROM h5_page_records p
|
||||||
JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||||||
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
|
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[pageId, userId],
|
[pageId, userId],
|
||||||
@@ -1258,7 +1321,7 @@ export function createPageService(pool, options = {}) {
|
|||||||
workspaceHtmlRelativePath = null;
|
workspaceHtmlRelativePath = null;
|
||||||
}
|
}
|
||||||
const embeddedAssetIds =
|
const embeddedAssetIds =
|
||||||
page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : [];
|
contentFormat === 'html' ? extractAssetIdsFromHtml(pageContent) : [];
|
||||||
|
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
let result;
|
let result;
|
||||||
@@ -1333,7 +1396,7 @@ export function createPageService(pool, options = {}) {
|
|||||||
const purgeResult = await purgeWorkspacePageArtifacts({
|
const purgeResult = await purgeWorkspacePageArtifacts({
|
||||||
publishDir: workspacePublishDir,
|
publishDir: workspacePublishDir,
|
||||||
htmlRelativePath: workspaceHtmlRelativePath,
|
htmlRelativePath: workspaceHtmlRelativePath,
|
||||||
html: page.content ?? '',
|
html: pageContent,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn('[MindSpace] workspace purge failed:', error?.message ?? error);
|
console.warn('[MindSpace] workspace purge failed:', error?.message ?? error);
|
||||||
return { removed: [], skipped: [] };
|
return { removed: [], skipped: [] };
|
||||||
@@ -1494,6 +1557,7 @@ export function createPageService(pool, options = {}) {
|
|||||||
redactPage,
|
redactPage,
|
||||||
createRedactedCopy: redactPage,
|
createRedactedCopy: redactPage,
|
||||||
listPages,
|
listPages,
|
||||||
|
listPageCreatedDateBuckets,
|
||||||
findPageBySourceAsset,
|
findPageBySourceAsset,
|
||||||
findPageBySourceMessage,
|
findPageBySourceMessage,
|
||||||
findPageByRelativePath,
|
findPageByRelativePath,
|
||||||
@@ -1602,6 +1666,7 @@ export const pageInternals = {
|
|||||||
normalizePageInput,
|
normalizePageInput,
|
||||||
normalizeListPageFilters,
|
normalizeListPageFilters,
|
||||||
parseJsonColumn,
|
parseJsonColumn,
|
||||||
|
pageResponse,
|
||||||
renderContent,
|
renderContent,
|
||||||
renderPreviewHtml,
|
renderPreviewHtml,
|
||||||
renderHtmlPreview,
|
renderHtmlPreview,
|
||||||
|
|||||||
@@ -88,6 +88,45 @@ test('normalizeListPageFilters clamps limit and offset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pageResponse includes publication metrics when publish row is present', () => {
|
||||||
|
const response = pageInternals.pageResponse(
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
category_id: 'cat-1',
|
||||||
|
category_code: 'public',
|
||||||
|
source_session_id: null,
|
||||||
|
source_message_id: null,
|
||||||
|
source_asset_id: null,
|
||||||
|
title: '成果页面',
|
||||||
|
summary: '摘要',
|
||||||
|
page_type: 'html',
|
||||||
|
template_id: 'static-html',
|
||||||
|
status: 'published',
|
||||||
|
visibility: 'public',
|
||||||
|
current_version_id: 'ver-1',
|
||||||
|
version_no: 3,
|
||||||
|
pub_access_mode: 'public',
|
||||||
|
pub_public_url: 'https://m.tkmind.cn/u/john/pages/demo',
|
||||||
|
pub_id: 'pub-1',
|
||||||
|
pub_status: 'online',
|
||||||
|
pub_view_count: 128,
|
||||||
|
pub_published_at: 1_700_000_000_000,
|
||||||
|
created_at: 1_699_000_000_000,
|
||||||
|
updated_at: 1_700_100_000_000,
|
||||||
|
},
|
||||||
|
{ includeContent: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.publicationId, 'pub-1');
|
||||||
|
assert.equal(response.publicationStatus, 'online');
|
||||||
|
assert.equal(response.viewCount, 128);
|
||||||
|
assert.equal(response.clickCount, null);
|
||||||
|
assert.equal(response.statsSource, 'publication');
|
||||||
|
assert.equal(response.publishedAt, 1_700_000_000_000);
|
||||||
|
assert.equal(response.publicationUrl, 'https://m.tkmind.cn/u/john/pages/demo');
|
||||||
|
});
|
||||||
|
|
||||||
test('preview rendering escapes active HTML and emits a restrictive CSP', () => {
|
test('preview rendering escapes active HTML and emits a restrictive CSP', () => {
|
||||||
const html = pageInternals.renderPreviewHtml({
|
const html = pageInternals.renderPreviewHtml({
|
||||||
title: '<script>alert(1)</script>',
|
title: '<script>alert(1)</script>',
|
||||||
@@ -295,3 +334,70 @@ test('getPage falls back to workspace html when storage asset is missing', async
|
|||||||
assert.equal(page.title, 'Page Demo');
|
assert.equal(page.title, 'Page Demo');
|
||||||
assert.match(page.content, /<h1>Body<\/h1>/);
|
assert.match(page.content, /<h1>Body<\/h1>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deletePage succeeds when page content asset chain is incomplete', async () => {
|
||||||
|
const userId = 'user-1';
|
||||||
|
const pageId = 'page-broken';
|
||||||
|
let deletedPage = false;
|
||||||
|
const conn = {
|
||||||
|
async beginTransaction() {},
|
||||||
|
async commit() {},
|
||||||
|
async rollback() {},
|
||||||
|
async release() {},
|
||||||
|
query: async (sql) => {
|
||||||
|
const normalized = String(sql);
|
||||||
|
if (normalized.includes('FOR UPDATE') && normalized.includes('h5_page_records')) {
|
||||||
|
return [[{ id: pageId, space_id: 'space-1', title: 'test', source_asset_id: null }]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('FROM h5_page_versions pv')) {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('FROM h5_publish_records') && normalized.includes("status = 'online'")) {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('FROM h5_agent_jobs')) {
|
||||||
|
return [[{ job_count: 0 }]];
|
||||||
|
}
|
||||||
|
if (normalized.includes("SET status = 'deleted'") && normalized.includes('h5_page_records')) {
|
||||||
|
deletedPage = true;
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (normalized.includes('UPDATE h5_agent_jobs')) {
|
||||||
|
return [{ affectedRows: 0 }];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pool = {
|
||||||
|
getConnection: async () => conn,
|
||||||
|
query: async (sql) => {
|
||||||
|
const normalized = String(sql);
|
||||||
|
if (
|
||||||
|
normalized.includes('FROM h5_page_records p') &&
|
||||||
|
normalized.includes('page_type')
|
||||||
|
) {
|
||||||
|
return [[{
|
||||||
|
id: pageId,
|
||||||
|
title: 'test',
|
||||||
|
page_type: 'markdown',
|
||||||
|
space_id: 'space-1',
|
||||||
|
source_asset_id: null,
|
||||||
|
}]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('JOIN h5_asset_versions')) {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('source_snapshot_json')) {
|
||||||
|
return [[{ source_snapshot_json: null }]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageService = createPageService(pool, { storageRoot: '/tmp/unused', h5Root: null });
|
||||||
|
const result = await pageService.deletePage(userId, pageId);
|
||||||
|
assert.equal(result.deleted, true);
|
||||||
|
assert.equal(result.pageId, pageId);
|
||||||
|
assert.equal(result.title, 'test');
|
||||||
|
assert.equal(deletedPage, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs';
|
||||||
|
import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
|
||||||
|
|
||||||
|
const DEFAULT_PUBLIC_HOST = 'm.tkmind.cn';
|
||||||
|
const SORT_COLUMNS = new Set([
|
||||||
|
'publishedAt',
|
||||||
|
'pageTitle',
|
||||||
|
'ownerLabel',
|
||||||
|
'totalViews',
|
||||||
|
'seoViews',
|
||||||
|
'geoViews',
|
||||||
|
'crawlerViews',
|
||||||
|
'lifetimeViewCount',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function asPositiveInt(value, fallback) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortBy(value, fallback = 'publishedAt') {
|
||||||
|
const sortBy = String(value ?? '').trim();
|
||||||
|
return SORT_COLUMNS.has(sortBy) ? sortBy : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortOrder(value, fallback = 'desc') {
|
||||||
|
return String(value ?? '').trim().toLowerCase() === 'asc' ? 'asc' : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAdminCatalogPublicUrl(publicUrl, { publicHost = DEFAULT_PUBLIC_HOST } = {}) {
|
||||||
|
const raw = String(publicUrl ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
if (/^https?:\/\//i.test(raw)) return raw.split('#')[0];
|
||||||
|
if (raw.startsWith('/')) return `https://${publicHost}${raw}`.split('#')[0];
|
||||||
|
return raw.split('#')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRows(a, b, sortBy, sortOrder) {
|
||||||
|
const direction = sortOrder === 'asc' ? 1 : -1;
|
||||||
|
const left = a[sortBy];
|
||||||
|
const right = b[sortBy];
|
||||||
|
if (typeof left === 'string' || typeof right === 'string') {
|
||||||
|
return String(left ?? '').localeCompare(String(right ?? ''), 'zh-CN') * direction;
|
||||||
|
}
|
||||||
|
return ((Number(left) || 0) - (Number(right) || 0)) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateViewStats(rows = []) {
|
||||||
|
const stats = {
|
||||||
|
totalViews: 0,
|
||||||
|
seoViews: 0,
|
||||||
|
geoViews: 0,
|
||||||
|
crawlerViews: 0,
|
||||||
|
};
|
||||||
|
for (const row of rows) {
|
||||||
|
const views = Number(row.views) || 0;
|
||||||
|
if (views <= 0) continue;
|
||||||
|
stats.totalViews += views;
|
||||||
|
if (String(row.device_type ?? '') === 'bot') {
|
||||||
|
stats.crawlerViews += views;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const discovery = classifyReferrerHost(row.referrer_host);
|
||||||
|
if (discovery.discovery_channel === 'seo') stats.seoViews += views;
|
||||||
|
if (discovery.discovery_channel === 'geo') stats.geoViews += views;
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminSeoGeoPublicationCatalog(
|
||||||
|
pool,
|
||||||
|
{
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
sortBy = 'publishedAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
startAt = null,
|
||||||
|
endAt = null,
|
||||||
|
search = '',
|
||||||
|
publicHost = DEFAULT_PUBLIC_HOST,
|
||||||
|
now = Date.now(),
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (!pool) {
|
||||||
|
return { data: [], total: 0, page: 1, pageSize: 20, totals: emptyTotals() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const safePage = asPositiveInt(page, 1);
|
||||||
|
const safePageSize = Math.min(asPositiveInt(pageSize, 20), 100);
|
||||||
|
const normalizedSortBy = normalizeSortBy(sortBy);
|
||||||
|
const normalizedSortOrder = normalizeSortOrder(sortOrder);
|
||||||
|
const searchTerm = String(search ?? '').trim();
|
||||||
|
const rangeStart = Number(startAt);
|
||||||
|
const rangeEnd = Number(endAt);
|
||||||
|
const hasRange = Number.isFinite(rangeStart) && Number.isFinite(rangeEnd) && rangeEnd >= rangeStart;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
let whereSql = `WHERE pr.status = 'online'`;
|
||||||
|
if (searchTerm) {
|
||||||
|
whereSql += ` AND (
|
||||||
|
p.title LIKE ?
|
||||||
|
OR u.username LIKE ?
|
||||||
|
OR u.display_name LIKE ?
|
||||||
|
OR pr.public_url LIKE ?
|
||||||
|
)`;
|
||||||
|
const like = `%${searchTerm}%`;
|
||||||
|
params.push(like, like, like, like);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS total
|
||||||
|
FROM h5_publish_records pr
|
||||||
|
JOIN h5_page_records p ON p.id = pr.page_id
|
||||||
|
JOIN h5_users u ON u.id = pr.user_id
|
||||||
|
${whereSql}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const total = Number(countRows[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const [publicationRows] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
pr.id AS publicationId,
|
||||||
|
pr.page_id AS pageId,
|
||||||
|
pr.user_id AS userId,
|
||||||
|
pr.public_url AS publicUrl,
|
||||||
|
pr.access_mode AS accessMode,
|
||||||
|
pr.status,
|
||||||
|
pr.user_confirmed_at AS userConfirmedAt,
|
||||||
|
pr.expires_at AS expiresAt,
|
||||||
|
pr.published_at AS publishedAt,
|
||||||
|
pr.view_count AS lifetimeViewCount,
|
||||||
|
p.title AS pageTitle,
|
||||||
|
p.summary AS pageSummary,
|
||||||
|
u.username,
|
||||||
|
u.display_name AS displayName
|
||||||
|
FROM h5_publish_records pr
|
||||||
|
JOIN h5_page_records p ON p.id = pr.page_id
|
||||||
|
JOIN h5_users u ON u.id = pr.user_id
|
||||||
|
${whereSql}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicationIds = publicationRows.map((row) => row.publicationId);
|
||||||
|
const statsByPublication = new Map();
|
||||||
|
if (publicationIds.length > 0 && hasRange) {
|
||||||
|
const placeholders = publicationIds.map(() => '?').join(', ');
|
||||||
|
const [viewRows] = await pool.query(
|
||||||
|
`SELECT publish_id AS publicationId,
|
||||||
|
device_type,
|
||||||
|
referrer_host,
|
||||||
|
COUNT(*) AS views
|
||||||
|
FROM h5_publication_views
|
||||||
|
WHERE publish_id IN (${placeholders})
|
||||||
|
AND viewed_at >= ?
|
||||||
|
AND viewed_at <= ?
|
||||||
|
GROUP BY publish_id, device_type, referrer_host`,
|
||||||
|
[...publicationIds, rangeStart, rangeEnd],
|
||||||
|
);
|
||||||
|
for (const row of viewRows) {
|
||||||
|
const key = String(row.publicationId);
|
||||||
|
const bucket = statsByPublication.get(key) ?? [];
|
||||||
|
bucket.push(row);
|
||||||
|
statsByPublication.set(key, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = publicationRows.map((row) => {
|
||||||
|
const publication = normalizePublicationSnapshot({
|
||||||
|
id: row.publicationId,
|
||||||
|
pageId: row.pageId,
|
||||||
|
publicUrl: row.publicUrl,
|
||||||
|
accessMode: row.accessMode,
|
||||||
|
status: row.status,
|
||||||
|
userConfirmedAt: row.userConfirmedAt,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
title: row.pageTitle,
|
||||||
|
summary: row.pageSummary,
|
||||||
|
});
|
||||||
|
const stats = hasRange
|
||||||
|
? aggregateViewStats(statsByPublication.get(String(row.publicationId)) ?? [])
|
||||||
|
: {
|
||||||
|
totalViews: 0,
|
||||||
|
seoViews: 0,
|
||||||
|
geoViews: 0,
|
||||||
|
crawlerViews: 0,
|
||||||
|
};
|
||||||
|
const ownerLabel =
|
||||||
|
String(row.displayName ?? '').trim() ||
|
||||||
|
String(row.username ?? '').trim() ||
|
||||||
|
'未命名用户';
|
||||||
|
return {
|
||||||
|
publicationId: row.publicationId,
|
||||||
|
pageId: row.pageId,
|
||||||
|
userId: row.userId,
|
||||||
|
ownerLabel,
|
||||||
|
username: String(row.username ?? '').trim(),
|
||||||
|
pageTitle: String(row.pageTitle ?? '').trim() || '未命名页面',
|
||||||
|
pageSummary: String(row.pageSummary ?? '').trim(),
|
||||||
|
publicUrl: resolveAdminCatalogPublicUrl(row.publicUrl, { publicHost }),
|
||||||
|
accessMode: row.accessMode,
|
||||||
|
indexable: isPublicationIndexable(publication, { now }),
|
||||||
|
publishedAt: Number(row.publishedAt) || null,
|
||||||
|
lifetimeViewCount: Number(row.lifetimeViewCount) || 0,
|
||||||
|
...stats,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const primary = compareRows(a, b, normalizedSortBy, normalizedSortOrder);
|
||||||
|
if (primary !== 0) return primary;
|
||||||
|
return compareRows(a, b, 'pageTitle', 'asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
const offset = (safePage - 1) * safePageSize;
|
||||||
|
const pageRows = rows.slice(offset, offset + safePageSize);
|
||||||
|
const totals = rows.reduce(
|
||||||
|
(acc, row) => ({
|
||||||
|
totalViews: acc.totalViews + row.totalViews,
|
||||||
|
seoViews: acc.seoViews + row.seoViews,
|
||||||
|
geoViews: acc.geoViews + row.geoViews,
|
||||||
|
crawlerViews: acc.crawlerViews + row.crawlerViews,
|
||||||
|
}),
|
||||||
|
emptyTotals(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: pageRows,
|
||||||
|
total,
|
||||||
|
page: safePage,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTotals() {
|
||||||
|
return {
|
||||||
|
totalViews: 0,
|
||||||
|
seoViews: 0,
|
||||||
|
geoViews: 0,
|
||||||
|
crawlerViews: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs';
|
||||||
|
import {
|
||||||
|
listAdminSeoGeoPublicationCatalog,
|
||||||
|
resolveAdminCatalogPublicUrl,
|
||||||
|
} from './mindspace-seo-geo-admin-catalog.mjs';
|
||||||
|
|
||||||
|
test('classifyReferrerHost maps search and ai referrers', () => {
|
||||||
|
assert.deepEqual(classifyReferrerHost('www.google.com'), {
|
||||||
|
discovery_channel: 'seo',
|
||||||
|
discovery_source: 'google',
|
||||||
|
});
|
||||||
|
assert.deepEqual(classifyReferrerHost('chatgpt.com'), {
|
||||||
|
discovery_channel: 'geo',
|
||||||
|
discovery_source: 'chatgpt',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveAdminCatalogPublicUrl normalizes relative public urls', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveAdminCatalogPublicUrl('/u/john/pages/demo'),
|
||||||
|
'https://m.tkmind.cn/u/john/pages/demo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stats', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
const normalized = String(sql).replace(/\s+/g, ' ').trim();
|
||||||
|
if (normalized.startsWith('SELECT COUNT(*) AS total')) {
|
||||||
|
return [[{ total: 2 }]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('FROM h5_publish_records pr') && normalized.includes('JOIN h5_users')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
publicationId: 'pub-1',
|
||||||
|
pageId: 'page-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
publicUrl: '/u/john/pages/demo',
|
||||||
|
accessMode: 'public',
|
||||||
|
status: 'online',
|
||||||
|
userConfirmedAt: 1000,
|
||||||
|
expiresAt: null,
|
||||||
|
publishedAt: 2000,
|
||||||
|
lifetimeViewCount: 12,
|
||||||
|
pageTitle: 'Demo',
|
||||||
|
pageSummary: 'Summary',
|
||||||
|
username: 'john',
|
||||||
|
displayName: 'John',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicationId: 'pub-2',
|
||||||
|
pageId: 'page-2',
|
||||||
|
userId: 'user-2',
|
||||||
|
publicUrl: '/u/jane/pages/empty',
|
||||||
|
accessMode: 'public',
|
||||||
|
status: 'online',
|
||||||
|
userConfirmedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
publishedAt: 1500,
|
||||||
|
lifetimeViewCount: 0,
|
||||||
|
pageTitle: 'Empty',
|
||||||
|
pageSummary: '',
|
||||||
|
username: 'jane',
|
||||||
|
displayName: 'Jane',
|
||||||
|
},
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
if (normalized.includes('FROM h5_publication_views')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
publicationId: 'pub-1',
|
||||||
|
device_type: 'desktop',
|
||||||
|
referrer_host: 'www.google.com',
|
||||||
|
views: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicationId: 'pub-1',
|
||||||
|
device_type: 'bot',
|
||||||
|
referrer_host: null,
|
||||||
|
views: 2,
|
||||||
|
},
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await listAdminSeoGeoPublicationCatalog(pool, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
startAt: 1,
|
||||||
|
endAt: 9_999_999_999_999,
|
||||||
|
sortBy: 'totalViews',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.total, 2);
|
||||||
|
assert.equal(result.data.length, 2);
|
||||||
|
assert.equal(result.data[0].publicationId, 'pub-1');
|
||||||
|
assert.equal(result.data[0].seoViews, 3);
|
||||||
|
assert.equal(result.data[0].crawlerViews, 2);
|
||||||
|
assert.equal(result.data[0].totalViews, 5);
|
||||||
|
assert.equal(result.data[0].indexable, true);
|
||||||
|
assert.equal(result.data[1].publicationId, 'pub-2');
|
||||||
|
assert.equal(result.data[1].totalViews, 0);
|
||||||
|
assert.equal(result.data[1].indexable, false);
|
||||||
|
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
|
||||||
|
});
|
||||||
@@ -81,6 +81,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({
|
|||||||
'getDeletePreview',
|
'getDeletePreview',
|
||||||
'getPage',
|
'getPage',
|
||||||
'listPages',
|
'listPages',
|
||||||
|
'listPageCreatedDateBuckets',
|
||||||
'listVersions',
|
'listVersions',
|
||||||
'localizePrivateResources',
|
'localizePrivateResources',
|
||||||
'redactPage',
|
'redactPage',
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ if [[ -n "${EXPLICIT_H5_USERS_ROOT}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${ROOT}/memind-source}"
|
export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${MEMIND_ROOT}}"
|
||||||
export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}"
|
export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}"
|
||||||
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}"
|
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}"
|
||||||
export H5_USERS_ROOT="${H5_USERS_ROOT:-${ROOT}/users}"
|
export H5_USERS_ROOT="${H5_USERS_ROOT:-${ROOT}/users}"
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **重要:本目录不再接受新的管理后台功能开发。**
|
> **重要:本目录不再接受新的管理后台功能开发。**
|
||||||
>
|
>
|
||||||
> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 gadm),API 端口 **8085**。
|
> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 [md.tkmind.cn](https://md.tkmind.cn)),API 端口 **8085**。
|
||||||
>
|
>
|
||||||
> 本目录(Memind `ops/`,本地约 **3002**)仅保留 **Plaza 运营**相关页面(审核、精选、创作者等)及历史遗留的超级管理路由;**不得**在此新增页面、导航项或 API 客户端。
|
> 本目录(Memind `ops/`,本地约 **3002**)仅保留 **Plaza 运营**相关页面(审核、精选、创作者等)及历史遗留的超级管理路由;**不得**在此新增页面、导航项或 API 客户端。
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
|||||||
'scripts/goosed-canary.compose.yml',
|
'scripts/goosed-canary.compose.yml',
|
||||||
'scripts/check-mindspace-public-links.mjs',
|
'scripts/check-mindspace-public-links.mjs',
|
||||||
'scripts/load-env.mjs',
|
'scripts/load-env.mjs',
|
||||||
|
'scripts/memind-runtime-profile.mjs',
|
||||||
'scripts/wechat-mp-menu.mjs',
|
'scripts/wechat-mp-menu.mjs',
|
||||||
'scripts/memind-portal-tunnel.sh',
|
'scripts/memind-portal-tunnel.sh',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -447,6 +447,10 @@ async function writeMetadata() {
|
|||||||
path.join(root, 'scripts', 'load-env.mjs'),
|
path.join(root, 'scripts', 'load-env.mjs'),
|
||||||
path.join(runtimeRoot, 'scripts', 'load-env.mjs'),
|
path.join(runtimeRoot, 'scripts', 'load-env.mjs'),
|
||||||
);
|
);
|
||||||
|
await fs.copyFile(
|
||||||
|
path.join(root, 'scripts', 'memind-runtime-profile.mjs'),
|
||||||
|
path.join(runtimeRoot, 'scripts', 'memind-runtime-profile.mjs'),
|
||||||
|
);
|
||||||
await fs.copyFile(
|
await fs.copyFile(
|
||||||
path.join(root, 'scripts', 'wechat-mp-menu.mjs'),
|
path.join(root, 'scripts', 'wechat-mp-menu.mjs'),
|
||||||
path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'),
|
path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'),
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ fi
|
|||||||
|
|
||||||
verify_runtime_artifact() {
|
verify_runtime_artifact() {
|
||||||
local missing=0
|
local missing=0
|
||||||
for required in server.mjs mindspace-sandbox-mcp.mjs tkmind-excel-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs; do
|
for required in server.mjs mindspace-sandbox-mcp.mjs tkmind-excel-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/memind-runtime-profile.mjs scripts/wechat-mp-menu.mjs; do
|
||||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||||
missing=1
|
missing=1
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ fi
|
|||||||
|
|
||||||
verify_runtime_artifact() {
|
verify_runtime_artifact() {
|
||||||
local missing=0
|
local missing=0
|
||||||
for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/memind-runtime-profile.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||||
missing=1
|
missing=1
|
||||||
|
|||||||
@@ -22,13 +22,23 @@ const MENU = {
|
|||||||
button: [
|
button: [
|
||||||
{
|
{
|
||||||
type: 'view',
|
type: 'view',
|
||||||
name: 'TKMind',
|
name: 'Memind',
|
||||||
url: 'https://m.tkmind.cn',
|
url: 'https://m.tkmind.cn',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'view',
|
|
||||||
name: 'M空间',
|
name: 'M空间',
|
||||||
url: 'https://m.tkmind.cn/space',
|
sub_button: [
|
||||||
|
{
|
||||||
|
type: 'view',
|
||||||
|
name: '空间首页',
|
||||||
|
url: 'https://m.tkmind.cn/space',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'view',
|
||||||
|
name: 'M成果',
|
||||||
|
url: 'https://m.tkmind.cn/space/achievements',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'view',
|
type: 'view',
|
||||||
|
|||||||
@@ -1334,6 +1334,7 @@ attachPortalMindSpacePageCoreRoutes(api, {
|
|||||||
getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit,
|
getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit,
|
||||||
getMindSpacePageEditSession: () => mindSpacePageEditSession,
|
getMindSpacePageEditSession: () => mindSpacePageEditSession,
|
||||||
getUserAuth: () => userAuth,
|
getUserAuth: () => userAuth,
|
||||||
|
getAuthPool: () => authPool,
|
||||||
syncUserGeneratedPages,
|
syncUserGeneratedPages,
|
||||||
ownsAgentSession,
|
ownsAgentSession,
|
||||||
ensureMindSpaceEnabled,
|
ensureMindSpaceEnabled,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { listAchievementPagesForUser } from '../mindspace-page-achievement-list.mjs';
|
||||||
|
|
||||||
function assertRouter(api) {
|
function assertRouter(api) {
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
@@ -72,6 +74,7 @@ export function attachPortalMindSpacePageCoreRoutes(
|
|||||||
getMindSpacePageLiveEdit = () => null,
|
getMindSpacePageLiveEdit = () => null,
|
||||||
getMindSpacePageEditSession = () => null,
|
getMindSpacePageEditSession = () => null,
|
||||||
getUserAuth = () => null,
|
getUserAuth = () => null,
|
||||||
|
getAuthPool = () => null,
|
||||||
syncUserGeneratedPages,
|
syncUserGeneratedPages,
|
||||||
ownsAgentSession,
|
ownsAgentSession,
|
||||||
ensureMindSpaceEnabled,
|
ensureMindSpaceEnabled,
|
||||||
@@ -161,6 +164,35 @@ export function attachPortalMindSpacePageCoreRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.get('/mindspace/v1/pages/achievements', async (req, res) => {
|
||||||
|
const pages = getMindSpacePages();
|
||||||
|
if (!pages) {
|
||||||
|
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
schedulePageListSync(req.currentUser.id);
|
||||||
|
const result = await listAchievementPagesForUser({
|
||||||
|
pages,
|
||||||
|
userId: req.currentUser.id,
|
||||||
|
query: req.query,
|
||||||
|
pool: getAuthPool?.() ?? null,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
return res.json({
|
||||||
|
data: result.items,
|
||||||
|
page: {
|
||||||
|
total: result.total,
|
||||||
|
limit: result.limit,
|
||||||
|
offset: result.offset,
|
||||||
|
has_more: result.hasMore,
|
||||||
|
next_cursor: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleMindSpaceError(res, req, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
|
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
|
||||||
const pages = getMindSpacePages();
|
const pages = getMindSpacePages();
|
||||||
if (!pages) {
|
if (!pages) {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ test('page core module preserves route inventory and order', () => {
|
|||||||
assert.deepEqual([...api.routes.keys()], [
|
assert.deepEqual([...api.routes.keys()], [
|
||||||
'POST /mindspace/v1/pages',
|
'POST /mindspace/v1/pages',
|
||||||
'GET /mindspace/v1/pages',
|
'GET /mindspace/v1/pages',
|
||||||
|
'GET /mindspace/v1/pages/achievements',
|
||||||
'GET /mindspace/v1/pages/:pageId',
|
'GET /mindspace/v1/pages/:pageId',
|
||||||
'DELETE /mindspace/v1/pages/:pageId',
|
'DELETE /mindspace/v1/pages/:pageId',
|
||||||
'GET /mindspace/v1/pages/:pageId/delete-preview',
|
'GET /mindspace/v1/pages/:pageId/delete-preview',
|
||||||
@@ -522,3 +523,31 @@ test('page core routes preserve unavailable and mapped error responses', async (
|
|||||||
assert.equal(failureRes.statusCode, 418);
|
assert.equal(failureRes.statusCode, 418);
|
||||||
assert.equal(failure.calls.routeErrors[0].error.message, 'list failed');
|
assert.equal(failure.calls.routeErrors[0].error.message, 'list failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('achievements route uses listPages and enriches stats before page detail route', async () => {
|
||||||
|
const api = createRouterRecorder();
|
||||||
|
const setup = createDependencies({
|
||||||
|
getMindSpacePages: () => ({
|
||||||
|
async listPages(userId, input) {
|
||||||
|
return {
|
||||||
|
items: [{ id: 'page-1', title: 'Demo', viewCount: 3, clickCount: null, statsSource: 'publication' }],
|
||||||
|
total: 1,
|
||||||
|
limit: input.limit ?? 20,
|
||||||
|
offset: input.offset ?? 0,
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getAuthPool: () => null,
|
||||||
|
});
|
||||||
|
attachPortalMindSpacePageCoreRoutes(api, setup.dependencies);
|
||||||
|
|
||||||
|
const res = createResponseRecorder();
|
||||||
|
await api.routes.get('GET /mindspace/v1/pages/achievements')(
|
||||||
|
createRequest({ query: { limit: '20', offset: '0' } }),
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.equal(res.body.data[0].viewCount, 3);
|
||||||
|
assert.equal(res.body.page.total, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function normalizeProductRoute(pathname: string, search = '') {
|
|||||||
export function resolveProductRouteName(pathname: string) {
|
export function resolveProductRouteName(pathname: string) {
|
||||||
if (pathname === '/') return 'chat';
|
if (pathname === '/') return 'chat';
|
||||||
if (pathname === '/space') return 'mindspace_home';
|
if (pathname === '/space') return 'mindspace_home';
|
||||||
|
if (pathname === '/space/achievements') return 'mindspace_achievements';
|
||||||
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
|
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
|
||||||
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
|
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
|
||||||
if (pathname === '/feedback') return 'feedback';
|
if (pathname === '/feedback') return 'feedback';
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ export {
|
|||||||
getMindSpacePage,
|
getMindSpacePage,
|
||||||
getMindSpacePageDeletePreview,
|
getMindSpacePageDeletePreview,
|
||||||
getMindSpacePageLiveRevision,
|
getMindSpacePageLiveRevision,
|
||||||
|
isMindSpacePageNotFoundError,
|
||||||
listMindSpacePages,
|
listMindSpacePages,
|
||||||
|
listMindSpaceAchievementPages,
|
||||||
openMindSpaceDraftPreviewWindow,
|
openMindSpaceDraftPreviewWindow,
|
||||||
regenerateMindSpacePageThumbnail,
|
regenerateMindSpacePageThumbnail,
|
||||||
rewriteMindSpacePageDownloadLinks,
|
rewriteMindSpacePageDownloadLinks,
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export async function getMindSpacePageDeletePreview(
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMindSpacePageNotFoundError(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
err instanceof ApiError &&
|
||||||
|
err.status === 404 &&
|
||||||
|
(err.code === 'page_not_found' || err.message === '页面不存在')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteMindSpacePage(
|
export async function deleteMindSpacePage(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
options?: { removeFromPlaza?: boolean },
|
options?: { removeFromPlaza?: boolean },
|
||||||
@@ -61,6 +69,26 @@ export async function listMindSpacePages(options?: {
|
|||||||
return { items: result.data, page: result.page ?? {} };
|
return { items: result.data, page: result.page ?? {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMindSpaceAchievementPages(options?: {
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
categoryCode?: string;
|
||||||
|
includeStats?: boolean;
|
||||||
|
}): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.status) params.set('status', options.status);
|
||||||
|
if (options?.limit != null) params.set('limit', String(options.limit));
|
||||||
|
if (options?.offset != null) params.set('offset', String(options.offset));
|
||||||
|
if (options?.categoryCode) params.set('category_code', options.categoryCode);
|
||||||
|
if (options?.includeStats) params.set('include_stats', '1');
|
||||||
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>(
|
||||||
|
`/mindspace/v1/pages/achievements${query}`,
|
||||||
|
);
|
||||||
|
return { items: result.data, page: result.page ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
|
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
|
||||||
const result = await apiFetch<{ data: MindSpacePage }>(
|
const result = await apiFetch<{ data: MindSpacePage }>(
|
||||||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { MindSpacePage } from '../types';
|
||||||
|
|
||||||
|
const PAGE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
draft: '草稿',
|
||||||
|
reviewing: '待检查',
|
||||||
|
risk_found: '发现风险',
|
||||||
|
ready: '可发布',
|
||||||
|
published: '已公开',
|
||||||
|
protected: '受保护',
|
||||||
|
expired: '已过期',
|
||||||
|
offline: '已下线',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateHeading(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetric(value: number | null | undefined) {
|
||||||
|
if (value == null) return '—';
|
||||||
|
return value.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClickMetric(page: MindSpacePage) {
|
||||||
|
if (page.clickCount == null) return '—';
|
||||||
|
return formatMetric(page.clickCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
||||||
|
const groups = new Map<string, MindSpacePage[]>();
|
||||||
|
for (const page of pages) {
|
||||||
|
const key = new Date(page.createdAt).toISOString().slice(0, 10);
|
||||||
|
const bucket = groups.get(key);
|
||||||
|
if (bucket) bucket.push(page);
|
||||||
|
else groups.set(key, [page]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()]
|
||||||
|
.sort(([left], [right]) => right.localeCompare(left))
|
||||||
|
.map(([dateKey, items]) => ({
|
||||||
|
dateKey,
|
||||||
|
heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T00:00:00`)),
|
||||||
|
items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type MindSpaceAchievementsPanelProps = {
|
||||||
|
items: MindSpacePage[];
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
selectedIds: string[];
|
||||||
|
deleting: boolean;
|
||||||
|
deletingPageId?: string | null;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenPage: (page: MindSpacePage) => void;
|
||||||
|
onShowPage: (pageId: string) => void;
|
||||||
|
onTogglePage: (pageId: string, checked: boolean) => void;
|
||||||
|
onToggleDateGroup: (pageIds: string[], checked: boolean) => void;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onDeleteSelected: () => void;
|
||||||
|
onDeletePage: (page: MindSpacePage) => void;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
onFirstPage: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MindSpaceAchievementsPanel({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
selectedIds,
|
||||||
|
deleting,
|
||||||
|
deletingPageId,
|
||||||
|
onBack,
|
||||||
|
onOpenPage,
|
||||||
|
onShowPage,
|
||||||
|
onTogglePage,
|
||||||
|
onToggleDateGroup,
|
||||||
|
onToggleAll,
|
||||||
|
onClearSelection,
|
||||||
|
onDeleteSelected,
|
||||||
|
onDeletePage,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
onFirstPage,
|
||||||
|
}: MindSpaceAchievementsPanelProps) {
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||||
|
const grouped = useMemo(() => groupPagesByCreatedDate(items), [items]);
|
||||||
|
const allVisibleSelected =
|
||||||
|
items.length > 0 && items.every((page) => selectedSet.has(page.id));
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mindspace-independent-page mindspace-achievements">
|
||||||
|
<div className="mindspace-section-heading">
|
||||||
|
<div>
|
||||||
|
<button type="button" className="mindspace-inline-back" onClick={onBack}>
|
||||||
|
返回空间首页
|
||||||
|
</button>
|
||||||
|
<p className="mindspace-eyebrow">MY ACHIEVEMENTS</p>
|
||||||
|
<h2>我的成果展示</h2>
|
||||||
|
</div>
|
||||||
|
<span>{total} 个页面</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mindspace-achievements-intro">
|
||||||
|
按创建日期汇总你制作过的页面,每页 {pageSize} 条。浏览量来自公开页访问统计;点击量需开启 Umami 后才会显示。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="mindspace-asset-bulkbar mindspace-achievements-bulkbar">
|
||||||
|
<button type="button" onClick={onToggleAll} disabled={deleting || items.length === 0}>
|
||||||
|
{allVisibleSelected ? '取消全选' : '全选本页'}
|
||||||
|
</button>
|
||||||
|
<span>已选 {selectedIds.length} 项</span>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<button type="button" onClick={onClearSelection} disabled={deleting}>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mindspace-asset-bulk-delete"
|
||||||
|
onClick={onDeleteSelected}
|
||||||
|
disabled={selectedIds.length === 0 || deleting}
|
||||||
|
>
|
||||||
|
{deleting ? '删除中…' : '删除选中'}
|
||||||
|
</button>
|
||||||
|
{total > pageSize && pageIndex > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mindspace-achievements-back-page"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={onFirstPage}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<div className="mindspace-state">正在加载成果列表…</div>
|
||||||
|
) : total > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mindspace-achievements-groups">
|
||||||
|
{grouped.map((group) => {
|
||||||
|
const groupIds = group.items.map((page) => page.id);
|
||||||
|
const groupSelected =
|
||||||
|
groupIds.length > 0 && groupIds.every((id) => selectedSet.has(id));
|
||||||
|
return (
|
||||||
|
<section className="mindspace-achievements-group" key={group.dateKey}>
|
||||||
|
<div className="mindspace-achievements-group-heading">
|
||||||
|
<h3>{group.heading}</h3>
|
||||||
|
<label className="mindspace-achievements-group-select">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={groupSelected}
|
||||||
|
disabled={deleting}
|
||||||
|
onChange={(event) =>
|
||||||
|
onToggleDateGroup(groupIds, event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{groupSelected ? '取消全选本日' : '全选本日'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mindspace-achievements-table-wrap">
|
||||||
|
<table className="mindspace-achievements-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="mindspace-achievements-col-select">
|
||||||
|
选择
|
||||||
|
</th>
|
||||||
|
<th scope="col">标题</th>
|
||||||
|
<th scope="col">状态</th>
|
||||||
|
<th scope="col">创建时间</th>
|
||||||
|
<th scope="col">最后修改</th>
|
||||||
|
<th scope="col">浏览量</th>
|
||||||
|
<th scope="col">点击量</th>
|
||||||
|
<th scope="col">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.items.map((page) => (
|
||||||
|
<tr key={page.id}>
|
||||||
|
<td className="mindspace-achievements-col-select">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSet.has(page.id)}
|
||||||
|
disabled={deleting}
|
||||||
|
aria-label={`选择 ${page.title}`}
|
||||||
|
onChange={(event) =>
|
||||||
|
onTogglePage(page.id, event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="mindspace-achievements-title">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mindspace-achievements-title-link"
|
||||||
|
onClick={() => void onOpenPage(page)}
|
||||||
|
>
|
||||||
|
{page.title}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mindspace-achievements-detail-link"
|
||||||
|
onClick={() => onShowPage(page.id)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{PAGE_STATUS_LABELS[page.status] ?? page.status}</td>
|
||||||
|
<td>{formatDateTime(page.createdAt)}</td>
|
||||||
|
<td>{formatDateTime(page.updatedAt)}</td>
|
||||||
|
<td>{formatMetric(page.viewCount ?? 0)}</td>
|
||||||
|
<td>{formatClickMetric(page)}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mindspace-achievements-delete-link"
|
||||||
|
disabled={deleting || deletingPageId === page.id}
|
||||||
|
onClick={() => onDeletePage(page)}
|
||||||
|
>
|
||||||
|
{deletingPageId === page.id ? '删除中…' : '删除'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > pageSize && (
|
||||||
|
<div className="mindspace-pagination mindspace-achievements-pagination">
|
||||||
|
<button type="button" disabled={pageIndex <= 0 || loading} onClick={onPrevPage}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
第 {pageIndex + 1} / {pageCount} 页
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || (pageIndex + 1) * pageSize >= total}
|
||||||
|
onClick={onNextPage}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mindspace-recent-work-empty">
|
||||||
|
<p>上传资料或从聊天保存页面后,你的成果会按日期集中展示在这里。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
getMindSpacePage,
|
getMindSpacePage,
|
||||||
getMindSpacePageDeletePreview,
|
getMindSpacePageDeletePreview,
|
||||||
ignoreMindSpaceScheduleReminder,
|
ignoreMindSpaceScheduleReminder,
|
||||||
|
isMindSpacePageNotFoundError,
|
||||||
listMindSpaceAgentJobs,
|
listMindSpaceAgentJobs,
|
||||||
listMindSpaceAssets,
|
listMindSpaceAssets,
|
||||||
listMindSpaceCleanupItems,
|
listMindSpaceCleanupItems,
|
||||||
listMindSpacePages,
|
listMindSpacePages,
|
||||||
|
listMindSpaceAchievementPages,
|
||||||
retryMindSpaceAgentJob,
|
retryMindSpaceAgentJob,
|
||||||
runMindSpaceAgentJob,
|
runMindSpaceAgentJob,
|
||||||
runMindSpaceCleanup,
|
runMindSpaceCleanup,
|
||||||
@@ -55,6 +57,7 @@ import {
|
|||||||
} from '../utils/mindspaceCards';
|
} from '../utils/mindspaceCards';
|
||||||
import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePreviewData';
|
import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePreviewData';
|
||||||
import { MindSpaceFeedCard } from './MindSpaceFeedCard';
|
import { MindSpaceFeedCard } from './MindSpaceFeedCard';
|
||||||
|
import { MindSpaceAchievementsPanel } from './MindSpaceAchievementsPanel';
|
||||||
import { MindSpacePageDetail } from './MindSpacePageDetail';
|
import { MindSpacePageDetail } from './MindSpacePageDetail';
|
||||||
import { MindSpaceModal } from './MindSpaceModal';
|
import { MindSpaceModal } from './MindSpaceModal';
|
||||||
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
|
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
|
||||||
@@ -83,6 +86,7 @@ const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
|
|||||||
const AGENT_JOBS_PAGE_SIZE = 10;
|
const AGENT_JOBS_PAGE_SIZE = 10;
|
||||||
const RECENT_PAGES_PAGE_SIZE = 6;
|
const RECENT_PAGES_PAGE_SIZE = 6;
|
||||||
const ALL_PAGES_PAGE_SIZE = 15;
|
const ALL_PAGES_PAGE_SIZE = 15;
|
||||||
|
const ACHIEVEMENTS_PAGE_SIZE = 20;
|
||||||
|
|
||||||
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
|
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
|
||||||
return [...pages].sort((left, right) => {
|
return [...pages].sort((left, right) => {
|
||||||
@@ -499,6 +503,7 @@ function canGenerateWithAgent(asset: MindSpaceAsset) {
|
|||||||
|
|
||||||
type MindSpaceRouteSync = {
|
type MindSpaceRouteSync = {
|
||||||
pushHome: () => void;
|
pushHome: () => void;
|
||||||
|
pushAchievements: () => void;
|
||||||
pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void;
|
pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void;
|
||||||
pushPage: (pageId: string) => void;
|
pushPage: (pageId: string) => void;
|
||||||
};
|
};
|
||||||
@@ -528,6 +533,7 @@ export function MindSpaceView({
|
|||||||
previewMode = false,
|
previewMode = false,
|
||||||
initialPageId,
|
initialPageId,
|
||||||
initialCategoryCode,
|
initialCategoryCode,
|
||||||
|
initialAchievementsOpen = false,
|
||||||
onBack,
|
onBack,
|
||||||
onLogout,
|
onLogout,
|
||||||
onOpenFeedback,
|
onOpenFeedback,
|
||||||
@@ -537,6 +543,7 @@ export function MindSpaceView({
|
|||||||
previewMode?: boolean;
|
previewMode?: boolean;
|
||||||
initialPageId?: string | null;
|
initialPageId?: string | null;
|
||||||
initialCategoryCode?: MindSpaceSaveCategory | null;
|
initialCategoryCode?: MindSpaceSaveCategory | null;
|
||||||
|
initialAchievementsOpen?: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onOpenFeedback?: () => void;
|
onOpenFeedback?: () => void;
|
||||||
@@ -589,6 +596,13 @@ export function MindSpaceView({
|
|||||||
const [allPagesTotal, setAllPagesTotal] = useState(0);
|
const [allPagesTotal, setAllPagesTotal] = useState(0);
|
||||||
const [allPagesPageIndex, setAllPagesPageIndex] = useState(0);
|
const [allPagesPageIndex, setAllPagesPageIndex] = useState(0);
|
||||||
const [allPagesLoading, setAllPagesLoading] = useState(false);
|
const [allPagesLoading, setAllPagesLoading] = useState(false);
|
||||||
|
const [achievementsOpen, setAchievementsOpen] = useState(initialAchievementsOpen);
|
||||||
|
const [achievementsItems, setAchievementsItems] = useState<MindSpacePage[]>([]);
|
||||||
|
const [achievementsTotal, setAchievementsTotal] = useState(0);
|
||||||
|
const [achievementsPageIndex, setAchievementsPageIndex] = useState(0);
|
||||||
|
const [achievementsLoading, setAchievementsLoading] = useState(false);
|
||||||
|
const [selectedAchievementPageIds, setSelectedAchievementPageIds] = useState<string[]>([]);
|
||||||
|
const [bulkDeletingAchievements, setBulkDeletingAchievements] = useState(false);
|
||||||
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
|
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
|
||||||
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
|
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
|
||||||
const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState<string[]>([]);
|
const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState<string[]>([]);
|
||||||
@@ -605,7 +619,7 @@ export function MindSpaceView({
|
|||||||
const [pageDeleteRemoveFromPlaza, setPageDeleteRemoveFromPlaza] = useState(false);
|
const [pageDeleteRemoveFromPlaza, setPageDeleteRemoveFromPlaza] = useState(false);
|
||||||
const [deletingPageId, setDeletingPageId] = useState<string | null>(null);
|
const [deletingPageId, setDeletingPageId] = useState<string | null>(null);
|
||||||
const [bulkPageDeleteOpen, setBulkPageDeleteOpen] = useState(false);
|
const [bulkPageDeleteOpen, setBulkPageDeleteOpen] = useState(false);
|
||||||
const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft'>('all');
|
const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft' | 'achievements'>('all');
|
||||||
const [bulkPageDeletePreviews, setBulkPageDeletePreviews] = useState<
|
const [bulkPageDeletePreviews, setBulkPageDeletePreviews] = useState<
|
||||||
MindSpacePageDeletePreview[] | null
|
MindSpacePageDeletePreview[] | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -649,11 +663,11 @@ export function MindSpaceView({
|
|||||||
}, [selectedPageId]);
|
}, [selectedPageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allPagesOpen && !selectedPageId) return;
|
if (!allPagesOpen && !achievementsOpen && !selectedPageId) return;
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
window.scrollTo({ top: 0, left: 0 });
|
window.scrollTo({ top: 0, left: 0 });
|
||||||
});
|
});
|
||||||
}, [allPagesOpen, selectedPageId]);
|
}, [allPagesOpen, achievementsOpen, selectedPageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
|
if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
|
||||||
@@ -802,8 +816,30 @@ export function MindSpaceView({
|
|||||||
setPageDeleteTarget(null);
|
setPageDeleteTarget(null);
|
||||||
setPageDeletePreview(null);
|
setPageDeletePreview(null);
|
||||||
setPageDeleteRemoveFromPlaza(false);
|
setPageDeleteRemoveFromPlaza(false);
|
||||||
|
setSelectedAchievementPageIds((ids) => ids.filter((id) => id !== pageDeleteTarget.id));
|
||||||
|
setAchievementsItems((items) => items.filter((page) => page.id !== pageDeleteTarget.id));
|
||||||
|
setAchievementsTotal((total) => Math.max(0, total - 1));
|
||||||
await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes });
|
await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes });
|
||||||
|
if (achievementsOpen) {
|
||||||
|
await loadAchievementsPage(achievementsPageIndex);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isMindSpacePageNotFoundError(err)) {
|
||||||
|
if (selectedPageId === pageDeleteTarget.id) {
|
||||||
|
closePage();
|
||||||
|
}
|
||||||
|
setPageDeleteTarget(null);
|
||||||
|
setPageDeletePreview(null);
|
||||||
|
setPageDeleteRemoveFromPlaza(false);
|
||||||
|
setSelectedAchievementPageIds((ids) => ids.filter((id) => id !== pageDeleteTarget.id));
|
||||||
|
setAchievementsItems((items) => items.filter((page) => page.id !== pageDeleteTarget.id));
|
||||||
|
setAchievementsTotal((total) => Math.max(0, total - 1));
|
||||||
|
await refreshMindSpaceSnapshot();
|
||||||
|
if (achievementsOpen) {
|
||||||
|
await loadAchievementsPage(achievementsPageIndex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : '删除页面失败');
|
setError(err instanceof Error ? err.message : '删除页面失败');
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingPageId(null);
|
setDeletingPageId(null);
|
||||||
@@ -959,12 +995,58 @@ export function MindSpaceView({
|
|||||||
setSelectedPageId(null);
|
setSelectedPageId(null);
|
||||||
setNewPageOpen(false);
|
setNewPageOpen(false);
|
||||||
setAgentJobsPanelOpen(false);
|
setAgentJobsPanelOpen(false);
|
||||||
|
setAchievementsOpen(false);
|
||||||
setAllPagesOpen(true);
|
setAllPagesOpen(true);
|
||||||
setSelectedAllPageIds([]);
|
setSelectedAllPageIds([]);
|
||||||
routeSync?.pushHome();
|
routeSync?.pushHome();
|
||||||
void loadAllPagesPage(0);
|
void loadAllPagesPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAchievementsPage = async (pageIndex: number) => {
|
||||||
|
if (previewMode) {
|
||||||
|
setAchievementsItems(PREVIEW_PAGES);
|
||||||
|
setAchievementsTotal(PREVIEW_PAGES.length);
|
||||||
|
setAchievementsPageIndex(0);
|
||||||
|
setSelectedAchievementPageIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAchievementsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { items, page } = await listMindSpaceAchievementPages({
|
||||||
|
limit: ACHIEVEMENTS_PAGE_SIZE,
|
||||||
|
offset: pageIndex * ACHIEVEMENTS_PAGE_SIZE,
|
||||||
|
includeStats: false,
|
||||||
|
});
|
||||||
|
setAchievementsItems(items);
|
||||||
|
setAchievementsPageIndex(pageIndex);
|
||||||
|
setAchievementsTotal(page.total ?? items.length);
|
||||||
|
setSelectedAchievementPageIds([]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '成果列表加载失败');
|
||||||
|
} finally {
|
||||||
|
setAchievementsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAchievementsPanel = () => {
|
||||||
|
setSelectedCategory(null);
|
||||||
|
setSelectedPageId(null);
|
||||||
|
setNewPageOpen(false);
|
||||||
|
setAgentJobsPanelOpen(false);
|
||||||
|
setAllPagesOpen(false);
|
||||||
|
setAchievementsOpen(true);
|
||||||
|
setSelectedAchievementPageIds([]);
|
||||||
|
routeSync?.pushAchievements();
|
||||||
|
void loadAchievementsPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAchievementsPanel = () => {
|
||||||
|
setAchievementsOpen(false);
|
||||||
|
setSelectedAchievementPageIds([]);
|
||||||
|
routeSync?.pushHome();
|
||||||
|
};
|
||||||
|
|
||||||
const refreshAgentJobsSummary = async () => {
|
const refreshAgentJobsSummary = async () => {
|
||||||
if (previewMode) return;
|
if (previewMode) return;
|
||||||
const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 });
|
const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 });
|
||||||
@@ -999,6 +1081,7 @@ export function MindSpaceView({
|
|||||||
setSelectedPageId(null);
|
setSelectedPageId(null);
|
||||||
setNewPageOpen(false);
|
setNewPageOpen(false);
|
||||||
setAllPagesOpen(false);
|
setAllPagesOpen(false);
|
||||||
|
setAchievementsOpen(false);
|
||||||
setAgentJobsPanelOpen(true);
|
setAgentJobsPanelOpen(true);
|
||||||
routeSync?.pushHome();
|
routeSync?.pushHome();
|
||||||
void loadAgentJobsPage(0);
|
void loadAgentJobsPage(0);
|
||||||
@@ -1066,6 +1149,9 @@ export function MindSpaceView({
|
|||||||
if (allPagesOpen) {
|
if (allPagesOpen) {
|
||||||
await loadAllPagesPage(allPagesPageIndex);
|
await loadAllPagesPage(allPagesPageIndex);
|
||||||
}
|
}
|
||||||
|
if (achievementsOpen) {
|
||||||
|
await loadAchievementsPage(achievementsPageIndex);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore background list refresh errors
|
// ignore background list refresh errors
|
||||||
}
|
}
|
||||||
@@ -1114,7 +1200,7 @@ export function MindSpaceView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previewMode) return;
|
if (previewMode) return;
|
||||||
const refreshSchedule = () => {
|
const refreshSchedule = () => {
|
||||||
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen) {
|
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen) {
|
||||||
void refreshSpaceQuietly();
|
void refreshSpaceQuietly();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1130,6 +1216,7 @@ export function MindSpaceView({
|
|||||||
newPageOpen,
|
newPageOpen,
|
||||||
agentJobsPanelOpen,
|
agentJobsPanelOpen,
|
||||||
allPagesOpen,
|
allPagesOpen,
|
||||||
|
achievementsOpen,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1137,10 +1224,21 @@ export function MindSpaceView({
|
|||||||
}, [initialPageId]);
|
}, [initialPageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!space || !initialCategoryCode || initialCategoryCode === 'draft') return;
|
if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen) return;
|
||||||
const category = space.categories.find((item) => item.code === initialCategoryCode);
|
const category = space.categories.find((item) => item.code === initialCategoryCode);
|
||||||
if (category) void openCategory(category);
|
if (category) void openCategory(category);
|
||||||
}, [space, initialCategoryCode]);
|
}, [space, initialCategoryCode, initialAchievementsOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialAchievementsOpen || previewMode) return;
|
||||||
|
setSelectedCategory(null);
|
||||||
|
setSelectedPageId(null);
|
||||||
|
setNewPageOpen(false);
|
||||||
|
setAgentJobsPanelOpen(false);
|
||||||
|
setAllPagesOpen(false);
|
||||||
|
setAchievementsOpen(true);
|
||||||
|
void loadAchievementsPage(0);
|
||||||
|
}, [initialAchievementsOpen, previewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return;
|
if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return;
|
||||||
@@ -1167,6 +1265,7 @@ export function MindSpaceView({
|
|||||||
const openCategory = async (category: MindSpaceCategory) => {
|
const openCategory = async (category: MindSpaceCategory) => {
|
||||||
setAgentJobsPanelOpen(false);
|
setAgentJobsPanelOpen(false);
|
||||||
setAllPagesOpen(false);
|
setAllPagesOpen(false);
|
||||||
|
setAchievementsOpen(false);
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
setSelectedPageId(null);
|
setSelectedPageId(null);
|
||||||
setNewPageOpen(false);
|
setNewPageOpen(false);
|
||||||
@@ -1586,13 +1685,13 @@ export function MindSpaceView({
|
|||||||
}, [bulkPageDeletePreviews]);
|
}, [bulkPageDeletePreviews]);
|
||||||
|
|
||||||
const closeBulkPageDeleteDialog = () => {
|
const closeBulkPageDeleteDialog = () => {
|
||||||
if (bulkDeletingAllPages || bulkDeletingDraftPages) return;
|
if (bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements) return;
|
||||||
setBulkPageDeleteOpen(false);
|
setBulkPageDeleteOpen(false);
|
||||||
setBulkPageDeletePreviews(null);
|
setBulkPageDeletePreviews(null);
|
||||||
setBulkPageDeleteRemoveFromPlaza(false);
|
setBulkPageDeleteRemoveFromPlaza(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft') => {
|
const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft' | 'achievements') => {
|
||||||
if (pageIds.length === 0) return;
|
if (pageIds.length === 0) return;
|
||||||
if (previewMode) {
|
if (previewMode) {
|
||||||
setError('预览模式下无法删除页面');
|
setError('预览模式下无法删除页面');
|
||||||
@@ -1632,7 +1731,11 @@ export function MindSpaceView({
|
|||||||
if (!bulkPageDeletePreviews || bulkPageDeletePreviews.length === 0) return;
|
if (!bulkPageDeletePreviews || bulkPageDeletePreviews.length === 0) return;
|
||||||
const deletingIds = bulkPageDeletePreviews.map((preview) => preview.page.id);
|
const deletingIds = bulkPageDeletePreviews.map((preview) => preview.page.id);
|
||||||
const setBulkDeleting =
|
const setBulkDeleting =
|
||||||
bulkPageDeleteSource === 'all' ? setBulkDeletingAllPages : setBulkDeletingDraftPages;
|
bulkPageDeleteSource === 'all'
|
||||||
|
? setBulkDeletingAllPages
|
||||||
|
: bulkPageDeleteSource === 'draft'
|
||||||
|
? setBulkDeletingDraftPages
|
||||||
|
: setBulkDeletingAchievements;
|
||||||
setBulkDeleting(true);
|
setBulkDeleting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const failedIds: string[] = [];
|
const failedIds: string[] = [];
|
||||||
@@ -1645,6 +1748,10 @@ export function MindSpaceView({
|
|||||||
});
|
});
|
||||||
if (selectedPageId === preview.page.id) closePage();
|
if (selectedPageId === preview.page.id) closePage();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isMindSpacePageNotFoundError(err)) {
|
||||||
|
if (selectedPageId === preview.page.id) closePage();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
failedIds.push(preview.page.id);
|
failedIds.push(preview.page.id);
|
||||||
failedNames.push(preview.page.title);
|
failedNames.push(preview.page.title);
|
||||||
console.error('[MindSpace] delete page failed:', preview.page.id, err);
|
console.error('[MindSpace] delete page failed:', preview.page.id, err);
|
||||||
@@ -1653,8 +1760,10 @@ export function MindSpaceView({
|
|||||||
const deletedIds = deletingIds.filter((id) => !failedIds.includes(id));
|
const deletedIds = deletingIds.filter((id) => !failedIds.includes(id));
|
||||||
if (bulkPageDeleteSource === 'all') {
|
if (bulkPageDeleteSource === 'all') {
|
||||||
setSelectedAllPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
setSelectedAllPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
||||||
} else {
|
} else if (bulkPageDeleteSource === 'draft') {
|
||||||
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
||||||
|
} else {
|
||||||
|
setSelectedAchievementPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
||||||
}
|
}
|
||||||
setBulkPageDeleteOpen(false);
|
setBulkPageDeleteOpen(false);
|
||||||
setBulkPageDeletePreviews(null);
|
setBulkPageDeletePreviews(null);
|
||||||
@@ -1668,6 +1777,12 @@ export function MindSpaceView({
|
|||||||
: allPagesPageIndex;
|
: allPagesPageIndex;
|
||||||
await loadAllPagesPage(nextPageIndex);
|
await loadAllPagesPage(nextPageIndex);
|
||||||
}
|
}
|
||||||
|
if (bulkPageDeleteSource === 'achievements' && deletedIds.length > 0) {
|
||||||
|
setAchievementsItems((items) => items.filter((page) => !deletedIds.includes(page.id)));
|
||||||
|
setAchievementsTotal((total) => Math.max(0, total - deletedIds.length));
|
||||||
|
setSelectedAchievementPageIds((ids) => ids.filter((id) => !deletedIds.includes(id)));
|
||||||
|
await loadAchievementsPage(achievementsPageIndex);
|
||||||
|
}
|
||||||
if (failedNames.length > 0) {
|
if (failedNames.length > 0) {
|
||||||
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
|
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
|
||||||
}
|
}
|
||||||
@@ -1698,6 +1813,45 @@ export function MindSpaceView({
|
|||||||
void startBulkPageDelete([...selectedAllPageIds], 'all');
|
void startBulkPageDelete([...selectedAllPageIds], 'all');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedAchievementPageSet = useMemo(
|
||||||
|
() => new Set(selectedAchievementPageIds),
|
||||||
|
[selectedAchievementPageIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAchievementPageSelected = (pageId: string, checked: boolean) => {
|
||||||
|
setSelectedAchievementPageIds((ids) =>
|
||||||
|
checked ? (ids.includes(pageId) ? ids : [...ids, pageId]) : ids.filter((id) => id !== pageId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAchievementDateGroupSelected = (pageIds: string[], checked: boolean) => {
|
||||||
|
setSelectedAchievementPageIds((ids) => {
|
||||||
|
if (!checked) {
|
||||||
|
const remove = new Set(pageIds);
|
||||||
|
return ids.filter((id) => !remove.has(id));
|
||||||
|
}
|
||||||
|
return [...new Set([...ids, ...pageIds])];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllAchievementPagesSelected = () => {
|
||||||
|
const allSelected =
|
||||||
|
achievementsItems.length > 0 &&
|
||||||
|
achievementsItems.every((page) => selectedAchievementPageSet.has(page.id));
|
||||||
|
if (allSelected) {
|
||||||
|
const visibleIds = new Set(achievementsItems.map((page) => page.id));
|
||||||
|
setSelectedAchievementPageIds((ids) => ids.filter((id) => !visibleIds.has(id)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAchievementPageIds((ids) => [
|
||||||
|
...new Set([...ids, ...achievementsItems.map((page) => page.id)]),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSelectedAchievementPages = () => {
|
||||||
|
void startBulkPageDelete([...selectedAchievementPageIds], 'achievements');
|
||||||
|
};
|
||||||
|
|
||||||
const allPagesPageCount = Math.max(1, Math.ceil(allPagesTotal / ALL_PAGES_PAGE_SIZE));
|
const allPagesPageCount = Math.max(1, Math.ceil(allPagesTotal / ALL_PAGES_PAGE_SIZE));
|
||||||
|
|
||||||
const previewAsset = useMemo(
|
const previewAsset = useMemo(
|
||||||
@@ -1926,7 +2080,13 @@ export function MindSpaceView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showHomeModules =
|
const showHomeModules =
|
||||||
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
|
space &&
|
||||||
|
!selectedCategory &&
|
||||||
|
!selectedPageId &&
|
||||||
|
!newPageOpen &&
|
||||||
|
!agentJobsPanelOpen &&
|
||||||
|
!allPagesOpen &&
|
||||||
|
!achievementsOpen;
|
||||||
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
|
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
|
||||||
const scheduleReminders = previewMode
|
const scheduleReminders = previewMode
|
||||||
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
|
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
|
||||||
@@ -2066,7 +2226,7 @@ export function MindSpaceView({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mindspace-content">
|
<main className="mindspace-content">
|
||||||
{!allPagesOpen && (
|
{!allPagesOpen && !achievementsOpen && (
|
||||||
<section className="mindspace-hero">
|
<section className="mindspace-hero">
|
||||||
<div className="mindspace-hero-left">
|
<div className="mindspace-hero-left">
|
||||||
<p className="mindspace-eyebrow">MINDSPACE</p>
|
<p className="mindspace-eyebrow">MINDSPACE</p>
|
||||||
@@ -2088,6 +2248,7 @@ export function MindSpaceView({
|
|||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setSelectedPageId(null);
|
setSelectedPageId(null);
|
||||||
setAllPagesOpen(false);
|
setAllPagesOpen(false);
|
||||||
|
setAchievementsOpen(false);
|
||||||
setNewPageOpen(true);
|
setNewPageOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2362,7 +2523,7 @@ export function MindSpaceView({
|
|||||||
<section className="mindspace-page-reader">
|
<section className="mindspace-page-reader">
|
||||||
<header className="mindspace-page-reader-bar">
|
<header className="mindspace-page-reader-bar">
|
||||||
<button type="button" className="mindspace-inline-back" onClick={closePage}>
|
<button type="button" className="mindspace-inline-back" onClick={closePage}>
|
||||||
{allPagesOpen ? '返回全部页面' : '返回空间首页'}
|
{allPagesOpen ? '返回全部页面' : achievementsOpen ? '返回成果展示' : '返回空间首页'}
|
||||||
</button>
|
</button>
|
||||||
<span>站内浏览</span>
|
<span>站内浏览</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -2444,6 +2605,29 @@ export function MindSpaceView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : achievementsOpen ? (
|
||||||
|
<MindSpaceAchievementsPanel
|
||||||
|
items={achievementsItems}
|
||||||
|
total={achievementsTotal}
|
||||||
|
loading={achievementsLoading}
|
||||||
|
pageIndex={achievementsPageIndex}
|
||||||
|
pageSize={ACHIEVEMENTS_PAGE_SIZE}
|
||||||
|
selectedIds={selectedAchievementPageIds}
|
||||||
|
deleting={bulkDeletingAchievements}
|
||||||
|
deletingPageId={deletingPageId}
|
||||||
|
onBack={closeAchievementsPanel}
|
||||||
|
onOpenPage={(page) => void openPageViewInNewTab(page)}
|
||||||
|
onShowPage={(pageId) => showPage(pageId)}
|
||||||
|
onTogglePage={toggleAchievementPageSelected}
|
||||||
|
onToggleDateGroup={toggleAchievementDateGroupSelected}
|
||||||
|
onToggleAll={toggleAllAchievementPagesSelected}
|
||||||
|
onClearSelection={() => setSelectedAchievementPageIds([])}
|
||||||
|
onDeleteSelected={() => void removeSelectedAchievementPages()}
|
||||||
|
onDeletePage={(page) => void openPageDeleteDialog(page)}
|
||||||
|
onPrevPage={() => void loadAchievementsPage(achievementsPageIndex - 1)}
|
||||||
|
onNextPage={() => void loadAchievementsPage(achievementsPageIndex + 1)}
|
||||||
|
onFirstPage={() => void loadAchievementsPage(0)}
|
||||||
|
/>
|
||||||
) : allPagesOpen ? (
|
) : allPagesOpen ? (
|
||||||
<section className="mindspace-independent-page mindspace-all-pages">
|
<section className="mindspace-independent-page mindspace-all-pages">
|
||||||
<div className="mindspace-section-heading">
|
<div className="mindspace-section-heading">
|
||||||
@@ -3023,6 +3207,17 @@ export function MindSpaceView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mindspace-grid-secondary">
|
<div className="mindspace-grid-secondary">
|
||||||
|
<article className="mindspace-card mindspace-card-compact mindspace-card-achievements">
|
||||||
|
<div className="mindspace-card-top">
|
||||||
|
<span className="mindspace-card-code">SHOWCASE</span>
|
||||||
|
<span>{recentPagesTotal} 项</span>
|
||||||
|
</div>
|
||||||
|
<h3>我的成果展示</h3>
|
||||||
|
<p>按日期查看页面标题、创建/修改时间与访问数据。</p>
|
||||||
|
<button type="button" onClick={openAchievementsPanel}>
|
||||||
|
查看成果
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
{space.categories
|
{space.categories
|
||||||
.filter((category) => category.code === 'draft')
|
.filter((category) => category.code === 'draft')
|
||||||
.map((category) => (
|
.map((category) => (
|
||||||
@@ -3048,7 +3243,7 @@ export function MindSpaceView({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && (
|
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && (
|
||||||
<section className="mindspace-section mindspace-recent-work">
|
<section className="mindspace-section mindspace-recent-work">
|
||||||
<div className="mindspace-section-heading">
|
<div className="mindspace-section-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -3433,7 +3628,7 @@ export function MindSpaceView({
|
|||||||
eyebrow="DELETE PAGES"
|
eyebrow="DELETE PAGES"
|
||||||
className="mindspace-delete-dialog"
|
className="mindspace-delete-dialog"
|
||||||
disableClose={
|
disableClose={
|
||||||
bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages
|
bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{bulkPageDeleteLoading && <div className="mindspace-state">正在分析连带内容…</div>}
|
{bulkPageDeleteLoading && <div className="mindspace-state">正在分析连带内容…</div>}
|
||||||
@@ -3460,7 +3655,7 @@ export function MindSpaceView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeBulkPageDeleteDialog}
|
onClick={closeBulkPageDeleteDialog}
|
||||||
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
|
disabled={bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -3468,9 +3663,11 @@ export function MindSpaceView({
|
|||||||
type="button"
|
type="button"
|
||||||
className="mindspace-primary"
|
className="mindspace-primary"
|
||||||
onClick={() => void confirmBulkPageDelete()}
|
onClick={() => void confirmBulkPageDelete()}
|
||||||
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
|
disabled={bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements}
|
||||||
>
|
>
|
||||||
{bulkDeletingAllPages || bulkDeletingDraftPages ? '删除中…' : '确认删除'}
|
{bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements
|
||||||
|
? '删除中…'
|
||||||
|
: '确认删除'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export const PREVIEW_PAGES: MindSpacePage[] = [
|
|||||||
currentVersionId: 'page-1-v2',
|
currentVersionId: 'page-1-v2',
|
||||||
versionNo: 2,
|
versionNo: 2,
|
||||||
contentFormat: 'markdown',
|
contentFormat: 'markdown',
|
||||||
|
viewCount: 86,
|
||||||
|
clickCount: 5,
|
||||||
createdAt: Date.now() - 172_800_000,
|
createdAt: Date.now() - 172_800_000,
|
||||||
updatedAt: Date.now() - 43_200_000,
|
updatedAt: Date.now() - 43_200_000,
|
||||||
},
|
},
|
||||||
@@ -130,6 +132,8 @@ export const PREVIEW_PAGES: MindSpacePage[] = [
|
|||||||
currentVersionId: 'page-2-v1',
|
currentVersionId: 'page-2-v1',
|
||||||
versionNo: 1,
|
versionNo: 1,
|
||||||
contentFormat: 'html',
|
contentFormat: 'html',
|
||||||
|
viewCount: 12,
|
||||||
|
clickCount: 2,
|
||||||
createdAt: Date.now() - 259_200_000,
|
createdAt: Date.now() - 259_200_000,
|
||||||
updatedAt: Date.now() - 86_400_000,
|
updatedAt: Date.now() - 86_400_000,
|
||||||
},
|
},
|
||||||
|
|||||||
+206
@@ -7964,6 +7964,212 @@ body,
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-intro {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: rgba(24, 33, 29, 0.72);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-bulkbar {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-bulkbar .mindspace-achievements-back-page {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border: 1px solid rgba(47, 111, 87, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #2f6f57;
|
||||||
|
background: rgba(235, 248, 240, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-bulkbar .mindspace-achievements-back-page:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-pagination {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-pagination button {
|
||||||
|
padding: 5px 11px;
|
||||||
|
border: 1px solid rgba(24, 33, 29, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 252, 244, 0.92);
|
||||||
|
color: #2f6f57;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-pagination span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-group h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #18211d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-group-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-group-select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(24, 33, 29, 0.72);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-group-select input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: #2f6f57;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-col-select {
|
||||||
|
width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-col-select input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: #2f6f57;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-delete-link {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #9b3d2f;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-delete-link:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18px 0 8px;
|
||||||
|
color: rgba(24, 33, 29, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-load-more button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid rgba(47, 111, 87, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(235, 248, 240, 0.8);
|
||||||
|
color: #1f4f3d;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(24, 33, 29, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 252, 244, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 760px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-table th,
|
||||||
|
.mindspace-achievements-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid rgba(24, 33, 29, 0.08);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-table th {
|
||||||
|
color: rgba(24, 33, 29, 0.62);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(245, 240, 229, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-title {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-title-link {
|
||||||
|
display: inline;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #2f6f57;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-title-link:hover {
|
||||||
|
color: #1f4f3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-achievements-detail-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(24, 33, 29, 0.56);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-card-achievements {
|
||||||
|
border-color: rgba(47, 111, 87, 0.18);
|
||||||
|
background: linear-gradient(180deg, rgba(235, 248, 240, 0.92), rgba(255, 252, 244, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
.mindspace-all-pages .mindspace-feed-card-wrap {
|
.mindspace-all-pages .mindspace-feed-card-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function MindSpaceRoute({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const pageMatch = matchPath('/space/page/:pageId', location.pathname);
|
const pageMatch = matchPath('/space/page/:pageId', location.pathname);
|
||||||
|
const achievementsMatch = matchPath('/space/achievements', location.pathname);
|
||||||
const pageId = pageMatch?.params.pageId ?? null;
|
const pageId = pageMatch?.params.pageId ?? null;
|
||||||
const categoryCode = parseCategory(searchParams.get('category'));
|
const categoryCode = parseCategory(searchParams.get('category'));
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ export function MindSpaceRoute({
|
|||||||
user={user}
|
user={user}
|
||||||
initialPageId={pageId}
|
initialPageId={pageId}
|
||||||
initialCategoryCode={categoryCode}
|
initialCategoryCode={categoryCode}
|
||||||
|
initialAchievementsOpen={Boolean(achievementsMatch)}
|
||||||
onBack={() => navigate('/')}
|
onBack={() => navigate('/')}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onOpenFeedback={() => navigate('/feedback')}
|
onOpenFeedback={() => navigate('/feedback')}
|
||||||
routeSync={{
|
routeSync={{
|
||||||
pushHome: () => navigate('/space'),
|
pushHome: () => navigate('/space'),
|
||||||
|
pushAchievements: () => navigate('/space/achievements'),
|
||||||
pushCategory: (code) => navigate(`/space?category=${code}`),
|
pushCategory: (code) => navigate(`/space?category=${code}`),
|
||||||
pushPage: (id) => navigate(`/space/page/${id}`),
|
pushPage: (id) => navigate(`/space/page/${id}`),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -870,6 +870,12 @@ export type MindSpacePage = {
|
|||||||
status: 'draft' | 'reviewing' | 'risk_found' | 'ready' | 'published' | 'protected' | 'expired' | 'offline';
|
status: 'draft' | 'reviewing' | 'risk_found' | 'ready' | 'published' | 'protected' | 'expired' | 'offline';
|
||||||
visibility: 'private' | 'internal' | 'public';
|
visibility: 'private' | 'internal' | 'public';
|
||||||
publicationAccessMode?: 'public' | 'password' | 'private_link' | 'time_limited' | 'login_required' | 'owner_only' | null;
|
publicationAccessMode?: 'public' | 'password' | 'private_link' | 'time_limited' | 'login_required' | 'owner_only' | null;
|
||||||
|
publicationId?: string | null;
|
||||||
|
publicationStatus?: string | null;
|
||||||
|
viewCount?: number;
|
||||||
|
clickCount?: number | null;
|
||||||
|
statsSource?: 'none' | 'publication' | 'umami';
|
||||||
|
publishedAt?: number | null;
|
||||||
publicationUrl?: string | null;
|
publicationUrl?: string | null;
|
||||||
workspaceRelativePath?: string | null;
|
workspaceRelativePath?: string | null;
|
||||||
workspacePublicUrl?: string | null;
|
workspacePublicUrl?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user