@allbluecn/web-app 0.4.16 → 0.4.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -135,14 +135,14 @@
135
135
  "three": "^0.180.0",
136
136
  "tw-animate-css": "^1.4.0",
137
137
  "zod": "^4.4.3",
138
- "@allbluecn/ai-gateway": "0.4.11",
138
+ "@allbluecn/ai-gateway": "0.4.12",
139
139
  "@allbluecn/kernel": "0.3.0",
140
- "@allbluecn/plugins-core": "0.4.11",
140
+ "@allbluecn/database": "0.4.12",
141
+ "@allbluecn/plugins-core": "0.4.12",
141
142
  "@allbluecn/prototype": "0.1.1",
142
- "@allbluecn/shared": "0.4.11",
143
- "@allbluecn/tiptap": "0.4.11",
144
- "@allbluecn/ui": "0.4.11",
145
- "@allbluecn/database": "0.4.11"
143
+ "@allbluecn/ui": "0.4.12",
144
+ "@allbluecn/shared": "0.4.12",
145
+ "@allbluecn/tiptap": "0.4.12"
146
146
  },
147
147
  "devDependencies": {
148
148
  "@babel/parser": "^8.0.4",
@@ -23,6 +23,7 @@ import {
23
23
  LogIn,
24
24
  LogOut,
25
25
  Settings,
26
+ Users,
26
27
  } from "lucide-react";
27
28
  import { logoutRemoteFn } from '@/server/serverFns/auth/session'
28
29
  import { toast } from 'sonner'
@@ -160,9 +161,9 @@ export function UserMenu() {
160
161
  <Settings />
161
162
  {t('sidebar.userMenu.settings')}
162
163
  </DropdownMenuItem>
163
- <DropdownMenuItem onClick={async () => { await router.navigate({ to: "/settings/account" }) }}>
164
- <BadgeCheck />
165
- {t('sidebar.userMenu.profile')}
164
+ <DropdownMenuItem onClick={async () => { await router.navigate({ to: "/settings/team" }) }}>
165
+ <Users />
166
+ {t('sidebar.userMenu.teamManagement')}
166
167
  </DropdownMenuItem>
167
168
 
168
169
 
@@ -29,10 +29,6 @@ vi.mock("@/components/billing/use-feature", () => ({
29
29
  useFeature: vi.fn(),
30
30
  }));
31
31
 
32
- vi.mock("@/components/billing/upgrade-prompt", () => ({
33
- UpgradePrompt: () => <div data-testid="upgrade-prompt">升级提示</div>,
34
- }));
35
-
36
32
  import { useFeature } from "@/components/billing/use-feature";
37
33
 
38
34
  const mockUseFeature = useFeature as ReturnType<typeof vi.fn>;
@@ -214,7 +210,28 @@ describe("DisplayDropdown", () => {
214
210
  expect(patch.showEmptyGroups).toBe(true);
215
211
  });
216
212
 
217
- it("大分页未解锁——500/1000 禁用且点击不触发 onChange", () => {
213
+ it("OSS 环境——只显示 100/200,不显示 500/1000", () => {
214
+ mockUseFeature.mockReturnValue({ enabled: false, loading: false });
215
+ const onChange = vi.fn();
216
+ renderWithTooltip(<DisplayDropdown filters={makeFilters()} onChange={onChange} layout="list" {...viewProps} />);
217
+ fireEvent.click(screen.getByRole("button", { name: /显示/ }));
218
+ const options = document.querySelector('[data-section="options"]') as HTMLElement;
219
+ const sizeButtons = within(options).getAllByRole("button").filter((b) => /^\d+$/.test(b.textContent ?? ""));
220
+ const labels = sizeButtons.map((b) => b.textContent);
221
+ expect(labels).toContain("100");
222
+ expect(labels).toContain("200");
223
+ expect(labels).not.toContain("500");
224
+ expect(labels).not.toContain("1000");
225
+ });
226
+
227
+ it("Pro 环境且 free 用户——500/1000 锁定但可见", async () => {
228
+ // mock implementedModules.billing = true(Pro 环境)
229
+ const modules = await import("@/plugins/modules.gen");
230
+ vi.spyOn(modules, "implementedModules", "get").mockReturnValue({
231
+ ...modules.implementedModules,
232
+ billing: true,
233
+ } as any);
234
+
218
235
  mockUseFeature.mockReturnValue({ enabled: false, loading: false });
219
236
  const onChange = vi.fn();
220
237
  renderWithTooltip(<DisplayDropdown filters={makeFilters()} onChange={onChange} layout="list" {...viewProps} />);
@@ -229,10 +246,16 @@ describe("DisplayDropdown", () => {
229
246
  expect(btn1000).toBeDisabled();
230
247
  fireEvent.click(btn500 as HTMLElement);
231
248
  expect(onChange).not.toHaveBeenCalled();
232
- expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
249
+ expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
233
250
  });
234
251
 
235
- it("大分页已解锁——点击 500 触发 onChange({ pageSize: 500 })", () => {
252
+ it("Pro 环境且 pro 用户——点击 500 触发 onChange", async () => {
253
+ const modules = await import("@/plugins/modules.gen");
254
+ vi.spyOn(modules, "implementedModules", "get").mockReturnValue({
255
+ ...modules.implementedModules,
256
+ billing: true,
257
+ } as any);
258
+
236
259
  mockUseFeature.mockReturnValue({ enabled: true, loading: false });
237
260
  const onChange = vi.fn();
238
261
  renderWithTooltip(<DisplayDropdown filters={makeFilters()} onChange={onChange} layout="list" {...viewProps} />);
@@ -22,7 +22,6 @@ import { Button } from "@/components/ui/button";
22
22
  import { Switch } from "@/components/ui/switch";
23
23
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
24
24
  import { useFeature } from "@/components/billing/use-feature";
25
- import { UpgradePrompt } from "@/components/billing/upgrade-prompt";
26
25
  import {
27
26
  Popover,
28
27
  PopoverContent,
@@ -34,6 +33,7 @@ import type { SavedView, ViewProjectInfo } from "../views/view-types";
34
33
  import type { StoryView } from "../views/view-switcher";
35
34
  import { DEFAULT_DISPLAY_OPTIONS, GROUP_KEYS, ORDER_KEYS, PAGE_SIZE_OPTIONS, type DisplayOptions, type DisplayProperty, type GroupKey, type OrderDirection, type OrderKey, type PageSize } from "./types";
36
35
  import { GROUP_LABELS, ORDER_LABELS, PROPERTY_LABELS, LAYOUT_SUPPORT, LAYOUT_PROPERTIES, GROUP_ICONS, ORDER_ICONS } from "./constants";
36
+ import { implementedModules } from "@/plugins/modules.gen";
37
37
 
38
38
  /** 分组选项:布局不支持分组时返回空数组;选中特定项目时隐藏"项目"分组 */
39
39
  export function getGroupOptions(layout: StoryView, projectId?: string): GroupKey[] {
@@ -377,7 +377,7 @@ function PropertiesSection({ selected, onChange, layout, t }: PropertiesSectionP
377
377
  };
378
378
 
379
379
  return (
380
- <div data-section="properties" className="border-t px-3 py-2">
380
+ <div data-section="properties" className=" px-3 py-2">
381
381
  <button
382
382
  type="button"
383
383
  onClick={() => setCollapsed(!collapsed)}
@@ -432,13 +432,15 @@ function OptionsSection({
432
432
  onPageSizeChange,
433
433
  }: OptionsSectionProps) {
434
434
  const { t } = useTranslation("display");
435
- const sizeOptions = PAGE_SIZE_OPTIONS as readonly PageSize[];
435
+ // OSS 环境(billing 模块未实现)只显示 100/200;Pro 环境显示全部
436
+ const sizeOptions = implementedModules.billing
437
+ ? (PAGE_SIZE_OPTIONS as readonly PageSize[])
438
+ : ([100, 200] as readonly PageSize[]);
436
439
  const { enabled: largePageSizeEnabled, loading: largePageSizeLoading } =
437
440
  useFeature("largePageSize");
438
441
 
439
442
  const isPageSizeLocked = (v: PageSize) =>
440
443
  v > 200 && !largePageSizeEnabled && !largePageSizeLoading;
441
- const showPageSizeUpgrade = !largePageSizeEnabled && !largePageSizeLoading;
442
444
 
443
445
  return (
444
446
  <div data-section="options" className="border-t px-3 py-2">
@@ -484,9 +486,6 @@ function OptionsSection({
484
486
  })}
485
487
  </div>
486
488
  </div>
487
- {showPageSizeUpgrade && (
488
- <UpgradePrompt feature="largePageSize" />
489
- )}
490
489
  </div>
491
490
  </div>
492
491
  );
@@ -16,7 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { useMemo, useState } from "react";
19
- import { CircleUser, Search, Check } from "lucide-react";
19
+ import { CircleUser, Search, Check, ArrowDownUp } from "lucide-react";
20
20
  import { useTranslation } from "react-i18next";
21
21
  import { Input } from "@/components/ui/input";
22
22
  import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -38,18 +38,22 @@ interface AssigneeEditorProps {
38
38
  className?: string;
39
39
  /** 渲染形态:chip=有值时显示 chip 边框(默认,筛选场景),button=始终 Button(列表/详情场景) */
40
40
  variant?: "chip" | "button";
41
+ /** 成员范围:'project'=仅项目成员(默认),'team'=所有团队成员 */
42
+ scope?: "project" | "team";
41
43
  }
42
44
 
43
- export function AssigneeEditor({ assignee, users, onChange, mode = "single", selected, onMultiChange, className, variant = "chip" }: AssigneeEditorProps) {
45
+ export function AssigneeEditor({ assignee, users, onChange, mode = "single", selected, onMultiChange, className, variant = "chip", scope = "project" }: AssigneeEditorProps) {
44
46
  const { t } = useTranslation("story");
45
47
  const [open, setOpen] = useState(false);
46
48
  const [search, setSearch] = useState("");
47
49
  const isMulti = mode === "multi";
48
50
  const showChip = variant === "chip";
51
+ const [currentScope, setCurrentScope] = useState(scope);
49
52
  const filtered = useMemo(
50
53
  () => (search ? users.filter((u) => u.username.includes(search)) : users),
51
54
  [users, search],
52
55
  );
56
+ const isProjectScope = currentScope === "project";
53
57
 
54
58
  return (
55
59
  <Popover open={open} onOpenChange={(v) => { setOpen(v); if (v) setSearch(""); }}>
@@ -110,69 +114,80 @@ export function AssigneeEditor({ assignee, users, onChange, mode = "single", sel
110
114
  className={className}
111
115
  />
112
116
  )}
113
- <PopoverContent className="w-52 p-1" align="start">
114
- <div className="relative mb-1">
115
- <Search className="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
117
+ <PopoverContent className="w-52 p-1 flex flex-col gap-0 max-h-72" align="start">
118
+ <div className="relative mb-1 shrink-0 sticky top-0 z-10 bg-popover py-0.5 -mx-1 px-1 -mt-1 flex items-center gap-1">
119
+ <Search className="absolute top-1/2 left-4 size-3.5 -translate-y-1/2 text-muted-foreground" />
116
120
  <Input
117
121
  value={search}
118
122
  onChange={(e) => setSearch(e.target.value)}
119
- placeholder={t("inline.searchMembers")}
120
- className="h-7 pl-7 pr-2 text-xs md:text-xs"
123
+ placeholder={isProjectScope ? t("inline.searchProjectMembers") : t("inline.searchTeamMembers")}
124
+ className="h-6 pl-8 pr-2 text-xs md:text-xs"
121
125
  />
122
- </div>
123
- {!isMulti && (
124
126
  <button
125
127
  type="button"
126
- onClick={() => { onChange(null); setOpen(false); }}
127
- className={cn(
128
- "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
129
- !assignee ? "bg-accent font-medium" : "hover:bg-muted",
130
- )}
128
+ onClick={() => setCurrentScope(isProjectScope ? "team" : "project")}
129
+ className="shrink-0 flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 rounded hover:bg-accent"
130
+ title={isProjectScope ? t("inline.switchToTeam") : t("inline.switchToProject")}
131
131
  >
132
- <CircleUser strokeWidth={1.5} className="size-6 text-muted-foreground" />
133
- {t("filter.unassignedAssignee")}
132
+ <ArrowDownUp className="size-3" />
133
+ {isProjectScope ? t("inline.scopeTeam") : t("inline.scopeProject")}
134
134
  </button>
135
- )}
136
- {filtered.length === 0 ? (
137
- <div className="px-2 py-1.5 text-xs text-muted-foreground">{t("inline.noMatchMembers")}</div>
138
- ) : (
139
- filtered.map((u) => {
140
- const active = isMulti ? (selected ?? []).includes(u.id) : assignee?.id === u.id;
141
- return (
142
- <button
143
- key={u.id}
144
- type="button"
145
- onClick={() => {
146
- if (isMulti) {
147
- onMultiChange?.(u.id);
148
- } else {
149
- onChange(u.id);
150
- setOpen(false);
151
- }
152
- }}
153
- className={cn(
154
- "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
155
- active ? "bg-accent font-medium" : "hover:bg-muted",
156
- )}
157
- >
158
- {isMulti && (
159
- <div className={cn("flex size-4 shrink-0 items-center justify-center rounded-sm border", active ? "border-primary bg-primary text-primary-foreground" : "border-input")}>
160
- {active && <Check className="size-3" />}
161
- </div>
162
- )}
163
- <UserAvatar
164
- avatarConfig={u.avatarConfig}
165
- bgShape={u.bgShape as "circle" | "rounded" | "square" | null}
166
- bgColor={u.bgColor}
167
- name={u.username}
168
- size="sm"
169
- />
170
- {u.username}
171
- {!isMulti && active && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
172
- </button>
173
- );
174
- })
175
- )}
135
+ </div>
136
+ <div className="overflow-y-auto min-h-0 flex-1 -mx-1 -mb-1 p-1">
137
+ {!isMulti && (
138
+ <button
139
+ type="button"
140
+ onClick={() => { onChange(null); setOpen(false); }}
141
+ className={cn(
142
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
143
+ !assignee ? "bg-accent font-medium" : "hover:bg-muted",
144
+ )}
145
+ >
146
+ <CircleUser strokeWidth={1.5} className="size-6 text-muted-foreground" />
147
+ {t("filter.unassignedAssignee")}
148
+ </button>
149
+ )}
150
+ {filtered.length === 0 ? (
151
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">{t("inline.noMatchMembers")}</div>
152
+ ) : (
153
+ filtered.map((u) => {
154
+ const active = isMulti ? (selected ?? []).includes(u.id) : assignee?.id === u.id;
155
+ return (
156
+ <button
157
+ key={u.id}
158
+ type="button"
159
+ onClick={() => {
160
+ if (isMulti) {
161
+ onMultiChange?.(u.id);
162
+ } else {
163
+ onChange(u.id);
164
+ setOpen(false);
165
+ }
166
+ }}
167
+ className={cn(
168
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
169
+ active ? "bg-accent font-medium" : "hover:bg-muted",
170
+ )}
171
+ >
172
+ {isMulti && (
173
+ <div className={cn("flex size-4 shrink-0 items-center justify-center rounded-sm border", active ? "border-primary bg-primary text-primary-foreground" : "border-input")}>
174
+ {active && <Check className="size-3" />}
175
+ </div>
176
+ )}
177
+ <UserAvatar
178
+ avatarConfig={u.avatarConfig}
179
+ bgShape={u.bgShape as "circle" | "rounded" | "square" | null}
180
+ bgColor={u.bgColor}
181
+ name={u.username}
182
+ size="sm"
183
+ />
184
+ {u.username}
185
+ {!isMulti && active && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
186
+ </button>
187
+ );
188
+ })
189
+ )}
190
+ </div>
176
191
  </PopoverContent>
177
192
  </Popover>
178
193
  );
@@ -28,7 +28,7 @@ import { CalendarView } from "./views/calendar-view";
28
28
  import { TimelineView } from "./views/timeline-view";
29
29
  import { StoryList } from "./views/story-list";
30
30
  import { serializeFilters, type FilterCondition } from "./filters";
31
- import { listStoriesFn, archiveStoryFn } from "@/server/serverFns/story";
31
+ import { listStoriesFn, archiveStoryFn, listUsersBriefFn } from "@/server/serverFns/story";
32
32
  import { listProjectsBriefFn } from "@/server/serverFns/project";
33
33
  import {
34
34
  listStoryViewsFn, createStoryViewFn, updateStoryViewFn, deleteStoryViewFn,
@@ -64,7 +64,7 @@ interface StoryListViewProps {
64
64
  const STORY_PROJECT_FILTER_KEY = "story.projectFilter";
65
65
  const STORY_SEARCH_KEY = "story.search";
66
66
 
67
- export function StoryListView({ stories: initialStories, users, projects: initialProjects, labels }: StoryListViewProps) {
67
+ export function StoryListView({ stories: initialStories, users: initialUsers, projects: initialProjects, labels }: StoryListViewProps) {
68
68
  const { t } = useTranslation("story");
69
69
  const { session } = useRouteContext({ from: "/_nav/stories/" });
70
70
  const navigate = useNavigate({ from: "/stories" });
@@ -77,6 +77,7 @@ export function StoryListView({ stories: initialStories, users, projects: initia
77
77
  const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
78
78
  const [stories, setStories] = useState(initialStories);
79
79
  const [projects, setProjects] = useState(initialProjects);
80
+ const [users, setUsers] = useState<UserBrief[]>(initialUsers);
80
81
  const [projectId, setProjectId] = useState<string | undefined>(() => {
81
82
  try {
82
83
  return localStorage.getItem(STORY_PROJECT_FILTER_KEY) ?? undefined;
@@ -105,6 +106,16 @@ export function StoryListView({ stories: initialStories, users, projects: initia
105
106
  setProjects(initialProjects);
106
107
  }, [initialStories, initialProjects]);
107
108
 
109
+ useEffect(() => {
110
+ if (!projectId || projectId === "__archived__") {
111
+ setUsers(initialUsers);
112
+ return;
113
+ }
114
+ listUsersBriefFn({ data: { projectId } })
115
+ .then((u: UserBrief[]) => setUsers(u))
116
+ .catch(() => {});
117
+ }, [projectId]);
118
+
108
119
  useEffect(() => {
109
120
  if (!projectId && !searchText) return;
110
121
  const isArchived = projectId === "__archived__";
@@ -9,70 +9,70 @@ export const SDK_GROUPS: SdkGroup[] = [
9
9
  {
10
10
  "name": "@tanstack/react-start",
11
11
  "version": "v1.168.46",
12
- "lastUpdate": "Aug 23, 2026",
12
+ "lastUpdate": "Aug 24, 2026",
13
13
  "source": "apps/web/package.json#dependencies",
14
14
  "docsUrl": "https://tanstack.com/start/latest"
15
15
  },
16
16
  {
17
17
  "name": "@tanstack/react-router",
18
18
  "version": "v1.170.29",
19
- "lastUpdate": "Aug 23, 2026",
19
+ "lastUpdate": "Aug 24, 2026",
20
20
  "source": "apps/web/package.json#dependencies",
21
21
  "docsUrl": "https://tanstack.com/router/latest"
22
22
  },
23
23
  {
24
24
  "name": "@tanstack/react-query",
25
25
  "version": "v5.101.2",
26
- "lastUpdate": "Aug 23, 2026",
26
+ "lastUpdate": "Aug 24, 2026",
27
27
  "source": "apps/web/package.json#dependencies",
28
28
  "docsUrl": "https://tanstack.com/query/latest"
29
29
  },
30
30
  {
31
31
  "name": "@tanstack/ai",
32
32
  "version": "v0.43.0",
33
- "lastUpdate": "Aug 23, 2026",
33
+ "lastUpdate": "Aug 24, 2026",
34
34
  "source": "apps/web/package.json#dependencies",
35
35
  "docsUrl": "https://tanstack.com/ai/latest"
36
36
  },
37
37
  {
38
38
  "name": "@tanstack/react-table",
39
39
  "version": "v9.1.0",
40
- "lastUpdate": "Aug 23, 2026",
40
+ "lastUpdate": "Aug 24, 2026",
41
41
  "source": "apps/web/package.json#dependencies",
42
42
  "docsUrl": "https://tanstack.com/table/latest"
43
43
  },
44
44
  {
45
45
  "name": "@tanstack/react-form",
46
46
  "version": "v2.0.0-alpha.0",
47
- "lastUpdate": "Aug 23, 2026",
47
+ "lastUpdate": "Aug 24, 2026",
48
48
  "source": "apps/web/package.json#dependencies",
49
49
  "docsUrl": "https://tanstack.com/form/latest"
50
50
  },
51
51
  {
52
52
  "name": "@tanstack/charts",
53
53
  "version": "v0.9.0",
54
- "lastUpdate": "Aug 23, 2026",
54
+ "lastUpdate": "Aug 24, 2026",
55
55
  "source": "apps/admin/package.json#dependencies",
56
56
  "docsUrl": "https://tanstack.com/charts/latest"
57
57
  },
58
58
  {
59
59
  "name": "@tanstack/react-pacer",
60
60
  "version": "v0.22.1",
61
- "lastUpdate": "Aug 23, 2026",
61
+ "lastUpdate": "Aug 24, 2026",
62
62
  "source": "apps/web/package.json#dependencies",
63
63
  "docsUrl": "https://tanstack.com/pacer/latest"
64
64
  },
65
65
  {
66
66
  "name": "@tanstack/react-hotkeys",
67
67
  "version": "v0.10.0",
68
- "lastUpdate": "Aug 23, 2026",
68
+ "lastUpdate": "Aug 24, 2026",
69
69
  "source": "apps/web/package.json#dependencies",
70
70
  "docsUrl": "https://tanstack.com/hotkeys/latest"
71
71
  },
72
72
  {
73
73
  "name": "@tanstack/react-virtual",
74
74
  "version": "v3.14.9",
75
- "lastUpdate": "Aug 23, 2026",
75
+ "lastUpdate": "Aug 24, 2026",
76
76
  "source": "apps/admin/package.json#dependencies",
77
77
  "docsUrl": "https://tanstack.com/virtual/latest"
78
78
  }
@@ -85,15 +85,15 @@ export const SDK_GROUPS: SdkGroup[] = [
85
85
  "items": [
86
86
  {
87
87
  "name": "@allbluecn/web-app",
88
- "version": "v0.4.16",
89
- "lastUpdate": "Aug 23, 2026",
88
+ "version": "v0.4.17",
89
+ "lastUpdate": "Aug 24, 2026",
90
90
  "source": "apps/web/package.json",
91
91
  "docsUrl": "https://github.com/allbluecn/allblue"
92
92
  },
93
93
  {
94
94
  "name": "@allbluecn/kernel",
95
95
  "version": "v0.3.0",
96
- "lastUpdate": "Aug 23, 2026",
96
+ "lastUpdate": "Aug 24, 2026",
97
97
  "source": "packages/kernel/package.json",
98
98
  "docsUrl": "https://github.com/allbluecn/allblue"
99
99
  }
@@ -107,42 +107,42 @@ export const SDK_GROUPS: SdkGroup[] = [
107
107
  {
108
108
  "name": "Vite",
109
109
  "version": "v8.1.5",
110
- "lastUpdate": "Aug 23, 2026",
110
+ "lastUpdate": "Aug 24, 2026",
111
111
  "source": "apps/web/package.json#devDependencies",
112
112
  "docsUrl": "https://vite.dev/"
113
113
  },
114
114
  {
115
115
  "name": "Node.js",
116
116
  "version": "v24.19.0",
117
- "lastUpdate": "Aug 23, 2026",
117
+ "lastUpdate": "Aug 24, 2026",
118
118
  "source": ".nvmrc",
119
119
  "docsUrl": "https://nodejs.org/"
120
120
  },
121
121
  {
122
122
  "name": "Prisma",
123
123
  "version": "v7.8.0",
124
- "lastUpdate": "Aug 23, 2026",
124
+ "lastUpdate": "Aug 24, 2026",
125
125
  "source": "packages/database/package.json#devDependencies",
126
126
  "docsUrl": "https://www.prisma.io/docs"
127
127
  },
128
128
  {
129
129
  "name": "shadcn UI",
130
130
  "version": "v1.2.4",
131
- "lastUpdate": "Aug 23, 2026",
131
+ "lastUpdate": "Aug 24, 2026",
132
132
  "source": "apps/web/package.json#dependencies",
133
133
  "docsUrl": "https://ui.shadcn.com/"
134
134
  },
135
135
  {
136
136
  "name": "TipTap",
137
137
  "version": "v3.27.4",
138
- "lastUpdate": "Aug 23, 2026",
138
+ "lastUpdate": "Aug 24, 2026",
139
139
  "source": "packages/shared/package.json#devDependencies",
140
140
  "docsUrl": "https://tiptap.dev/"
141
141
  },
142
142
  {
143
143
  "name": "Lucide React",
144
144
  "version": "v1.24.0",
145
- "lastUpdate": "Aug 23, 2026",
145
+ "lastUpdate": "Aug 24, 2026",
146
146
  "source": "packages/ui/package.json#dependencies",
147
147
  "docsUrl": "https://lucide.dev/"
148
148
  }
@@ -83,8 +83,8 @@ function StoryDetailSkeleton() {
83
83
 
84
84
  export const Route = createFileRoute("/_nav/stories/$storyId")({
85
85
  loader: async ({ params }) => {
86
+ const story = await getStoryDetailFn({ data: { storyId: params.storyId } });
86
87
  const [
87
- story,
88
88
  users,
89
89
  comments,
90
90
  activities,
@@ -99,8 +99,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
99
99
  prds,
100
100
  docs,
101
101
  ] = await Promise.all([
102
- getStoryDetailFn({ data: { storyId: params.storyId } }),
103
- listUsersBriefFn(),
102
+ listUsersBriefFn({ data: story.projectId ? { projectId: story.projectId } : undefined }),
104
103
  listCommentsFn({ data: { storyId: params.storyId } }),
105
104
  listActivitiesFn({ data: { storyId: params.storyId, take: 50 } }),
106
105
  getStoryNeighborsFn({ data: { storyId: params.storyId } }),
@@ -50,12 +50,14 @@ const mocks = vi.hoisted(() => ({
50
50
  groupBy: vi.fn(),
51
51
  storyLabelGroupBy: vi.fn(),
52
52
  currentSession: vi.fn(),
53
+ projectFindFirst: vi.fn(),
53
54
  }));
54
55
 
55
56
  vi.mock("@allbluecn/database", () => ({
56
57
  prisma: {
57
58
  story: { findMany: mocks.findMany, count: mocks.count, groupBy: mocks.groupBy },
58
59
  storyLabel: { groupBy: mocks.storyLabelGroupBy },
60
+ project: { findFirst: mocks.projectFindFirst },
59
61
  },
60
62
  Prisma: {},
61
63
  }));
@@ -70,6 +72,7 @@ describe("listStoriesInGroupFn", () => {
70
72
  beforeEach(() => {
71
73
  vi.clearAllMocks();
72
74
  mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
75
+ mocks.projectFindFirst.mockResolvedValue({ id: "proj-independent-1" });
73
76
  });
74
77
 
75
78
  it("构建含 group 过滤的 where 并返回 total/hasMore", async () => {
@@ -170,6 +173,7 @@ describe("listStoryGroupsFn", () => {
170
173
  beforeEach(() => {
171
174
  vi.clearAllMocks();
172
175
  mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
176
+ mocks.projectFindFirst.mockResolvedValue({ id: "proj-independent-1" });
173
177
  });
174
178
 
175
179
  it("按 groupBy 聚合返回组 key 列表", async () => {
@@ -27,12 +27,52 @@ const labelCreateSchema = z.object({
27
27
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6b7280"),
28
28
  });
29
29
 
30
- export const listUsersBriefFn = createServerFn({ method: "GET" }).handler(
31
- async () => {
30
+ export const listUsersBriefFn = createServerFn({ method: "GET" })
31
+ .validator((data?: { projectId?: string }) => data)
32
+ .handler(async ({ data }) => {
32
33
  const request = getRequest();
33
- await requireAuth(request);
34
+ const session = await requireAuth(request);
35
+ const currentUserId = session.user!.id!;
36
+
37
+ if (data?.projectId) {
38
+ // 项目级别:只返回项目成员
39
+ const project = await prisma.project.findUnique({
40
+ where: { id: data.projectId },
41
+ select: { ownerId: true, workspaceId: true },
42
+ });
43
+ if (!project) return [];
44
+
45
+ const members = await prisma.projectMember.findMany({
46
+ where: { projectId: data.projectId },
47
+ select: { userId: true },
48
+ });
49
+ const memberIds = new Set(members.map((m) => m.userId));
50
+ memberIds.add(project.ownerId);
51
+
52
+ const users = await prisma.user.findMany({
53
+ where: { id: { in: Array.from(memberIds) }, status: "ACTIVE" },
54
+ orderBy: { username: "asc" },
55
+ select: USER_BRIEF_SELECT,
56
+ });
57
+ return users.map((u) => ({
58
+ ...u,
59
+ avatarConfig: (u.avatarConfig as import("@allbluecn/shared").AvatarConfig | null) ?? null,
60
+ }));
61
+ }
62
+
63
+ // 团队级别:只返回当前用户团队(team_memberships.ownerId = 当前用户)的成员
64
+ const memberships = await prisma.teamMembership.findMany({
65
+ where: { ownerId: currentUserId },
66
+ select: { memberId: true },
67
+ });
68
+ if (memberships.length === 0) {
69
+ return [];
70
+ }
71
+ const memberIds = new Set(memberships.map((m) => m.memberId));
72
+ memberIds.add(currentUserId); // 包含自己
73
+
34
74
  const users = await prisma.user.findMany({
35
- where: { status: "ACTIVE" },
75
+ where: { id: { in: Array.from(memberIds) }, status: "ACTIVE" },
36
76
  orderBy: { username: "asc" },
37
77
  select: USER_BRIEF_SELECT,
38
78
  });
@@ -40,8 +80,7 @@ export const listUsersBriefFn = createServerFn({ method: "GET" }).handler(
40
80
  ...u,
41
81
  avatarConfig: (u.avatarConfig as import("@allbluecn/shared").AvatarConfig | null) ?? null,
42
82
  }));
43
- },
44
- );
83
+ });
45
84
 
46
85
  export const listLabelsFn = createServerFn({ method: "GET" }).handler(
47
86
  async () => {
@@ -56,9 +56,15 @@ export const listStoriesFn = createServerFn({ method: "GET" })
56
56
  .validator(listSchema)
57
57
  .handler(async ({ data }) => {
58
58
  const request = getRequest();
59
- await requireAuth(request);
59
+ const session = await requireAuth(request);
60
+ const userId = session.user!.id!;
61
+
62
+ let projectId = data.projectId;
63
+ if (!projectId) {
64
+ projectId = await ensureBuiltinIndependentProject(userId);
65
+ }
60
66
 
61
- const where = buildListWhere(data);
67
+ const where = buildListWhere({ ...data, projectId });
62
68
 
63
69
  const [items, total] = await Promise.all([
64
70
  prisma.story.findMany({
@@ -20,6 +20,7 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma, Prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
+ import { ensureBuiltinIndependentProject } from "@/server/builtin-project";
23
24
  import { GROUP_KEYS, ORDER_KEYS, ORDER_DIRECTIONS, type GroupKey, type OrderKey } from "@/components/story/display/types";
24
25
  import { STORY_STATES, STORY_PRIORITIES } from "@/components/story/constants";
25
26
  import { STORY_LIST_SELECT_LIGHT, castStoryUsersLight, type StoryListRowLight } from "./types";
@@ -57,9 +58,15 @@ export const listStoriesInGroupFn = createServerFn({ method: "GET" })
57
58
  .validator(schema)
58
59
  .handler(async ({ data }) => {
59
60
  const request = getRequest();
60
- await requireAuth(request);
61
+ const session = await requireAuth(request);
62
+ const userId = session.user!.id!;
61
63
 
62
- const base = buildListWhere(data);
64
+ let projectId = data.projectId;
65
+ if (!projectId) {
66
+ projectId = await ensureBuiltinIndependentProject(userId);
67
+ }
68
+
69
+ const base = buildListWhere({ ...data, projectId });
63
70
  const baseAnd = Array.isArray(base.AND) ? base.AND : base.AND ? [base.AND] : [];
64
71
  const extras: Prisma.StoryWhereInput[] = [];
65
72
  const groupWhere = buildGroupFilter(data.groupBy, data.groupKey);
@@ -114,9 +121,15 @@ export const listAllGroupsItemsFn = createServerFn({ method: "GET" })
114
121
  .validator(allGroupsItemsSchema)
115
122
  .handler(async ({ data }) => {
116
123
  const request = getRequest();
117
- await requireAuth(request);
124
+ const session = await requireAuth(request);
125
+ const userId = session.user!.id!;
126
+
127
+ let projectId = data.projectId;
128
+ if (!projectId) {
129
+ projectId = await ensureBuiltinIndependentProject(userId);
130
+ }
118
131
 
119
- const base = buildListWhere(data);
132
+ const base = buildListWhere({ ...data, projectId });
120
133
  const field = ORDER_FIELD[data.orderBy];
121
134
  const dir: "asc" | "desc" = data.orderDir === "asc" ? "asc" : "desc";
122
135
  const orderBy: Prisma.StoryOrderByWithRelationInput = field
@@ -207,10 +220,16 @@ export const listStoryGroupsFn = createServerFn({ method: "GET" })
207
220
  .validator(listGroupsSchema)
208
221
  .handler(async ({ data }) => {
209
222
  const request = getRequest();
210
- await requireAuth(request);
211
- if (data.groupBy === "none") return { groups: [] };
223
+ const session = await requireAuth(request);
224
+ const userId = session.user!.id!;
225
+
226
+ let projectId = data.projectId;
227
+ if (!projectId) {
228
+ projectId = await ensureBuiltinIndependentProject(userId);
229
+ }
212
230
 
213
- const where = buildListWhere(data);
231
+ const where = buildListWhere({ ...data, projectId });
232
+ if (data.groupBy === "none") return { groups: [] };
214
233
  if (data.parentGroupBy !== "none" && data.parentGroupKey) {
215
234
  const parentWhere = buildGroupFilter(data.parentGroupBy, data.parentGroupKey);
216
235
  if (Object.keys(parentWhere).length) {
@@ -20,16 +20,20 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
+ import { ensureBuiltinIndependentProject } from "@/server/builtin-project";
23
24
 
24
25
  export const searchStoriesFn = createServerFn({ method: "GET" })
25
26
  .validator(z.object({ query: z.string(), excludeIds: z.array(z.string()).optional() }))
26
27
  .handler(async ({ data }) => {
27
28
  const request = getRequest();
28
29
  const session = await requireAuth(request);
30
+ const userId = session.user!.id!;
31
+ const projectId = await ensureBuiltinIndependentProject(userId);
29
32
 
30
33
  const stories = await prisma.story.findMany({
31
34
  where: {
32
35
  id: data.excludeIds?.length ? { notIn: data.excludeIds } : undefined,
36
+ projectId,
33
37
  OR: [
34
38
  { name: { contains: data.query, mode: "insensitive" } },
35
39
  { identifier: { contains: data.query, mode: "insensitive" } },
@@ -22,6 +22,7 @@ import { z } from "zod"
22
22
  import { prisma, type Prisma } from "@allbluecn/database"
23
23
  import { requireAuth } from "@/server/auth-helpers"
24
24
  import { requireFeature } from "@/server/entitlements"
25
+ import { isProEdition } from "@/server/pro-services"
25
26
  import {
26
27
  DEFAULT_DISPLAY_OPTIONS,
27
28
  GROUP_KEYS,
@@ -123,11 +124,16 @@ export const getStoryDisplayOptionsFn = createServerFn({ method: "GET" }).handle
123
124
  const options = parseStoredOptions(user?.storyDisplayOptions)
124
125
 
125
126
  // 订阅过期/降级:已持久化的 500/1000 无权限时降级为 200
127
+ // OSS 环境直接 cap 到 200;Pro 环境检查 feature
126
128
  if (options.pageSize > 200) {
127
- try {
128
- await requireFeature(request, "largePageSize")
129
- } catch {
129
+ if (!isProEdition()) {
130
130
  options.pageSize = 200
131
+ } else {
132
+ try {
133
+ await requireFeature(request, "largePageSize")
134
+ } catch {
135
+ options.pageSize = 200
136
+ }
131
137
  }
132
138
  }
133
139
  return options
@@ -143,8 +149,13 @@ export const updateStoryDisplayOptionsFn = createServerFn({ method: "POST" })
143
149
  const userId = session.user!.id!
144
150
 
145
151
  // 服务端强制校验:pageSize > 200(500/1000)为 Pro 专属,前端锁定只是 UX
152
+ // OSS 环境直接 cap 到 200;Pro 环境走 feature gate
146
153
  if (data.pageSize > 200) {
147
- await requireFeature(request, "largePageSize")
154
+ if (!isProEdition()) {
155
+ data.pageSize = 200
156
+ } else {
157
+ await requireFeature(request, "largePageSize")
158
+ }
148
159
  }
149
160
 
150
161
  const options: DisplayOptions = data