1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-10-22 02:03:11 +00:00
This commit is contained in:
SunWuyuan 2025-10-06 10:49:48 +08:00
parent 7b1e224f70
commit aec482cbcb
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
10 changed files with 193 additions and 153 deletions

View File

@ -38,18 +38,12 @@ const error = ref(null);
const showDialog = ref(false); const showDialog = ref(false);
// assets URL // 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(() => { const iconUrl = computed(() => {
if (!app.value?.iconHash) return null; if (!app.value?.logo_url) return null;
const hash = app.value.iconHash; return `${assetsBaseUrl}/${app.value.logo_url}`;
if (hash.length < 4) return null;
const folder1 = hash.substring(0, 2);
const folder2 = hash.substring(2, 4);
return `${assetsBaseUrl}/${folder1}/${folder2}/${hash}.webp`;
}); });
// Markdown HTML // Markdown HTML
@ -61,9 +55,15 @@ const renderedReadme = computed(() => {
// //
const fetchApp = async () => { const fetchApp = async () => {
try { 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(); await fetchReadme();
} }
} catch (err) { } catch (err) {
@ -75,9 +75,9 @@ const fetchApp = async () => {
// Git README // Git README
const fetchReadme = async () => { 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; let readmeUrl = null;
try { try {
@ -207,7 +207,7 @@ fetchApp();
{{ app.description }} {{ app.description }}
</CardDescription> </CardDescription>
<div class="mt-2 text-xs text-muted-foreground"> <div class="mt-2 text-xs text-muted-foreground">
<span>{{ app.developerName }}</span> <span>{{ app.owner?.display_name || app.owner?.username }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -239,23 +239,12 @@ fetchApp();
<div class="grid grid-cols-2 gap-4 py-4 border-y"> <div class="grid grid-cols-2 gap-4 py-4 border-y">
<div class="space-y-1"> <div class="space-y-1">
<div class="text-sm text-muted-foreground">开发者</div> <div class="text-sm text-muted-foreground">开发者</div>
<div class="font-medium">{{ app.developerName }}</div> <div class="font-medium">{{ app.owner?.display_name || app.owner?.username }}</div>
</div> </div>
<div v-if="app.developerLink" class="space-y-1"> <div v-if="app.homepage_url" class="space-y-1">
<div class="text-sm text-muted-foreground">开发者链接</div>
<a
:href="app.developerLink"
target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1"
>
访问
<ExternalLink class="h-3 w-3" />
</a>
</div>
<div v-if="app.homepageLink" class="space-y-1">
<div class="text-sm text-muted-foreground">应用主页</div> <div class="text-sm text-muted-foreground">应用主页</div>
<a <a
:href="app.homepageLink" :href="app.homepage_url"
target="_blank" target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1" class="text-primary hover:underline inline-flex items-center gap-1"
> >
@ -263,14 +252,25 @@ fetchApp();
<ExternalLink class="h-3 w-3" /> <ExternalLink class="h-3 w-3" />
</a> </a>
</div> </div>
<div v-if="app.repositoryUrl" class="space-y-1"> <div v-if="app.terms_url" class="space-y-1">
<div class="text-sm text-muted-foreground">仓库地址</div> <div class="text-sm text-muted-foreground">服务条款</div>
<a <a
:href="app.repositoryUrl" :href="app.terms_url"
target="_blank" target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1 truncate" class="text-primary hover:underline inline-flex items-center gap-1 truncate"
> >
查看仓库 查看
<ExternalLink class="h-3 w-3" />
</a>
</div>
<div v-if="app.privacy_url" class="space-y-1">
<div class="text-sm text-muted-foreground">隐私政策</div>
<a
:href="app.privacy_url"
target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
>
查看
<ExternalLink class="h-3 w-3" /> <ExternalLink class="h-3 w-3" />
</a> </a>
</div> </div>
@ -286,7 +286,7 @@ fetchApp();
></div> ></div>
</div> </div>
<div <div
v-else-if="!loading && app?.repositoryUrl" v-else-if="!loading && app?.homepage_url"
class="mt-6 text-center text-muted-foreground" class="mt-6 text-center text-muted-foreground"
> >
无法加载 README 文件 无法加载 README 文件

View File

@ -13,6 +13,7 @@ import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, C
import AppCard from '@/components/AppCard.vue' import AppCard from '@/components/AppCard.vue'
import PasswordInput from '@/components/PasswordInput.vue' import PasswordInput from '@/components/PasswordInput.vue'
import LoginDialog from '@/components/LoginDialog.vue' import LoginDialog from '@/components/LoginDialog.vue'
import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
const route = useRoute() const route = useRoute()
@ -34,6 +35,8 @@ const deviceAccount = ref(null)
const showLoginDialog = ref(false) const showLoginDialog = ref(false)
const showDeviceList = ref(false) const showDeviceList = ref(false)
const customDeviceUuid = ref('') const customDeviceUuid = ref('')
const showRegisterDialog = ref(false)
const deviceRequired = ref(false)
// //
const hasPassword = computed(() => deviceInfo.value?.hasPassword || 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 () => { 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() await loadAppInfo()
@ -261,20 +280,16 @@ onMounted(async () => {
<div class="space-y-2 text-center"> <div class="space-y-2 text-center">
<CardTitle class="text-2xl">应用授权</CardTitle> <CardTitle class="text-2xl">应用授权</CardTitle>
<CardDescription> <CardDescription>
<template v-if="appInfo">
授权 <span class="font-semibold">{{ appInfo.name }}</span> 访问您的 KV 存储
</template>
<template v-else>
授权应用访问您的 KV 存储 授权应用访问您的 KV 存储
</template>
</CardDescription> </CardDescription>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
<!-- 应用信息 --> <!-- 应用信息 -->
<div v-if="appInfo"> <div>
<AppCard :app-id="parseInt(appId)" class="mb-4" /> <AppCard :app-id="appId" class="mb-4" />
</div> </div>
<!-- 设备信息 --> <!-- 设备信息 -->
@ -467,5 +482,12 @@ onMounted(async () => {
<!-- 登录弹框 --> <!-- 登录弹框 -->
<LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess" /> <LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess" />
<!-- 设备注册弹框 -->
<DeviceRegisterDialog
v-model="showRegisterDialog"
@confirm="updateUuid"
:required="deviceRequired"
/>
</div> </div>
</template> </template>

View File

@ -27,6 +27,7 @@ const copied = ref(null)
const deviceInfo = ref(null) // const deviceInfo = ref(null) //
const deviceAccount = ref(null) // const deviceAccount = ref(null) //
const accountStore = useAccountStore() const accountStore = useAccountStore()
const appInfoCache = ref({}) //
// Dialogs // Dialogs
const showAuthorizeDialog = ref(false) const showAuthorizeDialog = ref(false)
@ -63,8 +64,6 @@ const groupedByApp = computed(() => {
if (!groups[appId]) { if (!groups[appId]) {
groups[appId] = { groups[appId] = {
appId: appId, appId: appId,
appName: token.appName || appId,
description: token.appDescription || '',
tokens: [] tokens: []
} }
} }
@ -108,6 +107,20 @@ const loadTokens = async () => {
try { try {
const response = await apiClient.getDeviceTokens(deviceUuid.value) const response = await apiClient.getDeviceTokens(deviceUuid.value)
tokens.value = response.tokens || [] 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) { } catch (error) {
console.error('Failed to load tokens:', error) console.error('Failed to load tokens:', error)
if (error.message.includes('设备不存在')) { if (error.message.includes('设备不存在')) {
@ -118,6 +131,11 @@ const loadTokens = async () => {
} }
} }
//
const getAppName = (appId) => {
return appInfoCache.value[appId]?.name || `应用 ${appId}`
}
const authorizeApp = async () => { const authorizeApp = async () => {
if (!appIdToAuthorize.value) return if (!appIdToAuthorize.value) return
@ -715,7 +733,7 @@ onMounted(async () => {
<div class="p-4 bg-muted rounded-lg space-y-2"> <div class="p-4 bg-muted rounded-lg space-y-2">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium">应用: </span> <span class="font-medium">应用: </span>
{{ selectedToken.appName }} {{ getAppName(selectedToken.appId) }}
</div> </div>
<div class="text-sm"> <div class="text-sm">
<span class="font-medium">令牌: </span> <span class="font-medium">令牌: </span>

View File

@ -42,6 +42,8 @@ const showChangePasswordDialog = ref(false)
const showDeletePasswordDialog = ref(false) const showDeletePasswordDialog = ref(false)
const showHintDialog = ref(false) const showHintDialog = ref(false)
const showResetDeviceDialog = ref(false) const showResetDeviceDialog = ref(false)
const showRegisterDialog = ref(false)
const deviceRequired = ref(false)
// Form data // Form data
const currentPassword = ref('') const currentPassword = ref('')
@ -237,15 +239,30 @@ const handleDeviceReset = () => {
}, 3000) }, 3000)
} }
// UUID
const updateUuid = () => {
showRegisterDialog.value = false
deviceUuid.value = deviceStore.getDeviceUuid()
loadDeviceInfo()
}
onMounted(async () => { 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) { if (hasPassword.value && !passwordHint.value) {
await loadPasswordHint() await loadPasswordHint()
}
} }
}) })
</script> </script>
@ -587,5 +604,12 @@ onMounted(async () => {
@confirm="handleDeviceReset" @confirm="handleDeviceReset"
@update:modelValue="val => showResetDeviceDialog = val" @update:modelValue="val => showResetDeviceDialog = val"
/> />
<!-- 必需注册弹框 -->
<DeviceRegisterDialog
v-model="showRegisterDialog"
@confirm="updateUuid"
:required="deviceRequired"
/>
</div> </div>
</template> </template>

View File

@ -27,7 +27,6 @@ export const kvTokenAuth = async (req, res, next) => {
const appInstall = await prisma.appInstall.findUnique({ const appInstall = await prisma.appInstall.findUnique({
where: { token }, where: { token },
include: { include: {
app: true,
device: true, device: true,
}, },
}); });
@ -38,7 +37,6 @@ export const kvTokenAuth = async (req, res, next) => {
// 将信息存储到res.locals供后续使用 // 将信息存储到res.locals供后续使用
res.locals.device = appInstall.device; res.locals.device = appInstall.device;
res.locals.app = appInstall.app;
res.locals.appInstall = appInstall; res.locals.appInstall = appInstall;
res.locals.deviceId = appInstall.device.id; res.locals.deviceId = appInstall.device.id;

View File

@ -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`;

View File

@ -56,27 +56,10 @@ model Device {
kvStore KVStore[] // 设备相关的KV存储 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 { model AppInstall {
id String @id @default(cuid()) id String @id @default(cuid())
deviceId Int // 关联的设备ID deviceId Int // 关联的设备ID
appId Int // 关联的应用ID appId String // 应用ID (SHA256 hash)
token String @unique // 应用安装的唯一访问令牌拥有完整KV读写权限 token String @unique // 应用安装的唯一访问令牌拥有完整KV读写权限
note String? // 安装备注 note String? // 安装备注
installedAt DateTime @default(now()) installedAt DateTime @default(now())
@ -84,5 +67,4 @@ model AppInstall {
// 关联关系 // 关联关系
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
} }

View File

@ -465,17 +465,6 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
name: true, name: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
appInstalls: {
include: {
app: {
select: {
id: true,
name: true,
iconHash: true,
},
},
},
},
}, },
}, },
}, },

View File

@ -28,14 +28,12 @@ router.get(
const installations = await prisma.appInstall.findMany({ const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id }, where: { deviceId: device.id },
include: { app: true },
}); });
const apps = installations.map(install => ({ const apps = installations.map(install => ({
id: install.app.id, appId: install.appId,
name: install.app.name,
description: install.app.description,
token: install.token, token: install.token,
note: install.note,
installedAt: install.createdAt, installedAt: install.createdAt,
})); }));
@ -49,6 +47,7 @@ router.get(
/** /**
* POST /apps/devices/:uuid/install/:appId * POST /apps/devices/:uuid/install/:appId
* 为设备安装应用 (需要UUID认证) * 为设备安装应用 (需要UUID认证)
* appId 现在是 SHA256 hash
*/ */
router.post( router.post(
"/devices/:uuid/install/:appId", "/devices/:uuid/install/:appId",
@ -58,16 +57,6 @@ router.post(
const { appId } = req.params; const { appId } = req.params;
const { note } = req.body; const { note } = req.body;
// 检查应用是否存在
const app = await prisma.app.findUnique({
where: { id: parseInt(appId) },
});
if (!app) {
return next(errors.createError(404, "应用不存在"));
}
// 生成token // 生成token
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
@ -75,7 +64,7 @@ router.post(
const installation = await prisma.appInstall.create({ const installation = await prisma.appInstall.create({
data: { data: {
deviceId: device.id, deviceId: device.id,
appId: app.id, appId: appId,
token, token,
note: note || null, note: note || null,
}, },
@ -83,8 +72,7 @@ router.post(
return res.status(201).json({ return res.status(201).json({
id: installation.id, id: installation.id,
appId: app.id, appId: installation.appId,
appName: app.name,
token: installation.token, token: installation.token,
note: installation.note, note: installation.note,
installedAt: installation.createdAt, 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 * GET /apps/tokens
* 获取设备的token列表 (需要设备UUID) * 获取设备的token列表 (需要设备UUID)
@ -173,24 +137,13 @@ router.get(
// 获取该设备的所有应用安装记录即token // 获取该设备的所有应用安装记录即token
const installations = await prisma.appInstall.findMany({ const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id }, where: { deviceId: device.id },
include: {
app: {
select: {
id: true,
name: true,
description: true,
},
},
},
orderBy: { installedAt: 'desc' }, orderBy: { installedAt: 'desc' },
}); });
const tokens = installations.map(install => ({ const tokens = installations.map(install => ({
id: install.id, // 安装记录ID id: install.id,
token: install.token, token: install.token,
appId: install.app.id, appId: install.appId,
appName: install.app.name,
appDescription: install.app.description,
installedAt: install.installedAt, installedAt: install.installedAt,
note: install.note, 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; export default router;

View File

@ -3,10 +3,58 @@ const router = Router();
import kvStore from "../utils/kvStore.js"; import kvStore from "../utils/kvStore.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// 使用KV专用token认证 // 使用KV专用token认证
router.use(kvTokenAuth); 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 * GET /_keys
* 获取当前token对应设备的键名列表分页不包括内容 * 获取当前token对应设备的键名列表分页不包括内容