From aec482cbcb4cb261168201997902d855dd9f9dad Mon Sep 17 00:00:00 2001 From: SunWuyuan Date: Mon, 6 Oct 2025 10:49:48 +0800 Subject: [PATCH] cskv --- kv-admin/src/components/AppCard.vue | 68 ++++++++--------- kv-admin/src/pages/authorize.vue | 46 +++++++++--- kv-admin/src/pages/index.vue | 24 +++++- kv-admin/src/pages/password-manager.vue | 36 +++++++-- middleware/kvTokenAuth.js | 2 - .../20251005011323_update/migration.sql | 17 +++++ prisma/schema.prisma | 20 +---- routes/accounts.js | 11 --- routes/apps.js | 74 ++----------------- routes/kv-token.js | 48 ++++++++++++ 10 files changed, 193 insertions(+), 153 deletions(-) create mode 100644 prisma/migrations/20251005011323_update/migration.sql diff --git a/kv-admin/src/components/AppCard.vue b/kv-admin/src/components/AppCard.vue index 472c5f6..39acfb6 100644 --- a/kv-admin/src/components/AppCard.vue +++ b/kv-admin/src/components/AppCard.vue @@ -38,18 +38,12 @@ const error = ref(null); const showDialog = ref(false); // 从环境变量获取 assets URL -const assetsBaseUrl = import.meta.env.VITE_ASSETS_URL || ""; +const assetsBaseUrl = "https://zerocat-bitiful.houlangs.com/material/asset"; -// 根据 iconHash 生成图片 URL +// 根据 logo_url 生成图片 URL const iconUrl = computed(() => { - if (!app.value?.iconHash) return null; - const hash = app.value.iconHash; - if (hash.length < 4) return null; - - const folder1 = hash.substring(0, 2); - const folder2 = hash.substring(2, 4); - - return `${assetsBaseUrl}/${folder1}/${folder2}/${hash}.webp`; + if (!app.value?.logo_url) return null; + return `${assetsBaseUrl}/${app.value.logo_url}`; }); // 渲染 Markdown 为 HTML @@ -61,9 +55,15 @@ const renderedReadme = computed(() => { // 获取应用信息 const fetchApp = async () => { try { - app.value = await axios.get(`/apps/info/${props.appId}`); + const response = await fetch(`https://zerocat-api.houlangs.com/oauth/applications/${props.appId}`); - if (app.value.repositoryUrl) { + if (!response.ok) { + throw new Error(`Failed to fetch app info: ${response.status}`); + } + + app.value = await response.json(); + + if (app.value.homepage_url) { await fetchReadme(); } } catch (err) { @@ -75,9 +75,9 @@ const fetchApp = async () => { // 检测 Git 平台并获取 README const fetchReadme = async () => { - if (!app.value?.repositoryUrl) return; + if (!app.value?.homepage_url) return; - const url = app.value.repositoryUrl; + const url = app.value.homepage_url; let readmeUrl = null; try { @@ -207,7 +207,7 @@ fetchApp(); {{ app.description }}
- {{ app.developerName }} + {{ app.owner?.display_name || app.owner?.username }}
@@ -239,23 +239,12 @@ fetchApp();
开发者
-
{{ app.developerName }}
+
{{ app.owner?.display_name || app.owner?.username }}
-
-
开发者链接
- - 访问 - - -
-
+ -
-
仓库地址
+ +
+
隐私政策
+ + 查看
@@ -286,7 +286,7 @@ fetchApp(); >
无法加载 README 文件 diff --git a/kv-admin/src/pages/authorize.vue b/kv-admin/src/pages/authorize.vue index 02ba687..2526087 100644 --- a/kv-admin/src/pages/authorize.vue +++ b/kv-admin/src/pages/authorize.vue @@ -13,6 +13,7 @@ import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, C import AppCard from '@/components/AppCard.vue' import PasswordInput from '@/components/PasswordInput.vue' import LoginDialog from '@/components/LoginDialog.vue' +import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue' import { toast } from 'vue-sonner' const route = useRoute() @@ -34,6 +35,8 @@ const deviceAccount = ref(null) const showLoginDialog = ref(false) const showDeviceList = ref(false) const customDeviceUuid = ref('') +const showRegisterDialog = ref(false) +const deviceRequired = ref(false) // 计算属性获取是否有密码 const hasPassword = computed(() => deviceInfo.value?.hasPassword || false) @@ -224,14 +227,30 @@ const loadDeviceInfo = async () => { } } +// 更新设备UUID回调 +const updateUuid = () => { + showRegisterDialog.value = false + deviceUuid.value = deviceStore.getDeviceUuid() + loadDeviceInfo() + loadDeviceAccount() +} + onMounted(async () => { - deviceUuid.value = deviceStore.getOrGenerate() + // 检查是否存在设备UUID + const existingUuid = deviceStore.getDeviceUuid() + if (!existingUuid) { + deviceRequired.value = true + // 如果没有设备UUID,显示设备管理弹框 + showRegisterDialog.value = true + } else { + deviceUuid.value = existingUuid - // 加载设备信息 - await loadDeviceInfo() + // 加载设备信息 + await loadDeviceInfo() - // 加载设备账户信息 - await loadDeviceAccount() + // 加载设备账户信息 + await loadDeviceAccount() + } // 加载应用信息 await loadAppInfo() @@ -261,20 +280,16 @@ onMounted(async () => {
应用授权 - -
-
- +
+
@@ -467,5 +482,12 @@ onMounted(async () => { + + +
diff --git a/kv-admin/src/pages/index.vue b/kv-admin/src/pages/index.vue index 809ed72..1ae2d8d 100644 --- a/kv-admin/src/pages/index.vue +++ b/kv-admin/src/pages/index.vue @@ -27,6 +27,7 @@ const copied = ref(null) const deviceInfo = ref(null) // 存储设备信息 const deviceAccount = ref(null) // 设备账户信息 const accountStore = useAccountStore() +const appInfoCache = ref({}) // 缓存应用信息 // Dialogs const showAuthorizeDialog = ref(false) @@ -63,8 +64,6 @@ const groupedByApp = computed(() => { if (!groups[appId]) { groups[appId] = { appId: appId, - appName: token.appName || appId, - description: token.appDescription || '', tokens: [] } } @@ -108,6 +107,20 @@ const loadTokens = async () => { try { const response = await apiClient.getDeviceTokens(deviceUuid.value) tokens.value = response.tokens || [] + + // 预加载应用信息 + for (const token of tokens.value) { + if (!appInfoCache.value[token.appId]) { + try { + const appResponse = await fetch(`https://zerocat-api.houlangs.com/oauth/applications/${token.appId}`) + if (appResponse.ok) { + appInfoCache.value[token.appId] = await appResponse.json() + } + } catch (err) { + console.error(`Failed to load app info for ${token.appId}:`, err) + } + } + } } catch (error) { console.error('Failed to load tokens:', error) if (error.message.includes('设备不存在')) { @@ -118,6 +131,11 @@ const loadTokens = async () => { } } +// 获取应用名称的辅助函数 +const getAppName = (appId) => { + return appInfoCache.value[appId]?.name || `应用 ${appId}` +} + const authorizeApp = async () => { if (!appIdToAuthorize.value) return @@ -715,7 +733,7 @@ onMounted(async () => {
应用: - {{ selectedToken.appName }} + {{ getAppName(selectedToken.appId) }}
令牌: diff --git a/kv-admin/src/pages/password-manager.vue b/kv-admin/src/pages/password-manager.vue index e19e2d2..67d07be 100644 --- a/kv-admin/src/pages/password-manager.vue +++ b/kv-admin/src/pages/password-manager.vue @@ -42,6 +42,8 @@ const showChangePasswordDialog = ref(false) const showDeletePasswordDialog = ref(false) const showHintDialog = ref(false) const showResetDeviceDialog = ref(false) +const showRegisterDialog = ref(false) +const deviceRequired = ref(false) // Form data const currentPassword = ref('') @@ -237,15 +239,30 @@ const handleDeviceReset = () => { }, 3000) } +// 更新设备UUID回调 +const updateUuid = () => { + showRegisterDialog.value = false + deviceUuid.value = deviceStore.getDeviceUuid() + loadDeviceInfo() +} + onMounted(async () => { - deviceUuid.value = deviceStore.getOrGenerate() + // 检查是否存在设备UUID + const existingUuid = deviceStore.getDeviceUuid() + if (!existingUuid) { + deviceRequired.value = true + // 如果没有设备UUID,显示设备管理弹框 + showRegisterDialog.value = true + } else { + deviceUuid.value = existingUuid - // 加载设备信息 - await loadDeviceInfo() + // 加载设备信息 + await loadDeviceInfo() - // 如果有密码但密码提示不存在,单独加载密码提示 - if (hasPassword.value && !passwordHint.value) { - await loadPasswordHint() + // 如果有密码但密码提示不存在,单独加载密码提示 + if (hasPassword.value && !passwordHint.value) { + await loadPasswordHint() + } } }) @@ -587,5 +604,12 @@ onMounted(async () => { @confirm="handleDeviceReset" @update:modelValue="val => showResetDeviceDialog = val" /> + + +
\ No newline at end of file diff --git a/middleware/kvTokenAuth.js b/middleware/kvTokenAuth.js index d1721d7..ff1b847 100644 --- a/middleware/kvTokenAuth.js +++ b/middleware/kvTokenAuth.js @@ -27,7 +27,6 @@ export const kvTokenAuth = async (req, res, next) => { const appInstall = await prisma.appInstall.findUnique({ where: { token }, include: { - app: true, device: true, }, }); @@ -38,7 +37,6 @@ export const kvTokenAuth = async (req, res, next) => { // 将信息存储到res.locals供后续使用 res.locals.device = appInstall.device; - res.locals.app = appInstall.app; res.locals.appInstall = appInstall; res.locals.deviceId = appInstall.device.id; diff --git a/prisma/migrations/20251005011323_update/migration.sql b/prisma/migrations/20251005011323_update/migration.sql new file mode 100644 index 0000000..109bf20 --- /dev/null +++ b/prisma/migrations/20251005011323_update/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the `App` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `AppInstall` DROP FOREIGN KEY `AppInstall_appId_fkey`; + +-- DropIndex +DROP INDEX `AppInstall_appId_fkey` ON `AppInstall`; + +-- AlterTable +ALTER TABLE `AppInstall` MODIFY `appId` VARCHAR(191) NOT NULL; + +-- DropTable +DROP TABLE `App`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b4c51e4..543b38a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,27 +56,10 @@ model Device { kvStore KVStore[] // 设备相关的KV存储 } -model App { - id Int @id @default(autoincrement()) // 自增ID - name String // 应用名称 - description String? // 应用简介 - developerName String // 开发者名称 - developerLink String? // 开发者链接 - homepageLink String? // 应用首页链接 - iconHash String? // 图标hash - repositoryUrl String? // Git仓库地址 (支持 GitHub, GitLab, Bitbucket, Gitea, Forgejo) - metadata Json? // 元数据 - createdAt DateTime @default(now()) // 自动生成创建时间 - updatedAt DateTime @updatedAt // 自动更新时间 - - // 关联的应用安装记录 - installs AppInstall[] -} - model AppInstall { id String @id @default(cuid()) deviceId Int // 关联的设备ID - appId Int // 关联的应用ID + appId String // 应用ID (SHA256 hash) token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限 note String? // 安装备注 installedAt DateTime @default(now()) @@ -84,5 +67,4 @@ model AppInstall { // 关联关系 device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) - app App @relation(fields: [appId], references: [id], onDelete: Cascade) } diff --git a/routes/accounts.js b/routes/accounts.js index 2628fe2..75ef0c4 100644 --- a/routes/accounts.js +++ b/routes/accounts.js @@ -465,17 +465,6 @@ router.get("/devices", jwtAuth, async (req, res, next) => { name: true, createdAt: true, updatedAt: true, - appInstalls: { - include: { - app: { - select: { - id: true, - name: true, - iconHash: true, - }, - }, - }, - }, }, }, }, diff --git a/routes/apps.js b/routes/apps.js index cbf408f..b1bf386 100644 --- a/routes/apps.js +++ b/routes/apps.js @@ -28,14 +28,12 @@ router.get( const installations = await prisma.appInstall.findMany({ where: { deviceId: device.id }, - include: { app: true }, }); const apps = installations.map(install => ({ - id: install.app.id, - name: install.app.name, - description: install.app.description, + appId: install.appId, token: install.token, + note: install.note, installedAt: install.createdAt, })); @@ -49,6 +47,7 @@ router.get( /** * POST /apps/devices/:uuid/install/:appId * 为设备安装应用 (需要UUID认证) + * appId 现在是 SHA256 hash */ router.post( "/devices/:uuid/install/:appId", @@ -58,16 +57,6 @@ router.post( const { appId } = req.params; const { note } = req.body; - // 检查应用是否存在 - const app = await prisma.app.findUnique({ - where: { id: parseInt(appId) }, - }); - - if (!app) { - return next(errors.createError(404, "应用不存在")); - } - - // 生成token const token = crypto.randomBytes(32).toString("hex"); @@ -75,7 +64,7 @@ router.post( const installation = await prisma.appInstall.create({ data: { deviceId: device.id, - appId: app.id, + appId: appId, token, note: note || null, }, @@ -83,8 +72,7 @@ router.post( return res.status(201).json({ id: installation.id, - appId: app.id, - appName: app.name, + appId: installation.appId, token: installation.token, note: installation.note, installedAt: installation.createdAt, @@ -124,30 +112,6 @@ router.delete( }) ); -/** - * GET /apps - * 获取所有可用应用列表 - */ -router.get( - "/", - errors.catchAsync(async (req, res) => { - const apps = await prisma.app.findMany({ - select: { - id: true, - name: true, - description: true, - createdAt: true, - }, - }); - - return res.json({ - success: true, - apps, - }); - }) -); - - /** * GET /apps/tokens * 获取设备的token列表 (需要设备UUID) @@ -173,24 +137,13 @@ router.get( // 获取该设备的所有应用安装记录(即token) const installations = await prisma.appInstall.findMany({ where: { deviceId: device.id }, - include: { - app: { - select: { - id: true, - name: true, - description: true, - }, - }, - }, orderBy: { installedAt: 'desc' }, }); const tokens = installations.map(install => ({ - id: install.id, // 安装记录ID + id: install.id, token: install.token, - appId: install.app.id, - appName: install.app.name, - appDescription: install.app.description, + appId: install.appId, installedAt: install.installedAt, note: install.note, })); @@ -202,16 +155,5 @@ router.get( }); }) ); -router.get("/info/:appid", - errors.catchAsync(async (req, res, next) => { - const { appid } = req.params; - const app = await prisma.app.findUnique({ - where: { id: parseInt(appid) }, - }); - if (!app) { - return next(errors.createError(404, "应用不存在")); - } - return res.json(app); - }) -); + export default router; \ No newline at end of file diff --git a/routes/kv-token.js b/routes/kv-token.js index 8bd9902..674703a 100644 --- a/routes/kv-token.js +++ b/routes/kv-token.js @@ -3,10 +3,58 @@ const router = Router(); import kvStore from "../utils/kvStore.js"; import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; import errors from "../utils/errors.js"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); // 使用KV专用token认证 router.use(kvTokenAuth); +/** + * GET /_info + * 获取当前token所属设备的信息,如果关联了账号也返回账号信息 + */ +router.get( + "/_info", + errors.catchAsync(async (req, res) => { + const deviceId = res.locals.deviceId; + + // 获取设备信息,包含关联的账号 + const device = await prisma.device.findUnique({ + where: { id: deviceId }, + include: { + account: true, + }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 构建响应对象 + const response = { + device: { + id: device.id, + uuid: device.uuid, + name: device.name, + createdAt: device.createdAt, + updatedAt: device.updatedAt, + }, + }; + + // 如果关联了账号,添加账号信息 + if (device.account) { + response.account = { + id: device.account.id, + name: device.account.name, + avatarUrl: device.account.avatarUrl, + }; + } + + return res.json(response); + }) +); + /** * GET /_keys * 获取当前token对应设备的键名列表(分页,不包括内容)