create-windy 0.2.4 → 0.2.6

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": "create-windy",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
  .gitignore
5
5
  .husky
6
6
  .DS_Store
7
+ .env
7
8
  docs
8
9
  *.log
9
10
  docker-compose*.yml
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "d4279d35adf20612a17b6d41128ce5852bfb4116",
4
- "generatorVersion": "0.2.4",
3
+ "templateCommit": "8b44208f73dcd5879232b3151c5344abfaeff53e",
4
+ "generatorVersion": "0.2.6",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -33,6 +33,7 @@
33
33
 
34
34
  ## 常用入口
35
35
 
36
+ - `bun run dev`:通过 Docker Compose 启动 PostgreSQL、自动 migration、Server 与 Web。
36
37
  - `bun run dev:web`:启动 Web 开发服务。
37
38
  - `bun run dev:server`:启动 Server 开发服务。
38
39
  - `bun run lint`、`bun run typecheck`、`bun run test`、`bun run build`:执行基础验证;未选择 Oxc 时没有 lint 脚本。
@@ -19,29 +19,36 @@ windy-starter 是由 create-windy 生成的单组织、单租户企业应用项
19
19
  不要使用 npm、Yarn 或 pnpm 安装本项目依赖,也不要提交它们产生的 lockfile;唯一的
20
20
  依赖锁文件是 `bun.lock`。
21
21
 
22
- ## 第一次运行
22
+ ## 第一次运行:完整开发环境
23
23
 
24
24
  ```bash
25
25
  bun install
26
- cp .env.example .env
27
- # 为 POSTGRES_PASSWORD 和 WINDY_BOOTSTRAP_ADMIN_PASSWORD 设置本地值
28
- docker compose up --build
26
+ bun run dev
29
27
  ```
30
28
 
29
+ create-windy 已创建仅供本机使用且被 Git 忽略的 `.env`,其中两个开发密码均为随机值,
30
+ 文件权限为 `0600`。`bun run dev` 会通过 Docker Compose 等待 PostgreSQL、执行
31
+ Drizzle migration,再启动 Server 与 Web;不要跳过 migration 后直接把 Server 连接到
32
+ 空数据库。首次启动需要本机已经安装并启动 Docker 与 Docker Compose,并可能需要下载
33
+ 基础镜像。
34
+
31
35
  | 服务 | 地址 |
32
36
  | ---- | ---- |
33
37
  | Web | http://localhost:9746 |
34
38
  | Server 健康检查 | http://localhost:3000/api/health |
35
39
  | PostgreSQL | localhost:5432 |
36
40
 
37
- Docker Web 固定使用 `9746`。只在宿主机运行 Web 时使用:
41
+ Docker Web 固定使用 `9746`。只启动单个应用时使用:
38
42
 
39
43
  ```bash
40
44
  bun run dev:web
45
+ bun run dev:server
41
46
  ```
42
47
 
43
- 本地 Web 从 `8746` 开始探测。同一项目已经运行时会直接输出已有地址;其它服务占用
44
- 时会自动使用后续端口,最多检查 100 个。实际地址始终以终端输出为准。
48
+ `dev:web` 不会隐式启动后端;本地 Web 从 `8746` 开始探测。同一项目已经运行时会
49
+ 直接输出已有地址,其它服务占用时会自动使用后续端口,最多检查 100 个。实际地址始终
50
+ 以终端输出为准。`dev:server` 不会自动启动 PostgreSQL 或执行 migration;未配置
51
+ `DATABASE_URL` 时使用内存模式。
45
52
 
46
53
  ## 页面与登录
47
54
 
@@ -96,6 +103,7 @@ bun run dev:web
96
103
  ## 常用命令
97
104
 
98
105
  ```bash
106
+ bun run dev
99
107
  bun run dev:web
100
108
  bun run dev:server
101
109
  bun run typecheck
@@ -102,6 +102,7 @@ export function createServerPersistence(
102
102
  };
103
103
  }
104
104
 
105
+ // @windy-module system.audit begin
105
106
  function readPositiveInteger(
106
107
  value: string | undefined,
107
108
  fallback: number,
@@ -116,3 +117,4 @@ function readSensitiveFields(value: string | undefined): string[] | undefined {
116
117
  .map((field) => field.trim())
117
118
  .filter(Boolean);
118
119
  }
120
+ // @windy-module system.audit end
@@ -25,6 +25,7 @@ async function load(force = false): Promise<PlatformBrandingSettings> {
25
25
  if (!force && loaded) return branding.value;
26
26
  loading.value = true;
27
27
  error.value = "";
28
+ // oxlint-disable-next-line prefer-const -- 精简项目会裁剪下面的远端加载分支
28
29
  let operation = Promise.resolve(applyBranding(branding.value));
29
30
  // @windy-module system.settings begin
30
31
  operation = loadPublicBranding()
@@ -70,9 +71,11 @@ function applyBranding(next: PlatformBrandingSettings) {
70
71
  return branding.value;
71
72
  }
72
73
 
74
+ // @windy-module system.settings begin
73
75
  function message(error: unknown): string {
74
76
  return error instanceof Error ? error.message : String(error);
75
77
  }
78
+ // @windy-module system.settings end
76
79
 
77
80
  export function usePlatformSettings() {
78
81
  return {
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+
6
+ const props = defineProps<{
7
+ reason: string;
8
+ from: string;
9
+ }>();
10
+
11
+ const message = computed(() =>
12
+ props.reason === "session-unavailable"
13
+ ? "暂时无法连接后端服务,无法确认当前登录状态。请启动或恢复后端服务后重试。"
14
+ : "暂时无法获取服务端数据。请确认后端服务正常运行后重试。",
15
+ );
16
+ </script>
17
+
18
+ <template>
19
+ <div class="mx-auto flex min-h-96 max-w-xl items-center">
20
+ <Card class="w-full">
21
+ <CardHeader>
22
+ <CardTitle>后端服务暂不可用</CardTitle>
23
+ </CardHeader>
24
+ <CardContent class="space-y-4">
25
+ <p class="text-sm text-muted-foreground">{{ message }}</p>
26
+ <p class="text-xs text-muted-foreground">原请求地址:{{ from }}</p>
27
+ <div class="flex flex-wrap gap-3">
28
+ <Button as-child>
29
+ <RouterLink :to="from">重新连接</RouterLink>
30
+ </Button>
31
+ <Button as-child variant="outline">
32
+ <RouterLink to="/login">进入登录页</RouterLink>
33
+ </Button>
34
+ </div>
35
+ </CardContent>
36
+ </Card>
37
+ </div>
38
+ </template>
@@ -50,7 +50,9 @@ export interface SystemResourceConfig {
50
50
  createDefaults: () => Record<string, unknown>;
51
51
  }
52
52
 
53
+ // @windy-module system.audit begin
53
54
  const emptyDefaults = () => ({});
55
+ // @windy-module system.audit end
54
56
 
55
57
  export const systemResourceConfigs: Record<
56
58
  SystemResourceKey,
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createMemoryHistory } from "vue-router";
3
+ import { ApiError } from "@/services/http";
4
+ import { createAppRouter } from "./index";
5
+ import {
6
+ accessSnapshot,
7
+ authProvider,
8
+ // @windy-module system.license begin
9
+ licenseProvider,
10
+ // @windy-module system.license end
11
+ provider,
12
+ } from "./router-test-fixtures";
13
+
14
+ describe("Web 认证与服务异常导航", () => {
15
+ test("未认证直访受保护页面会保存安全 returnTo 并进入登录页", async () => {
16
+ const router = createAppRouter(
17
+ createMemoryHistory(),
18
+ provider(accessSnapshot()),
19
+ // @windy-module system.license begin
20
+ licenseProvider(),
21
+ // @windy-module system.license end
22
+ authProvider("anonymous"),
23
+ );
24
+
25
+ await router.push("/admin/system/users?page=2");
26
+
27
+ expect(router.currentRoute.value.name).toBe("login");
28
+ expect(router.currentRoute.value.query.returnTo).toBe(
29
+ "/admin/system/users?page=2",
30
+ );
31
+ });
32
+
33
+ test("已认证用户访问登录页只接受站内绝对 returnTo", async () => {
34
+ const router = createAppRouter(
35
+ createMemoryHistory(),
36
+ provider(accessSnapshot()),
37
+ // @windy-module system.license begin
38
+ licenseProvider(),
39
+ // @windy-module system.license end
40
+ authProvider(),
41
+ );
42
+
43
+ await router.push({
44
+ name: "login",
45
+ query: { returnTo: "https://evil.test" },
46
+ });
47
+
48
+ expect(router.currentRoute.value.path).toBe("/");
49
+ });
50
+
51
+ test("管理员重置后的账号只能进入强制改密页且不会重定向循环", async () => {
52
+ const session = authProvider();
53
+ session.actor.credentialState = "must-change-password";
54
+ const router = createAppRouter(
55
+ createMemoryHistory(),
56
+ provider(accessSnapshot()),
57
+ // @windy-module system.license begin
58
+ licenseProvider(),
59
+ // @windy-module system.license end
60
+ session,
61
+ );
62
+
63
+ await router.push("/admin/system/users");
64
+ expect(router.currentRoute.value.name).toBe("change-password");
65
+
66
+ await router.push("/change-password");
67
+ expect(router.currentRoute.value.name).toBe("change-password");
68
+ });
69
+
70
+ test("后端未启动时登录页仍是可恢复的公开入口", async () => {
71
+ const router = createAppRouter(
72
+ createMemoryHistory(),
73
+ provider(accessSnapshot()),
74
+ // @windy-module system.license begin
75
+ licenseProvider(),
76
+ // @windy-module system.license end
77
+ offlineAuthProvider(new ApiError("Bad Gateway", 502)),
78
+ );
79
+
80
+ await router.push("/login");
81
+
82
+ expect(router.currentRoute.value.name).toBe("login");
83
+ expect(router.currentRoute.value.query).toEqual({});
84
+ });
85
+
86
+ test("后端未启动时首页进入服务恢复页而非无权限页", async () => {
87
+ const router = createAppRouter(
88
+ createMemoryHistory(),
89
+ provider(accessSnapshot()),
90
+ // @windy-module system.license begin
91
+ licenseProvider(),
92
+ // @windy-module system.license end
93
+ offlineAuthProvider(new TypeError("Failed to fetch")),
94
+ );
95
+
96
+ await router.push("/");
97
+
98
+ expect(router.currentRoute.value.name).toBe("service-unavailable");
99
+ expect(router.currentRoute.value.query.reason).toBe("session-unavailable");
100
+ expect(router.currentRoute.value.query.from).toBe("/");
101
+ });
102
+
103
+ test("权限快照网络异常与 403 权限拒绝使用不同页面", async () => {
104
+ const router = createAppRouter(
105
+ createMemoryHistory(),
106
+ { refresh: async () => Promise.reject(new Error("offline")) },
107
+ // @windy-module system.license begin
108
+ licenseProvider(),
109
+ // @windy-module system.license end
110
+ authProvider(),
111
+ );
112
+
113
+ await router.push("/admin");
114
+
115
+ expect(router.currentRoute.value.name).toBe("service-unavailable");
116
+ expect(router.currentRoute.value.query).toMatchObject({
117
+ reason: "snapshot-unavailable",
118
+ from: "/admin",
119
+ });
120
+ });
121
+
122
+ // @windy-module system.license begin
123
+ test("License 快照 401 会清退到登录页", async () => {
124
+ const router = createAppRouter(
125
+ createMemoryHistory(),
126
+ provider(accessSnapshot()),
127
+ { refresh: async () => Promise.reject(new ApiError("expired", 401)) },
128
+ authProvider(),
129
+ );
130
+
131
+ await router.push("/admin/system/license");
132
+
133
+ expect(router.currentRoute.value.name).toBe("login");
134
+ expect(router.currentRoute.value.query.returnTo).toBe(
135
+ "/admin/system/license",
136
+ );
137
+ });
138
+ // @windy-module system.license end
139
+ });
140
+
141
+ function offlineAuthProvider(error: Error) {
142
+ return {
143
+ ensureInitialized: async () => Promise.reject(error),
144
+ clear() {},
145
+ };
146
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { safeReturnTo } from "./auth-navigation";
3
+
4
+ describe("安全 returnTo", () => {
5
+ test("只接受应用内绝对路径并排除登录页自身", () => {
6
+ expect(safeReturnTo("/admin/system/users?tab=active#top")).toBe(
7
+ "/admin/system/users?tab=active#top",
8
+ );
9
+ expect(safeReturnTo("https://evil.test/admin/system/users")).toBe("/");
10
+ expect(safeReturnTo("//evil.test/admin/system/users")).toBe("/");
11
+ expect(safeReturnTo("/\\evil.test/admin/system/users")).toBe("/");
12
+ expect(safeReturnTo("/login?returnTo=/admin/system/users")).toBe("/");
13
+ });
14
+ });
@@ -7,6 +7,7 @@ import {
7
7
  import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
8
8
  import HomePage from "@/app/home/HomePage.vue";
9
9
  import AccessDeniedPage from "@/pages/AccessDeniedPage.vue";
10
+ import ServiceUnavailablePage from "@/pages/ServiceUnavailablePage.vue";
10
11
  import LoginPage from "@/pages/auth/LoginPage.vue";
11
12
  import ChangePasswordPage from "@/pages/auth/ChangePasswordPage.vue";
12
13
  // @windy-module system.license begin
@@ -106,6 +107,16 @@ export const routes: RouteRecordRaw[] = [
106
107
  }),
107
108
  meta: { title: "无法访问", layout: "auth" },
108
109
  },
110
+ {
111
+ path: "/service-unavailable",
112
+ name: "service-unavailable",
113
+ component: ServiceUnavailablePage,
114
+ props: (route) => ({
115
+ reason: String(route.query.reason || "snapshot-unavailable"),
116
+ from: safeReturnTo(String(route.query.from || "/")),
117
+ }),
118
+ meta: { title: "服务暂不可用", layout: "auth", public: true },
119
+ },
109
120
  systemRoute("system.user", "users"),
110
121
  systemRoute("system.role", "roles"),
111
122
  systemRoute("system.department", "departments"),
@@ -157,10 +168,8 @@ export function createAppRouter(
157
168
  try {
158
169
  session = await authProvider.ensureInitialized();
159
170
  } catch {
160
- return to.name === "access-denied" &&
161
- to.query.reason === "session-unavailable"
162
- ? true
163
- : deniedRoute(to.fullPath, "session-unavailable");
171
+ if (to.name === "login") return true;
172
+ return serviceUnavailableRoute(to.fullPath, "session-unavailable");
164
173
  }
165
174
  if (to.name === "login") {
166
175
  if (session.phase !== "authenticated") return true;
@@ -191,12 +200,9 @@ export function createAppRouter(
191
200
  authProvider.clear();
192
201
  return loginRoute(to.fullPath);
193
202
  }
194
- return deniedRoute(
195
- to.fullPath,
196
- isApiErrorStatus(error, 403)
197
- ? "admin-access-denied"
198
- : "snapshot-unavailable",
199
- );
203
+ return isApiErrorStatus(error, 403)
204
+ ? deniedRoute(to.fullPath, "admin-access-denied")
205
+ : serviceUnavailableRoute(to.fullPath, "snapshot-unavailable");
200
206
  }
201
207
 
202
208
  // @windy-module system.license begin
@@ -208,7 +214,7 @@ export function createAppRouter(
208
214
  authProvider.clear();
209
215
  return loginRoute(to.fullPath);
210
216
  }
211
- return deniedRoute(to.fullPath, "snapshot-unavailable");
217
+ return serviceUnavailableRoute(to.fullPath, "snapshot-unavailable");
212
218
  }
213
219
  if (licenseSnapshot.restricted && to.name !== "runtime-license") {
214
220
  return {
@@ -243,4 +249,12 @@ function deniedRoute(from: string, reason: string) {
243
249
  };
244
250
  }
245
251
 
252
+ function serviceUnavailableRoute(from: string, reason: string) {
253
+ return {
254
+ name: "service-unavailable",
255
+ query: { from, reason },
256
+ replace: true,
257
+ };
258
+ }
259
+
246
260
  export const router = createAppRouter();