11
This commit is contained in:
@@ -30,7 +30,7 @@ src-tauri/ # Tauri Rust 与配置
|
|||||||
|
|
||||||
- `npm run dev` — 仅前端(端口 1420)
|
- `npm run dev` — 仅前端(端口 1420)
|
||||||
- `npm run tauri dev` — 桌面开发
|
- `npm run tauri dev` — 桌面开发
|
||||||
- `npm run build` / `npm run tauri build` — 构建(前端无 `tsc` 步骤)
|
- `npm run build` / `npm run tauri:build` — 构建桌面端(仅生成 zip 便携包,不生成 MSI/NSIS 安装包;前端无 `tsc` 步骤)
|
||||||
|
|
||||||
## 后端 API 约定(pythonbackend)
|
## 后端 API 约定(pythonbackend)
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ alwaysApply: false
|
|||||||
## 开发注意
|
## 开发注意
|
||||||
|
|
||||||
- Vite 固定端口 `1420`(见 `vite.config.js`),与 `tauri.conf.json` 的 `devUrl` 一致
|
- Vite 固定端口 `1420`(见 `vite.config.js`),与 `tauri.conf.json` 的 `devUrl` 一致
|
||||||
- 修改 capabilities 或插件后需重新 `tauri dev` / `tauri build`
|
- 修改 capabilities 或插件后需重新 `tauri dev` / `tauri:build`
|
||||||
|
- 发布构建使用 `npm run tauri:build`:`bundle.targets` 为空,跳过 MSI/NSIS;构建结束后由 `src-tauri/scripts/zip-portable.cjs` 生成 `target/release/bundle/zip/*.zip`
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"tauri:build": "tauri build && node src-tauri/scripts/zip-portable.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primeuix/themes": "^1.2.1",
|
"@primeuix/themes": "^1.2.1",
|
||||||
|
|||||||
31
src-tauri/Cargo.lock
generated
31
src-tauri/Cargo.lock
generated
@@ -2319,6 +2319,16 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac_address"
|
||||||
|
version = "1.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||||
|
dependencies = [
|
||||||
|
"nix",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2330,6 +2340,12 @@ dependencies = [
|
|||||||
"web_atoms",
|
"web_atoms",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -2443,6 +2459,19 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4154,6 +4183,8 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"log",
|
"log",
|
||||||
|
"mac_address",
|
||||||
|
"md5",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ urlencoding = "2.1"
|
|||||||
chrono = { version = "0.4", default-features = false, features = [
|
chrono = { version = "0.4", default-features = false, features = [
|
||||||
"clock",
|
"clock",
|
||||||
"std",
|
"std",
|
||||||
|
"serde",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
# Logging + errors
|
# Logging + errors
|
||||||
@@ -78,5 +79,9 @@ rand = "0.8"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
# 设备序列号(CPUID + MAC → MD5)
|
||||||
|
md5 = "0.7"
|
||||||
|
mac_address = "1.1"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
80
src-tauri/scripts/zip-portable.cjs
Normal file
80
src-tauri/scripts/zip-portable.cjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 将 target/release 下的可执行文件与 sidecar 资源打成 zip(便携分发,非安装包)。
|
||||||
|
* 在 `npm run tauri:build` 中于 `tauri build` 之后调用。
|
||||||
|
*/
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const tauriDir = path.join(__dirname, "..");
|
||||||
|
const conf = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(tauriDir, "tauri.conf.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const { productName, version } = conf;
|
||||||
|
const releaseDir = path.join(tauriDir, "target", "release");
|
||||||
|
const cargoName = "tauri-app";
|
||||||
|
const exeName =
|
||||||
|
process.platform === "win32" ? `${cargoName}.exe` : cargoName;
|
||||||
|
|
||||||
|
const platformTag =
|
||||||
|
process.platform === "win32"
|
||||||
|
? "win"
|
||||||
|
: process.platform === "darwin"
|
||||||
|
? "osx"
|
||||||
|
: "linux";
|
||||||
|
const archTag = process.arch === "arm64" ? "arm64" : "x64";
|
||||||
|
const zipDir = path.join(releaseDir, "bundle", "zip");
|
||||||
|
const zipPath = path.join(
|
||||||
|
zipDir,
|
||||||
|
`${productName}_${version}_${archTag}-${platformTag}.zip`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const exePath = path.join(releaseDir, exeName);
|
||||||
|
if (!fs.existsSync(exePath)) {
|
||||||
|
console.error(`未找到可执行文件: ${exePath},请先执行 tauri build`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagingRoot = path.join(releaseDir, "bundle", "_portable_staging");
|
||||||
|
const stageDir = path.join(stagingRoot, productName);
|
||||||
|
|
||||||
|
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(stageDir, { recursive: true });
|
||||||
|
|
||||||
|
const stagedExe =
|
||||||
|
process.platform === "win32" ? `${productName}.exe` : productName;
|
||||||
|
fs.copyFileSync(exePath, path.join(stageDir, stagedExe));
|
||||||
|
|
||||||
|
for (const dir of ["resources", "binaries"]) {
|
||||||
|
const src = path.join(releaseDir, dir);
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.cpSync(src, path.join(stageDir, dir), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(zipDir, { recursive: true });
|
||||||
|
if (fs.existsSync(zipPath)) {
|
||||||
|
fs.unlinkSync(zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const psStage = stageDir.replace(/'/g, "''");
|
||||||
|
const psZip = zipPath.replace(/'/g, "''");
|
||||||
|
execSync(
|
||||||
|
`powershell -NoProfile -Command "Compress-Archive -Path '${psStage}\\*' -DestinationPath '${psZip}' -Force"`,
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
execSync(`cd "${stageDir}" && zip -qr "${zipPath}" .`, { stdio: "inherit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
||||||
|
|
||||||
|
for (const dir of ["msi", "nsis"]) {
|
||||||
|
fs.rmSync(path.join(releaseDir, "bundle", dir), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`便携压缩包已生成: ${zipPath}`);
|
||||||
@@ -3,11 +3,18 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use rand::Rng;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::api_crypto::{self, ENCRYPTED_HEADER};
|
use crate::api_crypto::{self, ENCRYPTED_HEADER};
|
||||||
use crate::app_config;
|
use crate::app_config;
|
||||||
|
use crate::auth_session::AuthSession;
|
||||||
|
use crate::device_serial;
|
||||||
|
|
||||||
pub async fn request_json(
|
pub async fn request_json(
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -39,6 +46,9 @@ pub async fn request_json(
|
|||||||
HeaderName::from_static("content-type"),
|
HeaderName::from_static("content-type"),
|
||||||
HeaderValue::from_static("application/json"),
|
HeaderValue::from_static("application/json"),
|
||||||
);
|
);
|
||||||
|
if let Ok(val) = HeaderValue::from_str(device_serial::device_serial()) {
|
||||||
|
headers.insert(HeaderName::from_static("device_serial"), val);
|
||||||
|
}
|
||||||
for (k, v) in extra_headers {
|
for (k, v) in extra_headers {
|
||||||
if let (Ok(name), Ok(val)) = (
|
if let (Ok(name), Ok(val)) = (
|
||||||
HeaderName::from_bytes(k.as_bytes()),
|
HeaderName::from_bytes(k.as_bytes()),
|
||||||
@@ -98,3 +108,93 @@ pub async fn request_json(
|
|||||||
|
|
||||||
Ok((status, value))
|
Ok((status, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// 与 pythonbackend `heartbeat_time_bucket` 一致:UTC ``%Y-%m-%d %H``。
|
||||||
|
fn heartbeat_time_bucket() -> String {
|
||||||
|
Utc::now().format("%Y-%m-%d %H").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MD5(时间桶 + verify_code),小写十六进制。
|
||||||
|
fn compute_server_verify_code(verify_code: &str) -> String {
|
||||||
|
let raw = format!("{}{}", heartbeat_time_bucket(), verify_code);
|
||||||
|
format!("{:x}", md5::compute(raw.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_verify_code() -> String {
|
||||||
|
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
(0..32)
|
||||||
|
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_server_verify_code(value: &Value) -> Option<&str> {
|
||||||
|
value
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("server_verify_code"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已登录时向服务端发送心跳(校验设备序列号与会员状态)。
|
||||||
|
pub async fn send_heartbeat(token: &str) -> Result<(), String> {
|
||||||
|
let token = token.trim();
|
||||||
|
if token.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let verify_code = random_verify_code();
|
||||||
|
let body = serde_json::json!({ "verify_code": verify_code }).to_string();
|
||||||
|
let expected = compute_server_verify_code(&verify_code);
|
||||||
|
|
||||||
|
let mut headers = HashMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"Authorization".to_string(),
|
||||||
|
format!("Bearer {token}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (status, value) =
|
||||||
|
request_json("POST", "/auth/heartbeat", Some(&body), headers).await?;
|
||||||
|
if status == 401 {
|
||||||
|
return Err("未登录或登录已过期".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_code = response_server_verify_code(&value);
|
||||||
|
if server_code != Some(expected.as_str()) {
|
||||||
|
return Err("会员已过期".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ok) = value.get("ok").and_then(|v| v.as_bool()) {
|
||||||
|
if !ok {
|
||||||
|
let msg = value
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("心跳校验失败");
|
||||||
|
return Err(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用启动后每 30 秒发送一次心跳(仅在有 token 时)。
|
||||||
|
pub fn spawn_heartbeat_loop(app: AppHandle) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut interval =
|
||||||
|
tokio::time::interval(Duration::from_secs(HEARTBEAT_INTERVAL_SECS));
|
||||||
|
interval.tick().await;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let token = app
|
||||||
|
.try_state::<AuthSession>()
|
||||||
|
.map(|s| s.token())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match send_heartbeat(&token).await {
|
||||||
|
Ok(()) => log::debug!(target: "heartbeat", "heartbeat ok"),
|
||||||
|
Err(e) => log::warn!(target: "heartbeat", "heartbeat failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
48
src-tauri/src/device_serial.rs
Normal file
48
src-tauri/src/device_serial.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! 设备唯一序列号:MD5(cpuid 序列 + MAC 地址),进程内缓存。
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
|
static DEVICE_SERIAL: Lazy<String> = Lazy::new(compute_device_serial);
|
||||||
|
|
||||||
|
/// 返回 MD5 十六进制小写字符串(32 字符)。
|
||||||
|
pub fn device_serial() -> &'static str {
|
||||||
|
&DEVICE_SERIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_device_serial() -> String {
|
||||||
|
let cpuid = cpuid_serial();
|
||||||
|
let mac = mac_serial();
|
||||||
|
let raw = format!("{cpuid}{mac}");
|
||||||
|
format!("{:x}", md5::compute(raw.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPUID leaf 3 的 edx+ecx(与示例 `__cpuid(3)` 一致)。
|
||||||
|
fn cpuid_serial() -> String {
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
{
|
||||||
|
use std::arch::x86_64::__cpuid;
|
||||||
|
let r = __cpuid(3);
|
||||||
|
return format!("{:08X}{:08X}", r.edx, r.ecx);
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "x86")]
|
||||||
|
{
|
||||||
|
use std::arch::x86::__cpuid;
|
||||||
|
let r = __cpuid(3);
|
||||||
|
return format!("{:08X}{:08X}", r.edx, r.ecx);
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
|
||||||
|
{
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_serial() -> String {
|
||||||
|
match mac_address::get_mac_address() {
|
||||||
|
Ok(Some(ma)) => ma.to_string(),
|
||||||
|
Ok(None) => String::new(),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(target: "device_serial", "读取 MAC 地址失败: {e}");
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod auth_session;
|
|||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod cover_python;
|
pub mod cover_python;
|
||||||
|
pub mod device_serial;
|
||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod js_runtime;
|
pub mod js_runtime;
|
||||||
@@ -38,6 +39,7 @@ pub fn run() {
|
|||||||
.manage(publish_store::PublishStore::new())
|
.manage(publish_store::PublishStore::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
let heartbeat_handle = handle.clone();
|
||||||
let oem_id = handle
|
let oem_id = handle
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
@@ -64,6 +66,8 @@ pub fn run() {
|
|||||||
Ok::<(), String>(())
|
Ok::<(), String>(())
|
||||||
})
|
})
|
||||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||||
|
|
||||||
|
api_client::spawn_heartbeat_loop(heartbeat_handle);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
// 心跳定时任务在 `tauri_app_lib::run` → `api_client::spawn_heartbeat_loop` 中启动(每 30 秒)。
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri_app_lib::run()
|
tauri_app_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"binaries/*"
|
"binaries/*"
|
||||||
],
|
],
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": [],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ defineProps({
|
|||||||
<div class="auth-bg flex h-full items-center justify-center overflow-auto p-6">
|
<div class="auth-bg flex h-full items-center justify-center overflow-auto p-6">
|
||||||
<div class="relative z-10 w-full max-w-md">
|
<div class="relative z-10 w-full max-w-md">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<h1 class="gradient-text text-3xl font-bold tracking-tight">aiclient</h1>
|
<h1 class="gradient-text text-3xl font-bold tracking-tight"></h1>
|
||||||
<p v-if="subtitle" class="mt-2 text-sm text-text-muted">{{ subtitle }}</p>
|
<p v-if="subtitle" class="mt-2 text-sm text-text-muted">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user