mirror of
https://github.com/certd/certd.git
synced 2026-08-02 19:10:15 +08:00
Merge remote-tracking branch 'origin/v2' into v2_audit_log
This commit is contained in:
@@ -31,6 +31,15 @@ export default {
|
||||
copyPreferences: "Copy Preferences",
|
||||
copyPreferencesSuccessTitle: "Copy successful",
|
||||
copyPreferencesSuccess: "Copy successful, please override in `src/preferences.ts` under app",
|
||||
importPreferences: "Import from Clipboard",
|
||||
importPreferencesSuccessTitle: "Import successful",
|
||||
importPreferencesSuccess: "Preferences imported from clipboard",
|
||||
importPreferencesErrorTitle: "Import failed",
|
||||
importPreferencesError: "Clipboard content is invalid. Please copy preferences JSON first",
|
||||
saveToAccount: "Save to Account",
|
||||
saveToAccountSuccess: "Preferences saved to account",
|
||||
saveToAccountError: "Failed to save preferences to account",
|
||||
saveToAccountNeedLogin: "Please sign in before saving to account",
|
||||
clearAndLogout: "Clear Cache & Logout",
|
||||
mode: "Mode",
|
||||
general: "General",
|
||||
|
||||
@@ -31,6 +31,15 @@ export default {
|
||||
copyPreferences: "复制偏好设置",
|
||||
copyPreferencesSuccessTitle: "复制成功",
|
||||
copyPreferencesSuccess: "复制成功,请在 app 下的 `src/preferences.ts`内进行覆盖",
|
||||
importPreferences: "从剪切板导入",
|
||||
importPreferencesSuccessTitle: "导入成功",
|
||||
importPreferencesSuccess: "已从剪切板导入偏好设置",
|
||||
importPreferencesErrorTitle: "导入失败",
|
||||
importPreferencesError: "剪切板内容无效,请先复制偏好设置 JSON",
|
||||
saveToAccount: "保存到账号",
|
||||
saveToAccountSuccess: "偏好设置已保存到账号",
|
||||
saveToAccountError: "保存到账号失败",
|
||||
saveToAccountNeedLogin: "请先登录后再保存到账号",
|
||||
clearAndLogout: "清空缓存 & 退出登录",
|
||||
mode: "模式",
|
||||
general: "通用",
|
||||
|
||||
@@ -395,4 +395,10 @@ export const useSettingStore = defineStore({
|
||||
|
||||
mitter.on("app.login", async () => {
|
||||
await useSettingStore().init();
|
||||
try {
|
||||
const { loadPreferencesFromAccount } = await import("/@/vben/layouts/widgets/preferences/account-sync");
|
||||
await loadPreferencesFromAccount();
|
||||
} catch (e) {
|
||||
console.error("加载账号偏好设置失败", e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ export {
|
||||
CircleCheckBig,
|
||||
CircleHelp,
|
||||
Copy,
|
||||
ClipboardPaste,
|
||||
CloudUpload,
|
||||
CornerDownLeft,
|
||||
Ellipsis,
|
||||
Expand,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { message as antdMessage } from "ant-design-vue";
|
||||
|
||||
import { $t, loadLocaleMessages } from "/@/locales";
|
||||
import { updatePreferences } from "/@/vben/preferences";
|
||||
|
||||
import { PreferencesSettingsGet, PreferencesSettingsSave } from "./api";
|
||||
|
||||
const PREFERENCES_KNOWN_KEYS = ["app", "theme", "logo", "sidebar", "header", "tabbar", "breadcrumb", "navigation", "widget", "footer", "copyright", "shortcutKeys", "transition"];
|
||||
|
||||
export function isPreferencesPayload(value: unknown): value is Record<string, any> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return PREFERENCES_KNOWN_KEYS.some(key => Object.prototype.hasOwnProperty.call(value, key));
|
||||
}
|
||||
|
||||
/** 保存到账号:允许空对象(重置后的默认偏好) */
|
||||
export function isPreferencesSavePayload(value: unknown): value is Record<string, any> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(value as Record<string, any>);
|
||||
if (keys.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return isPreferencesPayload(value);
|
||||
}
|
||||
|
||||
export async function applyPreferencesFromAccount(data: Record<string, any>) {
|
||||
updatePreferences(data);
|
||||
if (data.app?.locale) {
|
||||
await loadLocaleMessages(data.app.locale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录后从账号加载偏好并应用到本地;
|
||||
* - null / {}:账号无有效配置(含保存为空),默认继续使用本地偏好
|
||||
*/
|
||||
export async function loadPreferencesFromAccount() {
|
||||
const data = await PreferencesSettingsGet();
|
||||
if (data == null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof data !== "object" || Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
// 保存为空时,后续拉取到空数据后默认从本地读取,不覆盖本地
|
||||
if (Object.keys(data).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isPreferencesPayload(data)) {
|
||||
return false;
|
||||
}
|
||||
await applyPreferencesFromAccount(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function savePreferencesToAccount(preferencesPayload: Record<string, any> | null | undefined) {
|
||||
const payload = preferencesPayload && typeof preferencesPayload === "object" ? preferencesPayload : {};
|
||||
if (!isPreferencesSavePayload(payload)) {
|
||||
throw new Error("invalid preferences payload");
|
||||
}
|
||||
await PreferencesSettingsSave(payload);
|
||||
antdMessage.success($t("preferences.saveToAccountSuccess"));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// @ts-ignore
|
||||
import { request } from "/@/api/service";
|
||||
|
||||
const apiPrefix = "/user/settings/preferences";
|
||||
|
||||
export async function PreferencesSettingsGet(): Promise<Record<string, any> | null> {
|
||||
const res = await request({
|
||||
url: apiPrefix + "/get",
|
||||
method: "post",
|
||||
showErrorNotify: false,
|
||||
});
|
||||
return (res as Record<string, any>) || null;
|
||||
}
|
||||
|
||||
export async function PreferencesSettingsSave(preferences: Record<string, any>) {
|
||||
return await request({
|
||||
url: apiPrefix + "/save",
|
||||
method: "post",
|
||||
data: { preferences },
|
||||
});
|
||||
}
|
||||
+61
-8
@@ -16,21 +16,27 @@ import type { SegmentedItem } from "/@/vben//shadcn-ui";
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { Copy, RotateCw, X } from "/@/vben/icons";
|
||||
import { ClipboardPaste, CloudUpload, Copy, RotateCw, X } from "/@/vben/icons";
|
||||
import { $t, loadLocaleMessages } from "/@/locales";
|
||||
import { clearPreferencesCache, preferences, resetPreferences, usePreferences } from "/@/vben/preferences";
|
||||
|
||||
import { useVbenDrawer } from "/@/vben//popup-ui";
|
||||
import { VbenButton, VbenIconButton, VbenSegmented } from "/@/vben//shadcn-ui";
|
||||
import { globalShareState } from "/@/vben//shared/global-state";
|
||||
import { useUserStore } from "/@/store/user";
|
||||
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { Animation, Block, Breadcrumb, BuiltinTheme, ColorMode, Content, Copyright, Footer, General, GlobalShortcutKeys, Header, Layout, Navigation, Radius, Sidebar, Tabbar, Theme, Widget } from "./blocks";
|
||||
import { applyPreferencesFromAccount, isPreferencesPayload, savePreferencesToAccount } from "./account-sync";
|
||||
|
||||
import { message as antdMessage } from "ant-design-vue";
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
const message = globalShareState.getMessage();
|
||||
const userStore = useUserStore();
|
||||
const savingToAccount = ref(false);
|
||||
|
||||
const appLocale = defineModel<SupportedLanguagesType>("appLocale");
|
||||
const appDynamicTitle = defineModel<boolean>("appDynamicTitle");
|
||||
@@ -150,6 +156,41 @@ async function handleCopy() {
|
||||
await copy(JSON.stringify(diffPreference.value, null, 2));
|
||||
|
||||
message.copyPreferencesSuccess?.($t("preferences.copyPreferencesSuccessTitle"), $t("preferences.copyPreferencesSuccess"));
|
||||
antdMessage.success($t("preferences.copyPreferencesSuccessTitle"));
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const data = JSON.parse(text);
|
||||
if (!isPreferencesPayload(data)) {
|
||||
throw new Error("invalid preferences payload");
|
||||
}
|
||||
await applyPreferencesFromAccount(data);
|
||||
message.copyPreferencesSuccess?.($t("preferences.importPreferencesSuccessTitle"), $t("preferences.importPreferencesSuccess"));
|
||||
antdMessage.success($t("preferences.importPreferencesSuccess"));
|
||||
} catch {
|
||||
antdMessage.error($t("preferences.importPreferencesError"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveToAccount() {
|
||||
if (!userStore.isLogined) {
|
||||
antdMessage.warning($t("preferences.saveToAccountNeedLogin"));
|
||||
return;
|
||||
}
|
||||
if (savingToAccount.value) {
|
||||
return;
|
||||
}
|
||||
savingToAccount.value = true;
|
||||
try {
|
||||
// 重置后 diff 为空时保存 {};后续登录拉取到空数据则继续使用本地偏好
|
||||
await savePreferencesToAccount((diffPreference.value as Record<string, any>) || {});
|
||||
} catch (e: any) {
|
||||
antdMessage.error(e?.message || $t("preferences.saveToAccountError"));
|
||||
} finally {
|
||||
savingToAccount.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
@@ -310,13 +351,25 @@ async function handleReset() {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VbenButton :disabled="!diffPreference" class="mx-4 w-full" size="sm" variant="default" @click="handleCopy">
|
||||
<Copy class="mr-2 size-3" />
|
||||
{{ $t("preferences.copyPreferences") }}
|
||||
</VbenButton>
|
||||
<VbenButton :disabled="!diffPreference" class="mr-4 w-full" size="sm" variant="ghost" @click="handleClearCache">
|
||||
{{ $t("preferences.clearAndLogout") }}
|
||||
</VbenButton>
|
||||
<div class="flex w-full flex-col gap-2 px-1">
|
||||
<div class="flex w-full gap-2">
|
||||
<VbenButton :disabled="!diffPreference" class="w-full" size="sm" variant="default" @click="handleCopy">
|
||||
<Copy class="mr-2 size-3" />
|
||||
{{ $t("preferences.copyPreferences") }}
|
||||
</VbenButton>
|
||||
<VbenButton class="w-full" size="sm" variant="outline" @click="handleImport">
|
||||
<ClipboardPaste class="mr-2 size-3" />
|
||||
{{ $t("preferences.importPreferences") }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
<VbenButton :disabled="!userStore.isLogined || savingToAccount" class="w-full" size="sm" variant="outline" @click="handleSaveToAccount">
|
||||
<CloudUpload class="mr-2 size-3" />
|
||||
{{ $t("preferences.saveToAccount") }}
|
||||
</VbenButton>
|
||||
<VbenButton :disabled="!diffPreference" class="w-full" size="sm" variant="ghost" @click="handleClearCache">
|
||||
{{ $t("preferences.clearAndLogout") }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/// <reference types="mocha" />
|
||||
/// <reference types="node" />
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseUserPreferencesPayload } from "./user-preferences.js";
|
||||
|
||||
describe("parseUserPreferencesPayload", () => {
|
||||
it("parses wrapped preferences payload", () => {
|
||||
const result = parseUserPreferencesPayload({
|
||||
preferences: {
|
||||
theme: { mode: "dark" },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, { theme: { mode: "dark" } });
|
||||
});
|
||||
|
||||
it("parses direct preferences payload", () => {
|
||||
const result = parseUserPreferencesPayload({
|
||||
app: { locale: "en-US" },
|
||||
theme: { mode: "light" },
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
app: { locale: "en-US" },
|
||||
theme: { mode: "light" },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses empty preferences as reset payload", () => {
|
||||
assert.deepEqual(parseUserPreferencesPayload({}), {});
|
||||
assert.deepEqual(parseUserPreferencesPayload({ preferences: {} }), {});
|
||||
});
|
||||
|
||||
it("returns null for invalid payload", () => {
|
||||
assert.equal(parseUserPreferencesPayload(null), null);
|
||||
assert.equal(parseUserPreferencesPayload([]), null);
|
||||
assert.equal(parseUserPreferencesPayload({ foo: 1 }), null);
|
||||
assert.equal(parseUserPreferencesPayload({ preferences: { foo: 1 } }), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const PREFERENCES_KNOWN_KEYS = [
|
||||
"app",
|
||||
"theme",
|
||||
"logo",
|
||||
"sidebar",
|
||||
"header",
|
||||
"tabbar",
|
||||
"breadcrumb",
|
||||
"navigation",
|
||||
"widget",
|
||||
"footer",
|
||||
"copyright",
|
||||
"shortcutKeys",
|
||||
"transition",
|
||||
];
|
||||
|
||||
/**
|
||||
* 解析用户偏好设置 payload。
|
||||
* 兼容 `{ preferences: {...} }` 与直接传偏好对象两种格式。
|
||||
* 空对象 `{}` 表示已重置为默认偏好,视为合法。
|
||||
*/
|
||||
export function parseUserPreferencesPayload(value: unknown): Record<string, any> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const raw = value as Record<string, any>;
|
||||
const preferences =
|
||||
raw.preferences != null && typeof raw.preferences === "object" && !Array.isArray(raw.preferences)
|
||||
? (raw.preferences as Record<string, any>)
|
||||
: raw;
|
||||
const preferenceKeys = Object.keys(preferences);
|
||||
// 空对象表示重置后的默认偏好
|
||||
if (preferenceKeys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const hasKnownKey = PREFERENCES_KNOWN_KEYS.some(key => Object.prototype.hasOwnProperty.call(preferences, key));
|
||||
if (!hasKnownKey) {
|
||||
return null;
|
||||
}
|
||||
return preferences;
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/c
|
||||
import { Constants, CrudController } from "@certd/lib-server";
|
||||
import { UserSettingsService } from "../../../modules/mine/service/user-settings-service.js";
|
||||
import { UserSettingsEntity } from "../../../modules/mine/entity/user-settings.js";
|
||||
import { UserGrantSetting } from "../../../modules/mine/service/models.js";
|
||||
import { UserGrantSetting, UserPreferencesSetting } from "../../../modules/mine/service/models.js";
|
||||
import { isPlus } from "@certd/plus-core";
|
||||
import { merge } from "lodash-es";
|
||||
import { ApiTags } from "@midwayjs/swagger";
|
||||
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
|
||||
import { parseUserPreferencesPayload } from "./user-preferences.js";
|
||||
|
||||
/**
|
||||
*/
|
||||
@@ -97,4 +98,37 @@ export class UserSettingsController extends CrudController<UserSettingsService>
|
||||
this.auditLog({ content: `保存了授权设置 「${setting.allowAdminViewCerts ? "允许管理员查看证书" : "禁止管理员查看证书"}」` });
|
||||
return this.ok({});
|
||||
}
|
||||
|
||||
@Post("/preferences/get", { description: Constants.per.authOnly, summary: "获取用户偏好设置" })
|
||||
async preferencesGet() {
|
||||
const userId = this.getUserId();
|
||||
const entity = await this.service.getByKey(UserPreferencesSetting.__key__, userId, null);
|
||||
if (!entity?.setting) {
|
||||
return this.ok(null);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(entity.setting);
|
||||
} catch {
|
||||
return this.ok(null);
|
||||
}
|
||||
return this.ok(parseUserPreferencesPayload(parsed));
|
||||
}
|
||||
|
||||
@Post("/preferences/save", { description: Constants.per.authOnly, summary: "保存用户偏好设置" })
|
||||
async preferencesSave(@Body(ALL) bean: any) {
|
||||
const userId = this.getUserId();
|
||||
const preferences = parseUserPreferencesPayload(bean);
|
||||
if (!preferences) {
|
||||
throw new Error("偏好设置内容无效");
|
||||
}
|
||||
// 整份替换,避免 saveSetting 深合并留下已恢复为默认值的旧字段
|
||||
const entity = new UserSettingsEntity();
|
||||
entity.key = UserPreferencesSetting.__key__;
|
||||
entity.title = UserPreferencesSetting.__title__;
|
||||
entity.userId = userId;
|
||||
entity.setting = JSON.stringify({ preferences });
|
||||
await this.service.save(entity);
|
||||
return this.ok({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ export class UserGrantSetting extends BaseSettings {
|
||||
allowAdminViewCerts = false;
|
||||
}
|
||||
|
||||
export class UserPreferencesSetting extends BaseSettings {
|
||||
static __title__ = "用户偏好设置";
|
||||
static __key__ = "user.preferences";
|
||||
|
||||
/** 偏好差异配置(相对默认值),与前端 diffPreference 结构一致 */
|
||||
preferences: Record<string, any> = {};
|
||||
}
|
||||
|
||||
export class UserDomainImportSetting extends BaseSettings {
|
||||
static __title__ = "用户域名导入设置";
|
||||
static __key__ = "user.domain.import";
|
||||
|
||||
Reference in New Issue
Block a user