fix: 修复鉴权与权限分配缺陷并清理前后端冗余
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -690,3 +690,17 @@
|
||||
- 原 `docs/development_page_guide.md` 迁移到仓库根目录并改名为 `DEVELOPMENT_GUIDE.md`,标题改为“开发指南”,用于承载新增页面、模块、权限、迁移、API 和前端交互规范。
|
||||
- `docs` 目录只保留 `project_context.md`,删除已不作为当前入口维护的 open items 与早期设计文档;当前开发者入口由 `README.md` 和 `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)全部通过。
|
||||
|
||||
@@ -163,7 +163,7 @@ export const __CONSTANT___MANAGE_OTHERS = actionPermission("__SNAKE__", "manage_
|
||||
function webViewTemplate() {
|
||||
return render(`<template>
|
||||
<section class="work-card table-page-card">
|
||||
<div class="toolbar __KEBAB__-toolbar">
|
||||
<div class="toolbar">
|
||||
<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.category" :placeholder="t('__CAMEL__.categoryPlaceholder')" clearable />
|
||||
|
||||
@@ -56,15 +56,6 @@ def 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)
|
||||
def create_user(
|
||||
payload: UserCreateRequest,
|
||||
|
||||
@@ -61,8 +61,8 @@ def decode_access_token(token: str) -> str | None:
|
||||
return None
|
||||
if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()):
|
||||
return None
|
||||
subject = payload.get("sub")
|
||||
return str(subject) if subject else None
|
||||
subject = str(payload.get("sub") or "")
|
||||
return subject if subject.isdigit() else None
|
||||
|
||||
|
||||
def create_api_token() -> str:
|
||||
|
||||
+1
-14
@@ -93,7 +93,7 @@ def sync_module_states(engine) -> None:
|
||||
continue
|
||||
state = state_by_key.get(module.key)
|
||||
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)
|
||||
|
||||
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:
|
||||
payload = json.dumps(list(dependencies), ensure_ascii=False)
|
||||
existing = conn.execute(text("SELECT id FROM module_states WHERE `key` = :key"), {"key": key}).first()
|
||||
|
||||
@@ -7,9 +7,6 @@ class ApiTokenRepository:
|
||||
def __init__(self, db: Session):
|
||||
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:
|
||||
return self.db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.user_id == user_id).first()
|
||||
|
||||
|
||||
@@ -74,6 +74,16 @@ class ApiTokenService:
|
||||
raise not_found("error.apiTokenNotFound", "API token not found")
|
||||
if not api_token.token_value:
|
||||
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
|
||||
|
||||
def _new_unique_token(self) -> str:
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.schemas.settings import PublicSettings
|
||||
from app.services.audit import audit_changes, audit_detail, record_audit
|
||||
from app.services.permissions import get_user_permission_codes
|
||||
from app.services.settings import SettingService
|
||||
from app.services.users import user_profile_snapshot
|
||||
|
||||
|
||||
class AuthService:
|
||||
@@ -113,7 +114,7 @@ class AuthService:
|
||||
return token, user, permissions
|
||||
|
||||
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.phone = payload.phone
|
||||
user.email = payload.email
|
||||
@@ -126,7 +127,7 @@ class AuthService:
|
||||
"user",
|
||||
str(user.id),
|
||||
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()
|
||||
return user
|
||||
@@ -145,13 +146,3 @@ class AuthService:
|
||||
audit_detail(user.username, meta={"password_changed": True}),
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class RoleService:
|
||||
if role.code == ADMIN_ROLE:
|
||||
role.permissions = self.roles.permissions()
|
||||
else:
|
||||
role.permissions = self.roles.permissions_by_ids(payload.permission_ids)
|
||||
role.permissions = self._permissions_by_ids_strict(payload.permission_ids)
|
||||
record_audit(
|
||||
self.db,
|
||||
actor_id,
|
||||
@@ -93,6 +93,13 @@ class RoleService:
|
||||
self.db.commit()
|
||||
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]:
|
||||
return {
|
||||
|
||||
@@ -64,6 +64,8 @@ class UserService:
|
||||
department=payload.department,
|
||||
password_hash=hash_password(payload.password),
|
||||
approval_status="approved",
|
||||
approved_by=actor.id,
|
||||
approved_at=utc_now(),
|
||||
is_active=True,
|
||||
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:
|
||||
user = self.get_user(user_id)
|
||||
before = _user_profile_snapshot(user)
|
||||
before = user_profile_snapshot(user)
|
||||
user.full_name = payload.full_name
|
||||
user.phone = payload.phone
|
||||
user.email = payload.email
|
||||
@@ -95,7 +97,7 @@ class UserService:
|
||||
"user",
|
||||
str(user.id),
|
||||
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()
|
||||
return user
|
||||
@@ -209,7 +211,7 @@ class UserService:
|
||||
|
||||
def assign_roles(self, actor: User, user_id: int, payload: AssignRolesRequest) -> User:
|
||||
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_self_admin_role(actor, user, roles)
|
||||
before = {"roles": _role_codes(user.roles)}
|
||||
@@ -231,12 +233,18 @@ class UserService:
|
||||
raise forbidden("error.lastAdminRequired", "The last administrator cannot be changed")
|
||||
|
||||
def _roles_or_default(self, role_ids: list[int] | None) -> list[Role]:
|
||||
roles = self.roles.by_ids(role_ids or [])
|
||||
if roles:
|
||||
return roles
|
||||
if role_ids:
|
||||
return self._roles_by_ids_strict(role_ids)
|
||||
default_role = self.roles.get_by_code(USER_ROLE)
|
||||
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:
|
||||
if not any(role.code == ADMIN_ROLE for role in target.roles) or self.users.count_admins() > 1:
|
||||
return
|
||||
@@ -254,7 +262,7 @@ def _role_codes(roles: list[Role]) -> list[str]:
|
||||
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 {
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
@@ -267,7 +275,7 @@ def _user_profile_snapshot(user: User) -> dict[str, str]:
|
||||
def _user_snapshot(user: User) -> dict[str, object]:
|
||||
return {
|
||||
"username": user.username,
|
||||
**_user_profile_snapshot(user),
|
||||
**user_profile_snapshot(user),
|
||||
"approval_status": user.approval_status,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
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:
|
||||
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()
|
||||
@@ -373,7 +378,7 @@ def test_module_lifecycle_hooks_run_for_install_upgrade_and_disable(tmp_path, mo
|
||||
assert events == ["install", "upgrade", "disable"]
|
||||
assert state == ("1.1.0", "disabled")
|
||||
assert records == [
|
||||
"hook:hooked:disable:counter",
|
||||
"hook:hooked:disable:1.1.0:counter",
|
||||
"hook:hooked:install:1.0.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
|
||||
|
||||
|
||||
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):
|
||||
client = create_client(tmp_path, monkeypatch)
|
||||
payload = install_sqlite(client, tmp_path)
|
||||
|
||||
@@ -13,6 +13,7 @@ const appConfig = readOptionalJson(path.join(projectDir, "app.config.json"));
|
||||
const moduleEntries = new Map();
|
||||
|
||||
assert(requiredLocales.length > 0, "No base locale files found");
|
||||
assertSameJsonKeys(baseMessages, "i18n/locales");
|
||||
assertModuleFilters(appConfig, moduleNames);
|
||||
|
||||
for (const moduleName of moduleNames) {
|
||||
|
||||
@@ -33,7 +33,7 @@ const FIELD_LABEL_KEYS: Record<string, I18nKey> = {
|
||||
description: "field.description",
|
||||
reason: "field.reason",
|
||||
role_ids: "field.roles",
|
||||
permission_ids: "field.roles",
|
||||
permission_ids: "field.permissions",
|
||||
title: "field.title",
|
||||
content: "field.content",
|
||||
target_type: "field.targetType",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<n-modal
|
||||
v-model:show="showAnnouncementModal"
|
||||
preset="card"
|
||||
class="modal-card announcement-popup-modal"
|
||||
class="modal-card"
|
||||
:title="popupAnnouncement?.title || t('dashboard.announcements')"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
@@ -107,7 +107,7 @@ const collapsed = ref(localStorage.getItem(SIDEBAR_KEY) === "1");
|
||||
const route = useRoute();
|
||||
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 activeMenu = computed(() => (hasMenuPath(menuItems.value, route.path) ? route.path : null));
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
@@ -160,10 +160,6 @@ function renderIcon(icon: Component) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) });
|
||||
}
|
||||
|
||||
function isFeatureEnabled(feature?: string) {
|
||||
return feature !== "api" || settingsStore.apiEnabled();
|
||||
}
|
||||
|
||||
function toMenuOptions(items: AppMenuItem[]) {
|
||||
return items.map((item) => {
|
||||
const option: MenuOption = {
|
||||
@@ -192,10 +188,10 @@ async function closeTickerAnnouncement(item: { id: number }) {
|
||||
|
||||
async function acknowledgePopup() {
|
||||
const item = popupAnnouncement.value;
|
||||
showAnnouncementModal.value = false;
|
||||
if (item) {
|
||||
await markAnnouncementRead(item);
|
||||
}
|
||||
showAnnouncementModal.value = Boolean(popupAnnouncement.value);
|
||||
}
|
||||
|
||||
async function markAnnouncementRead(item: { id: number }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rawName = __APP_CONFIG__.appName?.trim();
|
||||
|
||||
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_SQLITE_PATH = `runtime/${APP_SLUG}.db`;
|
||||
|
||||
@@ -9,6 +9,8 @@ export function appKey(name: string) {
|
||||
return `${APP_SLUG}.${name}`;
|
||||
}
|
||||
|
||||
export const LOCALE_STORAGE_KEY = appKey("locale");
|
||||
|
||||
function normalizeSlug(value: string | undefined) {
|
||||
return value?.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "";
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return !DEPRECATED_PERMISSION_CODES.has(code);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, reactive } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
import { appKey } from "../config/app";
|
||||
import { LOCALE_STORAGE_KEY } from "../config/app";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
defaultMessages,
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
export { DEFAULT_LOCALE, isLocale, locales };
|
||||
export type { I18nKey, Locale, TranslateParams };
|
||||
|
||||
const LOCALE_KEY = appKey("locale");
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale(),
|
||||
@@ -81,8 +79,8 @@ export function formatDateTime(value: string | number | Date) {
|
||||
return new Date(value).toLocaleString(currentLocale());
|
||||
}
|
||||
|
||||
function initialLocale(): Locale {
|
||||
const saved = localStorage.getItem(LOCALE_KEY);
|
||||
export function initialLocale(): Locale {
|
||||
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
return isLocale(saved) ? saved : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"departmentOrPosition": "Department/Position",
|
||||
"role": "Role",
|
||||
"roles": "Roles",
|
||||
"permissions": "Permissions",
|
||||
"reason": "Reason",
|
||||
"roleCode": "Role code",
|
||||
"roleName": "Role name",
|
||||
@@ -148,6 +149,7 @@
|
||||
"lastAdminRoleRequired": "The last administrator role cannot be removed",
|
||||
"selfAdminRoleRequired": "You cannot remove your own administrator role",
|
||||
"roleNotFound": "Role not found",
|
||||
"permissionNotFound": "Permission not found",
|
||||
"roleCodeExists": "Role code already exists",
|
||||
"builtinRoleCannotDelete": "Built-in roles cannot be deleted",
|
||||
"databaseConnectionFailed": "Database connection failed. Check the connection config",
|
||||
@@ -376,7 +378,8 @@
|
||||
},
|
||||
"api_token": {
|
||||
"create": "Created token",
|
||||
"delete": "Deleted token"
|
||||
"delete": "Deleted token",
|
||||
"reveal": "Revealed token secret"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"departmentOrPosition": "部门/职位",
|
||||
"role": "角色",
|
||||
"roles": "角色",
|
||||
"permissions": "权限",
|
||||
"reason": "驳回原因",
|
||||
"roleCode": "角色编码",
|
||||
"roleName": "角色名称",
|
||||
@@ -148,6 +149,7 @@
|
||||
"lastAdminRoleRequired": "不能移除最后一个管理员的管理员角色",
|
||||
"selfAdminRoleRequired": "不能移除自己的管理员角色",
|
||||
"roleNotFound": "角色不存在",
|
||||
"permissionNotFound": "权限不存在",
|
||||
"roleCodeExists": "角色编码已存在",
|
||||
"builtinRoleCannotDelete": "内置角色不能删除",
|
||||
"databaseConnectionFailed": "数据库连接失败,请检查连接配置",
|
||||
@@ -376,7 +378,8 @@
|
||||
},
|
||||
"api_token": {
|
||||
"create": "创建 Token",
|
||||
"delete": "删除 Token"
|
||||
"delete": "删除 Token",
|
||||
"reveal": "显示完整 Token"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
|
||||
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 MessageTree = { [key: string]: string | MessageTree };
|
||||
export type LocaleMessages = MessageTree & { language: string };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<section class="work-card table-page-card">
|
||||
<div class="toolbar demo-crud-toolbar">
|
||||
<div class="toolbar">
|
||||
<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.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>
|
||||
</div>
|
||||
<permission-button :permission="DEMO_ITEM_CREATE" type="primary" @click="openCreate">{{ t("demoCrud.add") }}</permission-button>
|
||||
|
||||
+6
-11
@@ -3,14 +3,12 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import { AUTH_EXPIRED_EVENT } from "../api/client";
|
||||
import { getInstallStatus } from "../api/install";
|
||||
import { getMe } from "../api/auth";
|
||||
import { appKey } from "../config/app";
|
||||
import { LOCALE_STORAGE_KEY } from "../config/app";
|
||||
import { appStore } from "../stores/app";
|
||||
import { authStore } from "../stores/auth";
|
||||
import { settingsStore } from "../stores/settings";
|
||||
import { createAppPageRoutes, getFallbackPath } from "./page-registry";
|
||||
|
||||
const LOCALE_KEY = appKey("locale");
|
||||
|
||||
const routes = [
|
||||
{ path: "/install", component: () => import("../views/InstallView.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) {
|
||||
try {
|
||||
const publicSettings = await settingsStore.loadPublic();
|
||||
if (!localStorage.getItem(LOCALE_KEY)) {
|
||||
if (!localStorage.getItem(LOCALE_STORAGE_KEY)) {
|
||||
await appStore.setLocale(publicSettings.default_locale);
|
||||
}
|
||||
} catch {
|
||||
@@ -77,12 +75,13 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
const permission = to.meta.permission as string | undefined;
|
||||
const feature = to.meta.feature as string | undefined;
|
||||
if (feature && !isFeatureEnabled(feature)) {
|
||||
const path = getFallbackPath((code) => authStore.has(code), isFeatureEnabled);
|
||||
const featureEnabled = (value?: string) => settingsStore.featureEnabled(value);
|
||||
if (feature && !featureEnabled(feature)) {
|
||||
const path = getFallbackPath((code) => authStore.has(code), featureEnabled);
|
||||
return to.path === path ? true : path;
|
||||
}
|
||||
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 true;
|
||||
@@ -95,7 +94,3 @@ async function loadInstallStatus() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isFeatureEnabled(feature?: string) {
|
||||
return feature !== "api" || settingsStore.apiEnabled();
|
||||
}
|
||||
|
||||
+3
-13
@@ -1,12 +1,10 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
import { appKey } from "../config/app";
|
||||
import { setI18nLocale } from "../i18n";
|
||||
import { appKey, LOCALE_STORAGE_KEY } from "../config/app";
|
||||
import { initialLocale, setI18nLocale } from "../i18n";
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from "../i18n/messages";
|
||||
import { settingsStore } from "./settings";
|
||||
|
||||
const THEME_KEY = appKey("dark");
|
||||
const LOCALE_KEY = appKey("locale");
|
||||
|
||||
export const appStore = reactive({
|
||||
dark: localStorage.getItem(THEME_KEY) === "1",
|
||||
@@ -20,7 +18,7 @@ export const appStore = reactive({
|
||||
const nextLocale = isLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
await setI18nLocale(nextLocale);
|
||||
appStore.locale = nextLocale;
|
||||
localStorage.setItem(LOCALE_KEY, appStore.locale);
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, appStore.locale);
|
||||
document.documentElement.lang = appStore.locale;
|
||||
}
|
||||
});
|
||||
@@ -31,11 +29,3 @@ document.documentElement.lang = appStore.locale;
|
||||
function applyTheme(dark: boolean) {
|
||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||
}
|
||||
|
||||
function initialLocale(): Locale {
|
||||
const saved = localStorage.getItem(LOCALE_KEY);
|
||||
if (isLocale(saved)) {
|
||||
return saved;
|
||||
}
|
||||
return settingsStore.defaultLocale();
|
||||
}
|
||||
|
||||
@@ -42,5 +42,8 @@ export const settingsStore = reactive({
|
||||
},
|
||||
apiTokenRevealEnabled() {
|
||||
return this.publicSettings.api_token_reveal_enabled;
|
||||
},
|
||||
featureEnabled(feature?: string) {
|
||||
return feature !== "api" || this.apiEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1054,13 +1054,6 @@ a {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.module-placeholder {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.announcement-ticker {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
@@ -1353,7 +1346,6 @@ a {
|
||||
}
|
||||
|
||||
.filter-keyword,
|
||||
.filter-select,
|
||||
.filter-date-range {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1980,21 +1972,6 @@ a {
|
||||
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 {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
max-height: min(86vh, 820px);
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<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="announcement-filter-row">
|
||||
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('announcement.searchPlaceholder')" clearable />
|
||||
|
||||
@@ -241,6 +241,7 @@ import {
|
||||
} from "../api/openapi";
|
||||
import { t } from "../i18n";
|
||||
import { openApiText } from "../i18n/openapi";
|
||||
import { copyText } from "../utils/clipboard";
|
||||
import { saveBlob } from "../utils/download";
|
||||
import { showError } from "../utils/message";
|
||||
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
|
||||
@@ -384,7 +385,7 @@ async function loadDocument() {
|
||||
|
||||
async function copyDocument() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(documentText.value);
|
||||
await copyText(documentText.value);
|
||||
message.success(t("common.copied"));
|
||||
} catch {
|
||||
message.error(t("message.operationFailed"));
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</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="form-actions">
|
||||
<n-button type="primary" @click="showDetailModal = false">{{ t("common.close") }}</n-button>
|
||||
|
||||
@@ -189,7 +189,12 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
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));
|
||||
roles.value = roles.value.map((role) => ({
|
||||
...role,
|
||||
|
||||
@@ -114,7 +114,6 @@ import { backupData, getSystemSettings, updateSystemSettings } from "../api/sett
|
||||
import type { SystemSettings } from "../api/types";
|
||||
import PermissionButton from "../components/PermissionButton.vue";
|
||||
import { ensureLocaleNames, localeOptions, t } from "../i18n";
|
||||
import { appStore } from "../stores/app";
|
||||
import { settingsStore } from "../stores/settings";
|
||||
import { saveBlob } from "../utils/download";
|
||||
import { showError } from "../utils/message";
|
||||
@@ -175,7 +174,6 @@ async function saveSettings() {
|
||||
const updated = await updateSystemSettings(form);
|
||||
assignSettings(updated);
|
||||
settingsStore.setPublic(updated);
|
||||
await appStore.setLocale(updated.default_locale);
|
||||
message.success(t("settings.saved"));
|
||||
} catch (error) {
|
||||
showError(message, error);
|
||||
|
||||
@@ -92,6 +92,7 @@ import StatusTag from "../components/StatusTag.vue";
|
||||
import { formatDateTime, t } from "../i18n";
|
||||
import { authStore } from "../stores/auth";
|
||||
import { settingsStore } from "../stores/settings";
|
||||
import { copyText } from "../utils/clipboard";
|
||||
import { messageText, showError } from "../utils/message";
|
||||
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
|
||||
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) {
|
||||
return value ? formatDateTime(value) : t("token.neverExpires");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user