feat: 集中应用名称并优化首页粒子
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"appName": "Metrix"
|
||||
}
|
||||
@@ -229,3 +229,13 @@
|
||||
- `web/src/views/DashboardView.vue` 使用本地 Canvas 粒子动画,粒子从随机位置聚合成 `Metrix` 字样,短暂停留后收缩淡出,最终显示动态渐变 `Metrix` 标签;不引入外部资源或新增依赖。
|
||||
- 清理前端未使用的首页摘要 API 封装 `web/src/api/system.ts`、`DashboardSummary` 类型和旧统计卡片样式;后端 `/api/dashboard/summary` 暂保留,避免扩大对外 API 契约变更。
|
||||
- 验证:前端 `npm run build` 通过,后端测试 10 passed;浏览器验证首页动画结束后 Canvas 隐藏、渐变 `Metrix` 标签可见、页面不溢出且无控制台错误。
|
||||
## 2026-06-04:集中应用名称配置
|
||||
- 新增根目录 `app.config.json` 作为应用默认名称配置入口,默认只需要修改 `appName`;技术前缀、localStorage key、默认 MySQL 数据库名和默认 SQLite 文件名由应用名派生,确需自定义时可补充 `appSlug`。
|
||||
- 前端通过 `web/vite.config.ts` 将配置注入为 `__APP_CONFIG__`,`web/src/config/app.ts` 统一导出 `APP_NAME`、`APP_SLUG`、`DEFAULT_DATABASE_NAME`、`DEFAULT_SQLITE_PATH` 和 `appKey`;侧栏品牌、登录页标题、首页粒子文字、浏览器标题、安装页默认数据库名和本地存储 key 均改为使用该配置。
|
||||
- 后端 `server/app/core/config.py` 读取同一份 `app.config.json`,并保留 `APP_NAME`、`APP_SLUG`、`METRIX_APP_NAME`、`METRIX_APP_SLUG` 环境变量覆盖;默认 SQLite 文件名和安装前临时 token 签名 key 改为从 `settings.app_slug` 派生。
|
||||
- 后端测试新增配置读取与环境变量覆盖验证;验证:前端 `npm run build` 通过,后端测试 11 passed;浏览器验证页面标题、侧栏品牌和首页最终标签均读取到配置中的默认应用名且无控制台错误。
|
||||
## 2026-06-04:首页粒子自适应与重绘规则
|
||||
- 首页粒子动画起始阶段改为先从中心轻微聚集状态按当前内容区尺寸扩散,再聚合为应用名称,聚合文字尺寸按容器宽高和设备像素比计算,避免高分屏或大窗口下字样偏小。
|
||||
- 粒子动画不再监听窗口尺寸变化,只有进入首页并挂载组件时重新绘制;窗口大小变化后仅最终渐变标签通过 CSS `clamp()` 自动适应大小,避免 resize 时动画反复重跑。
|
||||
- 最终渐变标签字号调整为 `clamp(76px, 14vw, 142px)`,并限制最大宽度与自动换行,保证小窗口不溢出。
|
||||
- 验证:前端 `npm run build` 通过,后端测试 11 passed;浏览器验证首页动画结束后最终标签为配置应用名、Canvas 隐藏、页面不溢出且无控制台错误。
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Metrix server package."""
|
||||
"""Server package."""
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
app_name: str = "Metrix"
|
||||
app_name: str = "App"
|
||||
app_slug: str = "app"
|
||||
app_version: str = "0.1.0"
|
||||
token_expire_minutes: int = 12 * 60
|
||||
runtime_dir: Path = Path("runtime")
|
||||
@@ -18,5 +24,29 @@ class Settings(BaseModel):
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
app_config = _load_app_config()
|
||||
app_name = _clean_name(os.getenv("APP_NAME")) or _clean_name(os.getenv("METRIX_APP_NAME")) or _clean_name(app_config.get("appName")) or "App"
|
||||
app_slug = _clean_slug(os.getenv("APP_SLUG")) or _clean_slug(os.getenv("METRIX_APP_SLUG")) or _clean_slug(app_config.get("appSlug")) or _slugify(app_name)
|
||||
runtime_dir = Path(os.getenv("METRIX_RUNTIME_DIR", "runtime"))
|
||||
return Settings(runtime_dir=runtime_dir)
|
||||
return Settings(app_name=app_name, app_slug=app_slug, runtime_dir=runtime_dir)
|
||||
|
||||
|
||||
def _load_app_config() -> dict[str, object]:
|
||||
try:
|
||||
return json.loads((PROJECT_DIR / "app.config.json").read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _clean_name(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _clean_slug(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return re.sub(r"(^_+|_+$)", "", re.sub(r"[^a-z0-9_]+", "_", value.strip().lower()))
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
return _clean_slug(value) or "app"
|
||||
|
||||
@@ -41,4 +41,5 @@ def write_install_config(database_type: str, database_url: str) -> InstallConfig
|
||||
|
||||
|
||||
def default_sqlite_path() -> Path:
|
||||
return get_settings().runtime_dir / "metrix.db"
|
||||
settings = get_settings()
|
||||
return settings.runtime_dir / f"{settings.app_slug}.db"
|
||||
|
||||
@@ -67,7 +67,7 @@ def decode_access_token(token: str) -> str | None:
|
||||
def _secret_key(settings: Settings) -> str:
|
||||
if is_installed():
|
||||
return load_install_config().secret_key
|
||||
return "metrix-installing"
|
||||
return f"{settings.app_slug}-installing"
|
||||
|
||||
|
||||
def _sign(payload_part: str, settings: Settings) -> str:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.schemas.install import InstallDatabaseTestRequest, InstallRequest, MysqlInstallConfig
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
||||
MYSQL_TEST_DATABASE = "app_test"
|
||||
|
||||
|
||||
def create_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("METRIX_RUNTIME_DIR", str(tmp_path / "runtime"))
|
||||
@@ -24,7 +30,7 @@ def install_sqlite(client: TestClient, tmp_path):
|
||||
"admin_username": "rootadmin",
|
||||
"admin_password": "RootPass123",
|
||||
"admin_full_name": "管理员",
|
||||
"admin_company": "Metrix",
|
||||
"admin_company": "平台公司",
|
||||
"admin_department": "平台",
|
||||
}
|
||||
response = client.post("/api/install", json=payload)
|
||||
@@ -39,6 +45,10 @@ def login(client: TestClient, username: str, password: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {response.json()['token']}"}
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
return re.sub(r"(^_+|_+$)", "", re.sub(r"[^a-z0-9_]+", "_", value.strip().lower())) or "app"
|
||||
|
||||
|
||||
def test_install_status_and_sqlite_install(tmp_path, monkeypatch):
|
||||
client = create_client(tmp_path, monkeypatch)
|
||||
assert client.get("/api/install/status").json() == {"installed": False, "database_type": None}
|
||||
@@ -61,6 +71,28 @@ def test_sqlite_database_test_endpoint(tmp_path, monkeypatch):
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
def test_app_config_defaults_and_env_override(monkeypatch):
|
||||
from app.core.config import get_settings
|
||||
|
||||
monkeypatch.delenv("APP_NAME", raising=False)
|
||||
monkeypatch.delenv("APP_SLUG", raising=False)
|
||||
monkeypatch.delenv("METRIX_APP_NAME", raising=False)
|
||||
monkeypatch.delenv("METRIX_APP_SLUG", raising=False)
|
||||
get_settings.cache_clear()
|
||||
app_config = json.loads((PROJECT_DIR / "app.config.json").read_text(encoding="utf-8"))
|
||||
settings = get_settings()
|
||||
assert settings.app_name == app_config["appName"]
|
||||
assert settings.app_slug == slugify(app_config["appName"])
|
||||
|
||||
monkeypatch.setenv("APP_NAME", "Custom Portal")
|
||||
monkeypatch.setenv("APP_SLUG", "custom_portal")
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
assert settings.app_name == "Custom Portal"
|
||||
assert settings.app_slug == "custom_portal"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_api_docs_use_local_assets(tmp_path, monkeypatch):
|
||||
client = create_client(tmp_path, monkeypatch)
|
||||
response = client.get("/docs")
|
||||
@@ -95,7 +127,7 @@ def test_mysql_install_runs_database_and_table_creation(monkeypatch):
|
||||
|
||||
payload = InstallRequest(
|
||||
database_type="mysql",
|
||||
mysql=MysqlInstallConfig(host="127.0.0.1", port=3306, database="metrix_test", username="root", password="pass"),
|
||||
mysql=MysqlInstallConfig(host="127.0.0.1", port=3306, database=MYSQL_TEST_DATABASE, username="root", password="pass"),
|
||||
admin_username="mysqladmin",
|
||||
admin_password="MysqlPass123",
|
||||
admin_full_name="MySQL 管理员",
|
||||
@@ -113,8 +145,8 @@ def test_mysql_install_runs_database_and_table_creation(monkeypatch):
|
||||
|
||||
install_service.install_system(payload)
|
||||
|
||||
assert ("create_db", "metrix_test") in calls
|
||||
assert any(call[0] == "tables" and call[1].startswith("mysql+pymysql://root:pass@127.0.0.1:3306/metrix_test") for call in calls)
|
||||
assert ("create_db", MYSQL_TEST_DATABASE) in calls
|
||||
assert any(call[0] == "tables" and call[1].startswith(f"mysql+pymysql://root:pass@127.0.0.1:3306/{MYSQL_TEST_DATABASE}") for call in calls)
|
||||
assert ("seed", "mysqladmin") in calls
|
||||
assert any(call[0] == "config" and call[1] == "mysql" for call in calls)
|
||||
assert "reset" in calls
|
||||
@@ -145,7 +177,7 @@ def test_mysql_database_creation_sql(monkeypatch):
|
||||
|
||||
payload = InstallRequest(
|
||||
database_type="mysql",
|
||||
mysql=MysqlInstallConfig(host="127.0.0.1", port=3306, database="metrix_test", username="root", password="pass"),
|
||||
mysql=MysqlInstallConfig(host="127.0.0.1", port=3306, database=MYSQL_TEST_DATABASE, username="root", password="pass"),
|
||||
admin_username="mysqladmin",
|
||||
admin_password="MysqlPass123",
|
||||
admin_full_name="MySQL 管理员",
|
||||
@@ -156,7 +188,7 @@ def test_mysql_database_creation_sql(monkeypatch):
|
||||
|
||||
install_service._create_mysql_database(payload)
|
||||
|
||||
assert "CREATE DATABASE IF NOT EXISTS `metrix_test`" in statements[0]
|
||||
assert f"CREATE DATABASE IF NOT EXISTS `{MYSQL_TEST_DATABASE}`" in statements[0]
|
||||
assert "CHARACTER SET utf8mb4" in statements[0]
|
||||
assert "disposed" in statements
|
||||
|
||||
@@ -190,7 +222,7 @@ def test_mysql_database_test_uses_server_connection(monkeypatch):
|
||||
|
||||
payload = InstallDatabaseTestRequest(
|
||||
database_type="mysql",
|
||||
mysql=MysqlInstallConfig(host="127.0.0.1", port=3306, database="metrix_test", username="root", password="pass"),
|
||||
mysql=MysqlInstallConfig(host="127.0.0.1", port=3306, database=MYSQL_TEST_DATABASE, username="root", password="pass"),
|
||||
)
|
||||
monkeypatch.setattr(install_service, "create_engine_for_url", lambda url: FakeEngine(url))
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Metrix</title>
|
||||
<title>%APP_NAME%</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<div class="brand">
|
||||
<BrandMark />
|
||||
<span class="brand-text">Metrix</span>
|
||||
<span class="brand-text">{{ APP_NAME }}</span>
|
||||
</div>
|
||||
<n-menu
|
||||
class="app-menu"
|
||||
@@ -66,12 +66,13 @@ import { NButton, NDropdown, NIcon, NLayout, NLayoutContent, NLayoutFooter, NLay
|
||||
import type { MenuOption } from "naive-ui";
|
||||
|
||||
import { logout } from "../api/auth";
|
||||
import { APP_NAME, appKey } from "../config/app";
|
||||
import { appStore } from "../stores/app";
|
||||
import { authStore } from "../stores/auth";
|
||||
import BrandMark from "./BrandMark.vue";
|
||||
import CopyrightNotice from "./CopyrightNotice.vue";
|
||||
|
||||
const SIDEBAR_KEY = "metrix.sidebar.collapsed";
|
||||
const SIDEBAR_KEY = appKey("sidebar.collapsed");
|
||||
|
||||
const collapsed = ref(localStorage.getItem(SIDEBAR_KEY) === "1");
|
||||
const route = useRoute();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<div class="brand-mark">
|
||||
<img class="brand-logo" :src="logoUrl" alt="Metrix" />
|
||||
<img class="brand-logo" :src="logoUrl" :alt="APP_NAME" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { APP_NAME } from "../config/app";
|
||||
|
||||
const logoUrl = "/favicon.svg";
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const rawName = __APP_CONFIG__.appName?.trim();
|
||||
|
||||
export const APP_NAME = rawName || "App";
|
||||
export const APP_SLUG = normalizeSlug(__APP_CONFIG__.appSlug) || normalizeSlug(APP_NAME) || "app";
|
||||
export const DEFAULT_DATABASE_NAME = APP_SLUG;
|
||||
export const DEFAULT_SQLITE_PATH = `runtime/${APP_SLUG}.db`;
|
||||
|
||||
export function appKey(name: string) {
|
||||
return `${APP_SLUG}.${name}`;
|
||||
}
|
||||
|
||||
function normalizeSlug(value: string | undefined) {
|
||||
return value?.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "";
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
const THEME_KEY = "metrix.dark";
|
||||
import { appKey } from "../config/app";
|
||||
|
||||
const THEME_KEY = appKey("dark");
|
||||
|
||||
export const appStore = reactive({
|
||||
dark: localStorage.getItem(THEME_KEY) === "1",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
import type { UserProfile } from "../api/types";
|
||||
import { appKey } from "../config/app";
|
||||
|
||||
const TOKEN_KEY = "metrix.token";
|
||||
const TOKEN_KEY = appKey("token");
|
||||
|
||||
export const authStore = reactive({
|
||||
token: localStorage.getItem(TOKEN_KEY) || "",
|
||||
|
||||
@@ -556,10 +556,12 @@ a {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: transparent;
|
||||
font-size: 92px;
|
||||
max-width: 90%;
|
||||
font-size: clamp(76px, 14vw, 142px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
background: linear-gradient(110deg, #4a90e2, #63e2b7, #9013fe, #4a90e2);
|
||||
background-size: 260% 100%;
|
||||
background-clip: text;
|
||||
@@ -784,11 +786,6 @@ a {
|
||||
.app-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-wordmark {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.profile-grid,
|
||||
.user-filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
+104
-26
@@ -1,21 +1,27 @@
|
||||
<template>
|
||||
<section class="dashboard-hero" aria-label="Metrix">
|
||||
<section class="dashboard-hero" :aria-label="APP_NAME">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="dashboard-particles"
|
||||
:class="{ 'is-hidden': particlesHidden }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div v-show="showWordmark" class="dashboard-wordmark">Metrix</div>
|
||||
<div v-show="showWordmark" class="dashboard-wordmark">{{ APP_NAME }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import { APP_NAME } from "../config/app";
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
vx: number;
|
||||
@@ -29,23 +35,21 @@ const showWordmark = ref(false);
|
||||
const particlesHidden = ref(false);
|
||||
let particles: Particle[] = [];
|
||||
let frameId = 0;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let startTime = 0;
|
||||
|
||||
const assembleDuration = 2400;
|
||||
const spreadDuration = 760;
|
||||
const assembleDuration = 2300;
|
||||
const holdDuration = 650;
|
||||
const shrinkDuration = 850;
|
||||
const fontFamily = "Segoe UI, Microsoft YaHei, Arial, sans-serif";
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
resizeObserver = new ResizeObserver(() => restartParticles(canvas));
|
||||
resizeObserver.observe(canvas);
|
||||
restartParticles(canvas);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
});
|
||||
@@ -63,35 +67,41 @@ function setupParticles(canvas: HTMLCanvasElement) {
|
||||
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
||||
showWordmark.value = false;
|
||||
particlesHidden.value = false;
|
||||
const targets = textTargets(canvas.width, canvas.height);
|
||||
const targets = textTargets(canvas.width, canvas.height, rect.width, rect.height, dpr);
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const centerJitter = Math.min(canvas.width, canvas.height) * 0.035;
|
||||
particles = targets.map((target, index) => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
x: centerX + randomOffset(centerJitter),
|
||||
y: centerY + randomOffset(centerJitter),
|
||||
sx: centerX + randomOffset(centerJitter),
|
||||
sy: centerY + randomOffset(centerJitter),
|
||||
...spreadPoint(canvas.width, canvas.height, dpr),
|
||||
tx: target.x,
|
||||
ty: target.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: 1.2 + Math.random() * 1.9,
|
||||
size: (1.3 + Math.random() * 2.1) * dpr,
|
||||
hue: 205 + (index % 90)
|
||||
}));
|
||||
startTime = performance.now();
|
||||
}
|
||||
|
||||
function textTargets(width: number, height: number) {
|
||||
function textTargets(width: number, height: number, cssWidth: number, cssHeight: number, dpr: number) {
|
||||
const buffer = document.createElement("canvas");
|
||||
const context = buffer.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return [];
|
||||
buffer.width = width;
|
||||
buffer.height = height;
|
||||
const fontSize = width < 700 ? 76 : 118;
|
||||
const fontSize = targetFontSize(context, cssWidth, cssHeight, dpr);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = "#fff";
|
||||
context.font = `700 ${fontSize}px Segoe UI, Microsoft YaHei, Arial, sans-serif`;
|
||||
context.font = `700 ${fontSize}px ${fontFamily}`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("Metrix", width / 2, height / 2);
|
||||
context.fillText(APP_NAME, width / 2, height / 2);
|
||||
const image = context.getImageData(0, 0, width, height).data;
|
||||
const step = width < 700 ? 5 : 6;
|
||||
const step = Math.max(4, Math.round((cssWidth < 700 ? 4 : 5) * dpr));
|
||||
const points: Array<{ x: number; y: number }> = [];
|
||||
for (let y = 0; y < height; y += step) {
|
||||
for (let x = 0; x < width; x += step) {
|
||||
@@ -103,31 +113,87 @@ function textTargets(width: number, height: number) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function targetFontSize(
|
||||
context: CanvasRenderingContext2D,
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
dpr: number
|
||||
) {
|
||||
const minSize = cssWidth < 560 ? 72 : 96;
|
||||
let cssSize = Math.min(196, Math.max(minSize, cssWidth * 0.14, cssHeight * 0.32));
|
||||
const maxWidth = cssWidth * dpr * 0.86;
|
||||
const maxHeight = cssHeight * dpr * 0.46;
|
||||
|
||||
while (cssSize > 56) {
|
||||
const physicalSize = cssSize * dpr;
|
||||
context.font = `700 ${physicalSize}px ${fontFamily}`;
|
||||
const metrics = context.measureText(APP_NAME);
|
||||
const textHeight =
|
||||
(metrics.actualBoundingBoxAscent || physicalSize * 0.72) +
|
||||
(metrics.actualBoundingBoxDescent || physicalSize * 0.18);
|
||||
|
||||
if (metrics.width <= maxWidth && textHeight <= maxHeight) {
|
||||
return physicalSize;
|
||||
}
|
||||
cssSize -= 4;
|
||||
}
|
||||
|
||||
return Math.max(56, cssSize) * dpr;
|
||||
}
|
||||
|
||||
function spreadPoint(width: number, height: number, dpr: number) {
|
||||
const padding = 18 * dpr;
|
||||
if (Math.random() < 0.58) {
|
||||
return {
|
||||
ox: padding + Math.random() * Math.max(1, width - padding * 2),
|
||||
oy: padding + Math.random() * Math.max(1, height - padding * 2)
|
||||
};
|
||||
}
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radius = 0.32 + Math.sqrt(Math.random()) * 0.68;
|
||||
return {
|
||||
ox: clamp(centerX + Math.cos(angle) * width * 0.54 * radius, padding, width - padding),
|
||||
oy: clamp(centerY + Math.sin(angle) * height * 0.54 * radius, padding, height - padding)
|
||||
};
|
||||
}
|
||||
|
||||
function draw(now: number) {
|
||||
const canvas = canvasRef.value;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
const elapsed = now - startTime;
|
||||
const assembleProgress = Math.min(1, elapsed / assembleDuration);
|
||||
const spreadProgress = Math.min(1, elapsed / spreadDuration);
|
||||
const assembleElapsed = Math.max(0, elapsed - spreadDuration);
|
||||
const assembleProgress = Math.min(1, assembleElapsed / assembleDuration);
|
||||
const shrinkProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (elapsed - assembleDuration - holdDuration) / shrinkDuration)
|
||||
Math.min(1, (assembleElapsed - assembleDuration - holdDuration) / shrinkDuration)
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const particle of particles) {
|
||||
if (shrinkProgress <= 0) {
|
||||
const drift = Math.sin(now / 700 + particle.hue) * (1 - assembleProgress) * 18;
|
||||
const ax = (particle.tx + drift - particle.x) * (0.012 + assembleProgress * 0.045);
|
||||
const ay = (particle.ty - particle.y) * (0.012 + assembleProgress * 0.045);
|
||||
particle.vx = (particle.vx + ax) * 0.82;
|
||||
particle.vy = (particle.vy + ay) * 0.82;
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
if (assembleElapsed <= 0) {
|
||||
const spreadEase = easeOutCubic(spreadProgress);
|
||||
particle.x = mix(particle.sx, particle.ox, spreadEase);
|
||||
particle.y = mix(particle.sy, particle.oy, spreadEase);
|
||||
} else {
|
||||
const drift = Math.sin(now / 700 + particle.hue) * (1 - assembleProgress) * 20;
|
||||
const ax = (particle.tx + drift - particle.x) * (0.012 + assembleProgress * 0.047);
|
||||
const ay = (particle.ty - particle.y) * (0.012 + assembleProgress * 0.047);
|
||||
particle.vx = (particle.vx + ax) * 0.82;
|
||||
particle.vy = (particle.vy + ay) * 0.82;
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
}
|
||||
}
|
||||
const shrinkEase = easeInOutCubic(shrinkProgress);
|
||||
const x = mix(particle.x, canvas.width / 2, shrinkEase);
|
||||
const y = mix(particle.y, canvas.height / 2, shrinkEase);
|
||||
const alpha = (0.34 + assembleProgress * 0.5) * (1 - shrinkEase);
|
||||
const phaseProgress = Math.max(spreadProgress * 0.7, assembleProgress);
|
||||
const alpha = (0.24 + phaseProgress * 0.6) * (1 - shrinkEase);
|
||||
const size = particle.size * (1 - shrinkEase * 0.72);
|
||||
context.fillStyle = `hsla(${particle.hue}, 88%, ${58 + assembleProgress * 10}%, ${alpha})`;
|
||||
context.beginPath();
|
||||
@@ -144,6 +210,10 @@ function draw(now: number) {
|
||||
frameId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function easeOutCubic(value: number) {
|
||||
return 1 - Math.pow(1 - value, 3);
|
||||
}
|
||||
|
||||
function easeInOutCubic(value: number) {
|
||||
return value < 0.5 ? 4 * value * value * value : 1 - Math.pow(-2 * value + 2, 3) / 2;
|
||||
}
|
||||
@@ -151,4 +221,12 @@ function easeInOutCubic(value: number) {
|
||||
function mix(from: number, to: number, progress: number) {
|
||||
return from + (to - from) * progress;
|
||||
}
|
||||
|
||||
function randomOffset(size: number) {
|
||||
return (Math.random() - 0.5) * size;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="auth-brand">
|
||||
<BrandMark />
|
||||
<div>
|
||||
<h1 class="auth-title">初始化 Metrix</h1>
|
||||
<h1 class="auth-title">初始化 {{ APP_NAME }}</h1>
|
||||
<p class="auth-subtitle">选择网站数据库并创建初始管理员</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.database_type === 'sqlite'" label="SQLite 数据库文件" path="sqlite_path">
|
||||
<n-input v-model:value="form.sqlite_path" placeholder="留空使用 runtime/metrix.db" />
|
||||
<n-input v-model:value="form.sqlite_path" :placeholder="`留空使用 ${DEFAULT_SQLITE_PATH}`" />
|
||||
</n-form-item>
|
||||
<template v-else>
|
||||
<div class="install-field-row compact">
|
||||
@@ -90,6 +90,7 @@ import { useRouter } from "vue-router";
|
||||
import { installSystem, testInstallDatabase } from "../api/install";
|
||||
import BrandMark from "../components/BrandMark.vue";
|
||||
import CopyrightNotice from "../components/CopyrightNotice.vue";
|
||||
import { APP_NAME, DEFAULT_DATABASE_NAME, DEFAULT_SQLITE_PATH } from "../config/app";
|
||||
import { showError } from "../utils/message";
|
||||
import { maxLengthRule, minLengthRule, numberRequiredRule, requiredRule, validateForm } from "../utils/validation";
|
||||
|
||||
@@ -109,7 +110,7 @@ const form = reactive({
|
||||
mysql: {
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
database: "metrix",
|
||||
database: DEFAULT_DATABASE_NAME,
|
||||
username: "root",
|
||||
password: ""
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template #icon><n-icon :component="themeIcon" /></template>
|
||||
</n-button>
|
||||
<div class="auth-card login-card">
|
||||
<h1 class="auth-wordmark">Metrix</h1>
|
||||
<h1 class="auth-wordmark">{{ APP_NAME }}</h1>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
class="form-stack inline-form"
|
||||
@@ -42,6 +42,7 @@ import { useRouter } from "vue-router";
|
||||
|
||||
import { login } from "../api/auth";
|
||||
import CopyrightNotice from "../components/CopyrightNotice.vue";
|
||||
import { APP_NAME } from "../config/app";
|
||||
import { appStore } from "../stores/app";
|
||||
import { authStore } from "../stores/auth";
|
||||
import { showError } from "../utils/message";
|
||||
|
||||
Vendored
+5
@@ -1,5 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_CONFIG__: {
|
||||
appName?: string;
|
||||
appSlug?: string;
|
||||
};
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
|
||||
+71
-1
@@ -1,11 +1,81 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appConfig = loadAppConfig();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__APP_CONFIG__: JSON.stringify(appConfig)
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: "app-config-html",
|
||||
transformIndexHtml(html) {
|
||||
return html.replaceAll("%APP_NAME%", escapeHtml(appConfig.appName));
|
||||
}
|
||||
}
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8000"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function loadAppConfig() {
|
||||
const configPath = path.resolve(ROOT_DIR, "../app.config.json");
|
||||
const fileConfig = readJson(configPath);
|
||||
const appName =
|
||||
cleanName(process.env.APP_NAME) ||
|
||||
cleanName(process.env.VITE_APP_NAME) ||
|
||||
cleanName(process.env.METRIX_APP_NAME) ||
|
||||
cleanName(fileConfig.appName) ||
|
||||
"App";
|
||||
return {
|
||||
appName,
|
||||
appSlug:
|
||||
cleanSlug(process.env.APP_SLUG) ||
|
||||
cleanSlug(process.env.VITE_APP_SLUG) ||
|
||||
cleanSlug(process.env.METRIX_APP_SLUG) ||
|
||||
cleanSlug(fileConfig.appSlug) ||
|
||||
slugify(appName)
|
||||
};
|
||||
}
|
||||
|
||||
function readJson(filePath: string) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as { appName?: unknown; appSlug?: unknown };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function cleanName(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function cleanSlug(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") : "";
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return cleanSlug(value) || "app";
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
};
|
||||
return entities[char];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user