diff --git a/src/App.ku.vue b/src/App.ku.vue
index b7975fb..83aa4d9 100644
--- a/src/App.ku.vue
+++ b/src/App.ku.vue
@@ -1,37 +1,33 @@
-
-
+
+
+
+
+
+
+
-
+
-
-
-
+
+
+
\ No newline at end of file
diff --git a/src/App.vue b/src/App.vue
index a18ce98..6d7eff8 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,3 +1,4 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/uh-scroll-top/uh-scroll-top.vue b/src/components/uh-scrolltop-button/uh-scrolltop-button.vue
similarity index 61%
rename from src/components/uh-scroll-top/uh-scroll-top.vue
rename to src/components/uh-scrolltop-button/uh-scrolltop-button.vue
index 4d4e017..5f9048f 100644
--- a/src/components/uh-scroll-top/uh-scroll-top.vue
+++ b/src/components/uh-scrolltop-button/uh-scrolltop-button.vue
@@ -1,32 +1,39 @@
-
+
diff --git a/src/components/uh-settings-button/uh-settings-button.vue b/src/components/uh-settings-button/uh-settings-button.vue
new file mode 100644
index 0000000..7276852
--- /dev/null
+++ b/src/components/uh-settings-button/uh-settings-button.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/uh-settings-popup/uh-settings-popup.vue b/src/components/uh-settings-popup/uh-settings-popup.vue
new file mode 100644
index 0000000..c7d7a40
--- /dev/null
+++ b/src/components/uh-settings-popup/uh-settings-popup.vue
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+ 偏好设置
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+ {{ group.label }}
+
+
+
+
+ {{ row.label }}
+
+ 默认
+
+ 已自定义
+
+
+
+
+
+
+ 默认
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
+
+
+
+
+ 功能
+
+ 一些常用的功能性设置
+
+
+
+
+
+
+
+ {{ row.label }}
+
+ 跟随站点默认
+
+
+ 已自定义
+
+
+ 恢复默认
+
+
+
+
+
+
+
+
+
+ {{ row.label }}
+
+ 跟随站点默认
+
+ 已自定义
+
+
+
+
+
+ 默认
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 关闭
+
+
+ 恢复默认
+
+
+
+
+
\ No newline at end of file
diff --git a/src/hooks/usePreferenceRows.ts b/src/hooks/usePreferenceRows.ts
new file mode 100644
index 0000000..e7d5fab
--- /dev/null
+++ b/src/hooks/usePreferenceRows.ts
@@ -0,0 +1,185 @@
+/**
+ * 偏好设置展示行与交互(页面 setting.vue 与全局弹窗 uh-settings-popup 共用)
+ * 收敛:字段定义(PrefDef/页面分组/布局/功能)、三态(跟随站点默认/已自定义/恢复默认)、
+ * 行构建、选值交互。两个消费方不再各自重复定义。
+ */
+import { computed } from 'vue';
+import { DefaultAppSettings } from '@/config/appSettings';
+import { useSettingStore } from '@/store/setting';
+import { isLocalOverride, readLocalPrefs } from '@/utils/preference';
+import type { LocalPrefs } from '@/utils/preference';
+
+/** 偏好字段定义 */
+export interface PrefDef {
+ key: string;
+ label: string;
+ kind: 'bool' | 'enum';
+ path: string[];
+ options?: { label: string; value: string }[];
+ siteLabelOf?: (value: string) => string;
+}
+
+/** 布局设置按页面分组(每组:列表布局 + 卡片样式) */
+export const PAGE_GROUPS = [
+ { key: 'home', label: '首页' },
+ { key: 'articles', label: '文章页面' },
+ { key: 'archives', label: '归档页面' }
+];
+
+/** 布局偏好字段(页面分组 × 列表布局/卡片样式) */
+export const LAYOUT_PREFS: PrefDef[] = PAGE_GROUPS.flatMap((group) => [
+ {
+ key: `${group.key}ListLayout`,
+ label: '列表布局',
+ kind: 'enum',
+ path: ['layout', group.key, 'listLayout'],
+ options: [
+ { label: '单列', value: 'single' },
+ { label: '双列', value: 'double' }
+ ]
+ },
+ {
+ key: `${group.key}CardType`,
+ label: '卡片样式',
+ kind: 'enum',
+ path: ['layout', group.key, 'cardType'],
+ options: [
+ { label: '上图下文', value: 'image_top' },
+ { label: '左文右图', value: 'image_right' },
+ { label: '上文下图', value: 'image_bottom' },
+ { label: '左图右文', value: 'image_left' }
+ ]
+ }
+]);
+
+/** 功能偏好字段 */
+export const FEATURE_PREFS: PrefDef[] = [{ key: 'isAvatarRadius', label: '是否圆形头像', kind: 'bool', path: ['isAvatarRadius'] }];
+
+/** 顶部分段器(布局 / 功能) */
+export const SETTING_TABS: { key: 'layout' | 'feature'; label: string }[] = [
+ { key: 'layout', label: '布局' },
+ { key: 'feature', label: '功能' }
+];
+
+export function usePreferenceRows() {
+ const settingStore = useSettingStore();
+
+ /* ---------------- 路径取值工具 ---------------- */
+ function getByPath(obj: unknown, path: string[]): unknown {
+ let cursor: unknown = obj;
+ for (const key of path) {
+ if (cursor === null || cursor === undefined) {
+ return undefined;
+ }
+ cursor = (cursor as Record)[key];
+ }
+ return cursor;
+ }
+
+ /** 按路径构造差异 patch(null 表示删除该键=跟随站点默认) */
+ function buildPatch(path: string[], value: unknown): LocalPrefs {
+ const [head, ...rest] = path;
+ if (rest.length === 0) return { [head]: value } as LocalPrefs;
+ return { [head]: buildPatch(rest, value) } as LocalPrefs;
+ }
+
+ /* ---------------- 状态读取 ---------------- */
+ function prefValueOf(path: string[]): unknown {
+ return getByPath(settingStore.settings, path);
+ }
+
+ /** 站点默认值(未配置时回退内置默认) */
+ function siteDefaultOf(path: string[]): unknown {
+ const site = getByPath(settingStore.siteDefaults, path);
+ if (site !== undefined && site !== null) return site;
+ return getByPath(DefaultAppSettings, path);
+ }
+
+ function isOverridden(path: string[]): boolean {
+ return isLocalOverride(readLocalPrefs(), path);
+ }
+
+ function enumLabelOf(def: PrefDef, value: unknown): string {
+ const hit = def.options?.find((opt) => opt.value === value);
+ if (hit) return hit.label;
+ if (def.siteLabelOf && typeof value === 'string') return def.siteLabelOf(value);
+ return value === undefined || value === null ? '—' : String(value);
+ }
+
+ /* ---------------- 展示行(预计算,避免模板渲染期函数调用) ---------------- */
+ interface PrefRow extends PrefDef {
+ displayValue: string;
+ following: boolean;
+ /** 仅 kind==='bool' 使用 */
+ boolValue: boolean;
+ }
+
+ function buildRows(defs: PrefDef[]): PrefRow[] {
+ return defs.map((def) => ({
+ ...def,
+ displayValue: enumLabelOf(def, prefValueOf(def.path)),
+ following: isFollowing(def.path),
+ boolValue: def.kind === 'bool' ? prefValueOf(def.path) === true : false
+ }));
+ }
+
+ const layoutRows = computed(() => buildRows(LAYOUT_PREFS));
+ const featureRows = computed(() => buildRows(FEATURE_PREFS));
+
+ /** 布局设置按页面分组的展示行 */
+ const layoutGroups = computed(() =>
+ PAGE_GROUPS.map((group) => ({
+ key: group.key,
+ label: group.label,
+ rows: layoutRows.value.filter((row) => row.path[1] === group.key)
+ }))
+ );
+
+ /** 当前项是否处于「跟随站点默认」(本地未覆盖) */
+ function isFollowing(path: string[]): boolean {
+ return !isOverridden(path);
+ }
+
+ /** 单项还原为跟随站点默认 */
+ function handleRevert(path: string[]): void {
+ settingStore.savePreference(buildPatch(path, null));
+ }
+
+ /** 开关类:选值等于站点默认则还原为跟随(只存差异) */
+ function handleBoolChange(path: string[], next: boolean): void {
+ if (next === siteDefaultOf(path)) {
+ settingStore.savePreference(buildPatch(path, null));
+ } else {
+ settingStore.savePreference(buildPatch(path, next));
+ }
+ }
+
+ /** 枚举选值:null 或等于站点默认则还原为跟随,否则写入差异(内联分段器与弹层共用) */
+ function handleChoose(path: string[], value: string | null): void {
+ if (value === null || value === siteDefaultOf(path)) {
+ handleRevert(path);
+ } else {
+ settingStore.savePreference(buildPatch(path, value));
+ }
+ }
+
+ return {
+ settingStore,
+ PAGE_GROUPS,
+ SETTING_TABS,
+ getByPath,
+ buildPatch,
+ prefValueOf,
+ siteDefaultOf,
+ isOverridden,
+ enumLabelOf,
+ buildRows,
+ layoutRows,
+ featureRows,
+ layoutGroups,
+ isFollowing,
+ handleRevert,
+ handleBoolChange,
+ handleChoose
+ };
+}
diff --git a/src/hooks/useSettingsPopup.ts b/src/hooks/useSettingsPopup.ts
new file mode 100644
index 0000000..490a7c4
--- /dev/null
+++ b/src/hooks/useSettingsPopup.ts
@@ -0,0 +1,16 @@
+import { ref } from 'vue';
+
+export function useSettingsPopup() {
+ const settingsPopupVisible = ref(false);
+
+ function openSettingsPopup() {
+ settingsPopupVisible.value = true;
+ }
+
+ function closeSettingsPopup() {
+ settingsPopupVisible.value = false;
+ console.log('Settings popup closed', settingsPopupVisible.value);
+ }
+
+ return { settingsPopupVisible, openSettingsPopup, closeSettingsPopup };
+}
diff --git a/src/pages-blog/setting/setting.vue b/src/pages-blog/setting/setting.vue
index 3cc1120..733a3aa 100644
--- a/src/pages-blog/setting/setting.vue
+++ b/src/pages-blog/setting/setting.vue
@@ -1,11 +1,11 @@