Ver código fonte

1. 实现了界面主题切换功能,内置蓝色、红色、绿色三套配色,顶栏新增“主题”按钮,下拉色样选择即点即生效;
2. 实现了主题的每用户持久化与账号级漫游,sys_user 新增 ui_theme 列(CHECK 白名单、缺省蓝色),新增 PUT /users/me/ui-theme 接口任意登录用户可改本人无需管理权限,登录与会话恢复以后端账号偏好为权威源覆盖本地,任意设备登录后自动跟随;
3. 完成了界面配色全量变量化,主界面、各类弹窗、目录面板、统计卡片等 20 余个组件的硬编码色值统一替换为 --dms-* 语义变量,theme.css 定义三套调色板(含图表配色),切换主题整体换肤;
4. 实现了主题切换防闪烁,index.html 内联脚本在首帧前按本地缓存设置 html[data-theme],localStorage 仅作上次会话缓存,隐私模式优雅回退缺省蓝色;
5. 完成了数据库双引擎幂等迁移(Alembic 0027 与 Flyway V12),加列与CHECK约束均先探测后执行,与Python/Java双版本并行升级互不冲突;
6. 新增了达梦适配迁移目录 db/migration-dm,V12 提供达梦可执行版本(MySQL 版的 SET 变量/PREPARE 专有语法达梦无法直接执行),与 MySQL 目录平级存放、不参与 Flyway 扫描;
7. 完成了Java后端主题接口同步,users/me/ui-theme 与登录响应携带 uiTheme 字段逐条对齐 Python 版,接口契约更新至1.20版本;
8. 补充了 .gitignore。

weisijie 1 mês atrás
pai
commit
61b46301ee
55 arquivos alterados com 966 adições e 382 exclusões
  1. 1 0
      .gitignore
  2. 1 1
      DMS_API_CONTRACT.md
  3. 7 0
      backend-java/src/main/java/com/dms/api/v1/UsersController.java
  4. 4 0
      backend-java/src/main/java/com/dms/mapper/UserMapper.java
  5. 2 0
      backend-java/src/main/java/com/dms/model/UserEntity.java
  6. 22 0
      backend-java/src/main/java/com/dms/service/UserService.java
  7. 1 0
      backend-java/src/main/java/com/dms/service/UserSummaryService.java
  8. 2 0
      backend-java/src/main/resources/application.yml
  9. 47 0
      backend-java/src/main/resources/db/migration-dm/V12__ui_theme.sql
  10. 34 0
      backend-java/src/main/resources/db/migration/V12__ui_theme.sql
  11. 8 0
      backend/dms/api/v1/users.py
  12. 10 0
      backend/dms/models/user.py
  13. 30 0
      backend/dms/services/user_service.py
  14. 81 0
      backend/migrations/versions/0027_ui_theme.py
  15. 12 0
      backend/openapi/components/schemas.yaml
  16. 3 1
      backend/openapi/openapi.yaml
  17. 27 0
      backend/openapi/paths/users.yaml
  18. 6 4
      backend/tests/dms/test_db_upgrade.py
  19. 1 1
      backend/tests/dms/test_migration.py
  20. 2 1
      backend/tests/dms/test_openapi_storage.py
  21. 1 1
      backend/tests/dms/test_q1b_ui_dictionaries.py
  22. 1 1
      backend/tests/dms/test_q2b_updated_range.py
  23. 1 1
      backend/tests/dms/test_q3b_business_content_preview.py
  24. 12 0
      frontend/index.html
  25. 171 159
      frontend/src/app/App.tsx
  26. 14 13
      frontend/src/features/audit/AuditDashboard.tsx
  27. 5 5
      frontend/src/features/auth/__tests__/authApi.test.ts
  28. 1 0
      frontend/src/features/auth/__tests__/authSession.test.ts
  29. 10 0
      frontend/src/features/auth/authApi.ts
  30. 13 1
      frontend/src/features/auth/authSession.ts
  31. 3 0
      frontend/src/features/auth/authTypes.ts
  32. 7 7
      frontend/src/features/bindings/AttachmentBindingModal.tsx
  33. 7 7
      frontend/src/features/bindings/SubPlanBindingModal.tsx
  34. 4 4
      frontend/src/features/bindings/UnbindAttachmentModal.tsx
  35. 20 20
      frontend/src/features/directory/DirectoryPage.tsx
  36. 9 9
      frontend/src/features/directory/IpEntryMaintenancePanel.tsx
  37. 4 4
      frontend/src/features/directory/OrgIpAssignmentModal.tsx
  38. 34 34
      frontend/src/features/directory/RoleMaintenancePanel.tsx
  39. 7 7
      frontend/src/features/directory/directoryUi.tsx
  40. 7 7
      frontend/src/features/documents/DocumentSearchToolbar.tsx
  41. 8 8
      frontend/src/features/documents/GlobalSearchBar.tsx
  42. 29 29
      frontend/src/features/files/FilePreviewModal.tsx
  43. 1 1
      frontend/src/features/files/PdfViewer.tsx
  44. 16 16
      frontend/src/features/mutations/MutationModals.tsx
  45. 7 7
      frontend/src/features/permissions/PermissionModal.tsx
  46. 8 8
      frontend/src/features/recycle-bin/RecycleBinModal.tsx
  47. 6 6
      frontend/src/features/recycle-bin/RestoreDocumentModal.tsx
  48. 3 3
      frontend/src/features/statistics/DocumentStatisticsCards.tsx
  49. 85 0
      frontend/src/shared/theme/ThemeSwitcher.tsx
  50. 65 0
      frontend/src/shared/theme/themeStore.ts
  51. 9 9
      frontend/src/shared/ui/ConfirmDialog.tsx
  52. 5 5
      frontend/src/shared/ui/PasswordPromptModal.tsx
  53. 1 1
      frontend/src/shared/ui/hoverTip.tsx
  54. 82 0
      frontend/src/styles/theme.css
  55. 9 1
      prompts.md

+ 1 - 0
.gitignore

@@ -141,6 +141,7 @@ deliverables/
 temp/
 方案计划文档管理系统技术方案*
 dist-package*
+src-check/
 
 # Codex 本地会话控制目录
 .agents/

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
DMS_API_CONTRACT.md


+ 7 - 0
backend-java/src/main/java/com/dms/api/v1/UsersController.java

@@ -51,6 +51,13 @@ public class UsersController {
                 userSummaryService.allowedModulesForUser(user)));
     }
 
+    /** 对齐 update_ui_theme_route(契约1.20):本人主题偏好,无需管理权限。 */
+    @AuthRequired
+    @PutMapping("/me/ui-theme")
+    public ApiResponse<Object> updateUiTheme(@RequestBody Object body) {
+        return ApiResponse.ok(userService.updateUiTheme(body));
+    }
+
     @AuthRequired(requireAdmin = true)
     @GetMapping
     public ApiResponse<Object> list(@RequestParam(required = false) String organizationId,

+ 4 - 0
backend-java/src/main/java/com/dms/mapper/UserMapper.java

@@ -191,6 +191,10 @@ public interface UserMapper {
     @Update("UPDATE sys_user SET auth_version = auth_version + 1, row_version = row_version + 1 WHERE id = #{id}")
     int incrementAuthVersion(@Param("id") long id);
 
+    /** 本人主题偏好(契约1.20):纯视觉偏好,不动 auth_version/row_version/审计。 */
+    @Update("UPDATE sys_user SET ui_theme = #{uiTheme} WHERE id = #{id}")
+    int updateUiTheme(@Param("id") long id, @Param("uiTheme") String uiTheme);
+
     /** 实时解析生效角色:取未删用户的显式角色(对齐 _effective_role)。 */
     @Select("SELECT role_id FROM sys_user WHERE id = #{id} AND is_deleted = 0 LIMIT 1")
     Long findRoleIdActive(@Param("id") long id);

+ 2 - 0
backend-java/src/main/java/com/dms/model/UserEntity.java

@@ -30,4 +30,6 @@ public class UserEntity {
     private LocalDateTime lockedUntil;
     private Long roleId;
     private String externalId;
+    /** 界面主题偏好(账号级漫游,契约1.20):blue/red/green,存量与缺省为 blue。 */
+    private String uiTheme;
 }

+ 22 - 0
backend-java/src/main/java/com/dms/service/UserService.java

@@ -39,6 +39,8 @@ public class UserService {
             "realName", "organizationId", "roleCode", "roleId",
             "securityLevel", "status", "rowVersion");
     private static final Set<String> RESET_PASSWORD_FIELDS = Set.of("newPassword", "rowVersion");
+    private static final Set<String> UI_THEME_FIELDS = Set.of("uiTheme");
+    private static final Set<String> UI_THEMES = Set.of("blue", "red", "green");
 
     private final UserMapper userMapper;
     private final OrganizationMapper organizationMapper;
@@ -307,6 +309,26 @@ public class UserService {
         return result;
     }
 
+    /**
+     * 对齐 update_ui_theme(契约1.20):本人主题偏好,白名单校验,仅操作本人行。
+     * 纯视觉偏好:不 bump rowVersion/authVersion、不写审计。
+     */
+    @Transactional
+    public Map<String, Object> updateUiTheme(Object payload) {
+        Map<String, Object> body = Payloads.exactPayload(payload, UI_THEME_FIELDS);
+        Object rawTheme = body.get("uiTheme");
+        if (!(rawTheme instanceof String theme) || !UI_THEMES.contains(theme)) {
+            throw Errors.invalidArgument("uiTheme必须是blue、red或green");
+        }
+        UserEntity user = userMapper.findActiveById(AuthContext.get().userId());
+        if (user == null) {
+            throw Errors.tokenInvalid();
+        }
+        userMapper.updateUiTheme(user.getId(), theme);
+        user.setUiTheme(theme);
+        return userSummaryService.userSummary(user);
+    }
+
     // ------------------------------------------------------------ 内部工具
 
     /** 对齐 _descendant_ids:仅沿 ENABLED 组织边展开(含根自身)。 */

+ 1 - 0
backend-java/src/main/java/com/dms/service/UserSummaryService.java

@@ -78,6 +78,7 @@ public class UserSummaryService {
         result.put("managedOrgId", managedOrgValue(superAdmin, manageRole));
         result.put("lastLoginAt", formatUtc(user.getLastLoginAt()));
         result.put("lastLoginIp", user.getLastLoginIp());
+        result.put("uiTheme", user.getUiTheme());
         result.put("allowedModules",
                 allowedModules != null ? allowedModules : this.allowedModules(user.getRoleCode()));
         return result;

+ 2 - 0
backend-java/src/main/resources/application.yml

@@ -15,6 +15,8 @@ spring:
     # 达梦(DM)不在 Flyway 社区版支持列表,切换 DM 后默认关闭内置迁移,
     # schema 由外部数据迁移(dts/手工)负责;可用 DMS_FLYWAY_ENABLED=true 强制开启。
     # 回切 MySQL 时改回 enabled: true 即可恢复 Flyway 自动迁移。
+    # 达梦手工执行脚本在 db/migration-dm/(与 db/migration 平级,不参与 Flyway 扫描;
+    # V12 起 db/migration 下的 MySQL 版含 SET @var/PREPARE 专有语法,达梦不可直接执行)。
     enabled: ${DMS_FLYWAY_ENABLED:false}
     # V1 为 Alembic 0016 基线占位;V2~V7 对齐 Python 版 Alembic 0017~0022
     # (均为幂等脚本,兼容已由 Alembic 升级的库);此后演进仅走 Flyway。

+ 47 - 0
backend-java/src/main/resources/db/migration-dm/V12__ui_theme.sql

@@ -0,0 +1,47 @@
+-- V12__ui_theme 的达梦版(DM8):MySQL 原版 ../migration/V12__ui_theme.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V12)。
+-- 语义与 MySQL 版完全一致:
+--   1. sys_user 加 ui_theme VARCHAR(12) NOT NULL DEFAULT 'blue'(存量用户统一回退蓝色);
+--   2. CHECK 白名单 blue/red/green,约束名 CK_SYS_USER_USER_UI_THEME(达梦自动大写,
+--      与 convention 渲染名 ck_sys_user_user_ui_theme 的达梦形态一致)。
+-- 幂等:列/约束均先探测后执行,重复运行无副作用;与 Python 版 Alembic 0027
+--   双引擎互冲安全(谁先跑,后者全部跳过)。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL 匿名块 +
+--   EXECUTE IMMEDIATE;系统字典用 Oracle 兼容视图 USER_TAB_COLUMNS / USER_CONSTRAINTS
+--   (未加引号建表时名称大写存储)。
+
+-- 1. 加列 ui_theme(已存在则跳过)
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'UI_THEME';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE sys_user ADD COLUMN ui_theme VARCHAR(12) NOT NULL DEFAULT ''blue''';
+    END IF;
+END;
+/
+
+-- 2. 删除旧 CHECK 约束(不存在则跳过;按达梦大写存储名探测与删除)
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_CONSTRAINTS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND CONSTRAINT_NAME = 'CK_SYS_USER_USER_UI_THEME';
+    IF v_count > 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE sys_user DROP CONSTRAINT CK_SYS_USER_USER_UI_THEME';
+    END IF;
+END;
+/
+
+-- 3. 重建 CHECK 白名单(不带引号,达梦自动大写,与探测名一致)
+ALTER TABLE sys_user ADD CONSTRAINT ck_sys_user_user_ui_theme
+    CHECK (ui_theme IN ('blue', 'red', 'green'));

+ 34 - 0
backend-java/src/main/resources/db/migration/V12__ui_theme.sql

@@ -0,0 +1,34 @@
+-- 0027_ui_theme 的 Flyway 等价迁移(Java 版同步,契约1.20)。
+-- 用户界面主题偏好(账号级漫游):sys_user 加 ui_theme VARCHAR(12)
+--   NOT NULL DEFAULT 'blue',CHECK 白名单 blue/red/green。
+-- 达梦兼容:DM 将约束名渲染为大写,MySQL 为小写;探测按 UPPER() 比较,
+--   DROP 用库中实际名,ADD 不带引号(两端结果一致)。
+-- 幂等:与 Python 版 Alembic 0027 并行执行互不冲突(谁先跑,后者全跳过)。
+
+-- 1. 加列 ui_theme(存量用户统一回退蓝色)
+SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+          AND COLUMN_NAME = 'ui_theme'),
+    'SELECT 1',
+    'ALTER TABLE sys_user ADD COLUMN ui_theme VARCHAR(12) NOT NULL DEFAULT ''blue''');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 2. CHECK 白名单(convention 渲染名 ck_sys_user_user_ui_theme)
+SET @ck_name = (
+    SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_user'
+      AND CONSTRAINT_TYPE = 'CHECK'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_USER_USER_UI_THEME'
+    LIMIT 1
+);
+SET @ddl = IF(@ck_name IS NULL, 'SELECT 1',
+    CONCAT('ALTER TABLE sys_user DROP CONSTRAINT ', @ck_name));
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+ALTER TABLE sys_user ADD CONSTRAINT ck_sys_user_user_ui_theme
+    CHECK (ui_theme IN ('blue', 'red', 'green'));

+ 8 - 0
backend/dms/api/v1/users.py

@@ -20,6 +20,7 @@ from dms.services.user_service import (
     list_users,
     parse_string_id,
     reset_user_password,
+    update_ui_theme,
     update_user,
     user_summary,
 )
@@ -81,6 +82,13 @@ def current_user():
     )
 
 
+@api_v1.put("/users/me/ui-theme")
+@bearer_auth_required
+def update_ui_theme_route():
+    # 本人主题偏好(契约1.20):无需管理权限,任意登录用户可改自己。
+    return success_response(update_ui_theme(_json_body()))
+
+
 @api_v1.get("/users")
 @bearer_auth_required
 @admin_required

+ 10 - 0
backend/dms/models/user.py

@@ -49,6 +49,10 @@ class User(BusinessTableMixin, db.Model):
             "status IN ('ENABLED', 'DISABLED')",
             name="user_status",
         ),
+        CheckConstraint(
+            "ui_theme IN ('blue', 'red', 'green')",
+            name="user_ui_theme",
+        ),
         Index(
             "ix_sys_user_organization_active_status",
             "organization_id",
@@ -118,6 +122,12 @@ class User(BusinessTableMixin, db.Model):
         nullable=True,
     )
     external_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    # 界面主题偏好(账号级漫游,契约1.20):blue/red/green,存量与缺省为 blue
+    ui_theme: Mapped[str] = mapped_column(
+        String(12),
+        nullable=False,
+        server_default=text("'blue'"),
+    )
     active_marker: Mapped[int | None] = mapped_column(
         Integer(),
         Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),

+ 30 - 0
backend/dms/services/user_service.py

@@ -17,6 +17,7 @@ from dms.common.enums import (
     SecurityLevel,
 )
 from dms.common.errors import (
+    AuthenticationError,
     ConflictError,
     ForbiddenError,
     InvalidArgumentError,
@@ -205,6 +206,7 @@ def user_summary(
         ),
         "lastLoginAt": _format_utc(user.last_login_at),
         "lastLoginIp": user.last_login_ip,
+        "uiTheme": user.ui_theme,
         "allowedModules": (
             allowed_modules
             if allowed_modules is not None
@@ -724,3 +726,31 @@ def reset_user_password(user_id: int, payload: Any) -> dict[str, object]:
         "reset": True,
         "authVersion": user.auth_version,
     }
+
+
+UI_THEMES = ("blue", "red", "green")
+
+
+def update_ui_theme(payload: Any) -> dict[str, object]:
+    """本人界面主题偏好(契约1.20):白名单校验,仅操作本人行。
+
+    主题是账号级漫游的纯视觉偏好:不 bump rowVersion(避免干扰并发
+    编辑乐观锁)、不 bump authVersion(不下线会话)、不写审计。
+    """
+    body = _exact_payload(payload, {"uiTheme"})
+    theme = body["uiTheme"]
+    if not isinstance(theme, str) or theme not in UI_THEMES:
+        raise InvalidArgumentError("uiTheme必须是blue、red或green")
+    context = get_auth_context()
+    with transaction() as session:
+        user = session.scalar(
+            select(User).where(
+                User.id == context.user_id,
+                User.is_deleted.is_(False),
+            )
+        )
+        if user is None:
+            raise AuthenticationError()
+        user.ui_theme = theme
+        session.flush()
+        return user_summary(user)

+ 81 - 0
backend/migrations/versions/0027_ui_theme.py

@@ -0,0 +1,81 @@
+"""用户界面主题偏好:sys_user 加 ui_theme 列(契约1.20)。
+
+Revision ID: 0027_ui_theme
+Revises: 0026_borrow_permission
+Create Date: 2026-09-03
+
+变更要点:
+1. 主题为账号级漫游偏好:任意设备登录后跟随账号;blue 为缺省
+   (存量用户与未设置路径统一回退蓝色);
+2. CHECK 白名单 blue/red/green(convention 渲染名
+   ck_sys_user_user_ui_theme,与模型 CheckConstraint(name="user_ui_theme") 一致);
+3. 达梦兼容:探测统一走 SQLAlchemy inspector、约束名大小写不敏感比较,
+   删除以库中实际名执行(达梦渲染为大写);
+4. 升级幂等:列/约束先探测,与 Flyway V12 并行执行互不冲突;
+   降级删列(偏好可丢失,无审计意义)。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0027_ui_theme"
+down_revision: str | None = "0026_borrow_permission"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_TABLE = "sys_user"
+_COLUMN = "ui_theme"
+# naming convention 渲染后的最终约束名(ck_%(table)s_%(constraint_name)s)
+_CHECK_NAME = "ck_sys_user_user_ui_theme"
+
+
+def _columns() -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    return {column["name"] for column in inspector.get_columns(_TABLE)}
+
+
+def _check_exists() -> bool:
+    existing = {
+        item["name"].lower()
+        for item in sa.inspect(op.get_bind()).get_check_constraints(_TABLE)
+    }
+    return _CHECK_NAME in existing
+
+
+def _drop_check() -> None:
+    # 达梦约束名为大写,以库中实际名执行删除。
+    for item in sa.inspect(op.get_bind()).get_check_constraints(_TABLE):
+        if item["name"].lower() == _CHECK_NAME:
+            op.get_bind().execute(
+                sa.text(f"ALTER TABLE {_TABLE} DROP CONSTRAINT {item['name']}")
+            )
+            return
+
+
+def upgrade() -> None:
+    if _COLUMN not in _columns():
+        op.add_column(
+            _TABLE,
+            sa.Column(
+                _COLUMN,
+                sa.String(length=12),
+                nullable=False,
+                server_default=sa.text("'blue'"),
+            ),
+        )
+    if not _check_exists():
+        op.create_check_constraint(
+            "user_ui_theme",
+            _TABLE,
+            "ui_theme IN ('blue', 'red', 'green')",
+        )
+
+
+def downgrade() -> None:
+    _drop_check()
+    if _COLUMN in _columns():
+        op.drop_column(_TABLE, _COLUMN)

+ 12 - 0
backend/openapi/components/schemas.yaml

@@ -241,6 +241,7 @@ UserSummary:
     - rowVersion
     - lastLoginAt
     - lastLoginIp
+    - uiTheme
     - permCategoryEdit
     - permOrgEdit
     - permUserEdit
@@ -292,6 +293,10 @@ UserSummary:
     lastLoginIp:
       type: string
       nullable: true
+    uiTheme:
+      type: string
+      enum: [blue, red, green]
+      description: 界面主题偏好(契约1.20,账号级漫游);存量与缺省为blue。
     permCategoryEdit:
       type: boolean
       description: 分类编辑管理位;值取自生效角色(显式角色活跃且启用时优先,否则回退默认角色),内置admin恒为true。
@@ -1928,6 +1933,13 @@ CreateUserRequest:
     securityLevel: {$ref: '#/SecurityLevel'}
     status: {$ref: '#/EnabledStatus'}
 
+UpdateUiThemeRequest:
+  type: object
+  additionalProperties: false
+  required: [uiTheme]
+  properties:
+    uiTheme: {type: string, enum: [blue, red, green]}
+
 UpdateUserRequest:
   type: object
   additionalProperties: false

+ 3 - 1
backend/openapi/openapi.yaml

@@ -1,7 +1,7 @@
 openapi: 3.0.3
 info:
   title: 方案计划文档管理系统 API
-  version: 1.19.0
+  version: 1.20.0
   description: DMS第一阶段接口;Q3-B正文检索、原文件预览、1.6 LibreOffice离线文档转换与PDF预览、1.7 机构用户维护与详细说明、1.8 角色管理与权限矩阵、1.9 方案关联关系、1.10 部门IP登录限制、1.11 统一门户跳转登录与子案挂载、1.12 正文提取状态可见与重新解析、1.13 登录界面自定义背景图、1.17 管理类权限矩阵化(管理位挂角色)、1.19 借阅授权收敛(限时用户借阅+审批单凭证)。
 servers:
   - url: /
@@ -28,6 +28,8 @@ paths:
     $ref: './paths/auth.yaml#/PortalSetupPassword'
   /api/v1/users/me:
     $ref: './paths/users.yaml#/CurrentUser'
+  /api/v1/users/me/ui-theme:
+    $ref: './paths/users.yaml#/CurrentUserUiTheme'
   /api/v1/organizations/tree:
     $ref: './paths/organizations.yaml#/OrganizationTree'
   /api/v1/organizations:

+ 27 - 0
backend/openapi/paths/users.yaml

@@ -20,6 +20,33 @@ CurrentUser:
       '500':
         $ref: '../components/responses.yaml#/InternalError'
 
+CurrentUserUiTheme:
+  put:
+    operationId: updateCurrentUserUiTheme
+    summary: 更新本人界面主题偏好(契约1.20)
+    description: 账号级漫游偏好;仅本人可改,无需管理权限。纯视觉偏好,不校验rowVersion、不使Token失效、不写审计日志。
+    parameters:
+      - $ref: '../components/parameters.yaml#/XRequestId'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas.yaml#/UpdateUiThemeRequest'
+    responses:
+      '200':
+        description: 更新成功,返回最新用户摘要
+        content:
+          application/json:
+            schema:
+              $ref: '../components/schemas.yaml#/UserResponse'
+      '400':
+        $ref: '../components/responses.yaml#/InvalidArgument'
+      '401':
+        $ref: '../components/responses.yaml#/AuthenticationError'
+      '500':
+        $ref: '../components/responses.yaml#/InternalError'
+
 Users:
   get:
     operationId: listUsers

+ 6 - 4
backend/tests/dms/test_db_upgrade.py

@@ -18,9 +18,9 @@ def test_resolve_migrations_dir_contains_env_and_versions():
 
 
 def test_migration_chain_head_and_steps_from_0013():
-    """远端生产库 0013 → head 恰好经过 0014~0026。"""
+    """远端生产库 0013 → head 恰好经过 0014~0027。"""
     script = _script()
-    assert script.get_current_head() == "0026_borrow_permission"
+    assert script.get_current_head() == "0027_ui_theme"
     steps = [
         r.revision
         for r in reversed(
@@ -42,13 +42,14 @@ def test_migration_chain_head_and_steps_from_0013():
         "0024_manage_permission_matrix",
         "0025_move_audit_actions",
         "0026_borrow_permission",
+        "0027_ui_theme",
     ]
 
 
 def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
-    """0016 升级到 head:待执行清单含 0017~0026,当前版本不重复出现。"""
+    """0016 升级到 head:待执行清单含 0017~0027,当前版本不重复出现。"""
     monkeypatch.setenv("DMS_DATABASE_URL", "mysql+pymysql://u:secret@127.0.0.1:3306/dms")
-    versions = iter(["0016_portal_user_provision", "0026_borrow_permission"])
+    versions = iter(["0016_portal_user_provision", "0027_ui_theme"])
     monkeypatch.setattr(
         "dms.db_upgrade._read_current_version", lambda url: next(versions)
     )
@@ -65,6 +66,7 @@ def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
     assert "  - 0024_manage_permission_matrix" in out
     assert "  - 0025_move_audit_actions" in out
     assert "  - 0026_borrow_permission" in out
+    assert "  - 0027_ui_theme" in out
     assert "  - 0016" not in out
     assert "secret" not in out  # 输出中的地址必须已脱敏
 

+ 1 - 1
backend/tests/dms/test_migration.py

@@ -81,7 +81,7 @@ def _load_revision_module():
 def test_alembic_config_and_head_are_valid():
     config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
     script = ScriptDirectory.from_config(config)
-    assert script.get_current_head() == "0026_borrow_permission"
+    assert script.get_current_head() == "0027_ui_theme"
 
 
 def test_initial_migration_exports_upgrade_and_downgrade():

+ 2 - 1
backend/tests/dms/test_openapi_storage.py

@@ -89,6 +89,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/auth/portal/verify",
         "/api/v1/auth/portal/setup-password",
         "/api/v1/users/me",
+        "/api/v1/users/me/ui-theme",
         "/api/v1/organizations/tree",
         "/api/v1/organizations",
         "/api/v1/organizations/{id}",
@@ -143,7 +144,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/recycle-bin/documents",
         "/api/v1/recycle-bin/documents/{id}/restore",
     }
-    assert document["info"]["version"] == "1.19.0"
+    assert document["info"]["version"] == "1.20.0"
     assert "/api/v1/documents/{id}/restore" not in document["paths"]
     assert document["paths"]["/api/v1/health"]["get"]["security"] == []
     login_path = yaml.safe_load(

+ 1 - 1
backend/tests/dms/test_q1b_ui_dictionaries.py

@@ -280,7 +280,7 @@ def test_openapi_ui_dictionary_contract_and_no_write_operation():
         )
     )
 
-    assert document["info"]["version"] == "1.19.0"
+    assert document["info"]["version"] == "1.20.0"
     assert set(path_item) == {"get"}
     assert path_item["get"]["operationId"] == "getUiDictionaries"
     assert {"200", "401", "500"} <= set(path_item["get"]["responses"])

+ 1 - 1
backend/tests/dms/test_q2b_updated_range.py

@@ -344,7 +344,7 @@ def test_openapi_declares_q2b_parameters_without_new_paths():
         (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
     )
 
-    assert document["info"]["version"] == "1.19.0"
+    assert document["info"]["version"] == "1.20.0"
     for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
         references = {
             parameter.get("$ref")

+ 1 - 1
backend/tests/dms/test_q3b_business_content_preview.py

@@ -458,7 +458,7 @@ def test_q3_openapi_contract_has_no_new_preview_path():
     paths = yaml.safe_load(
         (root / "paths" / "documents.yaml").read_text("utf-8")
     )
-    assert document["info"]["version"] == "1.19.0"
+    assert document["info"]["version"] == "1.20.0"
     assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
     parameters = paths["Documents"]["get"]["parameters"]
     assert any(item.get("name") == "includeDescendants" for item in parameters)

+ 12 - 0
frontend/index.html

@@ -9,6 +9,18 @@
       <meta name="description" content="方案数据管理" />
       <meta name="robots" content="noindex, nofollow" />
       <style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
+      <script>
+        // 主题防闪烁(契约1.20):首帧前按本地缓存设置 html[data-theme],
+        // 登录后由后端账号偏好对齐覆盖。
+        (function () {
+          try {
+            var theme = localStorage.getItem("dms.ui-theme");
+            if (theme === "red" || theme === "green") {
+              document.documentElement.dataset.theme = theme;
+            }
+          } catch (error) { /* 隐私模式等场景保持缺省蓝色 */ }
+        })();
+      </script>
       
     </head>
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 171 - 159
frontend/src/app/App.tsx


+ 14 - 13
frontend/src/features/audit/AuditDashboard.tsx

@@ -43,10 +43,11 @@ import {
   type AuditUserStatisticsItem,
 } from "./auditTypes";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 const WHITE = "#fff";
-// 多彩配色(不再拘泥蓝色系):蓝为主色,辅以橙/绿/紫/红/青等,扇区按序取色
+// 多彩配色(不再拘泥蓝色系):蓝为主色,辅以橙/绿/紫/红/青等,扇区按序取色。
+// 类别色板是内容语义色(计划例外),不随界面主题换肤。
 const COLORS = ["#3191eb", "#f5a623", "#34a853", "#a55eea", "#e74c3c",
                 "#00bcd4", "#f8e71c", "#ff7043", "#7cb342", "#5c6bc0"];
 const EMPTY = "—";
@@ -113,7 +114,7 @@ function StatsPanel({
   return (
     <section
       className="bg-white p-3 min-h-[170px]"
-      style={{ border: "1px solid rgba(40,118,193,.18)" }}
+      style={{ border: "1px solid rgba(var(--dms-line-rgb),.18)" }}
     >
       <div className="flex items-center gap-2 mb-2">
         {icon}
@@ -219,11 +220,11 @@ export function AuditDashboard() {
     <>
       <aside
         className="w-[286px] flex-shrink-0 m-3 overflow-y-auto bg-white"
-        style={{ border: "1px solid rgba(40,118,193,.2)", color: NAVY }}
+        style={{ border: "1px solid rgba(var(--dms-line-rgb),.2)", color: NAVY }}
       >
         <div
           className="px-4 py-3 flex items-center gap-2 border-b"
-          style={{ borderColor: "rgba(40,118,193,.14)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.14)" }}
         >
           <Filter size={14} style={{ color: BLUE }} />
           <span className="text-base font-black">日志筛选</span>
@@ -358,11 +359,11 @@ export function AuditDashboard() {
       <main className="flex-1 flex gap-3 m-3 ml-0 min-w-0 min-h-0">
         <section
           className="flex-1 min-w-0 flex flex-col bg-white"
-          style={{ border: "1px solid rgba(40,118,193,.18)" }}
+          style={{ border: "1px solid rgba(var(--dms-line-rgb),.18)" }}
         >
           <div
             className="px-4 py-2.5 flex items-center gap-3 border-b"
-            style={{ color: NAVY, borderColor: "rgba(40,118,193,.14)" }}
+            style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
           >
           <span className="font-black">操作日志</span>
             <span className="text-base font-black" style={{ color: BLUE }}>
@@ -466,10 +467,10 @@ export function AuditDashboard() {
                     ) : (
                     <tr
                       key={log.id}
-                      className={`border-b transition-colors hover:bg-[rgba(49,145,235,0.18)] ${index % 2 ? "bg-[rgba(40,118,193,.04)]" : "bg-white"}`}
+                      className={`border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] ${index % 2 ? "bg-[rgba(var(--dms-line-rgb),.04)]" : "bg-white"}`}
                       style={{
                         color: NAVY,
-                        borderColor: "rgba(40,118,193,.1)",
+                        borderColor: "rgba(var(--dms-line-rgb),.1)",
                       }}
                     >
                       <td className="px-3 py-2 text-base font-bold whitespace-nowrap">
@@ -537,7 +538,7 @@ export function AuditDashboard() {
           )}
           <div
             className="p-2 border-t flex items-center justify-center gap-3 text-base font-bold"
-            style={{ color: NAVY, borderColor: "rgba(40,118,193,.14)" }}
+            style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
           >
             <button
               disabled={page <= 1}
@@ -572,7 +573,7 @@ export function AuditDashboard() {
                   className="flex-1 py-1 text-sm font-black"
                   style={{
                     color: granularity === value ? WHITE : NAVY,
-                    background: granularity === value ? NAVY : "#EBF4FD",
+                    background: granularity === value ? NAVY : "var(--dms-canvas)",
                   }}
                 >
                   {value}
@@ -588,7 +589,7 @@ export function AuditDashboard() {
               <>
                 <ResponsiveContainer width="100%" height={115}>
                   <LineChart data={trend.data}>
-                    <CartesianGrid strokeDasharray="3 3" stroke="rgba(40,118,193,.12)" />
+                    <CartesianGrid strokeDasharray="3 3" stroke="rgba(var(--dms-line-rgb),.12)" />
                     <XAxis dataKey="period" tick={{ fontSize: 12, fill: NAVY }} />
                     <YAxis tick={{ fontSize: 12, fill: NAVY }} />
                     <Tooltip />

+ 5 - 5
frontend/src/features/auth/__tests__/authApi.test.ts

@@ -57,7 +57,7 @@ describe("authApi", () => {
       user,
     };
     const post = vi.fn().mockResolvedValue(result(response));
-    const api = createAuthApi({ post, get: vi.fn() });
+    const api = createAuthApi({ post, get: vi.fn(), put: vi.fn() });
 
     await expect(api.login(request)).resolves.toEqual(response);
     expect(post).toHaveBeenCalledWith("auth/login", request, { auth: false });
@@ -65,7 +65,7 @@ describe("authApi", () => {
 
   it("logout 只调用 auth/logout 且请求体为空", async () => {
     const post = vi.fn().mockResolvedValue(result({}));
-    const api = createAuthApi({ post, get: vi.fn() });
+    const api = createAuthApi({ post, get: vi.fn(), put: vi.fn() });
 
     await expect(api.logout()).resolves.toBeUndefined();
     expect(post).toHaveBeenCalledWith("auth/logout");
@@ -73,7 +73,7 @@ describe("authApi", () => {
 
   it("getCurrentUser 只调用 users/me", async () => {
     const get = vi.fn().mockResolvedValue(result(user));
-    const api = createAuthApi({ post: vi.fn(), get });
+    const api = createAuthApi({ post: vi.fn(), get, put: vi.fn() });
 
     await expect(api.getCurrentUser()).resolves.toEqual(user);
     expect(get).toHaveBeenCalledWith("users/me");
@@ -81,7 +81,7 @@ describe("authApi", () => {
 
   it("拒绝 number ID,不静默兼容错误后端响应", async () => {
     const get = vi.fn().mockResolvedValue(result({ ...user, id: 1 }));
-    const api = createAuthApi({ post: vi.fn(), get });
+    const api = createAuthApi({ post: vi.fn(), get, put: vi.fn() });
 
     await expect(api.getCurrentUser()).rejects.toMatchObject({
       code: "INTERNAL_ERROR",
@@ -99,7 +99,7 @@ describe("authApi", () => {
         user,
       }),
     );
-    const api = createAuthApi({ post, get: vi.fn() });
+    const api = createAuthApi({ post, get: vi.fn(), put: vi.fn() });
 
     await expect(
       api.login({

+ 1 - 0
frontend/src/features/auth/__tests__/authSession.test.ts

@@ -75,6 +75,7 @@ function api(overrides: Partial<AuthApi> = {}): AuthApi {
     }),
     logout: vi.fn().mockResolvedValue(undefined),
     getCurrentUser: vi.fn().mockResolvedValue(user),
+    updateUiTheme: vi.fn().mockResolvedValue(undefined),
     ...overrides,
   };
 }

+ 10 - 0
frontend/src/features/auth/authApi.ts

@@ -19,6 +19,11 @@ interface AuthHttpClient {
     body?: unknown | FormData,
     options?: RequestOptions,
   ): Promise<JsonResult<T>>;
+  put<T>(
+    path: string,
+    body?: unknown,
+    options?: RequestOptions,
+  ): Promise<JsonResult<T>>;
 }
 
 export function createAuthApi(client: AuthHttpClient = httpClient) {
@@ -48,6 +53,11 @@ export function createAuthApi(client: AuthHttpClient = httpClient) {
       }
       return result.data;
     },
+
+    /** 主题偏好持久化(契约1.20):仅校验错误码,成功载荷不再回读。 */
+    async updateUiTheme(theme: string): Promise<void> {
+      await client.put<unknown>("users/me/ui-theme", { uiTheme: theme });
+    },
   };
 }
 

+ 13 - 1
frontend/src/features/auth/authSession.ts

@@ -3,6 +3,7 @@ import {
   type AllowedModule as AllowedModuleCode,
 } from "../../shared/api/enums";
 import { tokenStore } from "../../shared/auth/tokenStore";
+import { setTheme, type UiTheme } from "../../shared/theme/themeStore";
 import { authApi, type AuthApi } from "./authApi";
 import { portalAuthApi, type PortalAuthApi } from "./portalAuthApi";
 import type { LoginRequest, UserSummary } from "./authTypes";
@@ -55,12 +56,20 @@ export function isPageAllowed(
   return allowedModules.some((module) => MODULE_PAGES[module] === page);
 }
 
+/** 登录/会话恢复时以后端账号偏好对齐本地主题(账号级漫游权威源)。 */
+function adoptServerTheme(user: UserSummary): void {
+  const theme: UiTheme =
+    user.uiTheme === "red" || user.uiTheme === "green" ? user.uiTheme : "blue";
+  setTheme(theme);
+}
+
 export async function loginSession(
   request: LoginRequest,
   api: AuthApi = authApi,
 ): Promise<UserSummary> {
   const response = await api.login(request);
   tokenStore.saveAccessToken(response.accessToken, request.keepSignedIn);
+  adoptServerTheme(response.user);
   return response.user;
 }
 
@@ -72,6 +81,7 @@ export async function setupPortalPasswordSession(
 ): Promise<UserSummary> {
   const response = await api.setupPassword(portalTicket, newPassword, keepSignedIn);
   tokenStore.saveAccessToken(response.accessToken, keepSignedIn);
+  adoptServerTheme(response.user);
   return response.user;
 }
 
@@ -79,7 +89,9 @@ export async function restoreSession(
   api: AuthApi = authApi,
 ): Promise<UserSummary | null> {
   if (!tokenStore.getAccessToken()) return null;
-  return api.getCurrentUser();
+  const user = await api.getCurrentUser();
+  adoptServerTheme(user);
+  return user;
 }
 
 export interface LogoutResult {

+ 3 - 0
frontend/src/features/auth/authTypes.ts

@@ -39,6 +39,8 @@ export interface UserSummary {
   managedOrgId: Id | "SELF" | null;
   lastLoginAt: string | null;
   lastLoginIp: string | null;
+  /** 界面主题偏好(契约1.20):blue/red/green;可选以兼容未升级后端。 */
+  uiTheme?: string;
   allowedModules: AllowedModule[];
 }
 
@@ -102,6 +104,7 @@ export function isUserSummary(value: unknown): value is UserSummary {
       isId(value.managedOrgId)) &&
     (value.lastLoginAt === null || typeof value.lastLoginAt === "string") &&
     (value.lastLoginIp === null || typeof value.lastLoginIp === "string") &&
+    (value.uiTheme === undefined || typeof value.uiTheme === "string") &&
     Array.isArray(value.allowedModules) &&
     value.allowedModules.every((module) =>
       isValueOf(AllowedModuleValues, module),

+ 7 - 7
frontend/src/features/bindings/AttachmentBindingModal.tsx

@@ -18,10 +18,10 @@ import {
   toggleAttachmentSelection,
 } from "./bindingState";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 const WHITE = "#FFFFFF";
-const PALE = "#EBF4FD";
+const PALE = "var(--dms-canvas)";
 interface AttachmentBindingModalProps {
   mainPlan: DocumentSummary;
   /** 附件类型筛选预置值(管理页附件 Tab 页"挂载"入口按当前页签类型预过滤)。 */
@@ -250,7 +250,7 @@ export function AttachmentBindingModal({
   return (
     <div
       className="fixed inset-0 z-[80] flex items-center justify-center p-6"
-      style={{ background: "rgba(26,82,153,.55)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
     >
       <div
         className="w-[980px] max-w-full h-[82vh] flex flex-col shadow-2xl"
@@ -264,7 +264,7 @@ export function AttachmentBindingModal({
           <button onClick={onClose} disabled={submitting}><X size={18} /></button>
         </div>
 
-        <div className="p-4 grid grid-cols-4 gap-2 border-b" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
+        <div className="p-4 grid grid-cols-4 gap-2 border-b" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
           <label className="col-span-2 flex items-center gap-2 border px-3 h-9">
             <Search size={14} />
             <input
@@ -330,7 +330,7 @@ export function AttachmentBindingModal({
                   const alreadyMounted = mounted.ids.has(item.id);
                   const checked = selected.includes(item.id);
                   return (
-                    <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(49,145,235,0.18)]" style={{ borderColor: "rgba(49,145,235,.12)", color: NAVY }}>
+                    <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]" style={{ borderColor: "rgba(var(--dms-bright-rgb),.12)", color: NAVY }}>
                       <td className="px-4 py-3">
                         <input
                           type="checkbox"
@@ -364,7 +364,7 @@ export function AttachmentBindingModal({
           </div>
         )}
 
-        <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
+        <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
           {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{error.httpStatus === 403 ? "当前账号无挂载管理权限" : error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           {notice && <div className="mb-2 text-base font-black">{notice}</div>}
           <div className="flex items-center">

+ 7 - 7
frontend/src/features/bindings/SubPlanBindingModal.tsx

@@ -11,10 +11,10 @@ import type { DocumentSummary } from "../documents/documentTypes";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
 import { subPlanBindingApi } from "./subPlanBindingApi";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 const WHITE = "#FFFFFF";
-const PALE = "#EBF4FD";
+const PALE = "var(--dms-canvas)";
 
 interface SubPlanBindingModalProps {
   mainPlan: DocumentSummary;
@@ -150,7 +150,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
   const loadError = candidates.error ?? existing.error;
 
   return (
-    <div className="fixed inset-0 z-[80] flex items-center justify-center p-6" style={{ background: "rgba(26,82,153,.55)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center p-6" style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}>
       <div className="w-[880px] max-w-full h-[80vh] flex flex-col shadow-2xl" style={{ background: WHITE }}>
         <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
           <div>
@@ -160,7 +160,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
           <button onClick={onClose} disabled={submitting}><X size={18} /></button>
         </div>
 
-        <div className="p-4 flex items-center gap-2 border-b" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
+        <div className="p-4 flex items-center gap-2 border-b" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
           <label className="flex-1 flex items-center gap-2 border px-3 h-9">
             <Search size={14} />
             <input
@@ -196,7 +196,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
                   const alreadyListed = existing.ids.has(item.id);
                   const checked = selected.includes(item.id);
                   return (
-                    <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(49,145,235,0.18)]" style={{ borderColor: "rgba(49,145,235,.12)", color: NAVY }}>
+                    <tr key={item.id} className="border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]" style={{ borderColor: "rgba(var(--dms-bright-rgb),.12)", color: NAVY }}>
                       <td className="px-4 py-3">
                         <input
                           type="checkbox"
@@ -230,7 +230,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
           </div>
         )}
 
-        <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(49,145,235,.2)", color: NAVY }}>
+        <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", color: NAVY }}>
           {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           {notice && <div className="mb-2 text-base font-black">{notice}</div>}
           <div className="flex items-center">

+ 4 - 4
frontend/src/features/bindings/UnbindAttachmentModal.tsx

@@ -10,8 +10,8 @@ import { invalidateDetail } from "../documents/documentState";
 import type { DocumentSummary } from "../documents/documentTypes";
 import { bindingApi } from "./bindingApi";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 const WHITE = "#FFFFFF";
 
 interface UnbindAttachmentModalProps {
@@ -85,7 +85,7 @@ export function UnbindAttachmentModal({
         : error?.message;
 
   return (
-    <div className="fixed inset-0 z-[90] flex items-center justify-center" style={{ background: "rgba(26,82,153,.55)" }}>
+    <div className="fixed inset-0 z-[90] flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}>
       <div className="w-[560px] shadow-2xl" style={{ background: WHITE, color: NAVY }}>
         <div className="h-12 px-5 flex items-center justify-between text-white" style={{ background: NAVY }}>
           <span className="font-black flex items-center gap-2"><Unlink size={17} />解除挂载</span>
@@ -94,7 +94,7 @@ export function UnbindAttachmentModal({
         <div className="p-6">
           <p className="font-black mb-4">确认解除“{mainPlan.documentName}”与“{attachment.documentName}”的挂载关系?</p>
           <div className="text-base font-bold">附件及其文件不会被删除。</div>
-          {error && <div className="mt-4 p-3 text-base font-black" style={{ background: "#EBF4FD" }}><AlertCircle size={14} className="inline mr-1" />{errorMessage}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
+          {error && <div className="mt-4 p-3 text-base font-black" style={{ background: "var(--dms-canvas)" }}><AlertCircle size={14} className="inline mr-1" />{errorMessage}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           <div className="flex justify-end gap-2 mt-6">
             <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
             <button onClick={() => void handleUnbind()} disabled={submitting} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>

+ 20 - 20
frontend/src/features/directory/DirectoryPage.tsx

@@ -110,7 +110,7 @@ export function DirectoryPage({
     >
       <div
         className="flex-shrink-0 flex items-center gap-1 px-6 py-2 border-b"
-        style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
+        style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.2)" }}
       >
         <button
           type="button"
@@ -370,7 +370,7 @@ function OrganizationMaintenancePanel({
     <div className="w-full h-full flex flex-col overflow-hidden">
       <div
         className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
-        style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
+        style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
       >
         <div className="flex items-center gap-2 flex-1 max-w-md">
           <input
@@ -378,7 +378,7 @@ function OrganizationMaintenancePanel({
             onChange={(e) => setKeyword(e.target.value)}
             placeholder="按机构名称或编码筛选"
             className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
-            style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+            style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
           />
           {keyword && (
             <button
@@ -438,7 +438,7 @@ function OrganizationMaintenancePanel({
         {tree.length > 0 && (
           <div
             className="inline-block min-w-full rounded-sm border"
-            style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
+            style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.2)" }}
           >
             {tree.map((node) => (
               <OrganizationTreeRow
@@ -545,10 +545,10 @@ function OrganizationTreeRow({
   return (
     <>
       <div
-        className={`flex items-center gap-2 px-3 py-2 cursor-pointer border-b last:border-b-0 transition-colors hover:bg-[rgba(49,145,235,0.18)] ${isSelected ? "bg-[rgba(49,145,235,0.08)]" : "bg-white"}`}
+        className={`flex items-center gap-2 px-3 py-2 cursor-pointer border-b last:border-b-0 transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] ${isSelected ? "bg-[rgba(var(--dms-bright-rgb),0.08)]" : "bg-white"}`}
         style={{
           paddingLeft: 12 + depth * 18,
-          borderColor: "rgba(49,145,235,0.1)",
+          borderColor: "rgba(var(--dms-bright-rgb),0.1)",
         }}
         onClick={() => onSelect(node.id)}
       >
@@ -582,7 +582,7 @@ function OrganizationTreeRow({
             color: node.status === EnabledStatus.ENABLED ? N : DANGER,
             background:
               node.status === EnabledStatus.ENABLED
-                ? "rgba(49,145,235,0.1)"
+                ? "rgba(var(--dms-bright-rgb),0.1)"
                 : "rgba(192,57,43,0.08)",
           }}
         >
@@ -1001,26 +1001,26 @@ function UserMaintenancePanel({
         style={{
           width: 320,
           background: W,
-          borderColor: "rgba(49,145,235,0.15)",
+          borderColor: "rgba(var(--dms-bright-rgb),0.15)",
         }}
       >
         <div
           className="flex-shrink-0 px-4 py-2 border-b text-base font-black tracking-wide"
           style={{
             color: N,
-            borderColor: "rgba(49,145,235,0.15)",
+            borderColor: "rgba(var(--dms-bright-rgb),0.15)",
             background: PBG,
           }}
         >
           机构筛选
         </div>
-        <div className="flex-shrink-0 px-3 py-2 border-b" style={{ borderColor: "rgba(49,145,235,0.1)" }}>
+        <div className="flex-shrink-0 px-3 py-2 border-b" style={{ borderColor: "rgba(var(--dms-bright-rgb),0.1)" }}>
           <input
             value={orgKeyword}
             onChange={(e) => setOrgKeyword(e.target.value)}
             placeholder="按机构名称或编码筛选"
             className="w-full px-2.5 py-1.5 text-base border rounded-sm outline-none"
-            style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+            style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
           />
         </div>
         <div className="flex-1 min-h-0 overflow-auto py-1">
@@ -1056,7 +1056,7 @@ function UserMaintenancePanel({
         </div>
         <div
           className="flex-shrink-0 px-3 py-2 border-t flex items-center justify-between"
-          style={{ borderColor: "rgba(49,145,235,0.15)" }}
+          style={{ borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
         >
           <label
             className="flex items-center gap-2 text-base font-bold cursor-pointer"
@@ -1080,7 +1080,7 @@ function UserMaintenancePanel({
       <div className="flex-1 min-w-0 flex flex-col overflow-hidden">
         <div
           className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
-          style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
+          style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
         >
           <div className="flex items-center gap-2 flex-1 max-w-md">
             <input
@@ -1091,7 +1091,7 @@ function UserMaintenancePanel({
               }}
               placeholder="按用户名或姓名筛选"
               className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
-              style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+              style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
             />
             {userKeyword && (
               <button
@@ -1112,7 +1112,7 @@ function UserMaintenancePanel({
                 setPage(1);
               }}
               className="px-2 py-1.5 text-base border rounded-sm outline-none"
-              style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+              style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
             >
               <option value="">全部状态</option>
               {statusOptions().map((code) => (
@@ -1188,7 +1188,7 @@ function UserMaintenancePanel({
                       className="text-left px-3 py-2 text-base font-black border"
                       style={{
                         color: N,
-                        borderColor: "rgba(49,145,235,0.2)",
+                        borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                       }}
                     >
                       {h}
@@ -1200,8 +1200,8 @@ function UserMaintenancePanel({
                 {data.items.map((user) => (
                   <tr
                     key={user.id}
-                    className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
-                    style={{ borderColor: "rgba(49,145,235,0.12)" }}
+                    className="border-t transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]"
+                    style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
                   >
                     <td className="px-3 py-2 font-bold" style={{ color: N }}>
                       {user.username}
@@ -1268,7 +1268,7 @@ function UserMaintenancePanel({
           style={{
             color: N,
             background: W,
-            borderColor: "rgba(49,145,235,0.15)",
+            borderColor: "rgba(var(--dms-bright-rgb),0.15)",
           }}
         >
           <span>
@@ -1375,7 +1375,7 @@ function UserOrgTreeRow({
   return (
     <>
       <div
-        className={`flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)] ${isSelected ? "bg-[rgba(49,145,235,0.08)]" : ""}`}
+        className={`flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded-sm transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] ${isSelected ? "bg-[rgba(var(--dms-bright-rgb),0.08)]" : ""}`}
         style={{ paddingLeft: 12 + depth * 14 }}
         onClick={() => onSelect(node.id)}
       >

+ 9 - 9
frontend/src/features/directory/IpEntryMaintenancePanel.tsx

@@ -184,7 +184,7 @@ export function IpEntryMaintenancePanel({
       {confirmDialogNode}
       <div
         className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
-        style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
+        style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
       >
         <div className="flex items-center gap-2 flex-1 max-w-md">
           <input
@@ -195,7 +195,7 @@ export function IpEntryMaintenancePanel({
             }}
             placeholder="按IP或备注筛选"
             className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
-            style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+            style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
           />
           {keyword && (
             <button
@@ -226,8 +226,8 @@ export function IpEntryMaintenancePanel({
         className="flex-shrink-0 flex items-center gap-1.5 px-6 py-2 text-base font-bold border-b"
         style={{
           color: N,
-          background: "rgba(49,145,235,0.05)",
-          borderColor: "rgba(49,145,235,0.15)",
+          background: "rgba(var(--dms-bright-rgb),0.05)",
+          borderColor: "rgba(var(--dms-bright-rgb),0.15)",
         }}
       >
         <Info size={12} />
@@ -275,7 +275,7 @@ export function IpEntryMaintenancePanel({
                     className="text-left px-3 py-2 text-base font-black border"
                     style={{
                       color: N,
-                      borderColor: "rgba(49,145,235,0.2)",
+                      borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                     }}
                   >
                     {h}
@@ -287,8 +287,8 @@ export function IpEntryMaintenancePanel({
               {data.items.map((entry) => (
                 <tr
                   key={entry.id}
-                  className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
-                  style={{ borderColor: "rgba(49,145,235,0.12)" }}
+                  className="border-t transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]"
+                  style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
                 >
                   <td className="px-3 py-2 font-black" style={{ color: N }}>
                     {entry.ipValue}
@@ -323,7 +323,7 @@ export function IpEntryMaintenancePanel({
         style={{
           color: N,
           background: W,
-          borderColor: "rgba(49,145,235,0.15)",
+          borderColor: "rgba(var(--dms-bright-rgb),0.15)",
         }}
       >
         <span>
@@ -418,7 +418,7 @@ function IpEntryEditorModal({
         />
         <div
           className="text-base font-bold px-2 py-1.5 rounded-sm"
-          style={{ color: N, background: "rgba(49,145,235,0.05)" }}
+          style={{ color: N, background: "rgba(var(--dms-bright-rgb),0.05)" }}
         >
           支持精确IP(IPv4/IPv6)或无主机位的CIDR网段;输入会被自动规范化,
           网段不允许携带主机位(如 10.0.0.1/24 将被拒绝)。

+ 4 - 4
frontend/src/features/directory/OrgIpAssignmentModal.tsx

@@ -116,7 +116,7 @@ export function OrgIpAssignmentModal({
       <div className="space-y-3">
         <div
           className="text-base font-bold px-2 py-1.5 rounded-sm"
-          style={{ color: N, background: "rgba(49,145,235,0.05)" }}
+          style={{ color: N, background: "rgba(var(--dms-bright-rgb),0.05)" }}
         >
           勾选该部门用户登录时允许的来源IP;不勾选任何条目表示不限制。
           修改为整体保存,ADMIN账号不受限制。
@@ -144,7 +144,7 @@ export function OrgIpAssignmentModal({
               onChange={(e) => setFilter(e.target.value)}
               placeholder="本地过滤IP或备注"
               className="w-full px-2.5 py-1.5 text-base border rounded-sm outline-none"
-              style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+              style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
             />
             {poolTotal > POOL_PAGE_SIZE && (
               <div
@@ -156,7 +156,7 @@ export function OrgIpAssignmentModal({
             )}
             <div
               className="max-h-60 overflow-auto rounded-sm border"
-              style={{ borderColor: "rgba(49,145,235,0.2)", background: W }}
+              style={{ borderColor: "rgba(var(--dms-bright-rgb),0.2)", background: W }}
             >
               {visibleEntries.length === 0 && (
                 <div
@@ -172,7 +172,7 @@ export function OrgIpAssignmentModal({
                 <label
                   key={entry.id}
                   className="flex items-center gap-2 px-3 py-1.5 cursor-pointer border-b last:border-b-0"
-                  style={{ borderColor: "rgba(49,145,235,0.08)" }}
+                  style={{ borderColor: "rgba(var(--dms-bright-rgb),0.08)" }}
                 >
                   <input
                     type="checkbox"

+ 34 - 34
frontend/src/features/directory/RoleMaintenancePanel.tsx

@@ -442,14 +442,14 @@ export function RoleMaintenancePanel({
         style={{
           width: 360,
           background: W,
-          borderColor: "rgba(49,145,235,0.15)",
+          borderColor: "rgba(var(--dms-bright-rgb),0.15)",
         }}
       >
         <div
           className="flex-shrink-0 px-4 py-2 border-b text-base font-black tracking-wide"
           style={{
             color: N,
-            borderColor: "rgba(49,145,235,0.15)",
+            borderColor: "rgba(var(--dms-bright-rgb),0.15)",
             background: PBG,
           }}
         >
@@ -457,7 +457,7 @@ export function RoleMaintenancePanel({
         </div>
         <div
           className="flex-shrink-0 px-3 py-2 border-b flex items-center gap-2"
-          style={{ borderColor: "rgba(49,145,235,0.1)" }}
+          style={{ borderColor: "rgba(var(--dms-bright-rgb),0.1)" }}
         >
           <input
             value={keyword}
@@ -467,7 +467,7 @@ export function RoleMaintenancePanel({
             }}
             placeholder="按角色名称筛选"
             className="flex-1 px-2.5 py-1.5 text-base border rounded-sm outline-none"
-            style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+            style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
           />
           {keyword && (
             <button
@@ -488,7 +488,7 @@ export function RoleMaintenancePanel({
               setPage(1);
             }}
             className="px-2 py-1.5 text-base border rounded-sm outline-none"
-            style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
+            style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
           >
             <option value="">全部状态</option>
             {statusOptions().map((code) => (
@@ -531,8 +531,8 @@ export function RoleMaintenancePanel({
                 className="flex items-center gap-2 px-3 py-2 cursor-pointer border-b transition-colors"
                 style={{
                   paddingLeft: 12,
-                  background: isSelected ? "rgba(49,145,235,0.08)" : W,
-                  borderColor: "rgba(49,145,235,0.08)",
+                  background: isSelected ? "rgba(var(--dms-bright-rgb),0.08)" : W,
+                  borderColor: "rgba(var(--dms-bright-rgb),0.08)",
                 }}
                 onClick={() => selectRole(role.id)}
               >
@@ -561,7 +561,7 @@ export function RoleMaintenancePanel({
                             : DANGER,
                         background:
                           role.status === EnabledStatus.ENABLED
-                            ? "rgba(49,145,235,0.1)"
+                            ? "rgba(var(--dms-bright-rgb),0.1)"
                             : "rgba(192,57,43,0.08)",
                       }}
                     >
@@ -632,7 +632,7 @@ export function RoleMaintenancePanel({
         </div>
         <div
           className="flex-shrink-0 px-3 py-2 border-t flex items-center gap-1.5"
-          style={{ borderColor: "rgba(49,145,235,0.15)" }}
+          style={{ borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
         >
           {smallButton(true, {
             label: "新建角色",
@@ -664,7 +664,7 @@ export function RoleMaintenancePanel({
           >
             <div
               className="rounded-sm border px-4 py-3 flex items-center gap-3"
-              style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
+              style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.2)" }}
             >
               <div className="flex-1">
                 <div className="text-base font-black" style={{ color: N }}>
@@ -711,12 +711,12 @@ export function RoleMaintenancePanel({
             {manageForm && (
               <section
                 className="rounded-sm border overflow-hidden"
-                style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
+                style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.2)" }}
               >
                 <div
                   className="flex items-center justify-between px-4 py-2 border-b"
                   style={{
-                    borderColor: "rgba(49,145,235,0.15)",
+                    borderColor: "rgba(var(--dms-bright-rgb),0.15)",
                     background: PBG,
                   }}
                 >
@@ -741,7 +741,7 @@ export function RoleMaintenancePanel({
                           className="px-2 py-2 text-center font-black border"
                           style={{
                             color: N,
-                            borderColor: "rgba(49,145,235,0.2)",
+                            borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                           }}
                         >
                           管理项
@@ -750,7 +750,7 @@ export function RoleMaintenancePanel({
                           className="px-2 py-2 border text-center font-black"
                           style={{
                             color: N,
-                            borderColor: "rgba(49,145,235,0.2)",
+                            borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                           }}
                         >
                           允许
@@ -767,14 +767,14 @@ export function RoleMaintenancePanel({
                       ).map((row) => (
                         <tr
                           key={row.key}
-                          className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
-                          style={{ borderColor: "rgba(49,145,235,0.12)" }}
+                          className="border-t transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]"
+                          style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
                         >
                           <td
                             className="px-2 py-2 text-center font-bold border"
                             style={{
                               color: N,
-                              borderColor: "rgba(49,145,235,0.2)",
+                              borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                             }}
                           >
                             {row.label}
@@ -782,7 +782,7 @@ export function RoleMaintenancePanel({
                           <td
                             className="px-2 py-2 border text-center"
                             style={{
-                              borderColor: "rgba(49,145,235,0.2)",
+                              borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                             }}
                           >
                             <input
@@ -801,13 +801,13 @@ export function RoleMaintenancePanel({
                       ))}
                       <tr
                         className="border-t"
-                        style={{ borderColor: "rgba(49,145,235,0.12)" }}
+                        style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
                       >
                         <td
                           className="px-2 py-2 text-center font-bold border"
                           style={{
                             color: N,
-                            borderColor: "rgba(49,145,235,0.2)",
+                            borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                           }}
                         >
                           范围锚点
@@ -815,7 +815,7 @@ export function RoleMaintenancePanel({
                         <td
                           className="px-2 py-2 border"
                           style={{
-                            borderColor: "rgba(49,145,235,0.2)",
+                            borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                           }}
                         >
                           <select
@@ -829,7 +829,7 @@ export function RoleMaintenancePanel({
                             }}
                             className="w-full px-2 py-1 text-base border rounded-sm outline-none"
                             style={{
-                              borderColor: "rgba(49,145,235,0.4)",
+                              borderColor: "rgba(var(--dms-bright-rgb),0.4)",
                               color: N,
                               background: W,
                             }}
@@ -857,12 +857,12 @@ export function RoleMaintenancePanel({
                 {/* 权限矩阵 */}
                 <section
                   className="rounded-sm border overflow-hidden"
-                  style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
+                  style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.2)" }}
                 >
                   <div
                     className="flex items-center justify-between px-4 py-2 border-b"
                     style={{
-                      borderColor: "rgba(49,145,235,0.15)",
+                      borderColor: "rgba(var(--dms-bright-rgb),0.15)",
                       background: PBG,
                     }}
                   >
@@ -887,7 +887,7 @@ export function RoleMaintenancePanel({
                             className="px-2 py-2 text-center font-black border"
                             style={{
                               color: N,
-                              borderColor: "rgba(49,145,235,0.2)",
+                              borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                             }}
                           >
                             密级
@@ -896,7 +896,7 @@ export function RoleMaintenancePanel({
                             className="px-2 py-2 text-center font-black border"
                             style={{
                               color: N,
-                              borderColor: "rgba(49,145,235,0.2)",
+                              borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                             }}
                           >
                             部门范围
@@ -905,7 +905,7 @@ export function RoleMaintenancePanel({
                             className="px-2 py-2 border"
                             style={{
                               color: N,
-                              borderColor: "rgba(49,145,235,0.2)",
+                              borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                             }}
                           >
                             全选
@@ -916,7 +916,7 @@ export function RoleMaintenancePanel({
                               className="px-2 py-2 border text-center"
                               style={{
                                 color: N,
-                                borderColor: "rgba(49,145,235,0.2)",
+                                borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                               }}
                             >
                               <label
@@ -942,8 +942,8 @@ export function RoleMaintenancePanel({
                           return (
                             <tr
                               key={`${row.securityLevel}-${row.deptScope}`}
-                              className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
-                              style={{ borderColor: "rgba(49,145,235,0.12)" }}
+                              className="border-t transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]"
+                              style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
                             >
                               {firstOfLevel && (
                                 <td
@@ -951,7 +951,7 @@ export function RoleMaintenancePanel({
                                   className="px-2 py-2 text-center font-black border align-middle"
                                   style={{
                                     color: N,
-                                    borderColor: "rgba(49,145,235,0.2)",
+                                    borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                                   }}
                                 >
                                   {dictionaries.label(
@@ -964,7 +964,7 @@ export function RoleMaintenancePanel({
                                 className="px-2 py-2 text-center font-bold border"
                                 style={{
                                   color: N,
-                                  borderColor: "rgba(49,145,235,0.2)",
+                                  borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                                 }}
                               >
                                 {dictionaries.label("deptScopes", row.deptScope)}
@@ -972,7 +972,7 @@ export function RoleMaintenancePanel({
                               <td
                                 className="px-2 py-2 border text-center"
                                 style={{
-                                  borderColor: "rgba(49,145,235,0.2)",
+                                  borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                                 }}
                               >
                                 <input
@@ -988,7 +988,7 @@ export function RoleMaintenancePanel({
                                   key={key}
                                   className="px-2 py-2 border text-center"
                                   style={{
-                                    borderColor: "rgba(49,145,235,0.2)",
+                                    borderColor: "rgba(var(--dms-bright-rgb),0.2)",
                                   }}
                                 >
                                   <input

+ 7 - 7
frontend/src/features/directory/directoryUi.tsx

@@ -12,12 +12,12 @@ import { HttpError } from "../../shared/api/errors";
 import { tipProps } from "../../shared/ui/hoverTip";
 
 /** 目录模块共享的配色常量与基础 UI 组件。 */
-export const N = "#1A5299";
-export const B = "#3191eb";
+export const N = "var(--dms-deep)";
+export const B = "var(--dms-bright)";
 export const W = "#FFFFFF";
-export const PBG = "#EBF4FD";
+export const PBG = "var(--dms-canvas)";
 export const DANGER = "#C0392B";
-export const FIELD_BORDER = "rgba(49,145,235,0.4)";
+export const FIELD_BORDER = "rgba(var(--dms-bright-rgb),0.4)";
 export const MAX_TREE_DEPTH = 50;
 
 export interface ApiResultState {
@@ -205,7 +205,7 @@ export function ModalShell({
   return (
     <div
       className="absolute inset-0 z-50 flex items-center justify-center"
-      style={{ background: "rgba(26,82,153,0.35)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),0.35)" }}
     >
       <div
         className="w-full max-w-lg rounded-sm shadow-2xl"
@@ -214,7 +214,7 @@ export function ModalShell({
         <div
           className="flex items-center justify-between px-5 py-3 border-b"
           style={{
-            borderColor: "rgba(49,145,235,0.2)",
+            borderColor: "rgba(var(--dms-bright-rgb),0.2)",
             background: B,
             color: W,
           }}
@@ -253,7 +253,7 @@ export function ModalFooter({
   return (
     <div
       className="flex items-center justify-end gap-2 mt-5 pt-3 border-t"
-      style={{ borderColor: "rgba(49,145,235,0.15)" }}
+      style={{ borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
     >
       <button
         type="button"

+ 7 - 7
frontend/src/features/documents/DocumentSearchToolbar.tsx

@@ -46,17 +46,17 @@ export function DocumentSearchToolbar({
     <div
       aria-label={ariaLabel}
       className="flex flex-wrap items-end gap-2 p-2"
-      style={{ color: "#1A5299", background: "#FFFFFF" }}
+      style={{ color: "var(--dms-deep)", background: "#FFFFFF" }}
     >
       <label className="flex items-center gap-2">
-        <Search size={14} style={{ color: "#3191eb" }} />
+        <Search size={14} style={{ color: "var(--dms-bright)" }} />
         <input
           aria-label={`${ariaLabel}关键词`}
           value={keyword}
           onChange={(event) => onKeywordChange(event.target.value)}
           placeholder={keywordPlaceholder}
           className={`${inputClass} ${compact ? "w-72" : "w-80"}`}
-          style={{ borderColor: "rgba(40,118,193,.25)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.25)" }}
         />
       </label>
       {selectFilter && (
@@ -65,7 +65,7 @@ export function DocumentSearchToolbar({
           value={selectFilter.value}
           onChange={(event) => selectFilter.onChange(event.target.value)}
           className={inputClass}
-          style={{ borderColor: "rgba(40,118,193,.25)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.25)" }}
         >
           <option value="">{selectFilter.emptyLabel}</option>
           {dictionaries.options(selectFilter.dictionary).map((option) => (
@@ -81,7 +81,7 @@ export function DocumentSearchToolbar({
           value={updatedFrom}
           onChange={(event) => onUpdatedFromChange(event.target.value)}
           className={inputClass}
-          style={{ borderColor: "rgba(40,118,193,.25)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.25)" }}
         />
       </label>
       <label className="text-base font-bold">
@@ -92,14 +92,14 @@ export function DocumentSearchToolbar({
           value={updatedTo}
           onChange={(event) => onUpdatedToChange(event.target.value)}
           className={inputClass}
-          style={{ borderColor: "rgba(40,118,193,.25)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.25)" }}
         />
       </label>
       <button
         type="button"
         onClick={onClear}
         className="h-8 px-3 text-base font-black border"
-        style={{ color: "#1A5299", borderColor: "#3191eb" }}
+        style={{ color: "var(--dms-deep)", borderColor: "var(--dms-bright)" }}
       >
         <X size={12} className="inline mr-1" />清空条件
       </button>

+ 8 - 8
frontend/src/features/documents/GlobalSearchBar.tsx

@@ -26,7 +26,7 @@ export function GlobalSearchBar({
   const dictionaries = useDictionaries();
 
   const inputClass = "h-8 px-2 text-base font-bold border outline-none";
-  const borderStyle = { borderColor: "rgba(40,118,193,.25)" };
+  const borderStyle = { borderColor: "rgba(var(--dms-line-rgb),.25)" };
 
   const securityOptions = dictionaries.options("securityLevels");
 
@@ -49,11 +49,11 @@ export function GlobalSearchBar({
     <div
       aria-label={ariaLabel}
       className="flex flex-col gap-2 p-2"
-      style={{ color: "#1A5299", background: "#FFFFFF" }}
+      style={{ color: "var(--dms-deep)", background: "#FFFFFF" }}
     >
       <div className="flex flex-wrap items-center gap-2">
         <label className="flex items-center gap-2">
-          <Search size={14} style={{ color: "#3191eb" }} />
+          <Search size={14} style={{ color: "var(--dms-bright)" }} />
           <input
             aria-label={`${ariaLabel}关键词`}
             value={state.simpleKeyword}
@@ -72,9 +72,9 @@ export function GlobalSearchBar({
           aria-expanded={state.advancedOpen}
           className="h-8 px-3 text-base font-black border flex items-center gap-1"
           style={{
-            color: "#1A5299",
-            borderColor: state.advancedOpen ? "#3191eb" : "rgba(40,118,193,.4)",
-            background: state.advancedOpen ? "rgba(49,145,235,0.08)" : "#FFFFFF",
+            color: "var(--dms-deep)",
+            borderColor: state.advancedOpen ? "var(--dms-bright)" : "rgba(var(--dms-line-rgb),.4)",
+            background: state.advancedOpen ? "rgba(var(--dms-bright-rgb),0.08)" : "#FFFFFF",
           }}
         >
           <SlidersHorizontal size={12} />
@@ -91,7 +91,7 @@ export function GlobalSearchBar({
           type="button"
           onClick={resetGlobalSearch}
           className="h-8 px-3 text-base font-black border"
-          style={{ color: "#1A5299", borderColor: "#3191eb" }}
+          style={{ color: "var(--dms-deep)", borderColor: "var(--dms-bright)" }}
         >
           <X size={12} className="inline mr-1" />
           清空条件
@@ -101,7 +101,7 @@ export function GlobalSearchBar({
         <div
           aria-label={`${ariaLabel}高级检索`}
           className="flex flex-wrap items-center gap-4 p-2 border"
-          style={{ borderColor: "rgba(40,118,193,.2)", background: "rgba(49,145,235,0.04)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.2)", background: "rgba(var(--dms-bright-rgb),0.04)" }}
         >
           <label className="flex items-center gap-2 text-base font-bold">
             <span>正文</span>

+ 29 - 29
frontend/src/features/files/FilePreviewModal.tsx

@@ -127,7 +127,7 @@ function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) {
       role="dialog"
       aria-label="图片浏览"
       className="fixed inset-0 z-[100] flex items-center justify-center overflow-hidden select-none"
-      style={{ background: "rgba(26,82,153,.88)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),.88)" }}
     >
       <img
         src={src}
@@ -614,7 +614,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
 
   const previewChildren = (
     <>
-      {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-base font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
+      {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
       {pdfBlob && (
         <PdfViewer
           blob={pdfBlob}
@@ -666,20 +666,20 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
       </div>
       {/* 错误提示必须绝对定位铺满:上方 docx 滚动容器带 h-full,即使为空也占满
           内容区,流式提示会被 overflow-hidden 裁掉而不可见(403 无权/415 不支持等同理) */}
-      {error && <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-8 text-center" style={{ color: "#1A5299" }}>
+      {error && <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-8 text-center" style={{ color: "var(--dms-deep)" }}>
         <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
         <div className="text-base font-black leading-6">{errorText(error, current.fileExtension)}</div>
         {error.requestId && <div className="text-sm font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
-        {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-base font-black border" style={{ borderColor: "#3191eb" }}>重试预览</button>}
+        {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-base font-black border" style={{ borderColor: "var(--dms-bright)" }}>重试预览</button>}
       </div>}
     </>
   );
 
   const textPanel = (
-    <aside ref={textPanelRef} className="w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0" style={{ borderColor: "rgba(40,118,193,.2)" }}>
+    <aside ref={textPanelRef} className="w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.2)" }}>
       {/* 补充说明:具备编辑权限的可编辑保存,其他用户只读展示 */}
-      <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(40,118,193,.15)" }}>
-        <div className="text-sm font-black mb-2" style={{ color: "#1A5299" }}>补充说明</div>
+      <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.15)" }}>
+        <div className="text-sm font-black mb-2" style={{ color: "var(--dms-deep)" }}>补充说明</div>
         {canEditDescription ? (
           <>
             <textarea
@@ -687,7 +687,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
               onChange={(event) => setDescriptionDraft(event.target.value)}
               placeholder="输入文档补充说明…"
               className="w-full h-28 px-2.5 py-2 text-base font-bold leading-6 rounded-sm outline-none resize-none border"
-              style={{ color: "#1A5299", borderColor: "rgba(40,118,193,.3)", background: "rgba(40,118,193,.03)" }}
+              style={{ color: "var(--dms-deep)", borderColor: "rgba(var(--dms-line-rgb),.3)", background: "rgba(var(--dms-line-rgb),.03)" }}
             />
             <div className="flex items-center gap-2 mt-2">
               <button
@@ -695,7 +695,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                 onClick={() => void saveDescription()}
                 disabled={savingDescription}
                 className="px-3 py-1 text-base font-black text-white rounded-sm disabled:opacity-60"
-                style={{ background: "#3191eb" }}
+                style={{ background: "var(--dms-bright)" }}
               >
                 {savingDescription ? "保存中…" : "保存说明"}
               </button>
@@ -704,26 +704,26 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                 onClick={() => { setDescriptionDraft(description ?? ""); setDescriptionMessage(""); }}
                 disabled={savingDescription}
                 className="px-3 py-1 text-base font-black border rounded-sm disabled:opacity-60"
-                style={{ color: "#1A5299", borderColor: "rgba(40,118,193,.35)" }}
+                style={{ color: "var(--dms-deep)", borderColor: "rgba(var(--dms-line-rgb),.35)" }}
               >
                 重置
               </button>
               {descriptionMessage && (
-                <span className="text-base font-bold opacity-70 break-all" style={{ color: "#1A5299" }}>{descriptionMessage}</span>
+                <span className="text-base font-bold opacity-70 break-all" style={{ color: "var(--dms-deep)" }}>{descriptionMessage}</span>
               )}
             </div>
           </>
         ) : (
-          <div className="max-h-56 overflow-y-auto text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>
+          <div className="max-h-56 overflow-y-auto text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "var(--dms-deep)" }}>
             {description?.trim() ? description : "暂无说明"}
           </div>
         )}
       </div>
       {/* 关联方案:modal 模式展示,点击后在本弹窗内跳转查看目标方案 */}
       {(relations.length > 0 || relationsError) && (
-        <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(40,118,193,.15)" }} data-testid="preview-relations">
-          <div className="text-base font-black mb-2 flex items-center gap-1" style={{ color: "#1A5299" }}><Link2 size={13} />关联方案</div>
-          {relationsError && <div className="text-base font-bold opacity-70 mb-1" style={{ color: "#1A5299" }}>{relationsError}</div>}
+        <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.15)" }} data-testid="preview-relations">
+          <div className="text-base font-black mb-2 flex items-center gap-1" style={{ color: "var(--dms-deep)" }}><Link2 size={13} />关联方案</div>
+          {relationsError && <div className="text-base font-bold opacity-70 mb-1" style={{ color: "var(--dms-deep)" }}>{relationsError}</div>}
           <div className="space-y-1">
             {relations.map((relation) => (
               <button
@@ -731,8 +731,8 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
                 type="button"
                 disabled={relationJumping}
                 onClick={() => void jumpToRelation(relation)}
-                className="block w-full text-left px-2.5 py-1.5 text-base font-bold rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)] disabled:opacity-60"
-                style={{ color: "#1A5299" }}
+                className="block w-full text-left px-2.5 py-1.5 text-base font-bold rounded-sm transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] disabled:opacity-60"
+                style={{ color: "var(--dms-deep)" }}
                 {...tipProps("点击查看该方案")}
               >
                 {relation.relationType} · {relation.relatedDocumentName}
@@ -741,7 +741,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           </div>
         </div>
       )}
-      <div className="px-4 py-3 text-base font-black flex-shrink-0" style={{ color: "#1A5299", borderBottom: "1px solid rgba(40,118,193,.15)" }}>
+      <div className="px-4 py-3 text-base font-black flex-shrink-0" style={{ color: "var(--dms-deep)", borderBottom: "1px solid rgba(var(--dms-line-rgb),.15)" }}>
         提取文本
       </div>
       <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-2.5">
@@ -755,12 +755,12 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
             <div
               key={index}
               className={`rounded-sm px-2.5 py-2 ${hasCurrent ? "ring-1 ring-orange-400" : ""}`}
-              style={{ background: "rgba(40,118,193,.04)" }}
+              style={{ background: "rgba(var(--dms-line-rgb),.04)" }}
             >
-              <div className="text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "#1A5299" }}>
+              <div className="text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "var(--dms-deep)" }}>
                 {renderParagraphWithHighlights(info, search.currentIndex)}
               </div>
-              <div className="mt-1.5 text-base font-black opacity-50" style={{ color: "#1A5299" }}>
+              <div className="mt-1.5 text-base font-black opacity-50" style={{ color: "var(--dms-deep)" }}>
                 第 {index + 1} 段 · 共 {info.text.length} 字{info.ranges.length > 0 && ` · 命中 ${info.ranges.length} 处`}
               </div>
             </div>
@@ -781,7 +781,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
         : sideWidth == null
           ? "relative flex-1 min-w-[360px] flex flex-col border-l bg-white min-h-0"
           : "relative flex-shrink-0 flex flex-col border-l bg-white min-h-0"}
-      style={{ borderColor: "rgba(40,118,193,.2)", ...(isModal ? null : { ...asideRestStyle, ...(sideWidth == null ? null : { width: sideWidth }) }) }}
+      style={{ borderColor: "rgba(var(--dms-line-rgb),.2)", ...(isModal ? null : { ...asideRestStyle, ...(sideWidth == null ? null : { width: sideWidth }) }) }}
     >
       {!isModal && (
         <div
@@ -789,7 +789,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           aria-orientation="vertical"
           aria-label="拖动调整预览区宽度"
           {...tipProps("拖动调整预览区宽度")}
-          className="absolute left-0 top-0 bottom-0 z-10 w-2.5 -ml-[5px] cursor-col-resize transition-colors hover:bg-[rgba(49,145,235,0.35)]"
+          className="absolute left-0 top-0 bottom-0 z-10 w-2.5 -ml-[5px] cursor-col-resize transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.35)]"
           onPointerDown={(event) => {
             if (event.button !== 0) return;
             event.preventDefault();
@@ -831,7 +831,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
       {!(isModal && windowState === "fullscreen") && (
         <div
           className={`px-4 py-3 flex gap-3 text-white ${isModal ? "items-center" : "items-start"}`}
-          style={{ background: "#1A5299" }}
+          style={{ background: "var(--dms-deep)" }}
           onDoubleClick={isModal ? (event) => {
             if ((event.target as HTMLElement).closest("input, button")) return;
             setWindowState((current) => (current === "maximized" ? "normal" : "maximized"));
@@ -855,7 +855,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           {isModal && (
             <div className="flex items-center gap-1.5 flex-shrink-0">
               <div className="relative">
-                <Search size={12} className="absolute left-1.5 top-1/2 -translate-y-1/2 opacity-60" style={{ color: "#1A5299" }} />
+                <Search size={12} className="absolute left-1.5 top-1/2 -translate-y-1/2 opacity-60" style={{ color: "var(--dms-deep)" }} />
                 <input
                   value={search.keyword}
                   onChange={(e) => search.setKeyword(e.target.value)}
@@ -903,12 +903,12 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
           type="button"
           onClick={() => setWindowState("normal")}
           className="absolute top-3 right-4 z-30 flex items-center gap-1.5 px-3 py-1.5 text-base font-black text-white rounded-sm shadow-lg"
-          style={{ background: "rgba(26,82,153,.85)" }}
+          style={{ background: "rgba(var(--dms-deep-rgb),.85)" }}
         >
           <Shrink size={14} />退出全屏
         </button>
       )}
-      <div className={`flex-1 min-h-0 overflow-hidden relative ${isModal ? "flex flex-row" : ""}`} style={{ background: "#EBF4FD" }}>
+      <div className={`flex-1 min-h-0 overflow-hidden relative ${isModal ? "flex flex-row" : ""}`} style={{ background: "var(--dms-canvas)" }}>
         {isModal ? (
           /* 外层 flex 子项容器把内容区限制在文本面板左侧(textPanel 360px),
               absolute inset-0 相对本层定位 */
@@ -941,7 +941,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
     // modalZoom.ref 挂遮罩根:弹窗全域(标题栏/提取文本面板/遮罩)Ctrl+滚轮统一缩放
     // 文档并 preventDefault,与 side 模式(监听挂 aside 根)语义一致。
     return createPortal(
-      <div ref={modalZoom.ref} className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(26,82,153,.58)" }}><div>{panel}</div>{lightboxSrc && <ImageLightbox key={lightboxSrc} src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}</div>,
+      <div ref={modalZoom.ref} className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.58)" }}><div>{panel}</div>{lightboxSrc && <ImageLightbox key={lightboxSrc} src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}</div>,
       document.body,
     );
   }
@@ -952,7 +952,7 @@ export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style
         ref={guideEl}
         aria-hidden
         className="fixed left-0 top-0 bottom-0 z-[90] w-0.5 pointer-events-none"
-        style={{ display: "none", background: "#3191eb", willChange: "transform" }}
+        style={{ display: "none", background: "var(--dms-bright)", willChange: "transform" }}
       />
       {panel}
       {/* 图片浏览遮罩在内容区之外渲染:docx 缩放后内层带 transform,

+ 1 - 1
frontend/src/features/files/PdfViewer.tsx

@@ -634,7 +634,7 @@ export function PdfViewer({ blob, documentName, onImageActivate, searchKeyword,
       {status === "loading" && (
         <div
           className="text-center text-base font-black py-6"
-          style={{ color: "#1A5299" }}
+          style={{ color: "var(--dms-deep)" }}
         >
           正在渲染 PDF…
         </div>

+ 16 - 16
frontend/src/features/mutations/MutationModals.tsx

@@ -56,8 +56,8 @@ import {
   type DocumentUploadMetadata,
 } from "./mutationTypes";
 
-const BLUE = "#3191eb";
-const NAVY = "#1A5299";
+const BLUE = "var(--dms-bright)";
+const NAVY = "var(--dms-deep)";
 const ACCEPT = ACCEPTED_DOCUMENT_EXTENSIONS.join(",");
 
 /** 密码锁可保护的全量操作(默认全选),标签与 allowedActions 字典对齐 */
@@ -129,7 +129,7 @@ function ErrorBlock({ error }: { error: HttpError | null }) {
     : null;
   const plans = Array.isArray(details?.mainPlans) ? details.mainPlans : [];
   return (
-    <div className="p-3 text-base font-bold leading-5" style={{ background: "rgba(26,82,153,.08)", color: NAVY }}>
+    <div className="p-3 text-base font-bold leading-5" style={{ background: "rgba(var(--dms-deep-rgb),.08)", color: NAVY }}>
       <div>{mutationMessage(error)}</div>
       {typeof details?.mountedPlanCount === "number" && <div>当前挂载主案数:{details.mountedPlanCount}</div>}
       {typeof details?.childCategoryCount === "number" && <div>下级分类数:{details.childCategoryCount}</div>}
@@ -149,7 +149,7 @@ function Modal({ title, children, onClose, wide = false }: {
   wide?: boolean;
 }) {
   return (
-    <div className="fixed inset-0 z-[60] flex items-center justify-center" style={{ background: "rgba(26,82,153,.55)" }}>
+    <div className="fixed inset-0 z-[60] flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}>
       <div className={`${wide ? "w-[1080px]" : "w-[660px]"} max-h-[90vh] overflow-auto bg-white shadow-2xl`}>
         <div className="h-11 px-5 flex items-center justify-between text-white sticky top-0 z-10" style={{ background: NAVY }}>
           <span className="font-black">{title}</span>
@@ -166,7 +166,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
 }
 
 const inputClass = "w-full h-9 px-3 text-base font-bold border outline-none";
-const selectStyle = { color: NAVY, borderColor: "rgba(26,82,153,.25)" };
+const selectStyle = { color: NAVY, borderColor: "rgba(var(--dms-deep-rgb),.25)" };
 
 function useReferenceData() {
   const categories = useCategoryTree();
@@ -336,12 +336,12 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
           <Field label="附件类型 *"><select value={attachmentType} disabled={submitting} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
         ) : (
           <>
-            {documentType === "MAIN" ? <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => { setCategoryId(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle}><option value="">请选择最末级分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-base font-bold" style={{ color: NAVY, background: "#EBF4FD" }}>方案分类继承自主案:{references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryPath ?? references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryName ?? "请选择所属主案"}</div>}
+            {documentType === "MAIN" ? <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => { setCategoryId(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle}><option value="">请选择最末级分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-base font-bold" style={{ color: NAVY, background: "var(--dms-canvas)" }}>方案分类继承自主案:{references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryPath ?? references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryName ?? "请选择所属主案"}</div>}
             <div className="grid grid-cols-1 gap-3">
               <Field label="密级 *"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
             </div>
             {documentType === "MAIN" && !overwrite && (
-              <div className="border" style={{ borderColor: "rgba(26,82,153,.18)" }}>
+              <div className="border" style={{ borderColor: "rgba(var(--dms-deep-rgb),.18)" }}>
                 <button type="button" disabled={submitting} onClick={() => setAdvancedOpen((open) => !open)} className="w-full px-3 h-9 flex items-center justify-between text-base font-black" style={{ color: NAVY }}>
                   <span className="flex items-center gap-1"><Lock size={13} />高级设置 · 方案密码锁(可选)</span>
                   <span className="opacity-70">{advancedOpen ? "收起" : "展开"}</span>
@@ -372,7 +372,7 @@ export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAI
         {/* 概述/标签输入框已按要求隐藏:创建时提交空值,后续可在编辑弹窗维护 */}
         {formError && <div className="text-base font-bold" style={{ color: NAVY }}>{formError}</div>}
         <ErrorBlock error={error} />
-        {overwrite && <div role="alertdialog" className="p-4 text-base font-bold border" style={{ color: NAVY, borderColor: BLUE, background: "#EBF4FD" }}><div>已存在同名{documentType === "MAIN" ? "主案" : "子案"}“{overwrite.existingDocumentName}”,是否覆盖?</div><div className="mt-1 text-base opacity-70">覆盖将保留原记录、权限及关联关系,仅替换文件和本次填写的元数据。</div><div className="flex justify-end gap-2 mt-3"><button type="button" onClick={() => setOverwrite(null)} className="px-3 py-1.5 border" style={selectStyle}>取消覆盖</button><button type="button" disabled={submitting} onClick={() => void submit(true)} className="px-3 py-1.5 text-white" style={{ background: BLUE }}>确认覆盖</button></div></div>}
+        {overwrite && <div role="alertdialog" className="p-4 text-base font-bold border" style={{ color: NAVY, borderColor: BLUE, background: "var(--dms-canvas)" }}><div>已存在同名{documentType === "MAIN" ? "主案" : "子案"}“{overwrite.existingDocumentName}”,是否覆盖?</div><div className="mt-1 text-base opacity-70">覆盖将保留原记录、权限及关联关系,仅替换文件和本次填写的元数据。</div><div className="flex justify-end gap-2 mt-3"><button type="button" onClick={() => setOverwrite(null)} className="px-3 py-1.5 border" style={selectStyle}>取消覆盖</button><button type="button" disabled={submitting} onClick={() => void submit(true)} className="px-3 py-1.5 text-white" style={{ background: BLUE }}>确认覆盖</button></div></div>}
         <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={selectStyle}>取消</button><button onClick={() => void submit()} disabled={submitting || dictionaries.status !== "success"} className="px-4 py-2 text-base font-black text-white disabled:opacity-60" style={{ background: BLUE }}>{submitting ? <><RefreshCw size={13} className="inline animate-spin mr-1" />正在上传/处理中</> : <><Upload size={13} className="inline mr-1" />确认上传</>}</button></div>
       </div>
     </Modal>
@@ -477,7 +477,7 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
     <Modal title={kind === "attachment" ? "批量导入配套资料" : "批量导入方案文档"} onClose={onClose} wide>
       <div className="p-5 space-y-3">
         <div onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); addFiles(Array.from(event.dataTransfer.files)); }}
-          className="p-5 text-center border-2 border-dashed text-base font-black" style={{ color: NAVY, borderColor: "rgba(49,145,235,.45)", background: "#EBF4FD" }}>
+          className="p-5 text-center border-2 border-dashed text-base font-black" style={{ color: NAVY, borderColor: "rgba(var(--dms-bright-rgb),.45)", background: "var(--dms-canvas)" }}>
           拖入多个文件,或 <label className="underline cursor-pointer"><input type="file" multiple accept={ACCEPT} className="hidden" onChange={(event) => addFiles(Array.from(event.target.files ?? []))} />点击选择</label>
         </div>
         {drafts.map((draft, index) => {
@@ -485,13 +485,13 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
           const documentMetadata = kind === "document" ? metadata as DocumentCreateMetadata : null;
           const attachmentMetadata = kind === "attachment" ? metadata as AttachmentCreateMetadata : null;
           return (
-            <div key={draft.key} className="p-3 border grid grid-cols-12 gap-2 items-end" style={{ borderColor: "rgba(26,82,153,.18)" }}>
+            <div key={draft.key} className="p-3 border grid grid-cols-12 gap-2 items-end" style={{ borderColor: "rgba(var(--dms-deep-rgb),.18)" }}>
               <div className="col-span-2 text-base font-black truncate" {...tipProps(draft.file.name)}>{index + 1}. {draft.file.name}</div>
               <div className="col-span-2"><input value={metadata.documentName} onChange={(event) => updateMetadata(draft.key, { documentName: event.target.value })} className={inputClass} style={selectStyle} /></div>
               {kind === "document" ? (
                 <>
                   <div className="col-span-1"><select value={documentMetadata!.documentType} onChange={(event) => changeDocumentType(draft.key, event.target.value as "MAIN" | "SUB_PLAN")} className={inputClass} style={selectStyle}>{dictionaries.options("documentTypes").filter((option) => option.code !== "ATTACHMENT").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
-                  <div className="col-span-2">{documentMetadata!.documentType === "MAIN" ? <select value={documentMetadata!.categoryId} onChange={(event) => updateMetadata(draft.key, { categoryId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}</option>)}</select> : <div className="h-9 px-2 flex items-center text-base font-bold" style={{ background: "#EBF4FD", color: NAVY }}>分类继承自主案</div>}</div>
+                  <div className="col-span-2">{documentMetadata!.documentType === "MAIN" ? <select value={documentMetadata!.categoryId} onChange={(event) => updateMetadata(draft.key, { categoryId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}</option>)}</select> : <div className="h-9 px-2 flex items-center text-base font-bold" style={{ background: "var(--dms-canvas)", color: NAVY }}>分类继承自主案</div>}</div>
                   <div className="col-span-2"><select disabled={documentMetadata!.documentType === "MAIN"} value={documentMetadata!.documentType === "SUB_PLAN" ? documentMetadata!.parentDocumentId : ""} onChange={(event) => updateMetadata(draft.key, { parentDocumentId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}><option value="">父主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></div>
                 </>
               ) : (
@@ -855,12 +855,12 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
         {detail && <>
           <Field label="名称 *"><input value={documentName} onChange={(event) => setDocumentName(event.target.value)} className={inputClass} style={selectStyle} /></Field>
           {attachment ? <Field label="附件类型"><select value={attachmentType} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field> : <>
-            {item.documentType === DocumentType.MAIN ? <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-base font-bold" style={{ background: "#EBF4FD", color: NAVY }}>方案分类继承自主案:{detail.categoryPath ?? detail.categoryName ?? "未分类"}</div>}
+            {item.documentType === DocumentType.MAIN ? <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-base font-bold" style={{ background: "var(--dms-canvas)", color: NAVY }}>方案分类继承自主案:{detail.categoryPath ?? detail.categoryName ?? "未分类"}</div>}
             <div className="grid grid-cols-1 gap-3">
               <Field label="密级"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
             </div>
             {mainPlan && canManagePassword && (
-              <div className="border p-3 space-y-3" style={{ borderColor: "rgba(26,82,153,.18)", background: "#F5FAFF" }}>
+              <div className="border p-3 space-y-3" style={{ borderColor: "rgba(var(--dms-deep-rgb),.18)", background: "#F5FAFF" }}>
                 <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}>
                   <Lock size={13} />方案密码锁:
                   {detail.hasPassword
@@ -894,7 +894,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
               </div>
             )}
             {canManageRelations ? (
-              <div className="border p-3 space-y-2" style={{ borderColor: "rgba(26,82,153,.18)", background: "#F5FAFF" }} data-testid="document-relations-editor">
+              <div className="border p-3 space-y-2" style={{ borderColor: "rgba(var(--dms-deep-rgb),.18)", background: "#F5FAFF" }} data-testid="document-relations-editor">
                 <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}>
                   <Link2 size={13} />关联方案(仅可关联主案,保存后整体替换)
                 </div>
@@ -951,7 +951,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
               </div>
             ) : (
               relationRows.length > 0 && (
-                <div className="border p-3 space-y-1" style={{ borderColor: "rgba(26,82,153,.18)" }} data-testid="document-relations-readonly">
+                <div className="border p-3 space-y-1" style={{ borderColor: "rgba(var(--dms-deep-rgb),.18)" }} data-testid="document-relations-readonly">
                   <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}><Link2 size={13} />关联方案</div>
                   {relationRows.map((row) => (
                     <div key={row.relatedDocumentId} className="text-base font-bold" style={{ color: NAVY }}>· {row.relationType} · {relationNames[row.relatedDocumentId] ?? references.mainPlans.find((plan) => plan.id === row.relatedDocumentId)?.documentName ?? row.relatedDocumentId}</div>
@@ -961,7 +961,7 @@ export function EditMetadataModal({ item, canManagePassword = false, canManageRe
             )}
           </>}
           {detail.contentExtractStatus !== undefined && (
-            <div className="border p-3 space-y-2" style={{ borderColor: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "rgba(200,30,30,.35)" : "rgba(26,82,153,.18)", background: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "#FFF5F5" : "#F5FAFF" }} data-testid="content-extract-panel">
+            <div className="border p-3 space-y-2" style={{ borderColor: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "rgba(200,30,30,.35)" : "rgba(var(--dms-deep-rgb),.18)", background: detail.contentExtractStatus === ContentExtractStatus.FAILED ? "#FFF5F5" : "#F5FAFF" }} data-testid="content-extract-panel">
               <div className="text-base font-black flex items-center gap-1" style={{ color: NAVY }}>
                 <FileSearch size={13} />正文解析:{EXTRACT_STATUS_LABELS[detail.contentExtractStatus]}
                 {detail.contentExtractedAt ? <span className="font-bold opacity-70">({detail.contentExtractedAt.replace("T", " ").replace("Z", " UTC")})</span> : null}

+ 7 - 7
frontend/src/features/permissions/PermissionModal.tsx

@@ -39,10 +39,10 @@ import {
 } from "./permissionState";
 import type { PermissionDetail, PermissionEntry } from "./permissionTypes";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 const WHITE = "#FFFFFF";
-const PALE = "#EBF4FD";
+const PALE = "var(--dms-canvas)";
 
 interface PermissionModalProps {
   document: DocumentSummary;
@@ -372,7 +372,7 @@ export function PermissionModal({
   const userCount = draft?.entries.length ?? 0;
 
   return (
-    <div className="fixed inset-0 z-[80] flex items-center justify-center p-5" style={{ background: "rgba(26,82,153,.55)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center p-5" style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}>
       {confirmDialogNode}
       <div className="w-[1080px] max-w-full h-[86vh] flex flex-col shadow-2xl" style={{ background: WHITE, color: NAVY }}>
         <div className="h-12 px-5 flex items-center justify-between text-white" style={{ background: NAVY }}>
@@ -400,7 +400,7 @@ export function PermissionModal({
           </div>
         ) : draft && detail ? (
           <>
-            <div className="px-5 py-3 border-b flex items-center gap-5 text-base font-bold" style={{ borderColor: "rgba(49,145,235,.2)", background: PALE }}>
+            <div className="px-5 py-3 border-b flex items-center gap-5 text-base font-bold" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)", background: PALE }}>
               <span>借阅人 {userCount} 项</span>
               <span className="ml-auto">{detail.inherited ? "动态继承主案" : "主案借阅授权"}</span>
             </div>
@@ -473,7 +473,7 @@ export function PermissionModal({
                   </thead>
                   <tbody>
                     {draft.entries.map((entry, index) => (
-                      <tr key={entry.subjectId} className="border-b" style={{ borderColor: "rgba(49,145,235,.12)" }}>
+                      <tr key={entry.subjectId} className="border-b" style={{ borderColor: "rgba(var(--dms-bright-rgb),.12)" }}>
                         <td className="px-3 py-2">
                           <div className="text-base font-black">{entry.subjectName}</div>
                           {isPersistedEntry(entry) && isExpired(entry) && (
@@ -573,7 +573,7 @@ export function PermissionModal({
               )}
             </div>
 
-            <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(49,145,235,.2)" }}>
+            <div className="px-5 py-3 border-t" style={{ borderColor: "rgba(var(--dms-bright-rgb),.2)" }}>
               {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{errorDescription(error)}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
               {notice && <div className="mb-2 text-base font-black">{notice}</div>}
               <div className="flex justify-end gap-2">

+ 8 - 8
frontend/src/features/recycle-bin/RecycleBinModal.tsx

@@ -25,8 +25,8 @@ import type {
 } from "./recycleBinTypes";
 import { RestoreDocumentModal } from "./RestoreDocumentModal";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 export function RecycleBinModal({ onClose }: { onClose: () => void }) {
   const dictionaries = useDictionaries();
   const [keyword, setKeyword] = useState("");
@@ -86,7 +86,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
   return createPortal(
     <div
       className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
-      style={{ background: "rgba(26,82,153,.55)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
     >
       <div
         className="w-[1500px] max-w-full h-[88vh] max-h-full flex flex-col bg-white shadow-2xl"
@@ -108,7 +108,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
 
         <div
           className="p-3 grid grid-cols-12 gap-2 border-b"
-          style={{ color: NAVY, borderColor: "rgba(40,118,193,.15)" }}
+          style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.15)" }}
         >
           <label className="col-span-2 flex items-center h-9 border px-2">
             <Search size={13} className="mr-2" style={{ color: BLUE }} />
@@ -219,7 +219,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
         {lastRestoredName && (
           <div
             className="px-4 py-2 text-base font-bold"
-            style={{ color: NAVY, background: "#EBF4FD" }}
+            style={{ color: NAVY, background: "var(--dms-canvas)" }}
           >
             已恢复:{lastRestoredName}。后台统计、分类、文档与附件数据已请求刷新。
           </div>
@@ -285,10 +285,10 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
                 {list.data.items.map((item, index) => (
                   <tr
                     key={item.id}
-                    className={`border-b transition-colors hover:bg-[rgba(49,145,235,0.18)] ${index % 2 ? "bg-[rgba(40,118,193,.04)]" : "bg-white"}`}
+                    className={`border-b transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] ${index % 2 ? "bg-[rgba(var(--dms-line-rgb),.04)]" : "bg-white"}`}
                     style={{
                       color: NAVY,
-                      borderColor: "rgba(40,118,193,.1)",
+                      borderColor: "rgba(var(--dms-line-rgb),.1)",
                     }}
                   >
                     <td className="px-3 py-2 text-base font-black">
@@ -343,7 +343,7 @@ export function RecycleBinModal({ onClose }: { onClose: () => void }) {
 
         <div
           className="h-12 px-4 flex items-center justify-center gap-3 border-t text-base font-bold"
-          style={{ color: NAVY, borderColor: "rgba(40,118,193,.15)" }}
+          style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.15)" }}
         >
           <span>共 {list.data?.total ?? 0} 条</span>
           <select

+ 6 - 6
frontend/src/features/recycle-bin/RestoreDocumentModal.tsx

@@ -18,8 +18,8 @@ import type {
   RestoreDocumentResult,
 } from "./recycleBinTypes";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 
 export function RestoreDocumentModal({
   item,
@@ -99,7 +99,7 @@ export function RestoreDocumentModal({
   return (
     <div
       className="fixed inset-0 z-[80] flex items-center justify-center"
-      style={{ background: "rgba(26,82,153,.58)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),.58)" }}
     >
       <div
         className="w-[650px] max-h-[88vh] overflow-auto bg-white shadow-2xl"
@@ -134,14 +134,14 @@ export function RestoreDocumentModal({
           </div>
           <div
             className="p-4 text-base font-bold leading-7"
-            style={{ background: "#EBF4FD" }}
+            style={{ background: "var(--dms-canvas)" }}
           >
             {RESTORE_DESCRIPTIONS[item.documentType]}
           </div>
           {error && (
             <div
               className="p-3 text-base font-bold leading-6"
-              style={{ background: "rgba(26,82,153,.08)" }}
+              style={{ background: "rgba(var(--dms-deep-rgb),.08)" }}
             >
               <div>
                 {RESTORE_ERROR_MESSAGES[error.code] ?? error.message}
@@ -158,7 +158,7 @@ export function RestoreDocumentModal({
             <div
               className="p-4 text-base font-bold leading-7"
               style={{
-                background: skipped ? "rgba(26,82,153,.1)" : "#EBF4FD",
+                background: skipped ? "rgba(var(--dms-deep-rgb),.1)" : "var(--dms-canvas)",
               }}
             >
               <div className="flex items-center gap-2 font-black">

+ 3 - 3
frontend/src/features/statistics/DocumentStatisticsCards.tsx

@@ -1,8 +1,8 @@
 import { RefreshCw } from "lucide-react";
 import { useDocumentStatistics } from "./statisticsState";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 
 export function DocumentStatisticsCards() {
   const state = useDocumentStatistics();
@@ -55,7 +55,7 @@ export function DocumentStatisticsCards() {
   return (
     <div
       className="mb-2 grid grid-cols-5 gap-px"
-      style={{ background: "rgba(40,118,193,.18)" }}
+      style={{ background: "rgba(var(--dms-line-rgb),.18)" }}
       aria-label="文档统计"
     >
       {cards.map(([label, value]) => (

+ 85 - 0
frontend/src/shared/theme/ThemeSwitcher.tsx

@@ -0,0 +1,85 @@
+import { useEffect, useRef, useState } from "react";
+import { Check, Palette } from "lucide-react";
+import { setTheme, UI_THEMES, useUiTheme, type UiTheme } from "./themeStore";
+
+/** 主题预览色样:固定色值(选择器本身不随主题变)。 */
+const THEME_SWATCHES: Record<UiTheme, string> = {
+  blue: "#3191eb",
+  red: "#E05252",
+  green: "#3FA34D",
+};
+
+const THEME_LABELS: Record<UiTheme, string> = {
+  blue: "蓝色",
+  red: "红色",
+  green: "绿色",
+};
+
+/**
+ * 顶栏主题切换入口(契约1.20)。
+ * 本地立即换肤(setTheme 写 DOM + 缓存),持久化由 onPersist 异步
+ * 同步到账号(authApi.updateUiTheme),失败不打断本地体验。
+ */
+export function ThemeSwitcher({
+  onPersist,
+}: {
+  onPersist: (theme: UiTheme) => Promise<void>;
+}) {
+  const current = useUiTheme();
+  const [open, setOpen] = useState(false);
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const close = (event: MouseEvent) => {
+      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
+    };
+    document.addEventListener("mousedown", close);
+    return () => document.removeEventListener("mousedown", close);
+  }, [open]);
+
+  return (
+    <div ref={rootRef} className="relative">
+      <button
+        type="button"
+        onClick={() => setOpen((value) => !value)}
+        className="flex items-center gap-1 whitespace-nowrap text-base font-black px-2.5 py-1.5 rounded-sm hover:bg-white/20 active:bg-white/30 transition-colors"
+        style={{ color: "rgba(255,255,255,0.75)" }}
+        title="切换界面主题"
+      >
+        <Palette size={13} />
+        主题
+      </button>
+      {open && (
+        <div
+          className="absolute right-0 top-full mt-2 py-1.5 rounded-sm shadow-lg z-50 min-w-[120px]"
+          style={{ background: "#fff", border: "1px solid rgba(var(--dms-line-rgb),0.3)" }}
+        >
+          {UI_THEMES.map((theme) => {
+            const active = theme === current;
+            return (
+              <button
+                key={theme}
+                type="button"
+                onClick={() => {
+                  setTheme(theme);
+                  void onPersist(theme);
+                  setOpen(false);
+                }}
+                className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-base transition-colors hover:bg-[rgba(var(--dms-primary-rgb),0.08)]"
+                style={{ color: "var(--dms-deep)" }}
+              >
+                <span
+                  className="inline-block w-3.5 h-3.5 rounded-full flex-shrink-0"
+                  style={{ background: THEME_SWATCHES[theme] }}
+                />
+                <span className="flex-1">{THEME_LABELS[theme]}</span>
+                {active && <Check size={14} style={{ color: "var(--dms-bright)" }} />}
+              </button>
+            );
+          })}
+        </div>
+      )}
+    </div>
+  );
+}

+ 65 - 0
frontend/src/shared/theme/themeStore.ts

@@ -0,0 +1,65 @@
+import { useSyncExternalStore } from "react";
+
+/**
+ * 界面主题(契约1.20):blue 缺省,red/green 内置。
+ *
+ * 账号级漫游:后端 sys_user.ui_theme 为权威来源,登录后以其覆盖本地;
+ * localStorage 仅作"上次会话缓存",供 index.html 内联脚本在首帧前
+ * 设置 html[data-theme],避免换肤闪烁。切换入口调 setTheme 本地立即
+ * 生效,并由调用方(authSession 的 PUT)异步同步后端。
+ */
+export type UiTheme = "blue" | "red" | "green";
+
+export const UI_THEMES: readonly UiTheme[] = ["blue", "red", "green"];
+
+const STORAGE_KEY = "dms.ui-theme";
+
+function parseTheme(value: string | null): UiTheme {
+  return value === "red" || value === "green" ? value : "blue";
+}
+
+function readStored(): UiTheme {
+  try {
+    return parseTheme(localStorage.getItem(STORAGE_KEY));
+  } catch {
+    return "blue";
+  }
+}
+
+let current: UiTheme = readStored();
+const listeners = new Set<() => void>();
+
+function applyTheme(theme: UiTheme): void {
+  // node 测试环境无 DOM:跳过挂载,仅维护内存态
+  if (typeof document === "undefined") return;
+  document.documentElement.dataset.theme = theme;
+}
+
+/** 切换/对齐主题:立即生效、写本地缓存、通知订阅者。 */
+export function setTheme(theme: UiTheme): void {
+  const changed = theme !== current;
+  current = theme;
+  applyTheme(theme);
+  try {
+    localStorage.setItem(STORAGE_KEY, theme);
+  } catch {
+    // 隐私模式等写入失败:仅当次会话生效
+  }
+  if (changed) listeners.forEach((listener) => listener());
+}
+
+export function currentTheme(): UiTheme {
+  return current;
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+/** React 订阅:主题切换后重渲染(切换入口高亮当前项)。 */
+export function useUiTheme(): UiTheme {
+  return useSyncExternalStore(subscribe, currentTheme);
+}

+ 9 - 9
frontend/src/shared/ui/ConfirmDialog.tsx

@@ -48,8 +48,8 @@ export function useConfirm() {
   return { confirm, dialog };
 }
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 const DANGER = "#c0392b";
 const WHITE = "#ffffff";
 
@@ -74,14 +74,14 @@ function ConfirmDialog({
   return (
     <div
       className="fixed inset-0 z-[95] flex items-center justify-center"
-      style={{ background: "rgba(26,82,153,0.35)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),0.35)" }}
       role="alertdialog"
       aria-modal="true"
       aria-label={options.title ?? "请确认"}
     >
       <div
         className="w-full max-w-md rounded-sm shadow-2xl"
-        style={{ background: WHITE, border: `1px solid rgba(40,118,193,.3)` }}
+        style={{ background: WHITE, border: `1px solid rgba(var(--dms-line-rgb),.3)` }}
         onKeyDown={(event) => {
           if (event.key === "Enter") {
             event.preventDefault();
@@ -92,8 +92,8 @@ function ConfirmDialog({
         <div
           className="flex items-center px-5 py-3 border-b rounded-t-sm"
           style={{
-            borderColor: "rgba(40,118,193,.18)",
-            background: "rgba(40,118,193,.06)",
+            borderColor: "rgba(var(--dms-line-rgb),.18)",
+            background: "rgba(var(--dms-line-rgb),.06)",
           }}
         >
           <span className="text-base font-black" style={{ color: NAVY }}>
@@ -105,13 +105,13 @@ function ConfirmDialog({
         </div>
         <div
           className="flex justify-end gap-2 px-5 py-3 border-t"
-          style={{ borderColor: "rgba(40,118,193,.18)" }}
+          style={{ borderColor: "rgba(var(--dms-line-rgb),.18)" }}
         >
           <button
             type="button"
             onClick={() => onResult(false)}
-            className="px-4 py-1.5 text-base font-black rounded-sm border transition-colors hover:bg-[rgba(49,145,235,0.12)]"
-            style={{ color: NAVY, borderColor: "rgba(40,118,193,.45)", background: WHITE }}
+            className="px-4 py-1.5 text-base font-black rounded-sm border transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.12)]"
+            style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.45)", background: WHITE }}
           >
             {options.cancelText ?? "取消"}
           </button>

+ 5 - 5
frontend/src/shared/ui/PasswordPromptModal.tsx

@@ -7,8 +7,8 @@ import {
   type PasswordRequest,
 } from "../state/documentPassword";
 
-const NAVY = "#1A5299";
-const BLUE = "#3191eb";
+const NAVY = "var(--dms-deep)";
+const BLUE = "var(--dms-bright)";
 
 /** 操作码 → 中文动词(与后端 allowedActions 字典对齐) */
 const ACTION_LABELS: Record<DocumentPasswordActionCode, string> = {
@@ -44,7 +44,7 @@ export function PasswordPromptModal() {
       role="dialog"
       aria-label="方案密码验证"
       className="fixed inset-0 z-[90] flex items-center justify-center"
-      style={{ background: "rgba(26,82,153,.55)" }}
+      style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
       onKeyDown={(event) => {
         if (event.key === "Enter") confirm();
         if (event.key === "Escape") completePasswordRequest(null);
@@ -68,14 +68,14 @@ export function PasswordPromptModal() {
             onChange={(event) => setPassword(event.target.value)}
             placeholder="请输入方案密码"
             className="w-full h-9 px-3 text-base font-bold border outline-none"
-            style={{ color: NAVY, borderColor: "rgba(26,82,153,.25)" }}
+            style={{ color: NAVY, borderColor: "rgba(var(--dms-deep-rgb),.25)" }}
           />
           <div className="flex justify-end gap-2">
             <button
               type="button"
               onClick={() => completePasswordRequest(null)}
               className="px-4 py-2 text-base font-black border"
-              style={{ color: NAVY, borderColor: "rgba(26,82,153,.25)" }}
+              style={{ color: NAVY, borderColor: "rgba(var(--dms-deep-rgb),.25)" }}
             >
               取消
             </button>

+ 1 - 1
frontend/src/shared/ui/hoverTip.tsx

@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
  * 不注入任何处理器,与原 `title={x ?? undefined}` 的语义一致。
  */
 
-const NAVY = "#0D2137";
+const NAVY = "var(--foreground)";
 
 export interface HoverTip {
   text: string;

+ 82 - 0
frontend/src/styles/theme.css

@@ -30,6 +30,7 @@
   --chart-3: #2196F3;
   --chart-4: #42A5F5;
   --chart-5: #90CAF9;
+  --chart-6: #B3D8F4;
   --radius: 0.375rem;
   --sidebar: #1565C0;
   --sidebar-foreground: #ffffff;
@@ -39,6 +40,17 @@
   --sidebar-accent-foreground: #ffffff;
   --sidebar-border: rgba(255,255,255,0.15);
   --sidebar-ring: #90CAF9;
+  /* DMS 主题语义色(契约1.20):组件硬编码色的变量化出口。
+     blue 为缺省;red/green 由下方 html[data-theme] 覆盖块整体换肤。
+     *-rgb 变量存 RGB 分量,供 rgba(var(--dms-*-rgb), α) 按透明度取色;
+     rgba 内不写空格,兼容 Tailwind 任意值语法。 */
+  --dms-deep: #1A5299;
+  --dms-deep-rgb: 26, 82, 153;
+  --dms-bright: #3191eb;
+  --dms-bright-rgb: 49, 145, 235;
+  --dms-line-rgb: 40, 118, 193;
+  --dms-primary-rgb: 21, 101, 192;
+  --dms-canvas: #EBF4FD;
 }
 
 .dark {
@@ -78,6 +90,76 @@
   --sidebar-ring: oklch(0.439 0 0);
 }
 
+/* ── 界面主题(契约1.20):账号级漫游偏好,html[data-theme] 整体换肤 ──
+   覆盖块特异性 (0,1,1) 高于 :root,只覆盖与换肤相关的变量;
+   destructive(警示红)与 radius、字重等不随主题变化。 */
+
+html[data-theme="red"] {
+  --background: #FBEDEB;
+  --foreground: #3B0F0F;
+  --card-foreground: #3B0F0F;
+  --popover-foreground: #3B0F0F;
+  --primary: #C62828;
+  --secondary: #FFEBEE;
+  --muted: #FFCDD2;
+  --muted-foreground: #7A1F1F;
+  --accent: #D32F2F;
+  --border: rgba(198, 40, 40, 0.18);
+  --input-background: #FDECEC;
+  --switch-background: #EF9A9A;
+  --ring: #C62828;
+  --chart-1: #C62828;
+  --chart-2: #D32F2F;
+  --chart-3: #E53935;
+  --chart-4: #EF5350;
+  --chart-5: #EF9A9A;
+  --chart-6: #FFCDD2;
+  --sidebar: #C62828;
+  --sidebar-primary: #4A0D0D;
+  --sidebar-accent: #D32F2F;
+  --sidebar-ring: #EF9A9A;
+  --dms-deep: #8E1F1F;
+  --dms-deep-rgb: 142, 31, 31;
+  --dms-bright: #E05252;
+  --dms-bright-rgb: 224, 82, 82;
+  --dms-line-rgb: 208, 88, 88;
+  --dms-primary-rgb: 198, 40, 40;
+  --dms-canvas: #FBEDEB;
+}
+
+html[data-theme="green"] {
+  --background: #EAF5EB;
+  --foreground: #0D2A10;
+  --card-foreground: #0D2A10;
+  --popover-foreground: #0D2A10;
+  --primary: #2E7D32;
+  --secondary: #E8F5E9;
+  --muted: #C8E6C9;
+  --muted-foreground: #1E4A20;
+  --accent: #388E3C;
+  --border: rgba(46, 125, 50, 0.18);
+  --input-background: #ECF6ED;
+  --switch-background: #A5D6A7;
+  --ring: #2E7D32;
+  --chart-1: #2E7D32;
+  --chart-2: #388E3C;
+  --chart-3: #43A047;
+  --chart-4: #66BB6A;
+  --chart-5: #A5D6A7;
+  --chart-6: #C8E6C9;
+  --sidebar: #2E7D32;
+  --sidebar-primary: #0D2E10;
+  --sidebar-accent: #388E3C;
+  --sidebar-ring: #A5D6A7;
+  --dms-deep: #1D5C2A;
+  --dms-deep-rgb: 29, 92, 42;
+  --dms-bright: #3FA34D;
+  --dms-bright-rgb: 63, 163, 77;
+  --dms-line-rgb: 63, 140, 74;
+  --dms-primary-rgb: 46, 125, 50;
+  --dms-canvas: #EAF5EB;
+}
+
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);

+ 9 - 1
prompts.md

@@ -1062,4 +1062,12 @@ java中命令执行用的cmd?还是powershell?
 使用技能 git-commit-log-gen 生成提交日志,可以参考 prompts.md 中的新增部分需求。本次提交为集中修复问题,种类繁多,按照重要性排序,同类问题合并,不要写得过于复杂。
 另外,判断是否有不应提交的代码需要补充到.gitignore中。
 
----
+---
+
+/plan 接下来一个新需求:用户想支持风格切换,主要是颜色。我们可以内置一些,如当前的蓝色、红色、绿色等。每个用户的设置要持久化。先计划一下。
+
+---
+
+flyway的升级sql,我在达梦下跑不通,给我另外生成一份达梦对应的版本吧(不要修改原来针对MySQL的版本)
+
+---

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff