panel-free 1.0.2 → 1.0.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/README.md +2 -16
- package/dist/main.md +194 -0
- package/dist/quality.md +26 -0
- package/dist/ser.md +85 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
1
|
# panel-free
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A free, simple web panel template for small projects. Built with Node.js, TypeScript, Express, EJS, and SQLite.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- Home/data/reports/admin data are preserved during EJS rendering.
|
|
8
|
-
- Consumer TypeScript projects receive the required Express and better-sqlite3 type packages.
|
|
9
|
-
- Public `Db` declaration is compatible with common TypeScript module settings.
|
|
10
|
-
- CLI fallback remains available through `exports.default`.
|
|
11
|
-
|
|
12
|
-
Main exam usage:
|
|
13
|
-
|
|
14
|
-
```powershell
|
|
15
|
-
npm install panel-free
|
|
16
|
-
npx panel-free
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
The CLI starts `main.ts` from the current directory.
|
|
5
|
+
Created by Alex Maksimov.
|
package/dist/main.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { num, panel, sqlite, type RequestBody } from "panel-free";
|
|
2
|
+
import { Service } from "./Service.js";
|
|
3
|
+
|
|
4
|
+
const db = sqlite("project.db");
|
|
5
|
+
|
|
6
|
+
db.exec(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS categories (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
name TEXT NOT NULL UNIQUE
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
login TEXT NOT NULL UNIQUE,
|
|
16
|
+
password TEXT NOT NULL,
|
|
17
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'manager', 'admin'))
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
title TEXT NOT NULL,
|
|
23
|
+
status TEXT NOT NULL,
|
|
24
|
+
category_id INTEGER NOT NULL,
|
|
25
|
+
user_id INTEGER NOT NULL,
|
|
26
|
+
FOREIGN KEY (category_id) REFERENCES categories(id),
|
|
27
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
const categoryCount = db.prepare("SELECT COUNT(*) AS total FROM categories").get() as { total: number };
|
|
32
|
+
if (categoryCount.total === 0) {
|
|
33
|
+
db.exec(`
|
|
34
|
+
INSERT INTO categories (name) VALUES ('Категория 1'), ('Категория 2');
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const userCount = db.prepare("SELECT COUNT(*) AS total FROM users").get() as { total: number };
|
|
39
|
+
if (userCount.total === 0) {
|
|
40
|
+
db.exec(`
|
|
41
|
+
INSERT INTO users (name, login, password, role) VALUES
|
|
42
|
+
('Пользователь', 'user', 'user', 'user'),
|
|
43
|
+
('Менеджер', 'manager', 'manager', 'manager'),
|
|
44
|
+
('Администратор', 'admin', 'admin', 'admin');
|
|
45
|
+
`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const recordCount = db.prepare("SELECT COUNT(*) AS total FROM records").get() as { total: number };
|
|
49
|
+
if (recordCount.total === 0) {
|
|
50
|
+
const userId = db.prepare("SELECT id FROM users WHERE login = 'user'").get() as { id: number };
|
|
51
|
+
const managerId = db.prepare("SELECT id FROM users WHERE login = 'manager'").get() as { id: number };
|
|
52
|
+
db.exec(`
|
|
53
|
+
INSERT INTO records (title, status, category_id, user_id) VALUES
|
|
54
|
+
('Пример заявки пользователя', 'new', 1, ${userId.id}),
|
|
55
|
+
('Пример заявки менеджера', 'done', 2, ${managerId.id});
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const service = new Service(db);
|
|
60
|
+
panel({
|
|
61
|
+
title: "Описание проекта",
|
|
62
|
+
port: 3000,
|
|
63
|
+
db,
|
|
64
|
+
logger: true,
|
|
65
|
+
labels: {
|
|
66
|
+
home: "Главная",
|
|
67
|
+
data: "Данные",
|
|
68
|
+
reports: "Аналитика",
|
|
69
|
+
reportsTitle: "Аналитические SQL-запросы",
|
|
70
|
+
empty: "Нет данных",
|
|
71
|
+
defaultSubmit: "Сохранить",
|
|
72
|
+
login: "Вход",
|
|
73
|
+
register: "Регистрация",
|
|
74
|
+
logout: "Выйти",
|
|
75
|
+
admin: "Админ-панель",
|
|
76
|
+
myRecords: "Мои заявки",
|
|
77
|
+
allRecords: "Все заявки"
|
|
78
|
+
},
|
|
79
|
+
auth: {
|
|
80
|
+
onLogin: (body: RequestBody) => service.login(String(body.login ?? ""), String(body.password ?? "")),
|
|
81
|
+
onRegister: (body: RequestBody) => service.register(
|
|
82
|
+
String(body.name ?? ""),
|
|
83
|
+
String(body.login ?? ""),
|
|
84
|
+
String(body.password ?? "")
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
records: {
|
|
88
|
+
listSql: (user) => user.role === "user"
|
|
89
|
+
? `
|
|
90
|
+
SELECT r.id, r.title, r.status, c.name AS category
|
|
91
|
+
FROM records r
|
|
92
|
+
JOIN categories c ON c.id = r.category_id
|
|
93
|
+
WHERE r.user_id = ${user.id}
|
|
94
|
+
ORDER BY r.id DESC
|
|
95
|
+
`
|
|
96
|
+
: `
|
|
97
|
+
SELECT r.id, r.title, r.status, c.name AS category, u.name AS user
|
|
98
|
+
FROM records r
|
|
99
|
+
JOIN categories c ON c.id = r.category_id
|
|
100
|
+
JOIN users u ON u.id = r.user_id
|
|
101
|
+
ORDER BY r.id DESC
|
|
102
|
+
`,
|
|
103
|
+
form: {
|
|
104
|
+
title: "Добавить заявку",
|
|
105
|
+
action: "/create",
|
|
106
|
+
submit: "Добавить",
|
|
107
|
+
fields: {
|
|
108
|
+
title: { type: "text", label: "Название" },
|
|
109
|
+
categoryId: {
|
|
110
|
+
type: "select",
|
|
111
|
+
label: "Категория",
|
|
112
|
+
sql: "SELECT id, name FROM categories ORDER BY id"
|
|
113
|
+
},
|
|
114
|
+
userId: {
|
|
115
|
+
type: "select",
|
|
116
|
+
label: "Пользователь",
|
|
117
|
+
sql: "SELECT id, name FROM users ORDER BY id",
|
|
118
|
+
adminOnly: true
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
onSubmit: (body: RequestBody, user) => {
|
|
122
|
+
const ownerId = user.role === "admin" ? num(body.userId) : user.id;
|
|
123
|
+
service.create(String(body.title ?? ""), num(body.categoryId), ownerId);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
admin: {
|
|
127
|
+
listSql: `
|
|
128
|
+
SELECT r.id,
|
|
129
|
+
r.title,
|
|
130
|
+
r.status,
|
|
131
|
+
r.category_id AS categoryId,
|
|
132
|
+
r.user_id AS userId
|
|
133
|
+
FROM records r
|
|
134
|
+
ORDER BY r.id DESC
|
|
135
|
+
`,
|
|
136
|
+
fields: {
|
|
137
|
+
title: { type: "text", label: "Название" },
|
|
138
|
+
status: {
|
|
139
|
+
type: "select",
|
|
140
|
+
label: "Статус",
|
|
141
|
+
sql: `
|
|
142
|
+
SELECT 'new' AS id, 'Новая' AS name
|
|
143
|
+
UNION ALL SELECT 'in_progress', 'В работе'
|
|
144
|
+
UNION ALL SELECT 'done', 'Завершена'
|
|
145
|
+
UNION ALL SELECT 'cancelled', 'Отменена'
|
|
146
|
+
`
|
|
147
|
+
},
|
|
148
|
+
categoryId: {
|
|
149
|
+
type: "select",
|
|
150
|
+
label: "Категория",
|
|
151
|
+
sql: "SELECT id, name FROM categories ORDER BY id"
|
|
152
|
+
},
|
|
153
|
+
userId: {
|
|
154
|
+
type: "select",
|
|
155
|
+
label: "Пользователь",
|
|
156
|
+
sql: "SELECT id, name FROM users ORDER BY id"
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
onUpdate: (id, body) => service.update(
|
|
160
|
+
id,
|
|
161
|
+
String(body.title ?? ""),
|
|
162
|
+
String(body.status ?? ""),
|
|
163
|
+
num(body.categoryId),
|
|
164
|
+
num(body.userId)
|
|
165
|
+
),
|
|
166
|
+
onDelete: (id) => service.delete(id)
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
reports: {
|
|
170
|
+
"JOIN — связанные записи": `
|
|
171
|
+
SELECT r.title, c.name AS category, u.name AS user
|
|
172
|
+
FROM records r
|
|
173
|
+
JOIN categories c ON c.id = r.category_id
|
|
174
|
+
JOIN users u ON u.id = r.user_id
|
|
175
|
+
`,
|
|
176
|
+
"GROUP BY — количество по статусам": `
|
|
177
|
+
SELECT status, COUNT(*) AS total
|
|
178
|
+
FROM records
|
|
179
|
+
GROUP BY status
|
|
180
|
+
`,
|
|
181
|
+
"HAVING — статусы с количеством больше 1": `
|
|
182
|
+
SELECT status, COUNT(*) AS total
|
|
183
|
+
FROM records
|
|
184
|
+
GROUP BY status
|
|
185
|
+
HAVING COUNT(*) > 1
|
|
186
|
+
`,
|
|
187
|
+
"LEFT JOIN — категории без заявок": `
|
|
188
|
+
SELECT c.name AS category
|
|
189
|
+
FROM categories c
|
|
190
|
+
LEFT JOIN records r ON r.category_id = c.id
|
|
191
|
+
WHERE r.id IS NULL
|
|
192
|
+
`
|
|
193
|
+
}
|
|
194
|
+
});
|
package/dist/quality.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
```mermaid
|
|
2
|
+
erDiagram
|
|
3
|
+
USERS ||--o{ RECORDS : creates
|
|
4
|
+
CATEGORIES ||--o{ RECORDS : classifies
|
|
5
|
+
|
|
6
|
+
USERS {
|
|
7
|
+
INTEGER id PK
|
|
8
|
+
TEXT name
|
|
9
|
+
TEXT login UK
|
|
10
|
+
TEXT password
|
|
11
|
+
TEXT role
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
CATEGORIES {
|
|
15
|
+
INTEGER id PK
|
|
16
|
+
TEXT name UK
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
RECORDS {
|
|
20
|
+
INTEGER id PK
|
|
21
|
+
TEXT title
|
|
22
|
+
TEXT status
|
|
23
|
+
INTEGER category_id FK
|
|
24
|
+
INTEGER user_id FK
|
|
25
|
+
}
|
|
26
|
+
```
|
package/dist/ser.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { fail, type Db, type PanelUser, type RequestBody } from "panel-free";
|
|
2
|
+
|
|
3
|
+
type Row = { id: number };
|
|
4
|
+
|
|
5
|
+
export class Service {
|
|
6
|
+
constructor(private db: Db) {}
|
|
7
|
+
|
|
8
|
+
login(login: string, password: string): PanelUser | null {
|
|
9
|
+
const row = this.db.prepare(`
|
|
10
|
+
SELECT id, name, login, role
|
|
11
|
+
FROM users
|
|
12
|
+
WHERE login = ? AND password = ?
|
|
13
|
+
`).get(login.trim(), password) as PanelUser | undefined;
|
|
14
|
+
return row ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
register(name: string, login: string, password: string): PanelUser {
|
|
18
|
+
const cleanName = name.trim();
|
|
19
|
+
const cleanLogin = login.trim();
|
|
20
|
+
if (!cleanName) fail("Имя обязательно");
|
|
21
|
+
if (!cleanLogin) fail("Логин обязателен");
|
|
22
|
+
if (!password) fail("Пароль обязателен");
|
|
23
|
+
|
|
24
|
+
const exists = this.db.prepare("SELECT id FROM users WHERE login = ?").get(cleanLogin) as Row | undefined;
|
|
25
|
+
if (exists) fail("Такой логин уже занят");
|
|
26
|
+
|
|
27
|
+
const result = this.db
|
|
28
|
+
.prepare("INSERT INTO users (name, login, password, role) VALUES (?, ?, ?, 'user')")
|
|
29
|
+
.run(cleanName, cleanLogin, password);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
id: Number(result.lastInsertRowid),
|
|
33
|
+
name: cleanName,
|
|
34
|
+
login: cleanLogin,
|
|
35
|
+
role: "user"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
create(title: string, categoryId: number, userId: number) {
|
|
40
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
41
|
+
fail("Название обязательно");
|
|
42
|
+
}
|
|
43
|
+
const category = this.db
|
|
44
|
+
.prepare("SELECT id FROM categories WHERE id = ?")
|
|
45
|
+
.get(categoryId) as Row | undefined;
|
|
46
|
+
if (!category) fail("Категория не найдена");
|
|
47
|
+
|
|
48
|
+
const user = this.db
|
|
49
|
+
.prepare("SELECT id FROM users WHERE id = ?")
|
|
50
|
+
.get(userId) as Row | undefined;
|
|
51
|
+
if (!user) fail("Пользователь не найден");
|
|
52
|
+
|
|
53
|
+
this.db
|
|
54
|
+
.prepare("INSERT INTO records (title, status, category_id, user_id) VALUES (?, 'new', ?, ?)")
|
|
55
|
+
.run(title.trim(), categoryId, userId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
createForUser(body: RequestBody, user: PanelUser) {
|
|
59
|
+
this.create(String(body.title ?? ""), Number(body.categoryId), user.id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
update(id: number, title: string, status: string, categoryId: number, userId: number) {
|
|
63
|
+
if (!Number.isInteger(id) || id <= 0) fail("Некорректный ID записи");
|
|
64
|
+
if (!title.trim()) fail("Название обязательно");
|
|
65
|
+
if (!['new', 'in_progress', 'done', 'cancelled'].includes(status)) fail("Некорректный статус");
|
|
66
|
+
|
|
67
|
+
const record = this.db.prepare("SELECT id FROM records WHERE id = ?").get(id) as Row | undefined;
|
|
68
|
+
if (!record) fail("Запись не найдена");
|
|
69
|
+
|
|
70
|
+
const category = this.db.prepare("SELECT id FROM categories WHERE id = ?").get(categoryId) as Row | undefined;
|
|
71
|
+
if (!category) fail("Категория не найдена");
|
|
72
|
+
|
|
73
|
+
const user = this.db.prepare("SELECT id FROM users WHERE id = ?").get(userId) as Row | undefined;
|
|
74
|
+
if (!user) fail("Пользователь не найден");
|
|
75
|
+
|
|
76
|
+
this.db
|
|
77
|
+
.prepare("UPDATE records SET title = ?, status = ?, category_id = ?, user_id = ? WHERE id = ?")
|
|
78
|
+
.run(title.trim(), status, categoryId, userId, id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
delete(id: number) {
|
|
82
|
+
if (!Number.isInteger(id) || id <= 0) fail("Некорректный ID записи");
|
|
83
|
+
this.db.prepare("DELETE FROM records WHERE id = ?").run(id);
|
|
84
|
+
}
|
|
85
|
+
}
|