fix: 修复鉴权与权限分配缺陷并清理前后端冗余

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 09:34:14 +08:00
co-authored by Cursor
parent bc61f6a01c
commit 2a9ec17af4
32 changed files with 193 additions and 137 deletions
+14
View File
@@ -690,3 +690,17 @@
- 原 `docs/development_page_guide.md` 迁移到仓库根目录并改名为 `DEVELOPMENT_GUIDE.md`,标题改为“开发指南”,用于承载新增页面、模块、权限、迁移、API 和前端交互规范。 - 原 `docs/development_page_guide.md` 迁移到仓库根目录并改名为 `DEVELOPMENT_GUIDE.md`,标题改为“开发指南”,用于承载新增页面、模块、权限、迁移、API 和前端交互规范。
- `docs` 目录只保留 `project_context.md`,删除已不作为当前入口维护的 open items 与早期设计文档;当前开发者入口由 `README.md` 和 `DEVELOPMENT_GUIDE.md` 承担。 - `docs` 目录只保留 `project_context.md`,删除已不作为当前入口维护的 open items 与早期设计文档;当前开发者入口由 `README.md` 和 `DEVELOPMENT_GUIDE.md` 承担。
- `README.md` 更新目录结构和文档入口说明,移除平台限定描述,命令示例改为更通用的 `python` / `npm` 写法,并把开发规范引用指向 `DEVELOPMENT_GUIDE.md`。 - `README.md` 更新目录结构和文档入口说明,移除平台限定描述,命令示例改为更通用的 `python` / `npm` 写法,并把开发规范引用指向 `DEVELOPMENT_GUIDE.md`。
## 2026-06-12:framework-stable 分支第三轮全量复查与修复
- 鉴权加固:`decode_access_token` 现在只接受纯数字 `sub`,伪造或异常 payload 统一走 401,不再因 `int(subject)` 抛 `ValueError` 返回 500;新增伪造 subject 回归测试。
- 模块生命周期一致性:`disable` 钩子改为与 install/upgrade/uninstall 相同的“先查 `migration_records` 再执行”模式,记录 key 带模块版本(`hook:<key>:disable:<version>:<hook>`);模块反复启停不会重复执行非幂等 SQL,删除了旧的无守卫 `_run_lifecycle_hooks`,生命周期测试补充重复启停场景。
- RBAC 静默降级修复:`assign_roles`、`approve_user`/`create_user` 的 `role_ids`、`assign_permissions` 的 `permission_ids` 现在严格校验 ID 全部存在,无效 ID 返回 400(`error.roleNotFound` / 新增 `error.permissionNotFound`),不再静默部分生效;新增对应接口测试。
- 数据一致性:管理员直接创建的用户补记 `approved_by` 和 `approved_at`,与注册审批通过、免审注册路径保持一致。
- 审计补缺:`get_token_secret` 显示完整 Token 时记录 `api_token.reveal` 审计;语言包补充 `auditLog.action.api_token.reveal`。
- 后端清理:删除无引用的 `ApiTokenRepository.get()` 和前端从未调用的 `GET /api/users/{user_id}` 端点;`_user_profile_snapshot` 重复实现合并为 `app/services/users.py` 的 `user_profile_snapshot`,`auth.py` 复用。
- 前端修复:保存系统设置不再调用 `appStore.setLocale` 强制覆盖当前用户语言(`default_locale` 只影响无本地偏好的会话);`AppShell` 公告弹窗改为标记已读成功后才关闭,失败时保持弹出;`PermissionView.loadData` 增加错误提示,避免首屏白屏;校验错误字段 `permission_ids` 的 label 修正为新增的 `field.permissions`。
- 前端去重与收敛:`LOCALE_STORAGE_KEY` 统一定义在 `config/app.ts`;`initialLocale()` 收敛到 `i18n/index.ts` 并导出复用,`stores/app.ts` 不再依赖 `settingsStore`;`isFeatureEnabled` 收敛为 `settingsStore.featureEnabled(...)`;剪贴板复制(含 `execCommand` 降级)抽到 `web/src/utils/clipboard.ts`,`TokenManageView` 与 `ApiDocsView` 共用,顺带修复 ApiDocs 复制无降级问题。
- 前端清理:`APP_SLUG`、`DEPRECATED_PERMISSION_CODES`、`TranslateParam` 改为模块内私有;删除无引用的 `.module-placeholder`、`.api-response-list` 样式和 `.filter-select` 选择器;移除模板中无样式的 `announcement-manage-card`、`demo-crud-toolbar`、`demo-crud-category-filter`、`announcement-popup-modal` class,脚手架模板同步去掉 `__KEBAB__-toolbar`。
- smoke 增强:`web/scripts/smoke.mjs` 新增基础语言包 `i18n/locales/*.json` 的 key 一致性校验(原来只校验模块语言包)。
- 有意保留的重复/设计边界:`modules/core.py` 与 `core/permissions.py` 的 READ 常量重复用于避免循环 import;`creator_usernames`/`actor_usernames` 在各模块 repository 重复属于模块自包含约定;dashboard API 和 `/api/audit-logs/export` 作为 API 契约保留;列表页分页/筛选模式按脚手架约定逐页实现,不抽公共 composable。
- 验证结果:后端 `compileall` 通过、`pytest` 36 passed(新增 2 个用例);前端 `npm run test:smoke`、`npx vue-tsc --noEmit --noUnusedLocals --noUnusedParameters`、`npm run build`、`npm run test:regression`(4 passed)全部通过。
+1 -1
View File
@@ -163,7 +163,7 @@ export const __CONSTANT___MANAGE_OTHERS = actionPermission("__SNAKE__", "manage_
function webViewTemplate() { function webViewTemplate() {
return render(`<template> return render(`<template>
<section class="work-card table-page-card"> <section class="work-card table-page-card">
<div class="toolbar __KEBAB__-toolbar"> <div class="toolbar">
<div class="toolbar-group"> <div class="toolbar-group">
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('__CAMEL__.searchPlaceholder')" clearable /> <n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('__CAMEL__.searchPlaceholder')" clearable />
<n-input v-model:value="filters.category" :placeholder="t('__CAMEL__.categoryPlaceholder')" clearable /> <n-input v-model:value="filters.category" :placeholder="t('__CAMEL__.categoryPlaceholder')" clearable />
-9
View File
@@ -56,15 +56,6 @@ def list_role_options(
return UserService(db).list_role_options() return UserService(db).list_role_options()
@router.get("/{user_id}", response_model=UserListItem)
def get_user(
user_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission(USER_READ)),
) -> User:
return UserService(db).get_user(user_id)
@router.post("", response_model=UserListItem) @router.post("", response_model=UserListItem)
def create_user( def create_user(
payload: UserCreateRequest, payload: UserCreateRequest,
+2 -2
View File
@@ -61,8 +61,8 @@ def decode_access_token(token: str) -> str | None:
return None return None
if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()): if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()):
return None return None
subject = payload.get("sub") subject = str(payload.get("sub") or "")
return str(subject) if subject else None return subject if subject.isdigit() else None
def create_api_token() -> str: def create_api_token() -> str:
+1 -14
View File
@@ -93,7 +93,7 @@ def sync_module_states(engine) -> None:
continue continue
state = state_by_key.get(module.key) state = state_by_key.get(module.key)
if state is not None and state["status"] == "enabled": if state is not None and state["status"] == "enabled":
_run_lifecycle_hooks(conn, module, "disable") _run_recorded_lifecycle_hooks(conn, module, "disable")
_upsert_module_state(conn, module.key, module.version, "disabled", module.dependencies) _upsert_module_state(conn, module.key, module.version, "disabled", module.dependencies)
for key, state in state_by_key.items(): for key, state in state_by_key.items():
@@ -147,19 +147,6 @@ def _run_recorded_lifecycle_hooks(conn, module, event: str) -> None:
) )
def _run_lifecycle_hooks(conn, module, event: str) -> None:
for hook in (item for item in module.lifecycle_hooks if item.event == event):
for statement in hook.statements:
conn.execute(text(statement))
_record_migration(
conn,
f"hook:{module.key}:{event}:{hook.key}",
"module_hook",
module.key,
hook.description or f"{module.key}@{module.version}:{event}:{hook.key}",
)
def _upsert_module_state(conn, key: str, version: str, status: str, dependencies: tuple[str, ...]) -> None: def _upsert_module_state(conn, key: str, version: str, status: str, dependencies: tuple[str, ...]) -> None:
payload = json.dumps(list(dependencies), ensure_ascii=False) payload = json.dumps(list(dependencies), ensure_ascii=False)
existing = conn.execute(text("SELECT id FROM module_states WHERE `key` = :key"), {"key": key}).first() existing = conn.execute(text("SELECT id FROM module_states WHERE `key` = :key"), {"key": key}).first()
-3
View File
@@ -7,9 +7,6 @@ class ApiTokenRepository:
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db
def get(self, token_id: int) -> ApiToken | None:
return self.db.get(ApiToken, token_id)
def get_for_user(self, token_id: int, user_id: int) -> ApiToken | None: def get_for_user(self, token_id: int, user_id: int) -> ApiToken | None:
return self.db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.user_id == user_id).first() return self.db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.user_id == user_id).first()
+10
View File
@@ -74,6 +74,16 @@ class ApiTokenService:
raise not_found("error.apiTokenNotFound", "API token not found") raise not_found("error.apiTokenNotFound", "API token not found")
if not api_token.token_value: if not api_token.token_value:
raise not_found("error.apiTokenSecretUnavailable", "API token secret is unavailable") raise not_found("error.apiTokenSecretUnavailable", "API token secret is unavailable")
record_audit(
self.db,
user.id,
"api_token.reveal",
"api_token",
str(api_token.id),
api_token.name,
audit_detail(api_token.name, meta=_token_snapshot(api_token)),
)
self.db.commit()
return api_token.token_value return api_token.token_value
def _new_unique_token(self) -> str: def _new_unique_token(self) -> str:
+3 -12
View File
@@ -12,6 +12,7 @@ from app.schemas.settings import PublicSettings
from app.services.audit import audit_changes, audit_detail, record_audit from app.services.audit import audit_changes, audit_detail, record_audit
from app.services.permissions import get_user_permission_codes from app.services.permissions import get_user_permission_codes
from app.services.settings import SettingService from app.services.settings import SettingService
from app.services.users import user_profile_snapshot
class AuthService: class AuthService:
@@ -113,7 +114,7 @@ class AuthService:
return token, user, permissions return token, user, permissions
def update_profile(self, user: User, payload: ProfileUpdateRequest) -> User: def update_profile(self, user: User, payload: ProfileUpdateRequest) -> User:
before = _user_profile_snapshot(user) before = user_profile_snapshot(user)
user.full_name = payload.full_name user.full_name = payload.full_name
user.phone = payload.phone user.phone = payload.phone
user.email = payload.email user.email = payload.email
@@ -126,7 +127,7 @@ class AuthService:
"user", "user",
str(user.id), str(user.id),
user.username, user.username,
audit_detail(user.username, audit_changes(before, _user_profile_snapshot(user))), audit_detail(user.username, audit_changes(before, user_profile_snapshot(user))),
) )
self.db.commit() self.db.commit()
return user return user
@@ -145,13 +146,3 @@ class AuthService:
audit_detail(user.username, meta={"password_changed": True}), audit_detail(user.username, meta={"password_changed": True}),
) )
self.db.commit() self.db.commit()
def _user_profile_snapshot(user: User) -> dict[str, str]:
return {
"full_name": user.full_name,
"phone": user.phone,
"email": user.email,
"company": user.company,
"department": user.department,
}
+8 -1
View File
@@ -80,7 +80,7 @@ class RoleService:
if role.code == ADMIN_ROLE: if role.code == ADMIN_ROLE:
role.permissions = self.roles.permissions() role.permissions = self.roles.permissions()
else: else:
role.permissions = self.roles.permissions_by_ids(payload.permission_ids) role.permissions = self._permissions_by_ids_strict(payload.permission_ids)
record_audit( record_audit(
self.db, self.db,
actor_id, actor_id,
@@ -93,6 +93,13 @@ class RoleService:
self.db.commit() self.db.commit()
return role return role
def _permissions_by_ids_strict(self, permission_ids: list[int]) -> list[Permission]:
unique_ids = list(set(permission_ids))
permissions = self.roles.permissions_by_ids(unique_ids)
if len(permissions) != len(unique_ids):
raise bad_request("error.permissionNotFound", "Permission not found")
return permissions
def _role_snapshot(role: Role) -> dict[str, object]: def _role_snapshot(role: Role) -> dict[str, object]:
return { return {
+16 -8
View File
@@ -64,6 +64,8 @@ class UserService:
department=payload.department, department=payload.department,
password_hash=hash_password(payload.password), password_hash=hash_password(payload.password),
approval_status="approved", approval_status="approved",
approved_by=actor.id,
approved_at=utc_now(),
is_active=True, is_active=True,
roles=self._roles_or_default(payload.role_ids), roles=self._roles_or_default(payload.role_ids),
) )
@@ -82,7 +84,7 @@ class UserService:
def update_user(self, actor: User, user_id: int, payload: UserUpdateRequest) -> User: def update_user(self, actor: User, user_id: int, payload: UserUpdateRequest) -> User:
user = self.get_user(user_id) user = self.get_user(user_id)
before = _user_profile_snapshot(user) before = user_profile_snapshot(user)
user.full_name = payload.full_name user.full_name = payload.full_name
user.phone = payload.phone user.phone = payload.phone
user.email = payload.email user.email = payload.email
@@ -95,7 +97,7 @@ class UserService:
"user", "user",
str(user.id), str(user.id),
user.username, user.username,
audit_detail(user.username, audit_changes(before, _user_profile_snapshot(user))), audit_detail(user.username, audit_changes(before, user_profile_snapshot(user))),
) )
self.db.commit() self.db.commit()
return user return user
@@ -209,7 +211,7 @@ class UserService:
def assign_roles(self, actor: User, user_id: int, payload: AssignRolesRequest) -> User: def assign_roles(self, actor: User, user_id: int, payload: AssignRolesRequest) -> User:
user = self.get_user(user_id) user = self.get_user(user_id)
roles = self.roles.by_ids(payload.role_ids) roles = self._roles_by_ids_strict(payload.role_ids)
self._guard_last_admin_role_change(user, roles) self._guard_last_admin_role_change(user, roles)
self._guard_self_admin_role(actor, user, roles) self._guard_self_admin_role(actor, user, roles)
before = {"roles": _role_codes(user.roles)} before = {"roles": _role_codes(user.roles)}
@@ -231,12 +233,18 @@ class UserService:
raise forbidden("error.lastAdminRequired", "The last administrator cannot be changed") raise forbidden("error.lastAdminRequired", "The last administrator cannot be changed")
def _roles_or_default(self, role_ids: list[int] | None) -> list[Role]: def _roles_or_default(self, role_ids: list[int] | None) -> list[Role]:
roles = self.roles.by_ids(role_ids or []) if role_ids:
if roles: return self._roles_by_ids_strict(role_ids)
return roles
default_role = self.roles.get_by_code(USER_ROLE) default_role = self.roles.get_by_code(USER_ROLE)
return [default_role] if default_role else [] return [default_role] if default_role else []
def _roles_by_ids_strict(self, role_ids: list[int]) -> list[Role]:
unique_ids = list(set(role_ids))
roles = self.roles.by_ids(unique_ids)
if len(roles) != len(unique_ids):
raise bad_request("error.roleNotFound", "Role not found")
return roles
def _guard_last_admin_role_change(self, target: User, roles: list[Role]) -> None: def _guard_last_admin_role_change(self, target: User, roles: list[Role]) -> None:
if not any(role.code == ADMIN_ROLE for role in target.roles) or self.users.count_admins() > 1: if not any(role.code == ADMIN_ROLE for role in target.roles) or self.users.count_admins() > 1:
return return
@@ -254,7 +262,7 @@ def _role_codes(roles: list[Role]) -> list[str]:
return sorted(role.code for role in roles) return sorted(role.code for role in roles)
def _user_profile_snapshot(user: User) -> dict[str, str]: def user_profile_snapshot(user: User) -> dict[str, str]:
return { return {
"full_name": user.full_name, "full_name": user.full_name,
"phone": user.phone, "phone": user.phone,
@@ -267,7 +275,7 @@ def _user_profile_snapshot(user: User) -> dict[str, str]:
def _user_snapshot(user: User) -> dict[str, object]: def _user_snapshot(user: User) -> dict[str, object]:
return { return {
"username": user.username, "username": user.username,
**_user_profile_snapshot(user), **user_profile_snapshot(user),
"approval_status": user.approval_status, "approval_status": user.approval_status,
"is_active": user.is_active, "is_active": user.is_active,
} }
+80 -1
View File
@@ -359,6 +359,11 @@ def test_module_lifecycle_hooks_run_for_install_upgrade_and_disable(tmp_path, mo
db_init.sync_module_states(engine) db_init.sync_module_states(engine)
db_init.sync_module_states(engine) db_init.sync_module_states(engine)
monkeypatch.setattr(db_init, "get_app_modules", lambda: (module_v2,))
db_init.sync_module_states(engine)
monkeypatch.setattr(db_init, "get_app_modules", lambda: ())
db_init.sync_module_states(engine)
with engine.begin() as conn: with engine.begin() as conn:
events = [row[0] for row in conn.execute(text("SELECT event FROM lifecycle_events ORDER BY rowid")).all()] events = [row[0] for row in conn.execute(text("SELECT event FROM lifecycle_events ORDER BY rowid")).all()]
state = conn.execute(text("SELECT version, status FROM module_states WHERE `key` = 'hooked'")).one() state = conn.execute(text("SELECT version, status FROM module_states WHERE `key` = 'hooked'")).one()
@@ -373,7 +378,7 @@ def test_module_lifecycle_hooks_run_for_install_upgrade_and_disable(tmp_path, mo
assert events == ["install", "upgrade", "disable"] assert events == ["install", "upgrade", "disable"]
assert state == ("1.1.0", "disabled") assert state == ("1.1.0", "disabled")
assert records == [ assert records == [
"hook:hooked:disable:counter", "hook:hooked:disable:1.1.0:counter",
"hook:hooked:install:1.0.0:counter", "hook:hooked:install:1.0.0:counter",
"hook:hooked:upgrade:1.1.0:counter", "hook:hooked:upgrade:1.1.0:counter",
] ]
@@ -883,6 +888,80 @@ def test_register_without_approval_can_login(tmp_path, monkeypatch):
assert client.get("/api/dashboard/summary", headers=user_headers).status_code == 200 assert client.get("/api/dashboard/summary", headers=user_headers).status_code == 200
def test_user_role_permission_assignment_rejects_unknown_ids(tmp_path, monkeypatch):
client = create_client(tmp_path, monkeypatch)
payload = install_sqlite(client, tmp_path)
admin_headers = login(client, payload["admin_username"], payload["admin_password"])
invalid_create = client.post(
"/api/users",
json={
"username": "managed01",
"password": "UserPass123",
"full_name": "受管用户",
"phone": "13800000006",
"email": "managed01@example.com",
"company": "公司",
"department": "部门",
"role_ids": [99999],
},
headers=admin_headers,
)
assert invalid_create.status_code == 400
assert invalid_create.json()["detail"]["code"] == "error.roleNotFound"
created = client.post(
"/api/users",
json={
"username": "managed01",
"password": "UserPass123",
"full_name": "受管用户",
"phone": "13800000006",
"email": "managed01@example.com",
"company": "公司",
"department": "部门",
"role_ids": [],
},
headers=admin_headers,
)
assert created.status_code == 200
user_id = created.json()["id"]
from app.core.install import load_install_config
engine = create_engine(load_install_config().database_url)
with engine.begin() as conn:
approved = conn.execute(
text("SELECT approved_by, approved_at FROM users WHERE id = :id"), {"id": user_id}
).one()
engine.dispose()
assert approved[0] is not None
assert approved[1] is not None
invalid_assign = client.put(f"/api/users/{user_id}/roles", json={"role_ids": [1, 99999]}, headers=admin_headers)
assert invalid_assign.status_code == 400
assert invalid_assign.json()["detail"]["code"] == "error.roleNotFound"
role = client.post("/api/roles", json={"code": "ops", "name": "运维", "description": ""}, headers=admin_headers)
assert role.status_code == 200
invalid_permissions = client.put(
f"/api/roles/{role.json()['id']}/permissions", json={"permission_ids": [99999]}, headers=admin_headers
)
assert invalid_permissions.status_code == 400
assert invalid_permissions.json()["detail"]["code"] == "error.permissionNotFound"
def test_forged_token_subject_is_rejected_as_unauthorized(tmp_path, monkeypatch):
client = create_client(tmp_path, monkeypatch)
install_sqlite(client, tmp_path)
from app.core.security import create_access_token
forged = create_access_token("not-a-number")
response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {forged}"})
assert response.status_code == 401
def test_profile_and_password_change(tmp_path, monkeypatch): def test_profile_and_password_change(tmp_path, monkeypatch):
client = create_client(tmp_path, monkeypatch) client = create_client(tmp_path, monkeypatch)
payload = install_sqlite(client, tmp_path) payload = install_sqlite(client, tmp_path)
+1
View File
@@ -13,6 +13,7 @@ const appConfig = readOptionalJson(path.join(projectDir, "app.config.json"));
const moduleEntries = new Map(); const moduleEntries = new Map();
assert(requiredLocales.length > 0, "No base locale files found"); assert(requiredLocales.length > 0, "No base locale files found");
assertSameJsonKeys(baseMessages, "i18n/locales");
assertModuleFilters(appConfig, moduleNames); assertModuleFilters(appConfig, moduleNames);
for (const moduleName of moduleNames) { for (const moduleName of moduleNames) {
+1 -1
View File
@@ -33,7 +33,7 @@ const FIELD_LABEL_KEYS: Record<string, I18nKey> = {
description: "field.description", description: "field.description",
reason: "field.reason", reason: "field.reason",
role_ids: "field.roles", role_ids: "field.roles",
permission_ids: "field.roles", permission_ids: "field.permissions",
title: "field.title", title: "field.title",
content: "field.content", content: "field.content",
target_type: "field.targetType", target_type: "field.targetType",
+3 -7
View File
@@ -57,7 +57,7 @@
<n-modal <n-modal
v-model:show="showAnnouncementModal" v-model:show="showAnnouncementModal"
preset="card" preset="card"
class="modal-card announcement-popup-modal" class="modal-card"
:title="popupAnnouncement?.title || t('dashboard.announcements')" :title="popupAnnouncement?.title || t('dashboard.announcements')"
:closable="false" :closable="false"
:mask-closable="false" :mask-closable="false"
@@ -107,7 +107,7 @@ const collapsed = ref(localStorage.getItem(SIDEBAR_KEY) === "1");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const menuItems = computed(() => getVisibleMenuItems((code) => authStore.has(code), isFeatureEnabled)); const menuItems = computed(() => getVisibleMenuItems((code) => authStore.has(code), (feature) => settingsStore.featureEnabled(feature)));
const menuOptions = computed<MenuOption[]>(() => toMenuOptions(menuItems.value)); const menuOptions = computed<MenuOption[]>(() => toMenuOptions(menuItems.value));
const activeMenu = computed(() => (hasMenuPath(menuItems.value, route.path) ? route.path : null)); const activeMenu = computed(() => (hasMenuPath(menuItems.value, route.path) ? route.path : null));
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
@@ -160,10 +160,6 @@ function renderIcon(icon: Component) {
return () => h(NIcon, null, { default: () => h(icon) }); return () => h(NIcon, null, { default: () => h(icon) });
} }
function isFeatureEnabled(feature?: string) {
return feature !== "api" || settingsStore.apiEnabled();
}
function toMenuOptions(items: AppMenuItem[]) { function toMenuOptions(items: AppMenuItem[]) {
return items.map((item) => { return items.map((item) => {
const option: MenuOption = { const option: MenuOption = {
@@ -192,10 +188,10 @@ async function closeTickerAnnouncement(item: { id: number }) {
async function acknowledgePopup() { async function acknowledgePopup() {
const item = popupAnnouncement.value; const item = popupAnnouncement.value;
showAnnouncementModal.value = false;
if (item) { if (item) {
await markAnnouncementRead(item); await markAnnouncementRead(item);
} }
showAnnouncementModal.value = Boolean(popupAnnouncement.value);
} }
async function markAnnouncementRead(item: { id: number }) { async function markAnnouncementRead(item: { id: number }) {
+3 -1
View File
@@ -1,7 +1,7 @@
const rawName = __APP_CONFIG__.appName?.trim(); const rawName = __APP_CONFIG__.appName?.trim();
export const APP_NAME = rawName || "App"; export const APP_NAME = rawName || "App";
export const APP_SLUG = normalizeSlug(__APP_CONFIG__.appSlug) || normalizeSlug(APP_NAME) || "app"; const APP_SLUG = normalizeSlug(__APP_CONFIG__.appSlug) || normalizeSlug(APP_NAME) || "app";
export const DEFAULT_DATABASE_NAME = APP_SLUG; export const DEFAULT_DATABASE_NAME = APP_SLUG;
export const DEFAULT_SQLITE_PATH = `runtime/${APP_SLUG}.db`; export const DEFAULT_SQLITE_PATH = `runtime/${APP_SLUG}.db`;
@@ -9,6 +9,8 @@ export function appKey(name: string) {
return `${APP_SLUG}.${name}`; return `${APP_SLUG}.${name}`;
} }
export const LOCALE_STORAGE_KEY = appKey("locale");
function normalizeSlug(value: string | undefined) { function normalizeSlug(value: string | undefined) {
return value?.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || ""; return value?.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "";
} }
+1 -1
View File
@@ -1,4 +1,4 @@
export const DEPRECATED_PERMISSION_CODES = new Set(["route:approvals", "action:announcement:operate"]); const DEPRECATED_PERMISSION_CODES = new Set(["route:approvals", "action:announcement:operate"]);
export function isActivePermissionCode(code: string) { export function isActivePermissionCode(code: string) {
return !DEPRECATED_PERMISSION_CODES.has(code); return !DEPRECATED_PERMISSION_CODES.has(code);
+3 -5
View File
@@ -1,7 +1,7 @@
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
import { appKey } from "../config/app"; import { LOCALE_STORAGE_KEY } from "../config/app";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
defaultMessages, defaultMessages,
@@ -18,8 +18,6 @@ import {
export { DEFAULT_LOCALE, isLocale, locales }; export { DEFAULT_LOCALE, isLocale, locales };
export type { I18nKey, Locale, TranslateParams }; export type { I18nKey, Locale, TranslateParams };
const LOCALE_KEY = appKey("locale");
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: initialLocale(), locale: initialLocale(),
@@ -81,8 +79,8 @@ export function formatDateTime(value: string | number | Date) {
return new Date(value).toLocaleString(currentLocale()); return new Date(value).toLocaleString(currentLocale());
} }
function initialLocale(): Locale { export function initialLocale(): Locale {
const saved = localStorage.getItem(LOCALE_KEY); const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
return isLocale(saved) ? saved : DEFAULT_LOCALE; return isLocale(saved) ? saved : DEFAULT_LOCALE;
} }
+4 -1
View File
@@ -52,6 +52,7 @@
"departmentOrPosition": "Department/Position", "departmentOrPosition": "Department/Position",
"role": "Role", "role": "Role",
"roles": "Roles", "roles": "Roles",
"permissions": "Permissions",
"reason": "Reason", "reason": "Reason",
"roleCode": "Role code", "roleCode": "Role code",
"roleName": "Role name", "roleName": "Role name",
@@ -148,6 +149,7 @@
"lastAdminRoleRequired": "The last administrator role cannot be removed", "lastAdminRoleRequired": "The last administrator role cannot be removed",
"selfAdminRoleRequired": "You cannot remove your own administrator role", "selfAdminRoleRequired": "You cannot remove your own administrator role",
"roleNotFound": "Role not found", "roleNotFound": "Role not found",
"permissionNotFound": "Permission not found",
"roleCodeExists": "Role code already exists", "roleCodeExists": "Role code already exists",
"builtinRoleCannotDelete": "Built-in roles cannot be deleted", "builtinRoleCannotDelete": "Built-in roles cannot be deleted",
"databaseConnectionFailed": "Database connection failed. Check the connection config", "databaseConnectionFailed": "Database connection failed. Check the connection config",
@@ -376,7 +378,8 @@
}, },
"api_token": { "api_token": {
"create": "Created token", "create": "Created token",
"delete": "Deleted token" "delete": "Deleted token",
"reveal": "Revealed token secret"
} }
}, },
"target": { "target": {
+4 -1
View File
@@ -52,6 +52,7 @@
"departmentOrPosition": "部门/职位", "departmentOrPosition": "部门/职位",
"role": "角色", "role": "角色",
"roles": "角色", "roles": "角色",
"permissions": "权限",
"reason": "驳回原因", "reason": "驳回原因",
"roleCode": "角色编码", "roleCode": "角色编码",
"roleName": "角色名称", "roleName": "角色名称",
@@ -148,6 +149,7 @@
"lastAdminRoleRequired": "不能移除最后一个管理员的管理员角色", "lastAdminRoleRequired": "不能移除最后一个管理员的管理员角色",
"selfAdminRoleRequired": "不能移除自己的管理员角色", "selfAdminRoleRequired": "不能移除自己的管理员角色",
"roleNotFound": "角色不存在", "roleNotFound": "角色不存在",
"permissionNotFound": "权限不存在",
"roleCodeExists": "角色编码已存在", "roleCodeExists": "角色编码已存在",
"builtinRoleCannotDelete": "内置角色不能删除", "builtinRoleCannotDelete": "内置角色不能删除",
"databaseConnectionFailed": "数据库连接失败,请检查连接配置", "databaseConnectionFailed": "数据库连接失败,请检查连接配置",
@@ -376,7 +378,8 @@
}, },
"api_token": { "api_token": {
"create": "创建 Token", "create": "创建 Token",
"delete": "删除 Token" "delete": "删除 Token",
"reveal": "显示完整 Token"
} }
}, },
"target": { "target": {
+1 -1
View File
@@ -1,7 +1,7 @@
import zhCN from "./locales/zh-CN.json"; import zhCN from "./locales/zh-CN.json";
export type Locale = string; export type Locale = string;
export type TranslateParam = string | number | boolean | null | undefined; type TranslateParam = string | number | boolean | null | undefined;
export type TranslateParams = Record<string, TranslateParam>; export type TranslateParams = Record<string, TranslateParam>;
export type MessageTree = { [key: string]: string | MessageTree }; export type MessageTree = { [key: string]: string | MessageTree };
export type LocaleMessages = MessageTree & { language: string }; export type LocaleMessages = MessageTree & { language: string };
@@ -1,9 +1,9 @@
<template> <template>
<section class="work-card table-page-card"> <section class="work-card table-page-card">
<div class="toolbar demo-crud-toolbar"> <div class="toolbar">
<div class="toolbar-group"> <div class="toolbar-group">
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('demoCrud.searchPlaceholder')" clearable /> <n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('demoCrud.searchPlaceholder')" clearable />
<n-input v-model:value="filters.category" class="demo-crud-category-filter" :placeholder="t('demoCrud.categoryPlaceholder')" clearable /> <n-input v-model:value="filters.category" :placeholder="t('demoCrud.categoryPlaceholder')" clearable />
<n-button @click="searchItems">{{ t("common.search") }}</n-button> <n-button @click="searchItems">{{ t("common.search") }}</n-button>
</div> </div>
<permission-button :permission="DEMO_ITEM_CREATE" type="primary" @click="openCreate">{{ t("demoCrud.add") }}</permission-button> <permission-button :permission="DEMO_ITEM_CREATE" type="primary" @click="openCreate">{{ t("demoCrud.add") }}</permission-button>
+6 -11
View File
@@ -3,14 +3,12 @@ import { createRouter, createWebHistory } from "vue-router";
import { AUTH_EXPIRED_EVENT } from "../api/client"; import { AUTH_EXPIRED_EVENT } from "../api/client";
import { getInstallStatus } from "../api/install"; import { getInstallStatus } from "../api/install";
import { getMe } from "../api/auth"; import { getMe } from "../api/auth";
import { appKey } from "../config/app"; import { LOCALE_STORAGE_KEY } from "../config/app";
import { appStore } from "../stores/app"; import { appStore } from "../stores/app";
import { authStore } from "../stores/auth"; import { authStore } from "../stores/auth";
import { settingsStore } from "../stores/settings"; import { settingsStore } from "../stores/settings";
import { createAppPageRoutes, getFallbackPath } from "./page-registry"; import { createAppPageRoutes, getFallbackPath } from "./page-registry";
const LOCALE_KEY = appKey("locale");
const routes = [ const routes = [
{ path: "/install", component: () => import("../views/InstallView.vue"), meta: { public: true } }, { path: "/install", component: () => import("../views/InstallView.vue"), meta: { public: true } },
{ path: "/login", component: () => import("../views/LoginView.vue"), meta: { public: true } }, { path: "/login", component: () => import("../views/LoginView.vue"), meta: { public: true } },
@@ -51,7 +49,7 @@ router.beforeEach(async (to) => {
if (status?.installed && !settingsStore.loaded) { if (status?.installed && !settingsStore.loaded) {
try { try {
const publicSettings = await settingsStore.loadPublic(); const publicSettings = await settingsStore.loadPublic();
if (!localStorage.getItem(LOCALE_KEY)) { if (!localStorage.getItem(LOCALE_STORAGE_KEY)) {
await appStore.setLocale(publicSettings.default_locale); await appStore.setLocale(publicSettings.default_locale);
} }
} catch { } catch {
@@ -77,12 +75,13 @@ router.beforeEach(async (to) => {
} }
const permission = to.meta.permission as string | undefined; const permission = to.meta.permission as string | undefined;
const feature = to.meta.feature as string | undefined; const feature = to.meta.feature as string | undefined;
if (feature && !isFeatureEnabled(feature)) { const featureEnabled = (value?: string) => settingsStore.featureEnabled(value);
const path = getFallbackPath((code) => authStore.has(code), isFeatureEnabled); if (feature && !featureEnabled(feature)) {
const path = getFallbackPath((code) => authStore.has(code), featureEnabled);
return to.path === path ? true : path; return to.path === path ? true : path;
} }
if (permission && !authStore.has(permission)) { if (permission && !authStore.has(permission)) {
const path = getFallbackPath((code) => authStore.has(code), isFeatureEnabled); const path = getFallbackPath((code) => authStore.has(code), featureEnabled);
return to.path === path ? true : path; return to.path === path ? true : path;
} }
return true; return true;
@@ -95,7 +94,3 @@ async function loadInstallStatus() {
return null; return null;
} }
} }
function isFeatureEnabled(feature?: string) {
return feature !== "api" || settingsStore.apiEnabled();
}
+3 -13
View File
@@ -1,12 +1,10 @@
import { reactive } from "vue"; import { reactive } from "vue";
import { appKey } from "../config/app"; import { appKey, LOCALE_STORAGE_KEY } from "../config/app";
import { setI18nLocale } from "../i18n"; import { initialLocale, setI18nLocale } from "../i18n";
import { DEFAULT_LOCALE, isLocale, type Locale } from "../i18n/messages"; import { DEFAULT_LOCALE, isLocale, type Locale } from "../i18n/messages";
import { settingsStore } from "./settings";
const THEME_KEY = appKey("dark"); const THEME_KEY = appKey("dark");
const LOCALE_KEY = appKey("locale");
export const appStore = reactive({ export const appStore = reactive({
dark: localStorage.getItem(THEME_KEY) === "1", dark: localStorage.getItem(THEME_KEY) === "1",
@@ -20,7 +18,7 @@ export const appStore = reactive({
const nextLocale = isLocale(locale) ? locale : DEFAULT_LOCALE; const nextLocale = isLocale(locale) ? locale : DEFAULT_LOCALE;
await setI18nLocale(nextLocale); await setI18nLocale(nextLocale);
appStore.locale = nextLocale; appStore.locale = nextLocale;
localStorage.setItem(LOCALE_KEY, appStore.locale); localStorage.setItem(LOCALE_STORAGE_KEY, appStore.locale);
document.documentElement.lang = appStore.locale; document.documentElement.lang = appStore.locale;
} }
}); });
@@ -31,11 +29,3 @@ document.documentElement.lang = appStore.locale;
function applyTheme(dark: boolean) { function applyTheme(dark: boolean) {
document.documentElement.dataset.theme = dark ? "dark" : "light"; document.documentElement.dataset.theme = dark ? "dark" : "light";
} }
function initialLocale(): Locale {
const saved = localStorage.getItem(LOCALE_KEY);
if (isLocale(saved)) {
return saved;
}
return settingsStore.defaultLocale();
}
+3
View File
@@ -42,5 +42,8 @@ export const settingsStore = reactive({
}, },
apiTokenRevealEnabled() { apiTokenRevealEnabled() {
return this.publicSettings.api_token_reveal_enabled; return this.publicSettings.api_token_reveal_enabled;
},
featureEnabled(feature?: string) {
return feature !== "api" || this.apiEnabled();
} }
}); });
-23
View File
@@ -1054,13 +1054,6 @@ a {
min-height: 0; min-height: 0;
} }
.module-placeholder {
min-height: 0;
flex: 1;
display: grid;
place-items: center;
}
.announcement-ticker { .announcement-ticker {
height: 36px; height: 36px;
display: flex; display: flex;
@@ -1353,7 +1346,6 @@ a {
} }
.filter-keyword, .filter-keyword,
.filter-select,
.filter-date-range { .filter-date-range {
width: 100%; width: 100%;
} }
@@ -1980,21 +1972,6 @@ a {
gap: 8px; gap: 8px;
} }
.api-response-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.api-response-list span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.api-detail-modal { .api-detail-modal {
width: min(980px, calc(100vw - 32px)); width: min(980px, calc(100vw - 32px));
max-height: min(86vh, 820px); max-height: min(86vh, 820px);
+12
View File
@@ -0,0 +1,12 @@
export async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const input = document.createElement("textarea");
input.value = value;
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<section class="work-card table-page-card announcement-manage-card"> <section class="work-card table-page-card">
<div class="toolbar announcement-toolbar"> <div class="toolbar announcement-toolbar">
<div class="announcement-filter-row"> <div class="announcement-filter-row">
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('announcement.searchPlaceholder')" clearable /> <n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('announcement.searchPlaceholder')" clearable />
+2 -1
View File
@@ -241,6 +241,7 @@ import {
} from "../api/openapi"; } from "../api/openapi";
import { t } from "../i18n"; import { t } from "../i18n";
import { openApiText } from "../i18n/openapi"; import { openApiText } from "../i18n/openapi";
import { copyText } from "../utils/clipboard";
import { saveBlob } from "../utils/download"; import { saveBlob } from "../utils/download";
import { showError } from "../utils/message"; import { showError } from "../utils/message";
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table"; import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
@@ -384,7 +385,7 @@ async function loadDocument() {
async function copyDocument() { async function copyDocument() {
try { try {
await navigator.clipboard.writeText(documentText.value); await copyText(documentText.value);
message.success(t("common.copied")); message.success(t("common.copied"));
} catch { } catch {
message.error(t("message.operationFailed")); message.error(t("message.operationFailed"));
+1 -1
View File
@@ -36,7 +36,7 @@
</button> </button>
</div> </div>
</aside> </aside>
<n-modal v-model:show="showDetailModal" preset="card" class="modal-card announcement-popup-modal" :title="selectedAnnouncement?.title || t('dashboard.announcements')"> <n-modal v-model:show="showDetailModal" preset="card" class="modal-card" :title="selectedAnnouncement?.title || t('dashboard.announcements')">
<div class="announcement-popup-content">{{ selectedAnnouncement?.content }}</div> <div class="announcement-popup-content">{{ selectedAnnouncement?.content }}</div>
<div class="form-actions"> <div class="form-actions">
<n-button type="primary" @click="showDetailModal = false">{{ t("common.close") }}</n-button> <n-button type="primary" @click="showDetailModal = false">{{ t("common.close") }}</n-button>
+6 -1
View File
@@ -189,7 +189,12 @@ onMounted(async () => {
}); });
async function loadData() { async function loadData() {
[roles.value, permissions.value] = await Promise.all([listRoles(), listPermissions()]); try {
[roles.value, permissions.value] = await Promise.all([listRoles(), listPermissions()]);
} catch (error) {
showError(message, error);
return;
}
permissions.value = permissions.value.filter((permission) => isActivePermissionCode(permission.code)); permissions.value = permissions.value.filter((permission) => isActivePermissionCode(permission.code));
roles.value = roles.value.map((role) => ({ roles.value = roles.value.map((role) => ({
...role, ...role,
-2
View File
@@ -114,7 +114,6 @@ import { backupData, getSystemSettings, updateSystemSettings } from "../api/sett
import type { SystemSettings } from "../api/types"; import type { SystemSettings } from "../api/types";
import PermissionButton from "../components/PermissionButton.vue"; import PermissionButton from "../components/PermissionButton.vue";
import { ensureLocaleNames, localeOptions, t } from "../i18n"; import { ensureLocaleNames, localeOptions, t } from "../i18n";
import { appStore } from "../stores/app";
import { settingsStore } from "../stores/settings"; import { settingsStore } from "../stores/settings";
import { saveBlob } from "../utils/download"; import { saveBlob } from "../utils/download";
import { showError } from "../utils/message"; import { showError } from "../utils/message";
@@ -175,7 +174,6 @@ async function saveSettings() {
const updated = await updateSystemSettings(form); const updated = await updateSystemSettings(form);
assignSettings(updated); assignSettings(updated);
settingsStore.setPublic(updated); settingsStore.setPublic(updated);
await appStore.setLocale(updated.default_locale);
message.success(t("settings.saved")); message.success(t("settings.saved"));
} catch (error) { } catch (error) {
showError(message, error); showError(message, error);
+1 -13
View File
@@ -92,6 +92,7 @@ import StatusTag from "../components/StatusTag.vue";
import { formatDateTime, t } from "../i18n"; import { formatDateTime, t } from "../i18n";
import { authStore } from "../stores/auth"; import { authStore } from "../stores/auth";
import { settingsStore } from "../stores/settings"; import { settingsStore } from "../stores/settings";
import { copyText } from "../utils/clipboard";
import { messageText, showError } from "../utils/message"; import { messageText, showError } from "../utils/message";
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table"; import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
import { maxLengthRule, requiredRule, validateForm } from "../utils/validation"; import { maxLengthRule, requiredRule, validateForm } from "../utils/validation";
@@ -331,19 +332,6 @@ async function copyRevealedToken() {
} }
} }
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const input = document.createElement("textarea");
input.value = value;
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
function formatExpiresAt(value: string | null) { function formatExpiresAt(value: string | null) {
return value ? formatDateTime(value) : t("token.neverExpires"); return value ? formatDateTime(value) : t("token.neverExpires");
} }