1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-10-21 17:53: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);
// 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 }}
</CardDescription>
<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>
@ -239,23 +239,12 @@ fetchApp();
<div class="grid grid-cols-2 gap-4 py-4 border-y">
<div class="space-y-1">
<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 v-if="app.developerLink" 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 v-if="app.homepage_url" class="space-y-1">
<div class="text-sm text-muted-foreground">应用主页</div>
<a
:href="app.homepageLink"
:href="app.homepage_url"
target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1"
>
@ -263,14 +252,25 @@ fetchApp();
<ExternalLink class="h-3 w-3" />
</a>
</div>
<div v-if="app.repositoryUrl" class="space-y-1">
<div class="text-sm text-muted-foreground">仓库地址</div>
<div v-if="app.terms_url" class="space-y-1">
<div class="text-sm text-muted-foreground">服务条款</div>
<a
:href="app.repositoryUrl"
:href="app.terms_url"
target="_blank"
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" />
</a>
</div>
@ -286,7 +286,7 @@ fetchApp();
></div>
</div>
<div
v-else-if="!loading && app?.repositoryUrl"
v-else-if="!loading && app?.homepage_url"
class="mt-6 text-center text-muted-foreground"
>
无法加载 README 文件

View File

@ -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 () => {
<div class="space-y-2 text-center">
<CardTitle class="text-2xl">应用授权</CardTitle>
<CardDescription>
<template v-if="appInfo">
授权 <span class="font-semibold">{{ appInfo.name }}</span> 访问您的 KV 存储
</template>
<template v-else>
授权应用访问您的 KV 存储
</template>
</CardDescription>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- 应用信息 -->
<div v-if="appInfo">
<AppCard :app-id="parseInt(appId)" class="mb-4" />
<div>
<AppCard :app-id="appId" class="mb-4" />
</div>
<!-- 设备信息 -->
@ -467,5 +482,12 @@ onMounted(async () => {
<!-- 登录弹框 -->
<LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess" />
<!-- 设备注册弹框 -->
<DeviceRegisterDialog
v-model="showRegisterDialog"
@confirm="updateUuid"
:required="deviceRequired"
/>
</div>
</template>

View File

@ -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 () => {
<div class="p-4 bg-muted rounded-lg space-y-2">
<div class="text-sm">
<span class="font-medium">应用: </span>
{{ selectedToken.appName }}
{{ getAppName(selectedToken.appId) }}
</div>
<div class="text-sm">
<span class="font-medium">令牌: </span>

View File

@ -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()
}
}
})
</script>
@ -587,5 +604,12 @@ onMounted(async () => {
@confirm="handleDeviceReset"
@update:modelValue="val => showResetDeviceDialog = val"
/>
<!-- 必需注册弹框 -->
<DeviceRegisterDialog
v-model="showRegisterDialog"
@confirm="updateUuid"
:required="deviceRequired"
/>
</div>
</template>

View File

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

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存储
}
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)
}

View File

@ -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,
},
},
},
},
},
},
},

View File

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

View File

@ -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对应设备的键名列表分页不包括内容