From 319f555569602d474d0222606cde18702ef018ac Mon Sep 17 00:00:00 2001 From: ahe Date: Sun, 2 Aug 2026 04:27:07 +0800 Subject: [PATCH] =?UTF-8?q?Feature/=E5=81=8F=E5=A5=BD=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20(#775)=20@nicheng-he?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 偏好设置支持从剪切板导入 在现有「复制偏好设置」基础上增加从剪切板导入,便于在浏览器间同步主题与布局配置。 * feat: 偏好设置支持保存到账号并在登录后自动同步 增加「保存到账号」能力,将本地偏好差异写入用户设置;登录成功后自动从账号拉取并应用到本地。 --- .../src/locales/langs/en-US/preferences.ts | 9 +++ .../src/locales/langs/zh-CN/preferences.ts | 9 +++ .../certd-client/src/store/settings/index.tsx | 6 ++ .../ui/certd-client/src/vben/icons/lucide.ts | 2 + .../widgets/preferences/account-sync.ts | 66 ++++++++++++++++++ .../vben/layouts/widgets/preferences/api.ts | 21 ++++++ .../preferences/preferences-drawer.vue | 69 ++++++++++++++++--- .../user/mine/user-preferences.test.ts | 40 +++++++++++ .../controller/user/mine/user-preferences.ts | 41 +++++++++++ .../user/mine/user-settings-controller.ts | 36 +++++++++- .../src/modules/mine/service/models.ts | 8 +++ 11 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 packages/ui/certd-client/src/vben/layouts/widgets/preferences/account-sync.ts create mode 100644 packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts create mode 100644 packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts create mode 100644 packages/ui/certd-server/src/controller/user/mine/user-preferences.ts diff --git a/packages/ui/certd-client/src/locales/langs/en-US/preferences.ts b/packages/ui/certd-client/src/locales/langs/en-US/preferences.ts index 669a1e77a..08912e48d 100644 --- a/packages/ui/certd-client/src/locales/langs/en-US/preferences.ts +++ b/packages/ui/certd-client/src/locales/langs/en-US/preferences.ts @@ -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", diff --git a/packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts b/packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts index 404ec2e7b..323741063 100644 --- a/packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts +++ b/packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts @@ -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: "通用", diff --git a/packages/ui/certd-client/src/store/settings/index.tsx b/packages/ui/certd-client/src/store/settings/index.tsx index 7f97acc64..c1e67ea48 100644 --- a/packages/ui/certd-client/src/store/settings/index.tsx +++ b/packages/ui/certd-client/src/store/settings/index.tsx @@ -390,4 +390,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); + } }); diff --git a/packages/ui/certd-client/src/vben/icons/lucide.ts b/packages/ui/certd-client/src/vben/icons/lucide.ts index b57a610ea..aa9715abe 100644 --- a/packages/ui/certd-client/src/vben/icons/lucide.ts +++ b/packages/ui/certd-client/src/vben/icons/lucide.ts @@ -18,6 +18,8 @@ export { CircleCheckBig, CircleHelp, Copy, + ClipboardPaste, + CloudUpload, CornerDownLeft, Ellipsis, Expand, diff --git a/packages/ui/certd-client/src/vben/layouts/widgets/preferences/account-sync.ts b/packages/ui/certd-client/src/vben/layouts/widgets/preferences/account-sync.ts new file mode 100644 index 000000000..adcbb6f23 --- /dev/null +++ b/packages/ui/certd-client/src/vben/layouts/widgets/preferences/account-sync.ts @@ -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 { + 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 { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const keys = Object.keys(value as Record); + if (keys.length === 0) { + return true; + } + return isPreferencesPayload(value); +} + +export async function applyPreferencesFromAccount(data: Record) { + 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 | 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")); +} diff --git a/packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts b/packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts new file mode 100644 index 000000000..b9bde5538 --- /dev/null +++ b/packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts @@ -0,0 +1,21 @@ +// @ts-ignore +import { request } from "/@/api/service"; + +const apiPrefix = "/user/settings/preferences"; + +export async function PreferencesSettingsGet(): Promise | null> { + const res = await request({ + url: apiPrefix + "/get", + method: "post", + showErrorNotify: false, + }); + return (res as Record) || null; +} + +export async function PreferencesSettingsSave(preferences: Record) { + return await request({ + url: apiPrefix + "/save", + method: "post", + data: { preferences }, + }); +} diff --git a/packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue b/packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue index a131f740b..ce3fc6baa 100644 --- a/packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue +++ b/packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue @@ -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("appLocale"); const appDynamicTitle = defineModel("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) || {}); + } catch (e: any) { + antdMessage.error(e?.message || $t("preferences.saveToAccountError")); + } finally { + savingToAccount.value = false; + } } async function handleClearCache() { @@ -310,13 +351,25 @@ async function handleReset() { diff --git a/packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts b/packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts new file mode 100644 index 000000000..f15352251 --- /dev/null +++ b/packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts @@ -0,0 +1,40 @@ +/// +/// + +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); + }); +}); diff --git a/packages/ui/certd-server/src/controller/user/mine/user-preferences.ts b/packages/ui/certd-server/src/controller/user/mine/user-preferences.ts new file mode 100644 index 000000000..c6b53e412 --- /dev/null +++ b/packages/ui/certd-server/src/controller/user/mine/user-preferences.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const raw = value as Record; + const preferences = + raw.preferences != null && typeof raw.preferences === "object" && !Array.isArray(raw.preferences) + ? (raw.preferences as Record) + : 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; +} diff --git a/packages/ui/certd-server/src/controller/user/mine/user-settings-controller.ts b/packages/ui/certd-server/src/controller/user/mine/user-settings-controller.ts index 1eb45bccf..b9f37cdeb 100644 --- a/packages/ui/certd-server/src/controller/user/mine/user-settings-controller.ts +++ b/packages/ui/certd-server/src/controller/user/mine/user-settings-controller.ts @@ -2,10 +2,11 @@ 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 { parseUserPreferencesPayload } from "./user-preferences.js"; /** */ @@ -90,4 +91,37 @@ export class UserSettingsController extends CrudController await this.service.saveSetting(userId, null, setting); 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({}); + } } diff --git a/packages/ui/certd-server/src/modules/mine/service/models.ts b/packages/ui/certd-server/src/modules/mine/service/models.ts index c0843f9ed..e426d0018 100644 --- a/packages/ui/certd-server/src/modules/mine/service/models.ts +++ b/packages/ui/certd-server/src/modules/mine/service/models.ts @@ -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 = {}; +} + export class UserDomainImportSetting extends BaseSettings { static __title__ = "用户域名导入设置"; static __key__ = "user.domain.import";