Compare commits
19 Commits
ead1cb4f71
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
f4edb07629 | ||
|
9b9d222546 | ||
|
86d7823aae | ||
|
019652ca67 | ||
|
6d97d437a2 | ||
|
2af30101cc | ||
|
d5e46e186e | ||
|
475524d36d | ||
|
4413b6a72c | ||
|
abec71ce52 | ||
|
23663c9a67 | ||
|
593131ccef | ||
|
e3f7bc155c | ||
|
fa9bc78226 | ||
|
f74f04bd4c | ||
|
59c8a316fe | ||
|
23c4a43826 | ||
|
86033cca39 | ||
|
9c48156345 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -305,3 +305,4 @@ Cargo.lock
|
|||||||
**/*.key
|
**/*.key
|
||||||
**/*.pem
|
**/*.pem
|
||||||
**/*.zip
|
**/*.zip
|
||||||
|
**/power.conf
|
||||||
|
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
FROM rust:1.77-bullseye AS builder
|
||||||
|
|
||||||
|
ADD sources.list /etc/apt/
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libc6-dev \
|
||||||
|
libclang-dev \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev
|
||||||
|
ADD crates.conf /root/.cargo/config
|
||||||
|
RUN USER=root cargo new --bin license_service
|
||||||
|
ADD ./cert_lib /cert_lib
|
||||||
|
WORKDIR /license_service
|
||||||
|
COPY ./license_server/Cargo.toml ./Cargo.toml
|
||||||
|
|
||||||
|
RUN cargo build --release && rm src/*.rs target/release/deps/license_server*
|
||||||
|
|
||||||
|
ADD ./license_server/src ./src
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bullseye-slim AS deployer
|
||||||
|
|
||||||
|
ADD sources.list /etc/apt/
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y ca-certificates tzdata && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
APP_USER=license_usr
|
||||||
|
|
||||||
|
RUN groupadd service && \
|
||||||
|
useradd -g service $APP_USER && \
|
||||||
|
mkdir -p /license_service
|
||||||
|
|
||||||
|
COPY --from=builder /license_service/target/release/license_server /license_service/license_server
|
||||||
|
|
||||||
|
RUN chown -R $APP_USER:service /license_service
|
||||||
|
|
||||||
|
USER $APP_USER
|
||||||
|
WORKDIR /license_service
|
||||||
|
|
||||||
|
VOLUME ["/license_service/license.key", "/license_service/license.pem", "/license_service/netfilter.zip", "/license_service/products.json"]
|
||||||
|
|
||||||
|
CMD ["./license_server"]
|
@@ -40,13 +40,12 @@ pub fn generate_certificate(
|
|||||||
|
|
||||||
let mut x509_name = X509NameBuilder::new()?;
|
let mut x509_name = X509NameBuilder::new()?;
|
||||||
x509_name.append_entry_by_text("CN", certificate_name)?;
|
x509_name.append_entry_by_text("CN", certificate_name)?;
|
||||||
x509_name.append_entry_by_text("C", "CN")?;
|
|
||||||
x509_name.append_entry_by_text("ST", "Hebei")?;
|
|
||||||
x509_name.append_entry_by_text("L", "Shi Jiazhuang")?;
|
|
||||||
x509_name.append_entry_by_text("O", "archgrid.xyz")?;
|
|
||||||
let x509_name = x509_name.build();
|
let x509_name = x509_name.build();
|
||||||
|
let mut x509_issuer = X509NameBuilder::new()?;
|
||||||
|
x509_issuer.append_entry_by_text("CN", "JetProfile CA")?;
|
||||||
|
let x509_issuer = x509_issuer.build();
|
||||||
builder.set_subject_name(&x509_name)?;
|
builder.set_subject_name(&x509_name)?;
|
||||||
builder.set_issuer_name(&x509_name)?;
|
builder.set_issuer_name(&x509_issuer)?;
|
||||||
builder.set_version(version)?;
|
builder.set_version(version)?;
|
||||||
|
|
||||||
let not_before = Asn1Time::days_from_now(0)?;
|
let not_before = Asn1Time::days_from_now(0)?;
|
||||||
|
@@ -10,3 +10,4 @@ cert_lib = { path = "../cert_lib" }
|
|||||||
clap = { version = "4.5.4", features = ["derive"] }
|
clap = { version = "4.5.4", features = ["derive"] }
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
serde_json = "1.0.115"
|
serde_json = "1.0.115"
|
||||||
|
zip = "0.6.6"
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs::File,
|
fs::File,
|
||||||
io::{BufWriter, Write},
|
io::{BufWriter, Read, Write},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
|
use zip::{write::FileOptions, ZipWriter};
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct PowerEqualResultOption {
|
pub struct PowerEqualResultOption {
|
||||||
@@ -12,13 +13,70 @@ pub struct PowerEqualResultOption {
|
|||||||
key_file: PathBuf,
|
key_file: PathBuf,
|
||||||
#[arg(short, long, default_value = "false", help = "Export to power.conf")]
|
#[arg(short, long, default_value = "false", help = "Export to power.conf")]
|
||||||
export: bool,
|
export: bool,
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
default_value = "false",
|
||||||
|
help = "Replace power.conf in given zip file, will override export option"
|
||||||
|
)]
|
||||||
|
zip_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_equal_result(options: PowerEqualResultOption) {
|
pub fn calculate_equal_result(options: PowerEqualResultOption) {
|
||||||
let cert = cert_lib::load_certificate(options.key_file).expect("load certificate failed");
|
let cert = cert_lib::load_certificate(options.key_file).expect("load certificate failed");
|
||||||
let result =
|
let result =
|
||||||
cert_lib::calculate_power_euqal_result(cert).expect("calculate equal result failed");
|
cert_lib::calculate_power_euqal_result(cert).expect("calculate equal result failed");
|
||||||
if options.export {
|
if let Some(zip_file) = options.zip_file {
|
||||||
|
let mut buffer: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut buf_writer = BufWriter::new(&mut buffer);
|
||||||
|
buf_writer
|
||||||
|
.write("[Args]\n\n[Result]\n".as_bytes())
|
||||||
|
.expect("write power.conf failed");
|
||||||
|
buf_writer
|
||||||
|
.write(result.as_bytes())
|
||||||
|
.expect("write power.conf failed");
|
||||||
|
buf_writer.flush().expect("write power.conf failed");
|
||||||
|
}
|
||||||
|
// 将zip_file的文件名增加一个`_replaced`后缀并形成一个新的位于相同目录的PathBuf实例。
|
||||||
|
let mut zip_file_path = zip_file.clone();
|
||||||
|
let file_name = zip_file_path.file_stem().unwrap().to_str().unwrap();
|
||||||
|
let mut zip_file_name = file_name.to_string();
|
||||||
|
zip_file_name.push_str("_replaced.");
|
||||||
|
zip_file_name.push_str(&zip_file_path.extension().unwrap().to_string_lossy());
|
||||||
|
zip_file_path.set_file_name(zip_file_name);
|
||||||
|
// 创建一个新的zip文件并将zip_file的内容复制到新的zip文件中。
|
||||||
|
let zip_file = File::open(zip_file).expect("open zip file failed");
|
||||||
|
let mut zip_file = zip::ZipArchive::new(zip_file).expect("open zip file failed");
|
||||||
|
let zip_file_path = File::create(zip_file_path).expect("create zip file failed");
|
||||||
|
let mut zip_file_writer = ZipWriter::new(zip_file_path);
|
||||||
|
for i in 0..zip_file.len() {
|
||||||
|
let mut file = zip_file.by_index(i).expect("get file from zip failed");
|
||||||
|
let options = FileOptions::default()
|
||||||
|
.compression_method(file.compression())
|
||||||
|
.unix_permissions(file.unix_mode().unwrap());
|
||||||
|
|
||||||
|
let file_name = file.name().to_owned();
|
||||||
|
let file_name = file_name.replace("power.conf", "power.conf");
|
||||||
|
|
||||||
|
let content = if file_name.ends_with("power.conf") {
|
||||||
|
buffer.clone()
|
||||||
|
} else {
|
||||||
|
let mut content = Vec::new();
|
||||||
|
file.read_to_end(&mut content).expect("read file failed");
|
||||||
|
content
|
||||||
|
};
|
||||||
|
|
||||||
|
zip_file_writer
|
||||||
|
.start_file(file_name, options)
|
||||||
|
.expect("start output file failed");
|
||||||
|
zip_file_writer
|
||||||
|
.write_all(&content)
|
||||||
|
.expect("write output file failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_file_writer.finish().expect("write zip file failed");
|
||||||
|
} else if options.export {
|
||||||
let mut power_conf_path = PathBuf::new();
|
let mut power_conf_path = PathBuf::new();
|
||||||
power_conf_path.push(".");
|
power_conf_path.push(".");
|
||||||
power_conf_path.push("power.conf");
|
power_conf_path.push("power.conf");
|
||||||
|
14
crates.conf
Normal file
14
crates.conf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[source.crates-io]
|
||||||
|
# To use sparse index, change 'rsproxy' to 'rsproxy-sparse'
|
||||||
|
replace-with = 'rsproxy-sparse'
|
||||||
|
|
||||||
|
[source.rsproxy]
|
||||||
|
registry = "https://rsproxy.cn/crates.io-index"
|
||||||
|
[source.rsproxy-sparse]
|
||||||
|
registry = "sparse+https://rsproxy.cn/index/"
|
||||||
|
|
||||||
|
[registries.rsproxy]
|
||||||
|
index = "https://rsproxy.cn/crates.io-index"
|
||||||
|
|
||||||
|
[net]
|
||||||
|
git-fetch-with-cli = true
|
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ use axum::{http::StatusCode, response::IntoResponse, routing, Json, Router};
|
|||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use openssl::{hash::MessageDigest, rsa::Padding, sign::Signer};
|
use openssl::{hash::MessageDigest, rsa::Padding, sign::Signer};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::vo::{self, LicenseRequestForm};
|
use crate::vo::{self, LicenseRequestForm};
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ impl LicenseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn issue_license(Json(request_form): Json<LicenseRequestForm>) -> impl IntoResponse {
|
async fn issue_license(Json(request_form): Json<LicenseRequestForm>) -> impl IntoResponse {
|
||||||
|
info!(request_form = ?request_form, "Received license request");
|
||||||
let mut license = vo::License::new(
|
let mut license = vo::License::new(
|
||||||
request_form.licensee_name,
|
request_form.licensee_name,
|
||||||
request_form.assignee_name,
|
request_form.assignee_name,
|
||||||
@@ -33,17 +35,14 @@ async fn issue_license(Json(request_form): Json<LicenseRequestForm>) -> impl Int
|
|||||||
license.add_product(p, request_form.valid_days);
|
license.add_product(p, request_form.valid_days);
|
||||||
}
|
}
|
||||||
let serialized_license = license.serialize();
|
let serialized_license = license.serialize();
|
||||||
|
debug!(license = ?license, "License created: {}", serialized_license);
|
||||||
|
|
||||||
let private_key = crate::certificate::get_private_key();
|
let private_key = crate::certificate::get_private_key();
|
||||||
let mut signer = Signer::new(MessageDigest::sha1(), private_key).unwrap();
|
let mut signer = Signer::new(MessageDigest::sha1(), private_key).unwrap();
|
||||||
signer.set_rsa_padding(Padding::PKCS1).unwrap();
|
signer.set_rsa_padding(Padding::PKCS1).unwrap();
|
||||||
signer.update(serialized_license.as_bytes()).unwrap();
|
signer.update(serialized_license.as_bytes()).unwrap();
|
||||||
|
|
||||||
let cert = crate::certificate::get_public_key()
|
let cert = crate::certificate::get_public_key().to_der().unwrap();
|
||||||
.public_key()
|
|
||||||
.unwrap()
|
|
||||||
.public_key_to_der()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let base64_license = STANDARD.encode(serialized_license);
|
let base64_license = STANDARD.encode(serialized_license);
|
||||||
let base64_signature = STANDARD.encode(signer.sign_to_vec().unwrap());
|
let base64_signature = STANDARD.encode(signer.sign_to_vec().unwrap());
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
#![feature(diagnostic_namespace)]
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
@@ -7,6 +7,7 @@ use tokio::{fs::File, io::AsyncReadExt};
|
|||||||
pub struct Product {
|
pub struct Product {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub couple: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static PRODUCTS: OnceLock<Vec<Product>> = OnceLock::new();
|
static PRODUCTS: OnceLock<Vec<Product>> = OnceLock::new();
|
||||||
|
Binary file not shown.
@@ -15,6 +15,7 @@
|
|||||||
"@mantine/form": "^7.7.1",
|
"@mantine/form": "^7.7.1",
|
||||||
"@mantine/hooks": "^7.7.1",
|
"@mantine/hooks": "^7.7.1",
|
||||||
"@mantine/modals": "^7.7.1",
|
"@mantine/modals": "^7.7.1",
|
||||||
|
"@mantine/notifications": "^7.7.1",
|
||||||
"@tabler/icons-react": "^3.1.0",
|
"@tabler/icons-react": "^3.1.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
@@ -6,7 +6,8 @@
|
|||||||
gap: var(--mantine-space-lg);
|
gap: var(--mantine-space-lg);
|
||||||
}
|
}
|
||||||
.form-column {
|
.form-column {
|
||||||
flex-grow: 1;
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
.product-column {
|
.product-column {
|
||||||
flex-grow: 5;
|
flex-grow: 5;
|
||||||
|
@@ -13,6 +13,7 @@ function App() {
|
|||||||
align="stretch"
|
align="stretch"
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
gap="lg"
|
gap="lg"
|
||||||
|
w={450}
|
||||||
className={classes["form-column"]}
|
className={classes["form-column"]}
|
||||||
>
|
>
|
||||||
<Steps />
|
<Steps />
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
.container {
|
.container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
.license-code-area {
|
.license-code-area {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@@ -1,22 +1,34 @@
|
|||||||
import { useLicenseCode } from "@/hooks/use-license-code";
|
import { useLicenseCodeStore } from "@/hooks/use_license_code_store";
|
||||||
import { ActionIcon, Box, Flex, Group, Paper, Title, Tooltip } from "@mantine/core";
|
import { ActionIcon, Box, Flex, Group, Paper, Title, Tooltip } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IconCopy } from "@tabler/icons-react";
|
import { IconCopy } from "@tabler/icons-react";
|
||||||
|
import { isEmpty, not } from "ramda";
|
||||||
import classes from "./LicenseCode.module.css";
|
import classes from "./LicenseCode.module.css";
|
||||||
|
|
||||||
export function LicenseCode() {
|
export function LicenseCode() {
|
||||||
const [licenseCode] = useLicenseCode();
|
const licenseCode = useLicenseCodeStore((state) => state.licenceCode);
|
||||||
|
const copyLicenseCode = async () => {
|
||||||
|
if (not(isEmpty(licenseCode))) {
|
||||||
|
await navigator.clipboard.writeText(licenseCode ?? "");
|
||||||
|
notifications.show({
|
||||||
|
message: "授权码已复制",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper shadow="md" p="lg" className={classes["container"]}>
|
<Paper shadow="md" p="lg" className={classes["container"]}>
|
||||||
<Flex direction="column" justify="flex-start" align="stretch" gap="md">
|
<Flex direction="column" justify="flex-start" align="stretch" gap="md" h="100%">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={4}>授权码</Title>
|
<Title order={4}>授权码</Title>
|
||||||
<Tooltip label="复制授权码" position="top">
|
<Tooltip label="复制授权码" position="top">
|
||||||
<ActionIcon>
|
<ActionIcon size="lg" onClick={copyLicenseCode}>
|
||||||
<IconCopy size={16} />
|
<IconCopy size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
<Box className={classes["license-code-area"]} c="green">
|
<Box className={classes["license-code-area"]} c="green" fz="xs">
|
||||||
{licenseCode}
|
{licenseCode}
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@@ -1,63 +1,104 @@
|
|||||||
|
import { licenseValidLength } from "@/constants";
|
||||||
|
import { useLicenseCodeStore } from "@/hooks/use_license_code_store";
|
||||||
|
import { useProductsStore } from "@/hooks/use_products_store";
|
||||||
|
import type { LicenseInfoForm } from "@/types";
|
||||||
import { Button, Flex, Group, Paper, Select, TextInput, Title } from "@mantine/core";
|
import { Button, Flex, Group, Paper, Select, TextInput, Title } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IconAt, IconCalendar, IconTag, IconUser } from "@tabler/icons-react";
|
import { IconAt, IconCalendar, IconTag, IconUser } from "@tabler/icons-react";
|
||||||
import { pluck } from "ramda";
|
import dayjs from "dayjs";
|
||||||
|
import { find, isEmpty, pluck, prop, propEq } from "ramda";
|
||||||
const licenseValidLength = [
|
import { useCallback } from "react";
|
||||||
{ value: 1, label: "一年" },
|
|
||||||
{ value: 2, label: "两年" },
|
|
||||||
{ value: 3, label: "三年" },
|
|
||||||
{ value: 4, label: "四年" },
|
|
||||||
{ value: 5, label: "五年" },
|
|
||||||
{ value: 6, label: "六年" },
|
|
||||||
{ value: 7, label: "七年" },
|
|
||||||
{ value: 8, label: "八年" },
|
|
||||||
{ value: 9, label: "九年" },
|
|
||||||
{ value: 10, label: "十年" },
|
|
||||||
{ value: 15, label: "十五年" },
|
|
||||||
{ value: 20, label: "二十年" },
|
|
||||||
{ value: 25, label: "二十五年" },
|
|
||||||
{ value: 30, label: "三十年" },
|
|
||||||
{ value: 35, label: "三十五年" },
|
|
||||||
{ value: 40, label: "四十年" },
|
|
||||||
{ value: 45, label: "四十五年" },
|
|
||||||
{ value: 50, label: "五十年" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LicenseForm() {
|
export function LicenseForm() {
|
||||||
|
const form = useForm<LicenseInfoForm>({
|
||||||
|
initialValues: {
|
||||||
|
licenseName: "",
|
||||||
|
assigneeName: "",
|
||||||
|
assigneeEmail: "",
|
||||||
|
validLength: licenseValidLength[0].label,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
licenseName: (value) => (isEmpty(value) ? "授权名称不能为空" : null),
|
||||||
|
assigneeName: (value) => (isEmpty(value) ? "被授权人不能为空" : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedProducts = useProductsStore((state) => state.selectedProducts);
|
||||||
|
const updateLicenseCode = useLicenseCodeStore((state) => state.setLicenseCode);
|
||||||
|
const licenseGenAction = useCallback(
|
||||||
|
async (formValue: LicenseInfoForm) => {
|
||||||
|
const license = await generateLicense(formValue, selectedProducts);
|
||||||
|
updateLicenseCode(license);
|
||||||
|
},
|
||||||
|
[selectedProducts, updateLicenseCode]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper shadow="md" p="lg">
|
<Paper shadow="md" p="lg">
|
||||||
<form>
|
<form onSubmit={form.onSubmit(licenseGenAction)}>
|
||||||
<Flex direction="column" gap="md">
|
<Flex direction="column" gap="md">
|
||||||
<Title order={4}>授权信息</Title>
|
<Title order={4}>授权信息</Title>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="授权名称"
|
label="授权名称"
|
||||||
placeholder="请输入授权名称"
|
placeholder="请输入授权名称"
|
||||||
required
|
withAsterisk
|
||||||
leftSection={<IconTag size={16} />}
|
leftSection={<IconTag size={16} />}
|
||||||
|
{...form.getInputProps("licenseName")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="被授权人"
|
label="被授权人"
|
||||||
placeholder="请输入授权人名称"
|
placeholder="请输入授权人名称"
|
||||||
required
|
withAsterisk
|
||||||
leftSection={<IconUser size={16} />}
|
leftSection={<IconUser size={16} />}
|
||||||
|
{...form.getInputProps("assigneeName")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="被授权人Email"
|
label="被授权人Email"
|
||||||
placeholder="请输入授权人Email"
|
placeholder="请输入授权人Email"
|
||||||
leftSection={<IconAt size={16} />}
|
leftSection={<IconAt size={16} />}
|
||||||
|
{...form.getInputProps("assigneeEmail")}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="授权有效时长"
|
label="授权有效时长"
|
||||||
placeholder="请选择授权有效时长"
|
placeholder="请选择授权有效时长"
|
||||||
data={pluck("label", licenseValidLength)}
|
data={pluck("label", licenseValidLength)}
|
||||||
defaultValue={licenseValidLength[0].label}
|
|
||||||
leftSection={<IconCalendar size={16} />}
|
leftSection={<IconCalendar size={16} />}
|
||||||
|
{...form.getInputProps("validLength")}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button>生成授权</Button>
|
<Button type="submit">生成授权</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
</Flex>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateLicense(form: LicenseInfoForm, selectedProducts: string[]) {
|
||||||
|
const validYears = find(propEq(form.validLength, "label"), licenseValidLength)?.value ?? 0;
|
||||||
|
const now = dayjs();
|
||||||
|
const validDays = now.add(validYears, "year").diff(now, "day");
|
||||||
|
if (isEmpty(selectedProducts)) {
|
||||||
|
notifications.show({
|
||||||
|
message: "至少需要选择一个产品",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const requestBody = {
|
||||||
|
licenseeName: form.licenseName,
|
||||||
|
assigneeName: form.assigneeName,
|
||||||
|
assigneeEmail: form.assigneeEmail,
|
||||||
|
validDays,
|
||||||
|
requestProducts: selectedProducts,
|
||||||
|
};
|
||||||
|
const response = await fetch("/api/license", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return prop<"license", { license: string }>("license", data);
|
||||||
|
}
|
||||||
|
@@ -7,3 +7,13 @@
|
|||||||
.title-search {
|
.title-search {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
.product-list-container {
|
||||||
|
align-content: flex-start;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.product-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@@ -1,22 +1,106 @@
|
|||||||
import { Flex, Paper, TextInput, Title } from "@mantine/core";
|
import { useProducts } from "@/hooks/use_products";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { useProductsStore } from "@/hooks/use_products_store";
|
||||||
|
import { ActionIcon, Box, Flex, Paper, Text, TextInput, Title, Tooltip } from "@mantine/core";
|
||||||
|
import { IconDeselect, IconListCheck, IconSearch, IconX } from "@tabler/icons-react";
|
||||||
|
import { isEmpty, not } from "ramda";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import classes from "./ProductList.module.css";
|
import classes from "./ProductList.module.css";
|
||||||
|
|
||||||
export function ProductList() {
|
export function ProductList() {
|
||||||
|
const [keyword, setKeyword] = useState<string>("");
|
||||||
|
const [selected, products, selectAll, unselectAll] = useProductsStore((state) => [
|
||||||
|
state.selectedProducts,
|
||||||
|
state.products,
|
||||||
|
state.selectAll,
|
||||||
|
state.unselectAll,
|
||||||
|
]);
|
||||||
|
const clearSearchKeyword = useCallback(() => setKeyword(""), []);
|
||||||
|
useProducts(keyword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper shadow="md" p="lg" h="100%">
|
<Paper shadow="md" p="lg" h="100%">
|
||||||
<Flex direction="column" justify="flex-start" align="stretch" gap="md">
|
<Flex direction="column" justify="flex-start" align="stretch" gap="md" h="100%">
|
||||||
<Flex direction="row" justify={"space-between"} align={"center"} gap="sm">
|
<Flex direction="row" justify={"space-between"} align={"center"} gap="sm">
|
||||||
<Title order={4} className={classes["title"]}>
|
<Title order={4} className={classes["title"]} maw={650}>
|
||||||
产品列表
|
产品列表{not(isEmpty(selected)) && `(已选择 ${selected.length} 个产品)`}
|
||||||
</Title>
|
</Title>
|
||||||
|
<Tooltip label="取消当前的所有选择">
|
||||||
|
<ActionIcon size="lg" color="red" onClick={unselectAll} disabled={isEmpty(selected)}>
|
||||||
|
<IconDeselect size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="选择当前所有">
|
||||||
|
<ActionIcon size="lg" onClick={selectAll}>
|
||||||
|
<IconListCheck size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="输入关键词检索产品"
|
placeholder="输入关键词检索产品"
|
||||||
className={classes["title-search"]}
|
className={classes["title-search"]}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.currentTarget.value)}
|
||||||
leftSection={<IconSearch size={16} />}
|
leftSection={<IconSearch size={16} />}
|
||||||
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
onClick={clearSearchKeyword}
|
||||||
|
disabled={isEmpty(keyword)}
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
justify="flex-start"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="sm"
|
||||||
|
className={classes["product-list-container"]}
|
||||||
|
>
|
||||||
|
{products.map((product) => (
|
||||||
|
<ProductItem key={product.id} id={product.id} name={product.name} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProductItemProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductItem(props: ProductItemProps) {
|
||||||
|
const [selectedProducts, append, remove] = useProductsStore((state) => [
|
||||||
|
state.selectedProducts,
|
||||||
|
state.append,
|
||||||
|
state.remove,
|
||||||
|
]);
|
||||||
|
const selected = useMemo(() => selectedProducts.includes(props.id), [props.id, selectedProducts]);
|
||||||
|
|
||||||
|
const handleSelectAction = useCallback(() => {
|
||||||
|
if (selected) {
|
||||||
|
remove(props.id);
|
||||||
|
} else {
|
||||||
|
append(props.id);
|
||||||
|
}
|
||||||
|
}, [props.id, selected, append, remove]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px="sm"
|
||||||
|
py="xs"
|
||||||
|
bg={selected ? "green" : "gray"}
|
||||||
|
className={classes["product-item"]}
|
||||||
|
onClick={handleSelectAction}
|
||||||
|
>
|
||||||
|
<Text size="xs" c={selected ? "white" : "gray"}>
|
||||||
|
{props.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
12
license_ui/src/constants.ts
Normal file
12
license_ui/src/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const licenseValidLength = [
|
||||||
|
{ value: 1, label: "一年" },
|
||||||
|
{ value: 2, label: "两年" },
|
||||||
|
{ value: 3, label: "三年" },
|
||||||
|
{ value: 4, label: "四年" },
|
||||||
|
{ value: 5, label: "五年" },
|
||||||
|
{ value: 6, label: "六年" },
|
||||||
|
{ value: 7, label: "七年" },
|
||||||
|
{ value: 8, label: "八年" },
|
||||||
|
{ value: 9, label: "九年" },
|
||||||
|
{ value: 10, label: "十年" },
|
||||||
|
];
|
@@ -1,6 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function useLicenseCode(): [string, (licenseCode: string) => void] {
|
|
||||||
const [licenseCode, setLicenseCode] = useState<string>("");
|
|
||||||
return [licenseCode, setLicenseCode];
|
|
||||||
}
|
|
11
license_ui/src/hooks/use_license_code_store.ts
Normal file
11
license_ui/src/hooks/use_license_code_store.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface LicenseCodeStore {
|
||||||
|
licenceCode?: string;
|
||||||
|
setLicenseCode: (code: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLicenseCodeStore = create<LicenseCodeStore>((set) => ({
|
||||||
|
licenceCode: undefined,
|
||||||
|
setLicenseCode: (code) => set({ licenceCode: code }),
|
||||||
|
}));
|
@@ -1,24 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useProductsStore } from "./use_products_store";
|
||||||
|
|
||||||
type ProductSearchKeyword = string | undefined | null;
|
type ProductSearchKeyword = string | undefined | null;
|
||||||
|
|
||||||
interface Product {
|
export function useProducts(keyword: ProductSearchKeyword) {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProducts(keyword: ProductSearchKeyword): Product[] {
|
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchProducts() {
|
async function fetchProducts() {
|
||||||
const response = await fetch(`/api/products?keyword=${keyword}`);
|
const response = await fetch(`/api/products?keyword=${keyword}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setProducts(data);
|
useProductsStore.setState((state) => (state.products = data));
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
}, [keyword]);
|
}, [keyword]);
|
||||||
|
|
||||||
return products;
|
|
||||||
}
|
}
|
36
license_ui/src/hooks/use_products_store.ts
Normal file
36
license_ui/src/hooks/use_products_store.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { concat, find, pluck, propEq, uniq } from "ramda";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
couple: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductsStore {
|
||||||
|
products: Product[];
|
||||||
|
selectedProducts: string[];
|
||||||
|
append: (code: string) => void;
|
||||||
|
remove: (code: string) => void;
|
||||||
|
unselectAll: () => void;
|
||||||
|
selectAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductsStore = create<ProductsStore>((set, get) => ({
|
||||||
|
products: [],
|
||||||
|
selectedProducts: [],
|
||||||
|
append: (code: string) => {
|
||||||
|
const selectedProduct: Product | undefined = find(propEq(code, "id"), get().products);
|
||||||
|
set((state) => ({
|
||||||
|
selectedProducts: uniq([...state.selectedProducts, code, ...(selectedProduct?.couple ?? [])]),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
remove: (code: string) =>
|
||||||
|
set((state) => ({ selectedProducts: state.selectedProducts.filter((item) => item !== code) })),
|
||||||
|
unselectAll: () => set({ selectedProducts: [] }),
|
||||||
|
selectAll: () => {
|
||||||
|
const products = pluck("id", get().products);
|
||||||
|
const selectedProducts = uniq(concat(get().selectedProducts, products));
|
||||||
|
set({ selectedProducts: selectedProducts });
|
||||||
|
},
|
||||||
|
}));
|
@@ -1,5 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
|
import { Notifications } from "@mantine/notifications";
|
||||||
|
import "@mantine/notifications/styles.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
@@ -9,6 +11,7 @@ import { theme } from "./theme";
|
|||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<MantineProvider defaultColorScheme="dark" theme={theme}>
|
<MantineProvider defaultColorScheme="dark" theme={theme}>
|
||||||
|
<Notifications />
|
||||||
<App />
|
<App />
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
@@ -4,5 +4,11 @@ export const theme = createTheme({
|
|||||||
focusRing: "never",
|
focusRing: "never",
|
||||||
fontSmoothing: true,
|
fontSmoothing: true,
|
||||||
defaultRadius: "xs",
|
defaultRadius: "xs",
|
||||||
lineHeights: "xs",
|
lineHeights: {
|
||||||
|
xs: "1.2",
|
||||||
|
sm: "1.25",
|
||||||
|
md: "1.35",
|
||||||
|
lg: "1.4",
|
||||||
|
xl: "1.5",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
6
license_ui/src/types.ts
Normal file
6
license_ui/src/types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface LicenseInfoForm {
|
||||||
|
licenseName: string;
|
||||||
|
assigneeName: string;
|
||||||
|
assigneeEmail: string;
|
||||||
|
validLength: string;
|
||||||
|
}
|
@@ -1,21 +1,5 @@
|
|||||||
import cx, { ClassDictionary } from "clsx";
|
import cx, { ClassDictionary } from "clsx";
|
||||||
import { defaultTo, isEmpty, isNil, prop } from "ramda";
|
import { isEmpty, isNil, prop } from "ramda";
|
||||||
import { ChangeHandler } from "react-hook-form";
|
|
||||||
|
|
||||||
export function convertFormEvent(
|
|
||||||
name: string,
|
|
||||||
event: InputEvent,
|
|
||||||
property: string = "value",
|
|
||||||
defaultValue?: unknown = null
|
|
||||||
): Parameters<ChangeHandler> {
|
|
||||||
return {
|
|
||||||
target: {
|
|
||||||
name,
|
|
||||||
value: defaultTo(defaultValue)(prop(property, event.currentTarget)),
|
|
||||||
},
|
|
||||||
type: event.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function composite(classesDefination: ClassDictionary, ...classes: string[]) {
|
export function composite(classesDefination: ClassDictionary, ...classes: string[]) {
|
||||||
/** @type {import("clsx").ClassArray} */
|
/** @type {import("clsx").ClassArray} */
|
||||||
|
15
sources.list
Normal file
15
sources.list
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
deb http://mirrors.163.com/debian/ bullseye main non-free contrib
|
||||||
|
|
||||||
|
deb http://mirrors.163.com/debian/ bullseye-updates main non-free contrib
|
||||||
|
|
||||||
|
deb http://mirrors.163.com/debian/ bullseye-backports main non-free contrib
|
||||||
|
|
||||||
|
deb-src http://mirrors.163.com/debian/ bullseye main non-free contrib
|
||||||
|
|
||||||
|
deb-src http://mirrors.163.com/debian/ bullseye-updates main non-free contrib
|
||||||
|
|
||||||
|
deb-src http://mirrors.163.com/debian/ bullseye-backports main non-free contrib
|
||||||
|
|
||||||
|
#deb http://mirrors.163.com/debian-security/ bullseye/updates main non-free contrib
|
||||||
|
|
||||||
|
#deb-src http://mirrors.163.com/debian-security/ bullseye/updates main non-free contrib
|
Reference in New Issue
Block a user