Merge remote-tracking branch 'origin/v2' into v2_audit_log

This commit is contained in:
xiaojunnuo
2026-08-02 17:06:31 +08:00
11 changed files with 298 additions and 9 deletions
@@ -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 },
});
}
@@ -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";