nx-kv 0.11.2 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.3] - 2026-10-23 工作空间模块并入清单(子弹窗),不再单独占一个 tab
8
+
9
+ ### Changed
10
+
11
+ - **工作空间(group)不再是独立模块,也不再单独占一个面板 tab**。它本质就是
12
+ **清单的设置**——它决定清单按哪一组隔离读写,单独把它抬成一个功能域反而割裂了。
13
+ - 独立的 `group` 模块目录删除;8 条命令的 **action id / CLI 命令 / HTTP 路由 /
14
+ 参数一字未改**(`nx-kv group list|get|add|update|remove|current|use|members`、
15
+ `/api/groups*`、`/api/group`),只是住进了清单模块(`src/modules/todo/group.js`)。
16
+ - 面板顶栏少了一个「工作空间」tab(本地任务 / 清单 / 仓库 / 登录)。
17
+ 清单页工具栏里那颗原本只读的工作空间药丸**变成可点的入口**,点开是**子弹窗**:
18
+ 当前空间、切换、成员、改名、解散、新建,都在那一层里;「成员」再弹一层。
19
+ - 清单模块同时管理 `todo` 与 `group` 两个资源(`resources: ['todo','group']`),
20
+ registry 的 CRUD 完备性断言两边都仍然覆盖。
21
+
7
22
  ## [0.11.2] - 2026-10-23 仓库页改成中心清单(扁平,不分组)+ 看板只当标签
8
23
 
9
24
  ### Changed
@@ -34,6 +34,11 @@ nx-kv group current # 当前工作空间
34
34
  数据按 `groupId` 隔离。**先确认工作空间对不对**——在错的空间里会「什么都查不到」,
35
35
  而那不是「没有任务」,是「看错地方了」。
36
36
 
37
+ > 工作空间是**清单的设置**(不是独立功能域):面板上没有单独的「工作空间」页,
38
+ > 入口在**清单页**工具栏里那颗可点的工作空间药丸 —— 点开是子弹窗,
39
+ > 切换 / 成员 / 改名 / 解散 / 新建都在那儿。命令与路由一字未改
40
+ > (`nx-kv group ...`、`/api/groups*`)。
41
+
37
42
  ## 最常用的四条
38
43
 
39
44
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,159 +1,171 @@
1
- // 工作空间页:列出、切换、新建、改名、解散。
2
- // 切换只改本机配置,不动服务端任何数据;解散有前置条件(后端要求组内已无 KV)。
3
- import { useCallback, useEffect, useState } from 'react';
4
- import { api } from '../../web/frontend/api/client.js';
5
- import { useStore } from '../../web/frontend/store.jsx';
6
- import { useToast, useGuard, useDialog, Copyable, Modal } from '../../web/frontend/components/ui.jsx';
7
- import { CliHints } from '../../web/frontend/components/CliHints.jsx';
8
-
9
- export default function GroupView() {
10
- const { boot, refreshBoot } = useStore();
11
- const toast = useToast();
12
- const guard = useGuard();
13
- const { dialog, node: dialogNode } = useDialog();
14
- const [groups, setGroups] = useState([]);
15
- const [err, setErr] = useState('');
16
- const [form, setForm] = useState({ name: '', description: '' });
17
- const [modal, setModal] = useState(null);
18
-
19
- const load = useCallback(async () => {
20
- if (!boot?.loggedIn) return;
21
- try {
22
- const d = await api('/api/groups');
23
- setGroups(d.groups || []);
24
- setErr('');
25
- } catch (e) {
26
- setErr(e.message);
27
- }
28
- }, [boot?.loggedIn]);
29
-
30
- useEffect(() => { load(); }, [load]);
31
-
32
- const use = (locator) => guard(async () => {
33
- const g = await api('/api/group', { method: 'POST', body: { locator } });
34
- toast(g.id === 0 ? '已切回默认组' : `已切换到 ${g.name}`);
35
- await refreshBoot();
36
- await load();
37
- });
38
-
39
- const create = () => guard(async () => {
40
- const name = form.name.trim();
41
- if (!name) { toast('请填名称'); return; }
42
- const g = await api('/api/groups', {
43
- method: 'POST',
44
- body: { name, description: form.description.trim() },
45
- });
46
- setForm({ name: '', description: '' });
47
- toast(`已新建工作空间 ${g.name}(id=${g.id})`);
48
- await refreshBoot();
49
- await load();
50
- });
51
-
52
- const rename = (g) => guard(async () => {
53
- const name = await dialog({ title: `重命名「${g.name}」`, input: true, value: g.name });
54
- if (name === null || !name.trim()) return;
55
- await api(`/api/groups/${g.id}`, { method: 'PATCH', body: { name: name.trim() } });
56
- toast('已更新名称');
57
- await refreshBoot();
58
- await load();
59
- });
60
-
61
- const remove = (g) => guard(async () => {
62
- const ok = await dialog({
63
- message: `解散工作空间「${g.name}」(id=${g.id})?\n\n后端要求组内已无 KV 数据,否则会拒绝。此操作不可撤销。`,
64
- danger: true,
65
- });
66
- if (!ok) return;
67
- await api(`/api/groups/${g.id}`, { method: 'DELETE' });
68
- toast(`已解散 ${g.name}`);
69
- await refreshBoot();
70
- await load();
71
- });
72
-
73
- const members = (g) => guard(async () => {
74
- const d = await api(`/api/groups/${g.id}/members`);
75
- setModal({
76
- title: `成员 · ${g.name}`,
77
- node: (
78
- <div className="list">
79
- {d.members.length
80
- ? d.members.map((m) => (
81
- <div className="row" key={m.userId}>
82
- <Copyable className="name" text={m.email} title="点击复制邮箱">{m.email}</Copyable>
83
- <span className="tag">{m.role || 'member'}</span>
84
- <span className="muted">{m.nickname}</span>
85
- </div>
86
- ))
87
- : <div className="row muted">(没有成员)</div>}
88
- </div>
89
- ),
90
- });
91
- });
92
-
93
- if (!boot?.loggedIn) {
94
- return (
95
- <div className="card" style={{ padding: 16 }}>
96
- <div className="colhead"><h3>请先登录</h3></div>
97
- <div className="muted">工作空间需要登录后才能列出。</div>
98
- </div>
99
- );
100
- }
101
-
102
- return (
103
- <>
104
- <div className="toolbar">
105
- <input placeholder="新工作空间名称" size="16" value={form.name}
106
- onChange={(e) => setForm({ ...form, name: e.target.value })}
107
- onKeyDown={(e) => { if (e.key === 'Enter') create(); }} />
108
- <input placeholder="描述(可选)" size="20" value={form.description}
109
- onChange={(e) => setForm({ ...form, description: e.target.value })} />
110
- <button className="btn" onClick={create}>新建</button>
111
- <span className="sep"></span>
112
- <button className="btn ghost" onClick={() => guard(load)}>刷新</button>
113
- </div>
114
-
115
- <div className="card">
116
- <div className="colhead">
117
- <h3>我的工作空间</h3>
118
- <span className="muted">切换后清单页立即跟着变</span>
119
- </div>
120
- <div className="list">
121
- {err ? <div className="row muted">{err}</div> : null}
122
-
123
- <div className="row">
124
- <span className="name">默认组</span>
125
- <span className="desc">不传 groupId,由后端回落</span>
126
- <span className="acts">
127
- {boot.groupId === 0
128
- ? <span className="tag strong">当前</span>
129
- : <button className="btn small" onClick={() => use('default')}>切到这里</button>}
130
- </span>
131
- </div>
132
-
133
- {groups.map((g) => (
134
- <div className="row" key={g.id}>
135
- <Copyable className="name" text={g.name} title="点击复制名称">{g.name}</Copyable>
136
- <span className="tag">{g.myRole || 'member'}</span>
137
- <span className="desc">{g.description || ''}</span>
138
- <span className="muted mono">id={g.id}</span>
139
- <span className="muted">{g.memberCount} 人</span>
140
- <span className="acts">
141
- {g.id === boot.groupId
142
- ? <span className="tag strong">当前</span>
143
- : <button className="btn small" onClick={() => use(String(g.id))}>切到这里</button>}
144
- <button className="btn small ghost" onClick={() => members(g)}>成员</button>
145
- <button className="btn small ghost" onClick={() => rename(g)}>改名</button>
146
- <button className="btn small ghost danger" onClick={() => remove(g)}>解散</button>
147
- </span>
148
- </div>
149
- ))}
150
- {!err && !groups.length ? <div className="row muted">(没有其他工作空间)</div> : null}
151
- </div>
152
- </div>
153
-
154
- <CliHints module="group" />
155
- {modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.node}</Modal> : null}
156
- {dialogNode}
157
- </>
158
- );
159
- }
1
+ // 工作空间弹窗:清单页的「工作空间」入口点开后弹出的那一层。
2
+ //
3
+ // 工作空间本质是**清单的设置**(它决定清单按哪一组隔离读写),所以它不再是
4
+ // 一个独立的 tab / 独立的模块,而是清单页工具栏里的一颗药丸,点开就是这个弹窗。
5
+ // 弹窗里能:切换当前工作空间 / 看成员 / 改名 / 解散 / 新建。
6
+ //
7
+ // 所有操作都走清单模块已经声明好的 /api/groups* 与 /api/group 路由,
8
+ // 与 CLI 的 `nx-kv group ...` 完全同源(面板上每个按钮都有一条等价 CLI 命令)。
9
+ import { useCallback, useEffect, useState } from 'react';
10
+ import { api } from '../../web/frontend/api/client.js';
11
+ import { useStore } from '../../web/frontend/store.jsx';
12
+ import { useToast, useGuard, useDialog, Copyable, Modal } from '../../web/frontend/components/ui.jsx';
13
+
14
+ export default function GroupPopup({ onClose }) {
15
+ const { boot, refreshBoot } = useStore();
16
+ const toast = useToast();
17
+ const guard = useGuard();
18
+ const { dialog, node: dialogNode } = useDialog();
19
+ const [groups, setGroups] = useState([]);
20
+ const [err, setErr] = useState('');
21
+ const [form, setForm] = useState({ name: '', description: '' });
22
+ const [members, setMembers] = useState(null); // { group, list }
23
+
24
+ const load = useCallback(async () => {
25
+ try {
26
+ const d = await api('/api/groups');
27
+ setGroups(d.groups || []);
28
+ setErr('');
29
+ } catch (e) {
30
+ setErr(e.message || String(e));
31
+ }
32
+ }, []);
33
+
34
+ useEffect(() => { load(); }, [load]);
35
+
36
+ const current = boot?.groupId ?? 0;
37
+
38
+ const use = (locator) => guard(async () => {
39
+ const g = await api('/api/group', { method: 'POST', body: { locator } });
40
+ toast(g.id === 0 ? '已切回默认组' : `已切换到 ${g.name}`);
41
+ await refreshBoot();
42
+ await load();
43
+ });
44
+
45
+ const create = () => guard(async () => {
46
+ const name = form.name.trim();
47
+ if (!name) { toast('请填名称'); return; }
48
+ const g = await api('/api/groups', {
49
+ method: 'POST',
50
+ body: { name, description: form.description.trim() },
51
+ });
52
+ setForm({ name: '', description: '' });
53
+ toast(`已新建工作空间 ${g.name}(id=${g.id})`);
54
+ await refreshBoot();
55
+ await load();
56
+ });
57
+
58
+ const rename = (g) => guard(async () => {
59
+ const name = await dialog({ title: `重命名「${g.name}」`, input: true, value: g.name });
60
+ if (name === null || !name.trim()) return;
61
+ await api(`/api/groups/${g.id}`, { method: 'PATCH', body: { name: name.trim() } });
62
+ toast('已更新名称');
63
+ await refreshBoot();
64
+ await load();
65
+ });
66
+
67
+ const remove = (g) => guard(async () => {
68
+ const ok = await dialog({
69
+ message: `解散工作空间「${g.name}」(id=${g.id})?\n\n后端要求组内已无 KV 数据,否则会拒绝。此操作不可撤销。`,
70
+ danger: true,
71
+ });
72
+ if (!ok) return;
73
+ await api(`/api/groups/${g.id}`, { method: 'DELETE' });
74
+ toast(`已解散 ${g.name}`);
75
+ await refreshBoot();
76
+ await load();
77
+ });
78
+
79
+ const showMembers = (g) => guard(async () => {
80
+ const d = await api(`/api/groups/${g.id}/members`);
81
+ setMembers({ group: d.group, list: d.members || [] });
82
+ });
83
+
84
+ if (!boot?.loggedIn) {
85
+ return (
86
+ <Modal title="工作空间" onClose={onClose}>
87
+ <div className="muted">工作空间需要登录后才能列出。</div>
88
+ </Modal>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <>
94
+ <Modal title="工作空间" onClose={onClose}>
95
+ <div className="muted" style={{ marginBottom: 8 }}>
96
+ 清单按工作空间隔离读写。切换只改本机配置,不动服务端数据。
97
+ </div>
98
+
99
+ {err ? <div className="row muted">{err}</div> : null}
100
+
101
+ <div className="list">
102
+ <div className="row">
103
+ <span className="name">默认组</span>
104
+ <span className="desc">不传 groupId,由后端回落</span>
105
+ <span className="acts">
106
+ {current === 0
107
+ ? <span className="tag strong">当前</span>
108
+ : <button className="btn small" onClick={() => use('default')}>切到这里</button>}
109
+ </span>
110
+ </div>
111
+
112
+ {groups.map((g) => (
113
+ <div className="row" key={g.id}>
114
+ <Copyable className="name" text={g.name} title="点击复制名称">{g.name}</Copyable>
115
+ <span className="tag">{g.myRole || 'member'}</span>
116
+ {g.description ? <span className="desc">{g.description}</span> : null}
117
+ <span className="muted mono">id={g.id}</span>
118
+ <span className="muted">{g.memberCount} 人</span>
119
+ <span className="acts">
120
+ {g.id === current
121
+ ? <span className="tag strong">当前</span>
122
+ : <button className="btn small" onClick={() => use(String(g.id))}>切到这里</button>}
123
+ <button className="btn small ghost" onClick={() => showMembers(g)}>成员</button>
124
+ <button className="btn small ghost" onClick={() => rename(g)}>改名</button>
125
+ <button className="btn small ghost danger" onClick={() => remove(g)}>解散</button>
126
+ </span>
127
+ </div>
128
+ ))}
129
+ {!err && !groups.length ? <div className="row muted">(没有其他工作空间)</div> : null}
130
+ </div>
131
+
132
+ <div className="toolbar" style={{ marginTop: 10 }}>
133
+ <input
134
+ placeholder="新工作空间名称"
135
+ size="16"
136
+ value={form.name}
137
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
138
+ onKeyDown={(e) => { if (e.key === 'Enter') create(); }}
139
+ />
140
+ <input
141
+ placeholder="描述(可选)"
142
+ size="20"
143
+ value={form.description}
144
+ onChange={(e) => setForm({ ...form, description: e.target.value })}
145
+ />
146
+ <button className="btn" onClick={create}>新建</button>
147
+ <button className="btn ghost" onClick={() => guard(load)}>刷新</button>
148
+ </div>
149
+ </Modal>
150
+
151
+ {/* 成员(再一层) */}
152
+ {members ? (
153
+ <Modal title={`成员 · ${members.group.name}`} onClose={() => setMembers(null)}>
154
+ <div className="list">
155
+ {members.list.length
156
+ ? members.list.map((m) => (
157
+ <div className="row" key={m.userId}>
158
+ <Copyable className="name" text={m.email} title="点击复制邮箱">{m.email}</Copyable>
159
+ <span className="tag">{m.role || 'member'}</span>
160
+ <span className="muted">{m.nickname}</span>
161
+ </div>
162
+ ))
163
+ : <div className="row muted">(没有成员)</div>}
164
+ </div>
165
+ </Modal>
166
+ ) : null}
167
+
168
+ {dialogNode}
169
+ </>
170
+ );
171
+ }
@@ -0,0 +1,194 @@
1
+ // 工作空间(group)动作:**清单模块的一部分**,2026-10-23 从独立的 group 模块并进来。
2
+ //
3
+ // 为什么合并:工作空间本质就是**清单的设置**——它决定清单按哪一组隔离读写,
4
+ // 离开清单单独占一个模块/一个 tab 只是把它抬成了独立功能域。命令、路由、
5
+ // 参数一字未改(`nx-kv group ...`、`/api/groups...`),只是住进了清单模块。
6
+ //
7
+ // 后端实际支持:`POST /groups`(建)、`GET /groups`(列)、`PATCH /groups/:id`(改)、
8
+ // `DELETE /groups/:id`(解散)、`GET /groups/:id/members`(成员)。
9
+ // 这是一个**集合资源**,声明 `resources: ['group']` —— 五个 CRUD 操作齐备且两端可达,
10
+ // tests/unit/registry.test.mjs 会据此断言。
11
+ //
12
+ // `current` / `use` / `members` 不是 CRUD,用动作动词命名。
13
+ //
14
+ // 切换是**本机态**:只改 ~/.nx-kv/config.json 的 groupId,不动服务端任何数据。
15
+ // 之后所有 todo 读写都带上它,后端按 groupId 隔离。
16
+ import { loadConfig, updateConfig, requireAuth } from '../../core/config.js';
17
+ import {
18
+ listGroups,
19
+ createGroup,
20
+ updateGroup,
21
+ deleteGroup,
22
+ listGroupMembers,
23
+ } from '../../core/kvapi.js';
24
+ import { badInput, notFound } from '../../core/errors.js';
25
+
26
+ // 纯文本渲染用:把一行的关键信息摆齐
27
+ const renderGroupLine = (g, current) =>
28
+ `${current ? '*' : ' '} ${String(g.id).padStart(4)} ${g.name.padEnd(14)} ${(g.myRole || '').padEnd(6)} ${g.memberCount} 人 ${g.description || ''}`;
29
+
30
+ // 按 id 或名字定位。后端没有「取单个组」的接口,所以从列表里找。
31
+ async function findGroup(locator) {
32
+ const s = String(locator ?? '').trim();
33
+ if (!s) throw badInput('必须给出工作空间 id 或名称(nx-kv group list 可查看)');
34
+ const groups = await listGroups();
35
+ const hit = /^\d+$/.test(s)
36
+ ? groups.find((g) => g.id === Number(s))
37
+ : groups.find((g) => g.name.toLowerCase() === s.toLowerCase());
38
+ if (!hit) {
39
+ throw notFound(`工作空间不存在或无权访问: ${s}(nx-kv group list 查看可用项)`);
40
+ }
41
+ return hit;
42
+ }
43
+
44
+ // 清单模块的 action 列表里直接展开这组(CLI / HTTP 表面与原来完全一致)。
45
+ export const groupActions = [
46
+ // ─── CRUD 五操作 ───────────────────────────────────────────
47
+ {
48
+ id: 'group.list',
49
+ cli: ['group', 'list'],
50
+ http: ['GET', '/api/groups'],
51
+ summary: '列出我的工作空间(* 为当前)',
52
+ run: async () => {
53
+ await requireAuth();
54
+ const [groups, cfg] = await Promise.all([listGroups(), loadConfig()]);
55
+ return {
56
+ current: cfg.groupId,
57
+ groups: groups.map((g) => ({ ...g, current: g.id === cfg.groupId })),
58
+ };
59
+ },
60
+ render: (d) => {
61
+ if (!d.groups.length) return '(没有可用工作空间)';
62
+ return d.groups.map((g) => renderGroupLine(g, g.current)).join('\n');
63
+ },
64
+ },
65
+ {
66
+ id: 'group.get',
67
+ cli: ['group', 'get'],
68
+ http: ['GET', '/api/groups/:id'],
69
+ summary: '查看单个工作空间',
70
+ args: ['id'],
71
+ run: async (ctx) => {
72
+ await requireAuth();
73
+ const g = await findGroup(ctx.id);
74
+ const cfg = await loadConfig();
75
+ return { ...g, current: g.id === cfg.groupId };
76
+ },
77
+ render: (g) =>
78
+ [
79
+ `${g.name}${g.current ? ' ← 当前' : ''}`,
80
+ ` id: ${g.id}`,
81
+ ` 角色: ${g.myRole || '-'}`,
82
+ ` 成员: ${g.memberCount} 人`,
83
+ ` 描述: ${g.description || '-'}`,
84
+ ` 创建: ${String(g.createdAt || '').slice(0, 10) || '-'}`,
85
+ ].join('\n'),
86
+ },
87
+ {
88
+ id: 'group.add',
89
+ cli: ['group', 'add'],
90
+ http: ['POST', '/api/groups'],
91
+ summary: '新建工作空间',
92
+ args: ['name'],
93
+ flags: { description: { type: 'string', hint: '描述' } },
94
+ run: async (ctx) => {
95
+ await requireAuth();
96
+ return createGroup({ name: ctx.name, description: ctx.description });
97
+ },
98
+ render: (g) => `已新建工作空间: ${g.name}(id=${g.id})\n用 nx-kv group use ${g.id} 切过去`,
99
+ },
100
+ {
101
+ id: 'group.update',
102
+ cli: ['group', 'update'],
103
+ http: ['PATCH', '/api/groups/:id'],
104
+ summary: '改名 / 改描述(只改传入的字段)',
105
+ args: ['id'],
106
+ flags: {
107
+ name: { type: 'string', hint: '新名称' },
108
+ description: { type: 'string', hint: '新描述' },
109
+ },
110
+ run: async (ctx) => {
111
+ await requireAuth();
112
+ const g = await findGroup(ctx.id); // 支持按名字定位,再拿真实 id
113
+ return updateGroup(g.id, { name: ctx.name, description: ctx.description });
114
+ },
115
+ render: (g) => `已更新工作空间: ${g.name}(id=${g.id})`,
116
+ },
117
+ {
118
+ id: 'group.remove',
119
+ cli: ['group', 'remove'],
120
+ http: ['DELETE', '/api/groups/:id'],
121
+ summary: '解散工作空间(后端要求组内已无 KV)',
122
+ args: ['id'],
123
+ run: async (ctx) => {
124
+ await requireAuth();
125
+ const g = await findGroup(ctx.id);
126
+ const cfg = await loadConfig();
127
+ if (g.id === cfg.groupId) {
128
+ // 解散当前空间会导致后续所有读写打到一个不存在的组上,先拦住
129
+ throw badInput(`不能解散当前正在使用的工作空间(${g.name}),先 nx-kv group use default 切走`);
130
+ }
131
+ await deleteGroup(g.id);
132
+ return { id: g.id, name: g.name };
133
+ },
134
+ render: (d) => `已解散工作空间: ${d.name}(id=${d.id})`,
135
+ },
136
+
137
+ // ─── 非 CRUD:切换与成员 ───────────────────────────────────
138
+ {
139
+ id: 'group.current',
140
+ cli: ['group', 'current'],
141
+ http: ['GET', '/api/group'],
142
+ summary: '查看当前工作空间',
143
+ run: async () => {
144
+ const cfg = await loadConfig();
145
+ if (!cfg.groupId) return { id: 0, name: '默认组' };
146
+ // 名字是尽力而为:后端不可达时不该让「看当前是什么」失败
147
+ const groups = await listGroups().catch(() => []);
148
+ const hit = groups.find((g) => g.id === cfg.groupId);
149
+ return { id: cfg.groupId, name: hit ? hit.name : `#${cfg.groupId}` };
150
+ },
151
+ render: (d) => `当前工作空间: ${d.name}(${d.id === 0 ? '默认组,不传 groupId' : 'id=' + d.id})`,
152
+ },
153
+ {
154
+ id: 'group.use',
155
+ cli: [['group', 'use'], ['group', 'switch']],
156
+ http: ['POST', '/api/group'],
157
+ summary: '切换当前工作空间(id 或名称;0 / default 回默认组)',
158
+ args: ['locator'],
159
+ run: async (ctx) => {
160
+ await requireAuth();
161
+ const s = String(ctx.locator ?? '').trim();
162
+ if (s === '0' || s.toLowerCase() === 'default') {
163
+ await updateConfig({ groupId: 0 });
164
+ return { id: 0, name: '默认组' };
165
+ }
166
+ const g = await findGroup(s);
167
+ await updateConfig({ groupId: g.id });
168
+ return g;
169
+ },
170
+ render: (d) =>
171
+ d.id === 0
172
+ ? '已切回默认组(后续读写不传 groupId)'
173
+ : `已切换到工作空间: ${d.name}(id=${d.id})`,
174
+ },
175
+ {
176
+ id: 'group.members',
177
+ cli: ['group', 'members'],
178
+ http: ['GET', '/api/groups/:id/members'],
179
+ summary: '查看工作空间成员',
180
+ args: ['id'],
181
+ run: async (ctx) => {
182
+ await requireAuth();
183
+ const g = await findGroup(ctx.id);
184
+ return { group: { id: g.id, name: g.name }, members: await listGroupMembers(g.id) };
185
+ },
186
+ render: (d) => {
187
+ if (!d.members.length) return `${d.group.name}: (没有成员)`;
188
+ const w = Math.max(...d.members.map((m) => m.email.length));
189
+ return d.members
190
+ .map((m) => ` ${m.email.padEnd(w)} ${m.role.padEnd(8)} ${m.nickname}`)
191
+ .join('\n');
192
+ },
193
+ },
194
+ ];
@@ -10,6 +10,7 @@
10
10
  // 同一主题里也本来就有内容重复的任务。按 id 定位迟早改错数据。
11
11
  // 详见 service.js 的「定位」一节。
12
12
  import * as service from './service.js';
13
+ import { groupActions } from './group.js';
13
14
 
14
15
  // 所有动作都接受 --group 覆盖当前工作空间;默认取本机配置里的当前值
15
16
  const GROUP = { type: 'number', hint: '工作空间 id(默认取当前)' };
@@ -91,14 +92,19 @@ function renderTaskAttachments(d) {
91
92
 
92
93
  export default {
93
94
  id: 'todo',
94
- title: '清单(= Web「清单」页)',
95
+ title: '清单(= Web「清单」页;含工作空间设置)',
95
96
  order: 30,
96
97
  view: () => import('./view.jsx'),
97
98
 
98
99
  // CRUD 资源声明:任务的增删查改必须齐备,且 CLI 与面板都能调用
99
100
  resource: 'todo',
101
+ // 工作空间(group)已于 2026-10-23 从独立模块并进来:它是**清单的设置**,
102
+ // 不该单独占一个模块/一个 tab。命令与路由一字未改,只是住进了清单模块。
103
+ // 两个资源都要过 registry 的 CRUD 完备性断言。
104
+ resources: ['todo', 'group'],
100
105
 
101
106
  actions: [
107
+ ...groupActions,
102
108
  // ─── CRUD 五操作 ───────────────────────────────────────────
103
109
  {
104
110
  id: 'todo.list',