Compare commits

...

3 Commits

42 changed files with 2090 additions and 243 deletions

3
.gitignore vendored
View File

@ -43,3 +43,6 @@ xcpdata.mv.db
/web-ui/api.spec.json
api-sandbox/app/node_modules
api-sandbox/app/api.spec.json
/xcpdata.trace.db
web-ui/vue-app/TODO.md
web-ui/vue-app/src/generated/*

View File

@ -11,7 +11,7 @@ async function loadUserProfile() {
params: {},
id: Date.now()
}
const response = await fetch('http://localhost:8080/api', {
method: 'POST',
headers: {
@ -20,7 +20,7 @@ async function loadUserProfile() {
credentials: 'include',
body: JSON.stringify(requestData)
})
const result = await response.json()
if (result.result) {
currentUser = result.result
@ -48,8 +48,9 @@ async function loadApiSpec() {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
apiSpec = await response.json()
console.log('API Spec loaded:', apiSpec)
const data = await response.json()
apiSpec = data.modules
console.log('API Spec loaded:', data)
return apiSpec
} catch (error) {
console.error('Ошибка загрузки API спецификации:', error)
@ -62,8 +63,8 @@ function populateServices() {
$serviceSelect.empty()
$serviceSelect.append('<option value="">Выберите сервис</option>')
Object.keys(apiSpec).forEach(serviceName => {
$serviceSelect.append(`<option value="${serviceName}">${serviceName}</option>`)
apiSpec.forEach(service => {
$serviceSelect.append(`<option value="${service.name}">${service.name}</option>`)
})
}
@ -72,10 +73,13 @@ function populateMethods(serviceName) {
$methodSelect.empty()
$methodSelect.append('<option value="">Выберите метод</option>')
if (serviceName && apiSpec[serviceName] && apiSpec[serviceName].methods) {
apiSpec[serviceName].methods.forEach(method => {
$methodSelect.append(`<option value="${method.name}">${method.name}</option>`)
})
if (serviceName) {
const service = apiSpec.find(s => s.name === serviceName)
if (service && service.methods) {
service.methods.forEach(method => {
$methodSelect.append(`<option value="${method.name}">${method.name}</option>`)
})
}
}
}
@ -85,7 +89,7 @@ function createParamInputs(methodName, serviceName) {
if (!serviceName || !methodName) return
const service = apiSpec[serviceName]
const service = apiSpec.find(s => s.name === serviceName)
if (!service || !service.methods) return
const method = service.methods.find(m => m.name === methodName)
@ -94,13 +98,13 @@ function createParamInputs(methodName, serviceName) {
method.params.forEach(param => {
const required = !param.optional ? ' (обязательно)' : ' (необязательно)'
const isOptional = param.optional
const isObjectOrArray = param.type === 'object' || param.type === 'array'
const defaultValue = param.type === 'object' ? '{}' : (param.type === 'array' ? '[]' : '')
const inputElement = isObjectOrArray
const inputElement = isObjectOrArray
? `<textarea id="param-${param.name}" data-param="${param.name}" placeholder="${param.description}" rows="4" style="font-family: 'Courier New', monospace;">${defaultValue}</textarea>`
: `<input type="text" id="param-${param.name}" data-param="${param.name}" placeholder="${param.description}">`
if (isOptional) {
const $paramDiv = $(`
<div class="param">
@ -115,8 +119,8 @@ function createParamInputs(methodName, serviceName) {
</div>
`)
$paramsContainer.append($paramDiv)
$(`#defined-${param.name}`).on('change', function() {
$(`#defined-${param.name}`).on('change', function () {
const $input = $(`#param-${param.name}`)
const isChecked = $(this).is(':checked')
$input.prop('disabled', !isChecked)
@ -124,7 +128,7 @@ function createParamInputs(methodName, serviceName) {
$input.val(isObjectOrArray ? defaultValue : '')
}
})
// Initially disable optional fields
$(`#param-${param.name}`).prop('disabled', true)
} else {
@ -156,13 +160,13 @@ async function sendRequest() {
const $input = $(this).find('input[type="text"], textarea')
const paramName = $input.data('param')
const paramType = $input.attr('id').replace('param-', '')
// Find parameter type from method definition
const service = apiSpec[serviceName]
const service = apiSpec.find(s => s.name === serviceName)
const method = service.methods.find(m => m.name === methodName)
const paramDef = method.params.find(p => p.name === paramName)
const isObjectOrArray = paramDef && (paramDef.type === 'object' || paramDef.type === 'array')
if ($checkbox.length > 0) {
// Optional parameter
if ($checkbox.is(':checked') && $input.val().trim() !== '') {
@ -223,10 +227,10 @@ async function sendRequest() {
const result = await response.json()
$('#result').text(JSON.stringify(result, null, 2))
$('#request').text(JSON.stringify(requestData, null, 2))
// Проверяем, нужно ли обновить профиль пользователя
if (result.result && (
methodName.startsWith('authenticateBy') ||
methodName.startsWith('authenticateBy') ||
methodName === 'logout'
)) {
await loadUserProfile()
@ -238,10 +242,10 @@ async function sendRequest() {
}
$(document).ready(async () => {
await loadApiSpec()
apiSpec = await loadApiSpec()
if (apiSpec) {
populateServices()
// Автоматически загружаем профиль пользователя
await loadUserProfile()

View File

@ -1,6 +1,7 @@
package ru.kirillius.XCP.ApiGenerator;
import lombok.SneakyThrows;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcMethod;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcService;
import tools.jackson.databind.JsonNode;
@ -9,9 +10,14 @@ import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import java.io.*;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.regex.Pattern;
public class SpecGenerator {
@ -46,10 +52,14 @@ public class SpecGenerator {
}
private final ObjectNode specs = JsonNodeFactory.instance.objectNode();
private final Set<Class<?>> types = new HashSet<>();
private ObjectMapper mapper = new ObjectMapper();
@SneakyThrows
public void generate() {
specs.removeAll();
var modules = specs.putArray("modules");
var typesArray = specs.putArray("types");
var scanPath = new File(scanDir, packageName.replaceAll(Pattern.quote("."), "/"));
for (var file : Objects.requireNonNull(scanPath.listFiles())) {
if (!file.getName().endsWith(".java") || file.getName().contains("$")) {
@ -64,7 +74,7 @@ public class SpecGenerator {
continue;
}
var classSpecs = specs.putObject(cls.getSimpleName());
var classSpecs = modules.addObject();
classSpecs.put("name", cls.getSimpleName());
var methods = classSpecs.putArray("methods");
@ -78,7 +88,7 @@ public class SpecGenerator {
methodSpecs.put("description", descriptor.description());
methodSpecs.put("return", getTypeName(descriptor.returnType()));
methodSpecs.put("accessLevel", descriptor.accessLevel().name());
registerType(descriptor.returnType());
var params = methodSpecs.putArray("params");
for (var parameter : descriptor.parameters()) {
@ -87,14 +97,72 @@ public class SpecGenerator {
paramSpecs.put("type", getTypeName(parameter.type()));
paramSpecs.put("description", parameter.description());
paramSpecs.put("optional", parameter.optional());
registerType(parameter.type());
}
methodSpecs.put("return", getTypeName(descriptor.returnType()));
}
}
var typesQueue = new LinkedBlockingQueue<>(types);
Class<?> type;
while (!typesQueue.isEmpty()) {
type = typesQueue.poll();
if (type.isEnum()) {
var typeDescriptor = typesArray.addObject();
typeDescriptor.put("name", type.getSimpleName());
typeDescriptor.put("type", "enum");
var values = typeDescriptor.putArray("values");
for (var value : type.getEnumConstants()) {
values.add(value.toString());
}
continue;
}
var classAnnotation = type.getAnnotation(GenerateApiSpec.class);
if (classAnnotation == null || !type.isInterface()) {
continue;
}
var typeDescriptor = typesArray.addObject();
typeDescriptor.put("name", classAnnotation.alias().isEmpty() ? type.getSimpleName() : classAnnotation.alias());
typeDescriptor.put("type", "class");
var fields = typeDescriptor.putArray("fields");
for (var method : type.getMethods()) {
var methodAnnotation = method.getAnnotation(GenerateApiSpec.class);
if (methodAnnotation == null) {
continue;
}
var returnType = methodAnnotation.type();
if (returnType == void.class) {
returnType = method.getReturnType();
}
if (!types.contains(returnType)) {
types.add(returnType);
typesQueue.put(returnType);
}
var methodName = method.getName();
if (!methodName.startsWith("get") && !methodName.startsWith("is")) {
throw new RuntimeException("Invalid @" + GenerateApiSpec.class.getSimpleName() + " usage on " + type.getSimpleName() + "::" + methodName + ": Only getters are supported");
}
var fieldDescriptor = fields.addObject();
fieldDescriptor.put("name", methodAnnotation.alias().isEmpty() ? getVariableName(methodName) : methodAnnotation.alias());
fieldDescriptor.put("type", getTypeName(returnType));
}
}
}
private String getVariableName(String methodName) {
if (methodName.startsWith("get")) {
return methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
} else if (methodName.startsWith("is")) {
return methodName.substring(2, 3).toLowerCase() + methodName.substring(3);
}
throw new RuntimeException("Invalid method name: " + methodName);
}
public void writeSpecs() throws IOException {
@ -108,14 +176,31 @@ public class SpecGenerator {
}
}
private static String getTypeName(Class<?> type) {
private void registerType(Class<?> type) {
if (type.isArray()) {
types.add(type.getComponentType());
} else {
types.add(type);
}
}
private String getTypeName(Class<?> type) {
if (type == null) return "unknown";
if (type == boolean.class) return "boolean";
if (type == int.class || type == long.class) return "number";
if (type == String.class) return "string";
if (type == void.class || type == Void.class) return "void";
if (type.isArray() || Collection.class.isAssignableFrom(type) || type == ArrayNode.class) {
if (type.isArray()) {
var componentType = type.getComponentType();
if (componentType != null) {
return componentType.getSimpleName() + "[]";
} else {
return "[]";
}
}
if (Collection.class.isAssignableFrom(type) || type == ArrayNode.class) {
return "array";
}

View File

@ -1,140 +0,0 @@
{
"Profile" : {
"name" : "Profile",
"methods" : [ {
"name" : "get",
"description" : "Get current user profile",
"return" : "void",
"accessLevel" : "Guest",
"params" : [ ]
}, {
"name" : "save",
"description" : "edit current user profile",
"return" : "boolean",
"accessLevel" : "User",
"params" : [ {
"name" : "login",
"type" : "string",
"description" : "User login. Have to be unique",
"optional" : false
}, {
"name" : "name",
"type" : "string",
"description" : "User display name",
"optional" : false
}, {
"name" : "password",
"type" : "string",
"description" : "Change user password if defined",
"optional" : true
}, {
"name" : "values",
"type" : "object",
"description" : "User custom values",
"optional" : false
} ]
} ]
},
"UserManagement" : {
"name" : "UserManagement",
"methods" : [ {
"name" : "getById",
"description" : "Load user object by ID",
"return" : "object",
"accessLevel" : "Admin",
"params" : [ {
"name" : "id",
"type" : "number",
"description" : "User id",
"optional" : false
} ]
}, {
"name" : "remove",
"description" : "Remove user by ID",
"return" : "object",
"accessLevel" : "Admin",
"params" : [ {
"name" : "id",
"type" : "number",
"description" : "User id",
"optional" : false
} ]
}, {
"name" : "save",
"description" : "Get all users as list",
"return" : "array",
"accessLevel" : "Admin",
"params" : [ {
"name" : "user",
"type" : "object",
"description" : "User object to save",
"optional" : false
} ]
}, {
"name" : "getAll",
"description" : "Get all users as list",
"return" : "array",
"accessLevel" : "Admin",
"params" : [ ]
} ]
},
"Auth" : {
"name" : "Auth",
"methods" : [ {
"name" : "authenticateByPassword",
"description" : "Authenticates a user using login and password. Returns true if authentication is successful.",
"return" : "boolean",
"accessLevel" : "Guest",
"params" : [ {
"name" : "login",
"type" : "string",
"description" : "User's login name",
"optional" : false
}, {
"name" : "password",
"type" : "string",
"description" : "User's password",
"optional" : false
} ]
}, {
"name" : "getTokens",
"description" : "Retrieves all API tokens associated with the current user",
"return" : "array",
"accessLevel" : "User",
"params" : [ ]
}, {
"name" : "generateToken",
"description" : "Generates a new API token and returns its details",
"return" : "object",
"accessLevel" : "User",
"params" : [ {
"name" : "permanent",
"type" : "boolean",
"description" : "If true, creates a token that never expires",
"optional" : true
}, {
"name" : "name",
"type" : "string",
"description" : "Display name for the token. If not provided, the User-Agent header will be used",
"optional" : true
} ]
}, {
"name" : "authenticateByToken",
"description" : "Authenticates a user using an API token. Returns true if authentication is successful.",
"return" : "boolean",
"accessLevel" : "Guest",
"params" : [ {
"name" : "token",
"type" : "string",
"description" : "API token string for authentication",
"optional" : false
} ]
}, {
"name" : "logout",
"description" : "Logs out the current user. Returns true if logout is successful, false if the user is not logged in",
"return" : "boolean",
"accessLevel" : "User",
"params" : [ ]
} ]
}
}

View File

@ -0,0 +1,14 @@
package ru.kirillius.XCP.Commons;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface GenerateApiSpec {
String alias() default "";
Class<?> type() default void.class;
}

View File

@ -1,27 +1,36 @@
package ru.kirillius.XCP.Persistence.Entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
import java.time.Instant;
import java.util.Date;
@GenerateApiSpec
public interface ApiToken extends PersistenceEntity {
@GenerateApiSpec(type = PersistenceEntity.class)
User getUser();
void setUser(User user);
@GenerateApiSpec
String getName();
void setName(String name);
@GenerateApiSpec
Date getExpirationDate();
void setExpirationDate(Date expirationDate);
@JsonIgnore
default boolean isExpired() {
return getExpirationDate().toInstant().isBefore(Instant.now());
var expirationDate = getExpirationDate();
if (expirationDate == null) {
return false;
}
return expirationDate.toInstant().isBefore(Instant.now());
}
}

View File

@ -1,12 +1,15 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.NodeEntity;
@GenerateApiSpec
public interface Group extends NodeEntity {
String getIcon();
void setIcon(String icon);
@GenerateApiSpec
boolean isPrototype();
void setPrototype(boolean prototype);

View File

@ -1,11 +1,13 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Data.PollSettings;
import ru.kirillius.XCP.Persistence.IOEntity;
import ru.kirillius.XCP.Persistence.NodeEntity;
@GenerateApiSpec
public interface Input extends IOEntity, NodeEntity {
@GenerateApiSpec
PollSettings getPollSettings();
void setPollSettings(PollSettings pollSettings);

View File

@ -1,7 +1,8 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.IOEntity;
import ru.kirillius.XCP.Persistence.NodeEntity;
@GenerateApiSpec
public interface Output extends IOEntity, NodeEntity {
}

View File

@ -1,8 +1,11 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
@GenerateApiSpec
public interface Tag extends PersistenceEntity {
@GenerateApiSpec
String getName();
void setName(String name);

View File

@ -1,26 +1,32 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
import ru.kirillius.XCP.Security.UserRole;
import tools.jackson.databind.node.ObjectNode;
@GenerateApiSpec
public interface User extends PersistenceEntity {
void setPassword(String password);
boolean verifyPassword(String password);
@GenerateApiSpec
String getLogin();
void setLogin(String login);
@GenerateApiSpec
UserRole getRole();
void setRole(UserRole role);
@GenerateApiSpec
ObjectNode getValues();
void setValues(ObjectNode values);
@GenerateApiSpec
String getName();
void setName(String name);

View File

@ -1,29 +1,36 @@
package ru.kirillius.XCP.Persistence;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.Entities.Group;
import tools.jackson.databind.node.ObjectNode;
public interface NodeEntity extends PersistenceEntity {
@GenerateApiSpec
String getName();
void setName(String name);
@GenerateApiSpec
boolean isProtectedEntity();
void setProtectedEntity(boolean essential);
@GenerateApiSpec
boolean isEnabled();
void setEnabled(boolean enabled);
@GenerateApiSpec(type = PersistenceEntity.class)
Group getParent();
void setParent(Group parent);
@GenerateApiSpec
ObjectNode getProperties();
void setProperties(ObjectNode properties);
@GenerateApiSpec
TagCollection getTags();
void setTags(TagCollection tags);

View File

@ -1,9 +1,14 @@
package ru.kirillius.XCP.Persistence;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import java.util.UUID;
@GenerateApiSpec
public interface PersistenceEntity {
@GenerateApiSpec
long getId();
@GenerateApiSpec(type = String.class)
UUID getUuid();
}

View File

@ -1,5 +1,6 @@
package ru.kirillius.XCP.RPC.Services;
import ru.kirillius.XCP.Persistence.Entities.ApiToken;
import ru.kirillius.XCP.Persistence.Repositories.ApiTokenRepository;
import ru.kirillius.XCP.Persistence.Repositories.UserRepository;
import ru.kirillius.XCP.Services.RepositoryService;
@ -56,11 +57,10 @@ public class Auth extends JsonRpcService {
return true;
}
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "Retrieves all API tokens associated with the current user",
returnType = ArrayNode.class
returnType = ApiToken[].class
)
public ArrayNode getTokens(CallContext call) throws IOException {
var tokenRepository = call.getContext().getService(RepositoryService.class).getRepository(ApiTokenRepository.class);
@ -70,14 +70,13 @@ public class Auth extends JsonRpcService {
var json = tokenRepository.serialize(token);
json.remove("uuid");
var uuid = token.getUuid().toString();
json.put("token", uuid.substring(0, 4) + "..." + uuid.substring(uuid.length() - 4));
json.put("uuid", uuid.substring(0, 4) + "..." + uuid.substring(uuid.length() - 4));
return json;
}).forEach(tokens::add);
}
return tokens;
}
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "Generates a new API token and returns its details",
@ -95,7 +94,7 @@ public class Auth extends JsonRpcService {
optional = true
)
},
returnType = ObjectNode.class
returnType = ApiToken.class
)
public ObjectNode generateToken(CallContext call) {
var repositoryService = call.getContext().getService(RepositoryService.class);
@ -112,6 +111,7 @@ public class Auth extends JsonRpcService {
token.setExpirationDate(permanent ? null : Date.from(Instant.now().plus(30, ChronoUnit.DAYS)));
token.setName(name);
token.setUser(call.getCurrentUser());
tokenRepository.save(token);

View File

@ -1,16 +1,16 @@
package ru.kirillius.XCP.RPC.Services;
import ru.kirillius.XCP.Persistence.Entities.User;
import ru.kirillius.XCP.Persistence.Repositories.UserRepository;
import ru.kirillius.XCP.Services.RepositoryService;
import ru.kirillius.XCP.RPC.JSONRPC.CallContext;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcMethod;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcService;
import ru.kirillius.XCP.Security.UserRole;
import ru.kirillius.XCP.Services.RepositoryService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
public class Profile extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "edit current user profile",
@ -18,6 +18,7 @@ public class Profile extends JsonRpcService {
@JsonRpcMethod.Parameter(name = "login", description = "User login. Have to be unique", type = String.class),
@JsonRpcMethod.Parameter(name = "name", description = "User display name", type = String.class),
@JsonRpcMethod.Parameter(name = "password", description = "Change user password if defined", type = String.class, optional = true),
@JsonRpcMethod.Parameter(name = "currentPassword", description = "Current user password", type = String.class, optional = true),
@JsonRpcMethod.Parameter(name = "values", description = "User custom values", type = ObjectNode.class)
},
returnType = boolean.class)
@ -28,6 +29,8 @@ public class Profile extends JsonRpcService {
var login = requireParam(call, "login", JsonNode::asString);
var name = requireParam(call, "name", JsonNode::asString);
var passwordOptional = getParam(call, "password", JsonNode::asString);
var currentPassword = passwordOptional.isPresent() ? requireParam(call, "currentPassword", JsonNode::asString) : null;
var values = requireParam(call, "values", n -> (ObjectNode) n);
if (!user.getLogin().equals(login) && userRepository.getByLogin(login) != null) {
@ -50,6 +53,9 @@ public class Profile extends JsonRpcService {
if (password.isBlank()) {
throw new RuntimeException("Password is blank");
}
if (!user.verifyPassword(currentPassword)) {
throw new RuntimeException("Current user password is invalid");
}
user.setPassword(password);
}
@ -60,7 +66,8 @@ public class Profile extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.Guest,
description = "Get current user profile"
description = "Get current user profile",
returnType = User.class
)
public ObjectNode get(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);

View File

@ -1,25 +1,26 @@
package ru.kirillius.XCP.RPC.Services;
import ru.kirillius.XCP.Persistence.Entities.User;
import ru.kirillius.XCP.Persistence.Repositories.UserRepository;
import ru.kirillius.XCP.Services.RepositoryService;
import ru.kirillius.XCP.RPC.JSONRPC.CallContext;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcMethod;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcService;
import ru.kirillius.XCP.Security.UserRole;
import ru.kirillius.XCP.Services.RepositoryService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
public class UserManagement extends JsonRpcService {
public class Users extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Get all users as list",
parameters = {
},
returnType = ArrayNode.class
returnType = User[].class
)
public ArrayNode getAll(CallContext call) throws IOException {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
@ -30,11 +31,11 @@ public class UserManagement extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Get all users as list",
description = "Save user data",
parameters = {
@JsonRpcMethod.Parameter(name = "user", description = "User object to save", type = ObjectNode.class)
@JsonRpcMethod.Parameter(name = "user", description = "User object to save", type = User.class)
},
returnType = ArrayNode.class
returnType = User.class
)
public void save(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
@ -48,7 +49,7 @@ public class UserManagement extends JsonRpcService {
parameters = {
@JsonRpcMethod.Parameter(name = "id", description = "User id", type = int.class)
},
returnType = ObjectNode.class
returnType = User.class
)
public ObjectNode getById(CallContext call) {
var userId = requireParam(call, "id", JsonNode::asLong);
@ -62,7 +63,7 @@ public class UserManagement extends JsonRpcService {
parameters = {
@JsonRpcMethod.Parameter(name = "id", description = "User id", type = int.class)
},
returnType = ObjectNode.class
returnType = boolean.class
)
public boolean remove(CallContext call) {
var userId = requireParam(call, "id", JsonNode::asLong);

View File

@ -9,7 +9,7 @@ import ru.kirillius.XCP.Commons.Context;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcServlet;
import ru.kirillius.XCP.RPC.Services.Auth;
import ru.kirillius.XCP.RPC.Services.Profile;
import ru.kirillius.XCP.RPC.Services.UserManagement;
import ru.kirillius.XCP.RPC.Services.Users;
import ru.kirillius.XCP.Services.ServiceLoadPriority;
import ru.kirillius.XCP.Services.WebService;
@ -28,7 +28,7 @@ public class WebServiceImpl implements WebService {
}
var jsonRpc = new JsonRpcServlet(context);
jsonRpc.registerRpcService(
UserManagement.class,
Users.class,
Auth.class,
Profile.class
);

View File

@ -25,4 +25,5 @@ coverage
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
/src/generated/RpcClient.ts

View File

@ -0,0 +1,38 @@
import pluginVue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
export default [
{
ignores: ['dist/**/*', 'node_modules/**/*']
},
{
files: ['src/**/*.{vue}'],
languageOptions: {
parser: pluginVue.parser,
parserOptions: {
parser: typescriptParser,
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
vue: pluginVue
},
rules: pluginVue.configs['vue3-essential'].rules
},
{
files: ['src/**/*.{js,mjs,cjs,ts}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: typescript.configs.recommended.rules
}
]

View File

@ -8,9 +8,11 @@
"name": "vue-app",
"version": "0.0.0",
"dependencies": {
"@mdi/font": "^7.4.47",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vuetify": "^3.11.6"
},
"devDependencies": {
"@cypress/vite-dev-server": "^7.1.0",
@ -19,6 +21,8 @@
"@tsconfig/node18": "^18.2.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
@ -26,9 +30,12 @@
"@vue/tsconfig": "^0.8.1",
"cypress": "^15.9.0",
"eslint": "^9.39.2",
"eslint-define-config": "^2.1.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^27.4.0",
"prettier": "^3.7.4",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.17",
@ -123,6 +130,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -1108,12 +1128,39 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@mdi/font": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
"integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==",
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1554,6 +1601,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node18": {
"version": "18.2.6",
"resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.6.tgz",
@ -2370,6 +2445,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -2504,6 +2592,13 @@
],
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -3009,6 +3104,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3237,6 +3339,16 @@
"node": ">=0.4.0"
}
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@ -3602,6 +3714,29 @@
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-define-config": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/eslint-define-config/-/eslint-define-config-2.1.0.tgz",
"integrity": "sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/Shinigami92"
},
{
"type": "paypal",
"url": "https://www.paypal.com/donate/?hosted_button_id=L7GY729FBKTZY"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0",
"pnpm": ">=8.6.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
@ -4400,6 +4535,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
@ -5268,6 +5416,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5986,6 +6141,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@ -6679,6 +6844,50 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -6686,6 +6895,26 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@ -6821,6 +7050,13 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
@ -7143,6 +7379,33 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vuetify": {
"version": "3.11.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.6.tgz",
"integrity": "sha512-vaWvEpDSeldRoib1tCKNVBp60paTD2/n0a0TmrVLF9BN4CJJn6/A4VKG2Sg+DE8Yc+SNOtFtipChxSlQxjcUvw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
"peerDependencies": {
"typescript": ">=4.7",
"vite-plugin-vuetify": ">=2.1.0",
"vue": "^3.5.0",
"webpack-plugin-vuetify": ">=3.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vite-plugin-vuetify": {
"optional": true
},
"webpack-plugin-vuetify": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@ -7404,6 +7667,16 @@
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",

View File

@ -4,18 +4,22 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"dev": "npm run generate-client && vite",
"build": "npm run generate-client && vue-tsc && vite build",
"build:prod": "npm run generate-client && vue-tsc && vite build",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "cypress run",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"format": "prettier --write src/",
"generate-client": "npx tsx scripts/generate-rpc-client.ts"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vuetify": "^3.11.6"
},
"devDependencies": {
"@cypress/vite-dev-server": "^7.1.0",
@ -24,6 +28,8 @@
"@tsconfig/node18": "^18.2.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
@ -31,9 +37,12 @@
"@vue/tsconfig": "^0.8.1",
"cypress": "^15.9.0",
"eslint": "^9.39.2",
"eslint-define-config": "^2.1.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^27.4.0",
"prettier": "^3.7.4",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.17",

View File

@ -0,0 +1,178 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
interface ApiMethodParam {
name: string
type: string
description: string
optional: boolean
}
interface ApiMethod {
name: string
description: string
return: string
accessLevel: string
params: ApiMethodParam[]
}
interface ApiModule {
name: string
methods: ApiMethod[]
}
interface ApiType {
name: string
type: 'class' | 'enum'
fields?: Array<{
name: string
type: string
}>
values?: string[]
}
interface ApiSpec {
modules: ApiModule[]
types: ApiType[]
}
function generateMethod(method: ApiMethod): string {
const sortedParams = [...method.params].sort((a, b) => {
if (a.optional && !b.optional) return 1
if (!a.optional && b.optional) return -1
return 0
})
const params = sortedParams
.map((param) => {
const optional = param.optional ? '?' : ''
return `${param.name}${optional}: ${param.type}`
})
.join(', ')
const callParams = method.params.map((param) => `${param.name}: ${param.name}`).join(', ')
return `
/**
* @description ${method.description}
${sortedParams.map((param) => ` * @param ${param.name} ${param.description}`).join('\n')}
* @return ${method.return}
*/
async ${method.name}(${params}): Promise<${method.return}> {
return this.call("${method.name}"${callParams ? ', {' + callParams + '}' : ''});
}`
}
function generateType(apiType: ApiType): string {
if (apiType.type === 'enum') {
const values = apiType.values || []
return `export enum ${apiType.name} {
${values.map((value) => ` ${value} = "${value}"`).join(',\n')}
}`
} else if (apiType.type === 'class') {
const fields = apiType.fields || []
const fieldDefinitions = fields.map((field) => ` ${field.name}: ${field.type};`).join('\n')
return `export interface ${apiType.name} {
${fieldDefinitions}
}`
}
return ''
}
function generateTypes(spec: ApiSpec): string {
const types = spec.types.map(generateType).join('\n\n')
return types ? `\n// Generated Types\n${types}` : ''
}
function generateModule(module: ApiModule): string {
const className = `${module.name}Module`
const methods = module.methods.map(generateMethod).join('\n')
return `export class ${className} extends RpcModuleBase {
constructor(client: RpcClientBase) {
super(client, "${module.name}");
}${methods}
}`
}
function generateClient(spec: ApiSpec): string {
const modules = spec.modules
.map((module) => {
const moduleName = module.name
const className = `${module.name}Module`
return ` public ${moduleName}: ${className};`
})
.join('\n')
const initializations = spec.modules
.map((module) => {
const moduleName = module.name
const className = `${module.name}Module`
return ` this.${moduleName} = new ${className}(this);`
})
.join('\n')
const moduleClasses = spec.modules.map(generateModule).join('\n\n')
const types = generateTypes(spec)
return `import {RpcClientBase} from "@/api/RpcClientBase.ts";
import {RpcModuleBase} from "@/api/RpcModuleBase.ts";
${types}
export class RpcClient extends RpcClientBase {${modules}
constructor(baseUrl: string) {
super(baseUrl);
${initializations}
}
}
${moduleClasses}`
}
function main() {
const specPath = path.resolve(__dirname, '../../../api.spec.json')
const outputPath = path.resolve(__dirname, '../src/generated/RpcClient.ts')
console.log('🚀 Generating RPC client...')
try {
if (!fs.existsSync(specPath)) {
throw new Error(`API spec file not found: ${specPath}`)
}
const specContent = fs.readFileSync(specPath, 'utf-8')
const spec: ApiSpec = JSON.parse(specContent)
const clientCode = generateClient(spec)
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(outputPath, clientCode)
console.log(`✅ RPC client generated successfully: ${outputPath}`)
console.log(`📋 Generated ${spec.modules.length} API modules`)
if (spec.types.length > 0) {
console.log(
`📝 Generated ${spec.types.length} types (${spec.types.filter((t) => t.type === 'class').length} interfaces, ${spec.types.filter((t) => t.type === 'enum').length} enums)`
)
}
} catch (error) {
console.error('❌ Error generating RPC client:', error)
process.exit(1)
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main()
}
export { main as generateClient }

View File

@ -1,21 +1,130 @@
<template>
<div id="app">
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<RouterView />
</div>
<v-app>
<LoginForm />
<Notifications />
<template v-if="!authStore.showLoginForm && !appStore.isLoading">
<div class="app-layout">
<!-- Часть А: Меню (слева для ПК, шторка для мобильных) -->
<NavigationMenu
ref="navigationMenu"
@update-collapsed="isCollapsed = $event"
@toggle-menu="toggleMobileMenu"
/>
<!-- Часть Б: Футер + Контент -->
<div
class="content-section"
:class="{ 'with-menu': !isMobile, 'menu-collapsed': !isMobile && isCollapsed }"
:style="{ marginLeft: !isMobile ? (isCollapsed ? '80px' : '250px') : '0' }"
>
<!-- Футер (HeaderBar) - скрыт для ПК, показан для мобильных -->
<HeaderBar v-if="isMobile || showFooter" @toggle-menu="toggleMobileMenu" />
<!-- Основной контент -->
<v-main
:class="{
'mobile-main': isMobile,
'with-menu': !isMobile
}"
>
<RouterView />
</v-main>
</div>
</div>
</template>
</v-app>
</template>
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router'
import NavigationMenu from './components/NavigationMenu.vue'
import HeaderBar from './components/HeaderBar.vue'
import LoginForm from './components/LoginForm.vue'
import Notifications from './components/Notifications.vue'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notification'
const appStore = useAppStore()
const authStore = useAuthStore()
const navigationMenu = ref()
const windowWidth = ref(window.innerWidth)
const isCollapsed = ref(false)
const showFooter = ref(false)
const isMobile = computed(() => windowWidth.value < 768)
const toggleMobileMenu = () => {
if (navigationMenu.value) {
navigationMenu.value.toggle()
}
}
const updateWidth = () => {
windowWidth.value = window.innerWidth
}
onMounted(async () => {
window.addEventListener('resize', updateWidth)
appStore.initializeApi('http://localhost:8080/api')
await authStore.initializeAuth()
})
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
</script>
<style scoped>
nav {
padding: 1rem;
display: flex;
gap: 1rem;
<style>
#app {
min-height: 100vh;
}
</style>
.v-application {
min-height: 100vh;
width: 100%;
}
/* Основной layout контейнер */
.app-layout {
display: flex;
min-height: 100vh;
width: 100%;
}
/* Часть Б: Контентная секция */
.content-section {
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
background: rgb(var(--v-theme-background));
transition: margin-left 0.3s ease;
}
/* Для ПК - основной контент без отступов */
.v-main.with-menu {
padding: 16px;
flex: 1;
background: transparent !important;
}
/* Для мобильных - контент с отступом под футером */
.mobile-main {
padding-top: 45px !important;
flex: 1 !important;
}
.mobile-main :deep(.v-main__content) {
padding: 0 !important;
}
.mobile-main :deep(.v-container) {
padding: 0 !important;
}
</style>

View File

@ -0,0 +1,34 @@
export class RpcClientBase {
constructor(private baseUrl: string) {
}
public async callModuleMethod<T>(module: string, methodName: string, params?: any):Promise<T> {
return this.call(module + "." + methodName, params);
}
protected async call<T>(method: string, params?: any): Promise<T> {
const response = await fetch(this.baseUrl, {
method: 'POST',
credentials: "include",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: Date.now()
})
});
if (!response.ok) {
throw new Error(`RPC call ${method} failed: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`RPC error: ${data.error.message}`);
}
return data.result;
}
}

View File

@ -0,0 +1,10 @@
import type {RpcClientBase} from "@/api/RpcClientBase.ts";
export class RpcModuleBase {
constructor(private client: RpcClientBase, private moduleName: string) {
}
protected async call<T>(method: string, params?: any): Promise<T> {
return this.client.callModuleMethod(this.moduleName, method, params);
}
}

View File

@ -16,17 +16,15 @@
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}
a {
@ -46,4 +44,4 @@ a:hover {
a:hover {
color: #747bff;
}
}
}

View File

@ -0,0 +1,86 @@
<template>
<v-app-bar
v-if="isMobile"
app
color="grey-darken-3"
class="mobile-header-bar"
elevation="4"
height="45"
>
<v-container fluid class="pa-0">
<v-row align="center" no-gutters>
<v-col cols="8" class="d-flex align-center">
<v-icon :icon="currentPageInfo.icon" color="white" class="ms-4 me-2" />
<span class="current-page-text">{{ currentPageInfo.title }}</span>
</v-col>
<v-col cols="4" class="text-end">
<v-btn icon="mdi-menu" variant="text" @click="toggleMenu" color="white" />
</v-col>
</v-row>
</v-container>
</v-app-bar>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const isMobile = ref(window.innerWidth < 768)
const getUserDisplayName = () => {
const user = appStore.currentUser
if (user?.name && user.name.trim()) {
return user.name
}
return user?.login || 'Профиль'
}
const currentPageInfo = computed(() => {
const routeMap: Record<string, { title: string; icon: string }> = {
'/': { title: 'Главная', icon: 'mdi-home' },
'/profile': { title: getUserDisplayName(), icon: 'mdi-account' },
'/settings': { title: 'Настройки', icon: 'mdi-cog' },
'/dashboard': { title: 'Панель управления', icon: 'mdi-view-dashboard' }
}
return routeMap[route.path] || { title: 'Неизвестная страница', icon: 'mdi-help' }
})
const emit = defineEmits<{
toggleMenu: []
}>()
const toggleMenu = () => {
emit('toggleMenu')
}
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.header-bar {
z-index: 1001;
}
.current-page-text {
color: white;
font-weight: 500;
font-size: 1rem;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<v-dialog v-model="show" persistent max-width="400">
<v-card>
<v-card-title class="text-h5">Авторизация</v-card-title>
<v-card-text>
<v-form @submit.prevent="handleSubmit">
<v-text-field
ref="loginField"
v-model="loginForm.login"
label="Логин"
prepend-inner-icon="mdi-account"
variant="outlined"
:disabled="isLoading"
class="mb-3"
@keyup.enter="focusPassword"
/>
<v-text-field
ref="passwordField"
v-model="loginForm.password"
label="Пароль"
prepend-inner-icon="mdi-lock"
type="password"
variant="outlined"
:disabled="isLoading"
class="mb-3"
@keyup.enter="handleSubmit"
/>
<v-checkbox v-model="loginForm.rememberMe" label="Запомнить меня" :disabled="isLoading" />
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="handleSubmit" :loading="isLoading" color="primary"> Войти </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
const authStore = useAuthStore()
const appStore = useAppStore()
const loginField = ref()
const passwordField = ref()
const loginForm = computed(() => authStore.loginForm)
const show = computed({
get: () => authStore.showLoginForm,
set: (value) => {
authStore.showLoginForm = value
}
})
const isLoading = computed(() => appStore.isLoading)
// Фокус на поле логина при открытии диалога
watch(show, (newShow) => {
if (newShow) {
nextTick(() => {
loginField.value?.focus()
})
}
})
const focusPassword = () => {
passwordField.value?.focus()
}
const handleSubmit = async () => {
const success = await authStore.login()
if (success) {
// Успешная авторизация
} else {
// Ошибка авторизации
}
}
</script>

View File

@ -0,0 +1,242 @@
<template>
<!-- Мобильное меню - как Android шторка с плитками -->
<v-navigation-drawer
v-if="isMobile"
v-model="menuOpen"
temporary
location="top"
class="mobile-menu-drawer"
>
<v-container fluid class="pa-4">
<v-row dense>
<v-col v-for="item in menuItems" :key="item.path" cols="4" class="d-flex justify-center">
<v-card
class="menu-tile text-center"
:color="currentPath === item.path ? 'primary' : 'surface-variant'"
elevation="2"
@click="navigateTo(item.path)"
width="100%"
>
<v-card-text class="pa-3">
<v-icon
size="32"
:icon="item.icon"
:color="currentPath === item.path ? 'white' : '#757575'"
/>
<div
class="text-caption mt-2 font-weight-medium"
:class="currentPath === item.path ? 'text-white' : ''"
:style="currentPath === item.path ? {} : { color: '#757575' }"
>
{{ item.title }}
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-navigation-drawer>
<!-- Десктопное меню - обычный div -->
<div
v-if="!isMobile"
class="desktop-menu"
:class="{ collapsed: collapsed }"
:style="{ width: collapsed ? '80px' : '250px' }"
>
<div class="menu-header">
<v-btn
v-if="!collapsed"
icon="mdi-chevron-left"
variant="text"
@click="toggleCollapsed"
class="collapse-btn"
size="small"
/>
<v-btn
v-else
icon="mdi-chevron-right"
variant="text"
@click="toggleCollapsed"
class="collapse-btn"
size="small"
/>
</div>
<v-list v-model="selectedItem" class="menu-list">
<v-list-item
v-for="item in menuItems"
:key="item.path"
:prepend-icon="collapsed ? item.icon : undefined"
:title="collapsed ? '' : item.title"
:value="item.path"
@click="navigateTo(item.path)"
class="menu-item"
>
<template v-if="!collapsed" #prepend>
<v-icon :icon="item.icon" />
</template>
</v-list-item>
</v-list>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
interface MenuItem {
title: string
icon: string
path: string
}
const menuItems = computed(() => [
{
title: getUserDisplayName(),
icon: 'mdi-account',
path: '/profile'
},
{ title: 'Настройки', icon: 'mdi-cog', path: '/settings' },
{ title: 'Панель управления', icon: 'mdi-view-dashboard', path: '/dashboard' }
])
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const menuOpen = ref(false)
const collapsed = ref(false)
const isMobile = ref(window.innerWidth < 768)
const currentPath = computed(() => route.path)
const selectedItem = computed(() => currentPath.value)
const getUserDisplayName = () => {
const user = appStore.currentUser
if (user?.name && user.name.trim()) {
return user.name
}
return user?.login || 'Профиль'
}
const emit = defineEmits<{
updateCollapsed: [value: boolean]
toggleMenu: []
}>()
const navigateTo = (path: string) => {
router.push(path)
if (isMobile.value) {
menuOpen.value = false
}
}
const toggleCollapsed = () => {
collapsed.value = !collapsed.value
emit('updateCollapsed', collapsed.value)
}
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) {
menuOpen.value = false
}
}
onMounted(() => {
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
const toggleMobileMenu = () => {
emit('toggleMenu')
}
defineExpose({
toggle: () => {
menuOpen.value = !menuOpen.value
}
})
</script>
<style scoped>
.mobile-menu-drawer {
z-index: 1001;
height: auto !important;
max-height: 50vh;
border-radius: 0 0 16px 16px;
}
.desktop-menu {
background: rgb(var(--v-theme-surface));
border-right: 1px solid rgb(var(--v-theme-outline));
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 1000;
transition: width 0.3s ease;
}
.desktop-menu.collapsed {
width: 80px !important;
}
.menu-tile {
cursor: pointer;
transition: all 0.2s ease;
aspect-ratio: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.menu-tile:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.menu-tile:active {
transform: translateY(-2px);
}
.menu-header {
padding: 16px 8px 8px 8px;
display: flex;
justify-content: flex-end;
}
.collapse-btn {
z-index: 10;
}
.menu-list {
padding: 8px 4px;
}
.menu-item {
margin: 2px 4px;
border-radius: 6px;
}
.menu-item :deep(.v-list-item__content) {
padding-left: 4px;
}
.menu-item :deep(.v-list-item-title) {
text-align: left;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.mobile-menu-drawer {
width: 100% !important;
}
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<div
v-if="currentNotification"
class="notification-toast"
:style="{ background: getColor(currentNotification.color) }"
>
{{ currentNotification.message }}
<button @click="hide" class="close-btn">×</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useNotificationStore } from '@/stores/notification'
interface Notification {
id: string
message: string
color: string
timeout: number
}
const notificationStore = useNotificationStore()
const notifications = computed(() => notificationStore.notifications)
const removeNotification = (id: string) => notificationStore.removeNotification(id)
const currentNotification = ref<Notification | null>(null)
watch(
notifications,
(newNotifications) => {
if (newNotifications && newNotifications.length > 0) {
currentNotification.value = newNotifications[0]
// Автоматически скрываем через timeout
const notif = newNotifications[0]
if (notif.timeout > 0) {
setTimeout(() => {
hide()
}, notif.timeout)
}
} else {
currentNotification.value = null
}
},
{ deep: true }
)
const hide = () => {
if (currentNotification.value) {
removeNotification(currentNotification.value.id)
currentNotification.value = null
}
}
const getColor = (color: string) => {
const colors = {
success: '#4caf50',
error: '#f44336',
warning: '#ff9800',
info: '#2196f3'
}
return colors[color] || colors.info
}
</script>
<style scoped>
.notification-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
color: white;
padding: 16px 24px;
border-radius: 4px;
z-index: 9999999;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
min-width: 300px;
animation: slideIn 0.3s ease-out;
}
.close-btn {
background: none;
border: none;
color: white;
margin-left: 16px;
cursor: pointer;
font-size: 18px;
font-weight: bold;
opacity: 0.8;
}
.close-btn:hover {
opacity: 1;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View File

@ -2,6 +2,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
}

View File

@ -1,14 +1,44 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import '@mdi/font/css/materialdesignicons.css'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'dark',
themes: {
dark: {
colors: {
background: '#121212',
surface: '#1e1e1e',
primary: '#2196F3',
'primary-darken-1': '#1976D2',
secondary: '#424242',
'secondary-darken-1': '#616161',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
warning: '#FB8C00'
}
}
}
}
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')
app.mount('#app')

View File

@ -1,20 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import ProfileView from '../views/ProfileView.vue'
import SettingsView from '../views/SettingsView.vue'
import DashboardView from '../views/DashboardView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
redirect: '/profile'
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/settings',
name: 'settings',
component: SettingsView
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardView
}
]
})
export default router
export default router

View File

@ -0,0 +1,36 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { RpcClient, UserRole } from '@/generated/RpcClient'
import type { User } from '@/generated/RpcClient'
import { RpcClientBase } from '@/api/RpcClientBase'
export const useAppStore = defineStore('app', () => {
const api = ref<RpcClient | null>(null)
const currentUser = ref<User | null>(null)
const isAuthenticated = computed(
() => currentUser.value?.role !== UserRole.Guest && currentUser.value !== null
)
const isLoading = ref(false)
const initializeApi = (baseUrl: string) => {
api.value = new RpcClient(baseUrl)
}
const setCurrentUser = (user: User) => {
currentUser.value = user
}
const setLoading = (loading: boolean) => {
isLoading.value = loading
}
return {
api,
currentUser,
isAuthenticated,
isLoading,
initializeApi,
setCurrentUser,
setLoading
}
})

View File

@ -0,0 +1,121 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useAppStore } from './app'
import { useNotificationStore } from './notification'
import { UserRole } from '@/generated/RpcClient'
interface LoginFormData {
login: string
password: string
rememberMe: boolean
}
export const useAuthStore = defineStore('auth', () => {
const appStore = useAppStore()
const loginForm = ref<LoginFormData>({
login: '',
password: '',
rememberMe: false
})
const showLoginForm = ref(false)
const notificationStore = useNotificationStore()
const showSuccess = notificationStore.showSuccess
const showError = notificationStore.showError
const initializeAuth = async () => {
if (!appStore.api) return
appStore.setLoading(true)
try {
const user = await appStore.api.Profile.get()
appStore.setCurrentUser(user)
if (user.role === UserRole.Guest) {
const authToken = localStorage.getItem('auth-token')
if (authToken) {
const success = await appStore.api.Auth.authenticateByToken(authToken)
if (success) {
const updatedUser = await appStore.api.Profile.get()
appStore.setCurrentUser(updatedUser)
} else {
localStorage.removeItem('auth-token')
showLoginForm.value = true
}
} else {
showLoginForm.value = true
}
}
} catch (error) {
console.error('Auth initialization error:', error)
showLoginForm.value = true
} finally {
appStore.setLoading(false)
}
}
const login = async () => {
if (!appStore.api) return false
appStore.setLoading(true)
try {
const success = await appStore.api.Auth.authenticateByPassword(
loginForm.value.login,
loginForm.value.password
)
if (success) {
const user = await appStore.api.Profile.get()
appStore.setCurrentUser(user)
if (loginForm.value.rememberMe) {
const tokenData = await appStore.api.Auth.generateToken(true)
localStorage.setItem('auth-token', tokenData.uuid)
}
showSuccess('Авторизация успешна!')
resetLoginForm()
showLoginForm.value = false
return true
} else {
showError('Неверный логин или пароль')
return false
}
} catch (error) {
console.error('Login error:', error)
showError('Ошибка авторизации')
return false
} finally {
appStore.setLoading(false)
}
}
const logout = async () => {
if (!appStore.api) return
try {
await appStore.api.Auth.logout()
localStorage.removeItem('auth-token')
appStore.setCurrentUser({ ...appStore.currentUser!, role: UserRole.Guest })
showLoginForm.value = true
} catch (error) {
console.error('Logout error:', error)
}
}
const resetLoginForm = () => {
loginForm.value = {
login: '',
password: '',
rememberMe: false
}
}
return {
loginForm,
showLoginForm,
initializeAuth,
login,
logout,
resetLoginForm
}
})

View File

@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface Notification {
id: string
message: string
color: string
timeout: number
}
export const useNotificationStore = defineStore('notification', () => {
const notifications = ref<Notification[]>([])
const showNotification = (message: string, color: string = 'info', timeout: number = 3000) => {
const id = Date.now().toString()
notifications.value.push({ id, message, color, timeout })
if (timeout > 0) {
setTimeout(() => {
removeNotification(id)
}, timeout)
}
}
const removeNotification = (id: string) => {
const index = notifications.value.findIndex((n) => n.id === id)
if (index > -1) {
notifications.value.splice(index, 1)
}
}
const showSuccess = (message: string) => showNotification(message, 'success')
const showError = (message: string) => showNotification(message, 'error')
const showWarning = (message: string) => showNotification(message, 'warning')
const showInfo = (message: string) => showNotification(message, 'info')
return {
notifications,
showNotification,
removeNotification,
showSuccess,
showError,
showWarning,
showInfo
}
})

View File

@ -0,0 +1,29 @@
<template>
<div class="mobile-full-height">
<div class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-icon size="64" color="primary" class="mb-4"> mdi-view-dashboard </v-icon>
<h1 class="text-h4 mb-2">Панель управления</h1>
<p class="text-body-1 text-medium-emphasis">Панель управления и мониторинга</p>
</div>
</div>
</div>
</template>
<style scoped>
.mobile-full-height {
height: 100%;
width: 100%;
padding: 16px;
}
@media (max-width: 768px) {
.mobile-full-height {
padding: 0;
}
}
</style>
<script setup lang="ts">
// Панель управления
</script>

View File

@ -0,0 +1,347 @@
<template>
<div class="profile-container">
<v-container fluid>
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<v-card elevation="2" class="profile-card">
<v-card-text>
<!-- Форма профиля -->
<v-form ref="profileForm" v-model="isFormValid">
<!-- Аватар и имя -->
<div class="text-center mb-6">
<v-avatar size="120" color="primary" class="mb-3">
<v-icon size="60" color="white">mdi-account</v-icon>
</v-avatar>
<div class="text-h6">{{ getUserDisplayName() }}</div>
<div class="text-body-2 text-medium-emphasis">{{ userRole }}</div>
</div>
<!-- Поля для редактирования -->
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="profileData.login"
:rules="loginRules"
:disabled="!isEditing"
label="Логин"
prepend-inner-icon="mdi-account"
variant="outlined"
hint="Только буквы, цифры, точка и дефис"
persistent-hint
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="profileData.name"
:rules="nameRules"
:disabled="!isEditing"
label="Отображаемое имя"
prepend-inner-icon="mdi-account-circle"
variant="outlined"
hint="Имя для отображения в интерфейсе"
persistent-hint
/>
</v-col>
</v-row>
<!-- Поля для смены пароля (только в режиме редактирования) -->
<template v-if="isEditing">
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-3">Смена пароля</div>
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="profileData.currentPassword"
:disabled="!isEditing"
label="Текущий пароль"
type="password"
prepend-inner-icon="mdi-lock"
variant="outlined"
hint="Введите текущий пароль для смены"
persistent-hint
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="profileData.newPassword"
:rules="passwordRules"
:disabled="!isEditing"
label="Новый пароль"
type="password"
prepend-inner-icon="mdi-lock-reset"
variant="outlined"
hint="Минимум 6 символов"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="profileData.confirmPassword"
:rules="confirmPasswordRules"
:disabled="!isEditing"
label="Подтверждение пароля"
type="password"
prepend-inner-icon="mdi-lock-check"
variant="outlined"
hint="Повторите новый пароль"
persistent-hint
/>
</v-col>
</v-row>
</template>
<!-- Кнопки действий -->
<div class="d-flex justify-end mt-4">
<v-btn
v-if="!isEditing"
@click="startEditing"
color="primary"
prepend-icon="mdi-pencil"
>
Редактировать
</v-btn>
<template v-else>
<v-btn @click="cancelEditing" variant="outlined" class="me-2"> Отмена </v-btn>
<v-btn
@click="saveProfile"
color="primary"
:loading="isSaving"
:disabled="!isFormValid"
prepend-icon="mdi-content-save"
>
Сохранить
</v-btn>
</template>
</div>
</v-form>
</v-card-text>
</v-card>
<!-- Карта с дополнительной информацией -->
<v-card elevation="2" class="mt-4">
<v-card-text>
<v-list density="compact">
<v-list-item>
<template #prepend>
<v-icon>mdi-fingerprint</v-icon>
</template>
<v-list-item-title>ID пользователя</v-list-item-title>
<v-list-item-subtitle>{{ currentUser?.id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-identifier</v-icon>
</template>
<v-list-item-title>UUID</v-list-item-title>
<v-list-item-subtitle>{{ currentUser?.uuid }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-shield-account</v-icon>
</template>
<v-list-item-title>Роль</v-list-item-title>
<v-list-item-subtitle>{{ userRole }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
import { useNotificationStore } from '@/stores/notification'
const appStore = useAppStore()
const { showSuccess, showError } = useNotificationStore()
// Данные формы
const profileForm = ref()
const isFormValid = ref(false)
const isEditing = ref(false)
const isSaving = ref(false)
const currentUser = computed(() => appStore.currentUser)
const userRole = computed(() => {
const role = currentUser.value?.role
const roleMap: Record<string, string> = {
Admin: 'Администратор',
Operator: 'Оператор',
User: 'Пользователь',
Guest: 'Гость'
}
return roleMap[role || ''] || role || 'Неизвестно'
})
// Данные профиля для редактирования
const profileData = ref({
login: '',
name: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
const originalData = ref({
login: '',
name: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
// Валидация логина: только точка, дефис, A-z, 0-9
const loginRules = [
(v: string) => !!v || 'Логин обязателен',
(v: string) =>
/^[a-zA-Z0-9.-]+$/.test(v) || 'Логин может содержать только буквы, цифры, точку и дефис',
(v: string) => v.length >= 3 || 'Минимальная длина логина - 3 символа',
(v: string) => v.length <= 50 || 'Максимальная длина логина - 50 символов'
]
const nameRules = [
(v: string) => !!v || 'Имя обязательно',
(v: string) => v.length >= 2 || 'Минимальная длина имени - 2 символа',
(v: string) => v.length <= 100 || 'Максимальная длина имени - 100 символов'
]
const passwordRules = [
(v: string) => !v || v.length >= 6 || 'Минимальная длина пароля - 6 символов'
]
const confirmPasswordRules = [
(v: string) => !profileData.newPassword || v === profileData.newPassword || 'Пароли не совпадают'
]
const getUserDisplayName = () => {
const user = currentUser.value
if (user?.name && user.name.trim()) {
return user.name
}
return user?.login || 'Профиль'
}
const startEditing = () => {
originalData.value = {
login: profileData.value.login,
name: profileData.value.name
}
isEditing.value = true
}
const cancelEditing = () => {
profileData.value = { ...originalData.value }
isEditing.value = false
profileForm.value?.resetValidation()
}
const saveProfile = async () => {
if (!profileForm.value?.validate() || !appStore.api) {
return
}
// Проверяем, если хочет сменить пароль - нужен текущий пароль
if (profileData.value.newPassword && !profileData.value.currentPassword) {
showError('Для смены пароля введите текущий пароль')
return
}
isSaving.value = true
try {
// Определяем параметр password только если меняем пароль
const password = profileData.value.newPassword ? profileData.value.newPassword : undefined
const success = await appStore.api.Profile.save(
profileData.value.login,
profileData.value.name,
currentUser.value?.values || {},
password,
profileData.value.currentPassword
)
if (success) {
// Обновляем данные пользователя в store
const updatedUser = await appStore.api.Profile.get()
appStore.setCurrentUser(updatedUser)
showSuccess('Профиль успешно обновлен!')
isEditing.value = false
// Очищаем поля пароля после успешного сохранения
profileData.value.currentPassword = ''
profileData.value.newPassword = ''
profileData.value.confirmPassword = ''
originalData.value = { ...profileData.value }
} else {
showError('Не удалось обновить профиль')
}
} catch (error) {
console.error('Error saving profile:', error)
showError('Ошибка при сохранении профиля')
} finally {
isSaving.value = false
}
}
const loadProfileData = () => {
const user = currentUser.value
if (user) {
profileData.value = {
login: user.login || '',
name: user.name || '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
}
originalData.value = { ...profileData.value }
}
}
// Загружаем данные при монтировании
onMounted(() => {
loadProfileData()
})
// Следим за изменениями currentUser
watch(
currentUser,
() => {
loadProfileData()
},
{ immediate: true }
)
</script>
<style scoped>
.profile-container {
padding: 16px;
}
.profile-card {
border-radius: 12px;
}
@media (max-width: 768px) {
.profile-container {
padding: 8px;
}
}
</style>

View File

@ -0,0 +1,29 @@
<template>
<div class="mobile-full-height">
<div class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-icon size="64" color="primary" class="mb-4"> mdi-cog </v-icon>
<h1 class="text-h4 mb-2">Настройки</h1>
<p class="text-body-1 text-medium-emphasis">Страница настроек приложения</p>
</div>
</div>
</div>
</template>
<style scoped>
.mobile-full-height {
height: 100%;
width: 100%;
padding: 16px;
}
@media (max-width: 768px) {
.mobile-full-height {
padding: 0;
}
}
</style>
<script setup lang="ts">
// Настройки приложения
</script>

View File

@ -1,14 +1,12 @@
import { defineConfig } from 'tsbuild'
export default defineConfig({
extends: '@vue/tsconfig/tsconfig.dom.json',
include: ['env.d.ts', 'src/**/*', 'src/**/*.vue'],
exclude: ['src/**/__tests__/*'],
compilerOptions: {
composite: true,
baseUrl: '.',
paths: {
'@/*': ['./src/*']
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
})
}

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,30 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { execSync } from 'child_process'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
{
name: 'generate-rpc-client',
buildStart() {
console.log('🔄 Generating RPC client before build...')
try {
execSync('npx tsx scripts/generate-rpc-client.ts', {
stdio: 'inherit',
cwd: process.cwd()
})
} catch (error) {
console.error('❌ Failed to generate RPC client:', error)
throw error
}
}
}
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
})