update
This commit is contained in:
@@ -1,70 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, toRef } from "vue";
|
||||||
|
import type { RepoInfo } from "~/composables/useGithubRepo";
|
||||||
interface Repo {
|
|
||||||
full_name: string;
|
|
||||||
html_url: string;
|
|
||||||
description?: string;
|
|
||||||
stargazers_count?: number;
|
|
||||||
forks_count?: number;
|
|
||||||
language?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
owner?: { login: string; avatar_url?: string; html_url?: string };
|
|
||||||
license?: { name?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{ repo: string }>();
|
const props = defineProps<{ repo: string }>();
|
||||||
const repoId = computed(() => props.repo.trim());
|
|
||||||
|
|
||||||
// parse owner/repo
|
const { data, loaded } = useGithubRepo(toRef(props, "repo"), "github");
|
||||||
const ownerRepo = computed(() => {
|
|
||||||
const parts = repoId.value
|
|
||||||
.split("/")
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
return parts.length === 2 ? { owner: parts[0], name: parts[1] } : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const key = computed(() => `github-${repoId.value}`);
|
const repoData = computed<RepoInfo | null>(() => data.value);
|
||||||
const url = computed(() => {
|
const pending = computed(() => !loaded.value);
|
||||||
if (ownerRepo.value && ownerRepo.value.owner && ownerRepo.value.name) {
|
|
||||||
return `https://api.github.com/repos/${encodeURIComponent(ownerRepo.value.owner)}/${encodeURIComponent(ownerRepo.value.name)}`;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, pending, error } = await useAsyncData<Repo | null>(
|
|
||||||
() => key.value,
|
|
||||||
() => {
|
|
||||||
if (!ownerRepo.value) {
|
|
||||||
throw new Error("repo prop must be in form owner/name");
|
|
||||||
}
|
|
||||||
return $fetch<Repo>(url.value);
|
|
||||||
},
|
|
||||||
{ watch: [repoId] },
|
|
||||||
);
|
|
||||||
|
|
||||||
const repoData = computed(() => data.value);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div
|
<div
|
||||||
v-if="!pending && !error && repoData"
|
v-if="!pending && repoData"
|
||||||
class="block rounded-xl p-3.5 md:p-4 bg-linear-to-b from-white/2 to-white/1 text-white/90 border border-white/4 shadow-lg hover:shadow-2xl hover:-translate-y-1.5 transition-all duration-180">
|
class="github-card block rounded-xl p-3.5 md:p-4 bg-linear-to-b from-white/2 to-white/1 text-white/90 border border-white/4 shadow-lg hover:shadow-2xl hover:-translate-y-1.5 transition-all duration-180">
|
||||||
<a
|
<a
|
||||||
:href="repoData.html_url"
|
:href="repoData.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="flex flex-col gap-0 no-underline text-inherit cursor-pointer">
|
class="flex flex-col gap-0 no-underline hover:no-underline text-inherit cursor-pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex justify-between items-start gap-3">
|
<div class="flex justify-between items-start gap-3">
|
||||||
<div class="flex flex-col flex-1">
|
<div class="flex flex-col flex-1">
|
||||||
<p class="inline-block no-underline text-inherit cursor-pointer group">
|
<p class="inline-block no-underline text-inherit cursor-pointer group">
|
||||||
<strong
|
<strong
|
||||||
class="text-sm font-semibold leading-tight group-hover:text-blue-400 transition-colors"
|
class="text-sm font-semibold leading-tight group-hover:text-blue-400 transition-colors"
|
||||||
>{{ repoData.full_name }}</strong
|
>{{ repoData.fullName }}</strong
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,21 +37,21 @@ const repoData = computed(() => data.value);
|
|||||||
>{{ repoData.language }}</span
|
>{{ repoData.language }}</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="repoData.stargazers_count"
|
v-if="repoData.convertStars"
|
||||||
class="inline-block bg-white/3 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
class="inline-block bg-white/3 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
||||||
>★ {{ repoData.stargazers_count }}</span
|
>★ {{ repoData.convertStars }}</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="repoData.forks_count"
|
v-if="repoData.convertForks"
|
||||||
class="inline-block bg-white/3 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
class="inline-block bg-white/3 text-white/90 px-2 py-0.5 rounded-full text-xs"
|
||||||
>⑂ {{ repoData.forks_count }}</span
|
>⑂ {{ repoData.convertForks }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
:href="repoData.html_url"
|
:href="repoData.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="shrink-0 flex items-center justify-center p-1 no-underline text-inherit cursor-pointer hover:opacity-100 transition-all duration-180">
|
class="shrink-0 flex items-center justify-center p-1 no-underline hover:no-underline text-inherit cursor-pointer hover:opacity-100 transition-all duration-180">
|
||||||
<svg
|
<svg
|
||||||
class="opacity-70 hover:opacity-100 hover:scale-110 transition-all duration-180"
|
class="opacity-70 hover:opacity-100 hover:scale-110 transition-all duration-180"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -108,8 +70,8 @@ const repoData = computed(() => data.value);
|
|||||||
|
|
||||||
<div class="mt-2.5 text-xs text-white/60 flex gap-2.5">
|
<div class="mt-2.5 text-xs text-white/60 flex gap-2.5">
|
||||||
<span v-if="repoData.license?.name">{{ repoData.license.name }}</span>
|
<span v-if="repoData.license?.name">{{ repoData.license.name }}</span>
|
||||||
<span v-if="repoData.updated_at"
|
<span v-if="repoData.updatedAt"
|
||||||
>· 更新于 {{ new Date(repoData.updated_at).toLocaleDateString() }}</span
|
>· 更新于 {{ new Date(repoData.updatedAt).toLocaleDateString() }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,3 +89,13 @@ const repoData = computed(() => data.value);
|
|||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(.prose .github-card a) {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.prose .github-card a:hover) {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
154
app/composables/useGithubRepo.ts
Normal file
154
app/composables/useGithubRepo.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { MaybeRef, Ref } from "vue";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import { computed, ref, toValue, watch } from "vue";
|
||||||
|
|
||||||
|
interface RepoLicense {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInfo {
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
stars: number;
|
||||||
|
forks: number;
|
||||||
|
convertStars: number | string;
|
||||||
|
convertForks: number | string;
|
||||||
|
watchers: number;
|
||||||
|
language: string;
|
||||||
|
languageColor: string;
|
||||||
|
archived: boolean;
|
||||||
|
visibility: "Private" | "Public";
|
||||||
|
template: boolean;
|
||||||
|
ownerType: "User" | "Organization";
|
||||||
|
license: RepoLicense | null;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGithubRepoResult {
|
||||||
|
data: Ref<RepoInfo | null>;
|
||||||
|
loaded: Ref<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = "__CLOUDBLOG_GITHUB_REPO__";
|
||||||
|
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const storage = useLocalStorage(
|
||||||
|
CACHE_KEY,
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
info: RepoInfo;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useGithubRepo(
|
||||||
|
repo: MaybeRef<string>,
|
||||||
|
provider: MaybeRef<"github" | "gitee" | undefined> = "github",
|
||||||
|
): UseGithubRepoResult {
|
||||||
|
const repoRef = computed(() => {
|
||||||
|
const info = toValue(repo);
|
||||||
|
const [owner = "", name = ""] = info.split("/");
|
||||||
|
return { owner: owner.trim(), name: name.trim() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerRef = computed(() => toValue(provider) ?? "github");
|
||||||
|
|
||||||
|
const data = ref<RepoInfo | null>(null);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
const { owner, name } = toValue(repoRef);
|
||||||
|
if (import.meta.server || !owner || !name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${providerRef.value === "github" ? "" : `${providerRef.value}:`}${owner}/${name}`;
|
||||||
|
const cached = storage.value[key];
|
||||||
|
if (cached?.info?.name && Date.now() - cached.updatedAt <= CACHE_TTL) {
|
||||||
|
data.value = cached.info;
|
||||||
|
loaded.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded.value = false;
|
||||||
|
try {
|
||||||
|
const raw = await $fetch<RepoInfo | Record<string, unknown>>(
|
||||||
|
`https://api.pengzhanbo.cn/${providerRef.value}/repo/${owner}/${name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = normalizeRepoInfo(raw, `${owner}/${name}`);
|
||||||
|
res.convertStars = convertThousand(res.stars ?? 0);
|
||||||
|
res.convertForks = convertThousand(res.forks ?? 0);
|
||||||
|
|
||||||
|
data.value = res;
|
||||||
|
loaded.value = true;
|
||||||
|
|
||||||
|
storage.value[key] = {
|
||||||
|
info: res,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
loaded.value = true;
|
||||||
|
console.error("github repo error:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
watch([repoRef, providerRef], fetchData, { immediate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, loaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertThousand(num: number): number | string {
|
||||||
|
if (num < 1000) return num;
|
||||||
|
return `${(num / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepoInfo(
|
||||||
|
raw: RepoInfo | Record<string, unknown>,
|
||||||
|
fallbackFullName: string,
|
||||||
|
): RepoInfo {
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
const fullName = (r.fullName ?? r.full_name ?? fallbackFullName) as string;
|
||||||
|
const name = (r.name ?? (typeof fullName === "string" ? fullName.split("/").at(1) : "")) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const stars = (r.stars ?? r.stargazers_count ?? 0) as number;
|
||||||
|
const forks = (r.forks ?? r.forks_count ?? 0) as number;
|
||||||
|
const watchers = (r.watchers ?? r.subscribers_count ?? 0) as number;
|
||||||
|
const url = (r.url ?? r.html_url ?? "") as string;
|
||||||
|
const description = (r.description ?? "") as string;
|
||||||
|
const language = (r.language ?? "") as string;
|
||||||
|
const licenseRaw = r.license as Record<string, unknown> | null | undefined;
|
||||||
|
const licenseName = (licenseRaw?.name ?? "") as string;
|
||||||
|
const updatedAt = (r.updatedAt ?? r.updated_at) as string | undefined;
|
||||||
|
|
||||||
|
const owner = r.owner as Record<string, unknown> | null | undefined;
|
||||||
|
const ownerTypeValue = typeof owner?.type === "string" ? owner.type : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name ?? "",
|
||||||
|
fullName: fullName ?? "",
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
stars,
|
||||||
|
forks,
|
||||||
|
convertStars: convertThousand(stars),
|
||||||
|
convertForks: convertThousand(forks),
|
||||||
|
watchers,
|
||||||
|
language,
|
||||||
|
languageColor: (r.languageColor ?? "") as string,
|
||||||
|
archived: Boolean(r.archived ?? false),
|
||||||
|
visibility: (r.visibility ?? "Public") as "Private" | "Public",
|
||||||
|
template: Boolean(r.template ?? false),
|
||||||
|
ownerType: (r.ownerType ?? ownerTypeValue ?? "User") as "User" | "Organization",
|
||||||
|
license: licenseName ? { name: licenseName } : null,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
- [Vercel](https://vercel.com) - 部署平台
|
- [Vercel](https://vercel.com) - 部署平台
|
||||||
- [Cloudflare](https://cloudflare.com) - CDN 服务 / 部署平台
|
- [Cloudflare](https://cloudflare.com) - CDN 服务 / 部署平台
|
||||||
- [Tencent EdgeOne](https://edgeone.ai) - CDN 服务 / 部署平台
|
- [Tencent EdgeOne](https://edgeone.ai) - CDN 服务 / 部署平台
|
||||||
|
- [Aliyun ESA](https://www.aliyun.com/product/esa) - CDN 服务 / 部署平台
|
||||||
- [Twikoo](https://twikoo.js.org) - 评论系统
|
- [Twikoo](https://twikoo.js.org) - 评论系统
|
||||||
- [Alpine-Starter](https://github.com/nuxt-themes/alpine-starter) - 主题模板
|
- [Alpine-Starter](https://github.com/nuxt-themes/alpine-starter) - 主题模板
|
||||||
- [Clarity](https://github.com/L33Z22L11/blog-v3) - 博客部分灵感来源
|
- [Clarity](https://github.com/L33Z22L11/blog-v3) - 博客部分灵感来源
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ categories: ["Development"]
|
|||||||
|
|
||||||
下面使用新增的 `GithubCard` 组件来渲染一个公开仓库的信息(服务器端获取 GitHub API):
|
下面使用新增的 `GithubCard` 组件来渲染一个公开仓库的信息(服务器端获取 GitHub API):
|
||||||
|
|
||||||
<!-- <GithubCard repo="RhenCloud/Cloud-Home" /> -->
|
<GithubCard repo="RhenCloud/Cloud-Home" />
|
||||||
|
|
||||||
你也可以把它嵌入到任意文章中:`<GithubCard repo="owner/repo" />`。
|
你也可以把它嵌入到任意文章中:`<GithubCard repo="owner/repo" />`。
|
||||||
|
|
||||||
@@ -63,11 +63,7 @@ categories: ["Development"]
|
|||||||
|
|
||||||
## 代码块与文件名
|
## 代码块与文件名
|
||||||
|
|
||||||
支持带文件名的代码块:
|
支持带文件名的代码块
|
||||||
|
|
||||||
```bash [install.sh]
|
|
||||||
echo "示例安装脚本"
|
|
||||||
```
|
|
||||||
|
|
||||||
以及高亮语言:
|
以及高亮语言:
|
||||||
|
|
||||||
@@ -101,7 +97,3 @@ hello()
|
|||||||
| ---------- | ------------- |
|
| ---------- | ------------- |
|
||||||
| GithubCard | 渲染仓库信息 |
|
| GithubCard | 渲染仓库信息 |
|
||||||
| Warning | 显示警告/提示 |
|
| Warning | 显示警告/提示 |
|
||||||
|
|
||||||
## 结语
|
|
||||||
|
|
||||||
这篇示例文章覆盖了:组件嵌入(GithubCard、Warning、Alert)、命名槽与默认槽、代码块、图片、列表与表格。若需我把这篇文章在本地 dev 环境中打开并截图验证渲染,请告诉我运行命令(`pnpm dev` / `npm run dev` / `yarn dev`)。
|
|
||||||
|
|||||||
@@ -6,15 +6,6 @@ export default withNuxt([eslintPluginPrettierRecommended], {
|
|||||||
files: ["src/**/*.ts", "src/**/*.vue"],
|
files: ["src/**/*.ts", "src/**/*.vue"],
|
||||||
language: "vue",
|
language: "vue",
|
||||||
|
|
||||||
"prettier/prettier": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
fileInfoOptions: {
|
|
||||||
usePrettierrc: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// rules: {
|
// rules: {
|
||||||
// 'vue/html-self-closing': 'off',
|
// 'vue/html-self-closing': 'off',
|
||||||
// },
|
// },
|
||||||
|
|||||||
Reference in New Issue
Block a user