github-actions[bot]
commited on
Commit
·
15ff6c7
1
Parent(s):
992ad96
Update from GitHub Actions
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .cnb.yml +10 -0
- .editorconfig +12 -0
- .prettierrc +6 -0
- .vscode/settings.json +5 -0
- Dockerfile +59 -0
- auto-imports.d.ts +10 -0
- components.d.ts +44 -0
- functions/api/_middleware.ts +57 -0
- functions/api/account.ts +58 -0
- functions/api/github/[[path]].ts +206 -0
- functions/api/hf/[[path]].ts +119 -0
- functions/api/huggingface.ts +45 -0
- functions/api/login.ts +42 -0
- functions/api/setting.ts +49 -0
- functions/types.d.ts +37 -0
- functions/utils/auth.ts +41 -0
- functions/utils/jwt.ts +87 -0
- index.html +12 -0
- index.ts +165 -0
- package-lock.json +0 -0
- package.json +48 -0
- public/vite.svg +1 -0
- src/App.vue +224 -0
- src/assets/base.css +3 -0
- src/assets/logo.png +0 -0
- src/assets/main.css +2 -0
- src/assets/vue.svg +1 -0
- src/components/MonacoEditor.vue +137 -0
- src/components/RepoHeader.vue +67 -0
- src/main.ts +16 -0
- src/router/index.ts +56 -0
- src/services/accountApi.ts +35 -0
- src/services/repoApi.ts +304 -0
- src/services/settingApi.ts +42 -0
- src/services/userApi.ts +22 -0
- src/services/util.ts +29 -0
- src/stores/accountStorage.ts +27 -0
- src/style.css +79 -0
- src/views/AccountView.vue +152 -0
- src/views/ContentView.vue +441 -0
- src/views/LoginView.vue +136 -0
- src/views/RepoView.vue +227 -0
- src/views/SettingView.vue +82 -0
- src/vite-env.d.ts +1 -0
- test/index.spec.ts +25 -0
- test/tsconfig.json +8 -0
- tsconfig.app.json +14 -0
- tsconfig.functions.json +50 -0
- tsconfig.json +8 -0
- tsconfig.node.json +24 -0
.cnb.yml
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
main:
|
2 |
+
push:
|
3 |
+
- docker:
|
4 |
+
image: node:18
|
5 |
+
imports: https://cnb.cool/godgodgame/oci-private-key/-/blob/main/envs.yml
|
6 |
+
stages:
|
7 |
+
- name: 环境检查
|
8 |
+
script: echo $GITHUB_TOKEN_GK && echo $GITHUB_TOKEN && node -v && npm -v
|
9 |
+
- name: 将master分支同步更新到github的master分支
|
10 |
+
script: git push https://$GITHUB_TOKEN_GK:[email protected]/zhezzma/git-proxy.git HEAD:main
|
.editorconfig
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# http://editorconfig.org
|
2 |
+
root = true
|
3 |
+
|
4 |
+
[*]
|
5 |
+
indent_style = tab
|
6 |
+
end_of_line = lf
|
7 |
+
charset = utf-8
|
8 |
+
trim_trailing_whitespace = true
|
9 |
+
insert_final_newline = true
|
10 |
+
|
11 |
+
[*.yml]
|
12 |
+
indent_style = space
|
.prettierrc
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"printWidth": 140,
|
3 |
+
"singleQuote": true,
|
4 |
+
"semi": true,
|
5 |
+
"useTabs": true
|
6 |
+
}
|
.vscode/settings.json
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"files.associations": {
|
3 |
+
"wrangler.json": "jsonc"
|
4 |
+
}
|
5 |
+
}
|
Dockerfile
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 基础镜像:使用 Node.js 20 的 Alpine Linux 版本
|
2 |
+
FROM node:20-alpine
|
3 |
+
|
4 |
+
# 设置工作目录
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# 安装系统依赖
|
8 |
+
RUN apk add --no-cache \
|
9 |
+
# 基本构建工具
|
10 |
+
python3 \
|
11 |
+
make \
|
12 |
+
g++ \
|
13 |
+
# Playwright 依赖
|
14 |
+
chromium \
|
15 |
+
nss \
|
16 |
+
freetype \
|
17 |
+
freetype-dev \
|
18 |
+
harfbuzz \
|
19 |
+
ca-certificates \
|
20 |
+
ttf-freefont \
|
21 |
+
# 其他依赖
|
22 |
+
gcompat
|
23 |
+
|
24 |
+
# 设置 Playwright 的环境变量
|
25 |
+
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
|
26 |
+
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
27 |
+
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
28 |
+
ENV PLAYWRIGHT_SKIP_BROWSER_VALIDATION=1
|
29 |
+
|
30 |
+
# 复制依赖文件并安装
|
31 |
+
COPY package*.json ./
|
32 |
+
COPY tsconfig*.json ./
|
33 |
+
COPY vite.config.ts ./
|
34 |
+
COPY index.html ./
|
35 |
+
COPY index.ts ./
|
36 |
+
COPY src/ ./src/
|
37 |
+
COPY public/ ./public/
|
38 |
+
COPY functions/ ./functions/
|
39 |
+
|
40 |
+
|
41 |
+
RUN npm install
|
42 |
+
RUN npm run build:server
|
43 |
+
|
44 |
+
# 创建非 root 用户和用户组
|
45 |
+
RUN addgroup -S -g 1001 nodejs && \
|
46 |
+
adduser -S -D -H -u 1001 -G nodejs hono
|
47 |
+
|
48 |
+
# 设置应用文件的所有权
|
49 |
+
RUN chown -R hono:nodejs /app
|
50 |
+
|
51 |
+
# 切换到非 root 用户
|
52 |
+
USER hono
|
53 |
+
|
54 |
+
# 声明容器要暴露的端口
|
55 |
+
EXPOSE 7860
|
56 |
+
ENV PORT=7860
|
57 |
+
|
58 |
+
# 启动应用
|
59 |
+
CMD ["node", "dist-server/index.js"]
|
auto-imports.d.ts
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
/* prettier-ignore */
|
3 |
+
// @ts-nocheck
|
4 |
+
// noinspection JSUnusedGlobalSymbols
|
5 |
+
// Generated by unplugin-auto-import
|
6 |
+
// biome-ignore lint: disable
|
7 |
+
export {}
|
8 |
+
declare global {
|
9 |
+
|
10 |
+
}
|
components.d.ts
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* eslint-disable */
|
2 |
+
// @ts-nocheck
|
3 |
+
// Generated by unplugin-vue-components
|
4 |
+
// Read more: https://github.com/vuejs/core/pull/3399
|
5 |
+
// biome-ignore lint: disable
|
6 |
+
export {}
|
7 |
+
|
8 |
+
/* prettier-ignore */
|
9 |
+
declare module 'vue' {
|
10 |
+
export interface GlobalComponents {
|
11 |
+
MonacoEditor: typeof import('./src/components/MonacoEditor.vue')['default']
|
12 |
+
RepoHeader: typeof import('./src/components/RepoHeader.vue')['default']
|
13 |
+
RouterLink: typeof import('vue-router')['RouterLink']
|
14 |
+
RouterView: typeof import('vue-router')['RouterView']
|
15 |
+
TAside: typeof import('tdesign-vue-next')['Aside']
|
16 |
+
TBreadcrumb: typeof import('tdesign-vue-next')['Breadcrumb']
|
17 |
+
TBreadcrumbItem: typeof import('tdesign-vue-next')['BreadcrumbItem']
|
18 |
+
TButton: typeof import('tdesign-vue-next')['Button']
|
19 |
+
TCard: typeof import('tdesign-vue-next')['Card']
|
20 |
+
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
|
21 |
+
TContent: typeof import('tdesign-vue-next')['Content']
|
22 |
+
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
23 |
+
TDivider: typeof import('tdesign-vue-next')['Divider']
|
24 |
+
TDrawer: typeof import('tdesign-vue-next')['Drawer']
|
25 |
+
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
|
26 |
+
TFooter: typeof import('tdesign-vue-next')['Footer']
|
27 |
+
TForm: typeof import('tdesign-vue-next')['Form']
|
28 |
+
TFormItem: typeof import('tdesign-vue-next')['FormItem']
|
29 |
+
THeader: typeof import('tdesign-vue-next')['Header']
|
30 |
+
TIcon: typeof import('tdesign-vue-next')['Icon']
|
31 |
+
TInput: typeof import('tdesign-vue-next')['Input']
|
32 |
+
TLayout: typeof import('tdesign-vue-next')['Layout']
|
33 |
+
TList: typeof import('tdesign-vue-next')['List']
|
34 |
+
TListItem: typeof import('tdesign-vue-next')['ListItem']
|
35 |
+
TLoading: typeof import('tdesign-vue-next')['Loading']
|
36 |
+
TMenu: typeof import('tdesign-vue-next')['Menu']
|
37 |
+
TMenuItem: typeof import('tdesign-vue-next')['MenuItem']
|
38 |
+
TOption: typeof import('tdesign-vue-next')['Option']
|
39 |
+
TPagination: typeof import('tdesign-vue-next')['Pagination']
|
40 |
+
TSelect: typeof import('tdesign-vue-next')['Select']
|
41 |
+
TTable: typeof import('tdesign-vue-next')['Table']
|
42 |
+
TTextarea: typeof import('tdesign-vue-next')['Textarea']
|
43 |
+
}
|
44 |
+
}
|
functions/api/_middleware.ts
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
|
3 |
+
/**
|
4 |
+
* CORS 相关的响应头配置
|
5 |
+
*/
|
6 |
+
export const CORS_HEADERS = {
|
7 |
+
'Access-Control-Allow-Origin': '*', // 允许所有来源
|
8 |
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', // 允许的HTTP方法
|
9 |
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', // 允许的请求头
|
10 |
+
'Access-Control-Max-Age': '86400', // 预检请求的有效期
|
11 |
+
};
|
12 |
+
|
13 |
+
/**
|
14 |
+
* 处理 OPTIONS 预检请求
|
15 |
+
*/
|
16 |
+
export function handleOptions(): Response {
|
17 |
+
return new Response(null, {
|
18 |
+
status: 204,
|
19 |
+
headers: CORS_HEADERS
|
20 |
+
});
|
21 |
+
}
|
22 |
+
|
23 |
+
/**
|
24 |
+
* 为响应添加 CORS 头
|
25 |
+
* @param response 原始响应对象
|
26 |
+
* @returns 添加了 CORS 头的新响应对象
|
27 |
+
*/
|
28 |
+
export function addCorsHeaders(response: Response): Response {
|
29 |
+
const newHeaders = new Headers(response.headers);
|
30 |
+
Object.entries(CORS_HEADERS).forEach(([key, value]) => {
|
31 |
+
newHeaders.set(key, value);
|
32 |
+
});
|
33 |
+
|
34 |
+
return new Response(response.body, {
|
35 |
+
status: response.status,
|
36 |
+
statusText: response.statusText,
|
37 |
+
headers: newHeaders
|
38 |
+
});
|
39 |
+
}
|
40 |
+
|
41 |
+
|
42 |
+
//cloundflare functions的中间件,处理跨域请求
|
43 |
+
export async function onRequest(context: any) {
|
44 |
+
// 处理预检请求
|
45 |
+
if (context.request.method === "OPTIONS") {
|
46 |
+
return handleOptions();
|
47 |
+
}
|
48 |
+
try {
|
49 |
+
const response = await context.next();
|
50 |
+
return addCorsHeaders(response);
|
51 |
+
} catch (err: any) {
|
52 |
+
return new Response(`${err.message}\n${err.stack}`, {
|
53 |
+
status: 500,
|
54 |
+
headers: CORS_HEADERS
|
55 |
+
});
|
56 |
+
}
|
57 |
+
}
|
functions/api/account.ts
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { authMiddleware } from "../utils/auth.js";
|
2 |
+
|
3 |
+
|
4 |
+
export const onRequest = async (context: RouteContext): Promise<Response> => {
|
5 |
+
const request = context.request;
|
6 |
+
const env = context.env as Env;
|
7 |
+
|
8 |
+
const authResponse = await authMiddleware(request, env);
|
9 |
+
if (authResponse) {
|
10 |
+
return authResponse;
|
11 |
+
}
|
12 |
+
|
13 |
+
const KV_KEY = "accounts"
|
14 |
+
|
15 |
+
try {
|
16 |
+
// GET 请求处理
|
17 |
+
if (request.method === 'GET') {
|
18 |
+
const accounts = await env.KV.get(KV_KEY);
|
19 |
+
return new Response(accounts || '[]', {
|
20 |
+
status: 200,
|
21 |
+
headers: { 'Content-Type': 'application/json' }
|
22 |
+
});
|
23 |
+
}
|
24 |
+
|
25 |
+
// POST 请求处理
|
26 |
+
if (request.method === 'POST') {
|
27 |
+
const data = await request.json();
|
28 |
+
|
29 |
+
// 验证数据格式
|
30 |
+
if (!Array.isArray(data)) {
|
31 |
+
return new Response(JSON.stringify({ error: '无效的数据格式' }), {
|
32 |
+
status: 400,
|
33 |
+
headers: { 'Content-Type': 'application/json' }
|
34 |
+
});
|
35 |
+
}
|
36 |
+
|
37 |
+
// 存储账号数据
|
38 |
+
await env.KV.put(KV_KEY, JSON.stringify(data));
|
39 |
+
|
40 |
+
return new Response(JSON.stringify({ message: '保存成功' }), {
|
41 |
+
status: 200,
|
42 |
+
headers: { 'Content-Type': 'application/json' }
|
43 |
+
});
|
44 |
+
}
|
45 |
+
|
46 |
+
// 不支持的请求方法
|
47 |
+
return new Response(JSON.stringify({ error: '不支持的请求方法' }), {
|
48 |
+
status: 405,
|
49 |
+
headers: { 'Content-Type': 'application/json' }
|
50 |
+
});
|
51 |
+
|
52 |
+
} catch (error) {
|
53 |
+
return new Response(JSON.stringify({ error: '服务器内部错误' }), {
|
54 |
+
status: 500,
|
55 |
+
headers: { 'Content-Type': 'application/json' }
|
56 |
+
});
|
57 |
+
}
|
58 |
+
};
|
functions/api/github/[[path]].ts
ADDED
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
//https://docs.github.com/en/rest/repos/contents
|
2 |
+
export const onRequest = async (context: RouteContext): Promise<Response> => {
|
3 |
+
const request = context.request;
|
4 |
+
const env = context.env as Env;
|
5 |
+
|
6 |
+
// 从 Authorization header 中获取 token
|
7 |
+
const authHeader = request.headers.get('Authorization');
|
8 |
+
if (!authHeader || (!authHeader.startsWith('Bearer ') && !authHeader.startsWith('token '))) {
|
9 |
+
return new Response(JSON.stringify({ error: '未提供有效的授权令牌' }), {
|
10 |
+
status: 401,
|
11 |
+
headers: { 'Content-Type': 'application/json' }
|
12 |
+
});
|
13 |
+
}
|
14 |
+
const githubToken = authHeader.startsWith('Bearer ')
|
15 |
+
? authHeader.replace('Bearer ', '')
|
16 |
+
: authHeader.replace('token ', '');
|
17 |
+
|
18 |
+
console.log('Request URL:', request.url);
|
19 |
+
try {
|
20 |
+
const url = new URL(request.url);
|
21 |
+
|
22 |
+
// 提取仓库所有者和仓库名称
|
23 |
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
24 |
+
const owner = pathParts[2] || url.searchParams.get('owner'); // 仓库所有者
|
25 |
+
const repo = pathParts[3] || url.searchParams.get('repo'); // 仓库名称
|
26 |
+
const path = pathParts[4] || url.searchParams.get('path') || ""; // 文件路径
|
27 |
+
if (!owner || !repo) {
|
28 |
+
return new Response(JSON.stringify({ error: '缺少仓库所有者或仓库名称' }), {
|
29 |
+
status: 400,
|
30 |
+
headers: { 'Content-Type': 'application/json' }
|
31 |
+
});
|
32 |
+
}
|
33 |
+
|
34 |
+
|
35 |
+
// GitHub API 基础 URL
|
36 |
+
const githubApiBase = 'https://api.github.com';
|
37 |
+
if (request.method === 'GET') {
|
38 |
+
const ref = url.searchParams.get('ref'); // 分支或标签
|
39 |
+
// 获取文件内容或列出目录
|
40 |
+
const githubUrl = `${githubApiBase}/repos/${owner}/${repo}/contents/${path}${ref ? '?ref=' + ref : ''}`;
|
41 |
+
console.log(githubUrl);
|
42 |
+
const response = await fetch(githubUrl, {
|
43 |
+
headers: {
|
44 |
+
'Authorization': `token ${githubToken}`,
|
45 |
+
'Accept': 'application/vnd.github.v3+json',
|
46 |
+
'User-Agent': 'Cloudflare-Worker'
|
47 |
+
}
|
48 |
+
});
|
49 |
+
|
50 |
+
const data = await response.json();
|
51 |
+
|
52 |
+
return new Response(JSON.stringify(data), {
|
53 |
+
status: response.status,
|
54 |
+
headers: { 'Content-Type': 'application/json' }
|
55 |
+
});
|
56 |
+
}
|
57 |
+
|
58 |
+
if (request.method === 'POST') {
|
59 |
+
|
60 |
+
// 创建新文件
|
61 |
+
const body = await request.json() as any;
|
62 |
+
|
63 |
+
const { content, message, branch } = body;
|
64 |
+
|
65 |
+
if (!path || content === undefined) {
|
66 |
+
return new Response(JSON.stringify({ error: '缺少必要参数: path, content' }), {
|
67 |
+
status: 400,
|
68 |
+
headers: { 'Content-Type': 'application/json' }
|
69 |
+
});
|
70 |
+
}
|
71 |
+
|
72 |
+
// Base64 编码内容
|
73 |
+
let encodedContent;
|
74 |
+
try {
|
75 |
+
// 检查内容是否已经是 Base64 编码
|
76 |
+
atob(content);
|
77 |
+
encodedContent = content;
|
78 |
+
} catch (e) {
|
79 |
+
// 如果不是,则编码它
|
80 |
+
encodedContent = btoa(unescape(encodeURIComponent(content)));
|
81 |
+
}
|
82 |
+
|
83 |
+
const githubUrl = `${githubApiBase}/repos/${owner}/${repo}/contents/${path}`;
|
84 |
+
|
85 |
+
const response = await fetch(githubUrl, {
|
86 |
+
method: 'PUT',
|
87 |
+
headers: {
|
88 |
+
'Authorization': `token ${githubToken}`,
|
89 |
+
'Accept': 'application/vnd.github.v3+json',
|
90 |
+
'Content-Type': 'application/json',
|
91 |
+
'User-Agent': 'Cloudflare-Worker'
|
92 |
+
},
|
93 |
+
body: JSON.stringify({
|
94 |
+
message: message || `Create file ${path}`,
|
95 |
+
content: encodedContent,
|
96 |
+
branch: branch
|
97 |
+
})
|
98 |
+
});
|
99 |
+
|
100 |
+
const data = await response.json();
|
101 |
+
|
102 |
+
return new Response(JSON.stringify(data), {
|
103 |
+
status: response.status,
|
104 |
+
headers: { 'Content-Type': 'application/json' }
|
105 |
+
});
|
106 |
+
}
|
107 |
+
|
108 |
+
if (request.method === 'PUT') {
|
109 |
+
// 更新现有文件
|
110 |
+
|
111 |
+
const body = await request.json() as any;
|
112 |
+
const { content, message, sha, branch } = body;
|
113 |
+
|
114 |
+
if (!path || content === undefined || !sha) {
|
115 |
+
return new Response(JSON.stringify({ error: '缺少必要参数: path, content, sha' }), {
|
116 |
+
status: 400,
|
117 |
+
headers: { 'Content-Type': 'application/json' }
|
118 |
+
});
|
119 |
+
}
|
120 |
+
|
121 |
+
// Base64 编码内容
|
122 |
+
let encodedContent;
|
123 |
+
try {
|
124 |
+
atob(content);
|
125 |
+
encodedContent = content;
|
126 |
+
} catch (e) {
|
127 |
+
encodedContent = btoa(unescape(encodeURIComponent(content)));
|
128 |
+
}
|
129 |
+
|
130 |
+
const githubUrl = `${githubApiBase}/repos/${owner}/${repo}/contents/${path}`;
|
131 |
+
|
132 |
+
const response = await fetch(githubUrl, {
|
133 |
+
method: 'PUT',
|
134 |
+
headers: {
|
135 |
+
'Authorization': `token ${githubToken}`,
|
136 |
+
'Accept': 'application/vnd.github.v3+json',
|
137 |
+
'Content-Type': 'application/json',
|
138 |
+
'User-Agent': 'Cloudflare-Worker'
|
139 |
+
},
|
140 |
+
body: JSON.stringify({
|
141 |
+
message: message || `Update file ${path}`,
|
142 |
+
content: encodedContent,
|
143 |
+
sha: sha,
|
144 |
+
branch: branch
|
145 |
+
})
|
146 |
+
});
|
147 |
+
|
148 |
+
const data = await response.json();
|
149 |
+
|
150 |
+
return new Response(JSON.stringify(data), {
|
151 |
+
status: response.status,
|
152 |
+
headers: { 'Content-Type': 'application/json' }
|
153 |
+
});
|
154 |
+
}
|
155 |
+
|
156 |
+
if (request.method === 'DELETE') {
|
157 |
+
|
158 |
+
const body = await request.json() as any;
|
159 |
+
// 单个文件删除
|
160 |
+
const { message, sha, branch } = body;
|
161 |
+
|
162 |
+
if (!path || !sha) {
|
163 |
+
return new Response(JSON.stringify({ error: '缺少必要参数: path, sha' }), {
|
164 |
+
status: 400,
|
165 |
+
headers: { 'Content-Type': 'application/json' }
|
166 |
+
});
|
167 |
+
}
|
168 |
+
|
169 |
+
const githubUrl = `${githubApiBase}/repos/${owner}/${repo}/contents/${path}`;
|
170 |
+
|
171 |
+
const response = await fetch(githubUrl, {
|
172 |
+
method: 'DELETE',
|
173 |
+
headers: {
|
174 |
+
'Authorization': `token ${githubToken}`,
|
175 |
+
'Accept': 'application/vnd.github.v3+json',
|
176 |
+
'Content-Type': 'application/json',
|
177 |
+
'User-Agent': 'Cloudflare-Worker'
|
178 |
+
},
|
179 |
+
body: JSON.stringify({
|
180 |
+
message: message || `Delete file ${path}`,
|
181 |
+
sha: sha,
|
182 |
+
branch: branch
|
183 |
+
})
|
184 |
+
});
|
185 |
+
|
186 |
+
const data = await response.json();
|
187 |
+
|
188 |
+
return new Response(JSON.stringify(data), {
|
189 |
+
status: response.status,
|
190 |
+
headers: { 'Content-Type': 'application/json' }
|
191 |
+
});
|
192 |
+
|
193 |
+
}
|
194 |
+
|
195 |
+
// 不支持的请求方法
|
196 |
+
return new Response(JSON.stringify({ error: '不支持的请求方法' }), {
|
197 |
+
status: 405,
|
198 |
+
headers: { 'Content-Type': 'application/json' }
|
199 |
+
});
|
200 |
+
} catch (error: any) {
|
201 |
+
return new Response(JSON.stringify({ error: '服务器内部错误', details: error.message }), {
|
202 |
+
status: 500,
|
203 |
+
headers: { 'Content-Type': 'application/json' }
|
204 |
+
});
|
205 |
+
}
|
206 |
+
};
|
functions/api/hf/[[path]].ts
ADDED
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
//没有固定的文档
|
3 |
+
//接口是通过查看huggingface.co的请求来实现的
|
4 |
+
//有个huggingface hub api
|
5 |
+
//https://huggingface.co/docs/huggingface.js/hub/README
|
6 |
+
//https://github.com/huggingface/huggingface.js/tree/main/packages/hub
|
7 |
+
//https://github.com/huggingface/huggingface.js/blob/main/packages/hub/src/lib/list-files.ts
|
8 |
+
//https://github.com/huggingface/huggingface.js/blob/main/packages/hub/src/lib/download-file.ts
|
9 |
+
//https://github.com/huggingface/huggingface.js/blob/main/packages/hub/src/lib/commit.ts
|
10 |
+
//https://github.com/huggingface/huggingface.js/blob/main/packages/hub/src/lib/delete-files.ts
|
11 |
+
export const onRequest = async (context: RouteContext): Promise<Response> => {
|
12 |
+
const request = context.request;
|
13 |
+
const env = context.env as Env;
|
14 |
+
|
15 |
+
// 从 Authorization header 中获取 token
|
16 |
+
const authHeader = request.headers.get('Authorization');
|
17 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
18 |
+
return new Response(JSON.stringify({ error: '未提供有效的授权令牌' }), {
|
19 |
+
status: 401,
|
20 |
+
headers: { 'Content-Type': 'application/json' }
|
21 |
+
});
|
22 |
+
}
|
23 |
+
const hfToken = authHeader.replace('Bearer ', '');
|
24 |
+
|
25 |
+
console.log('Request URL:', request.url);
|
26 |
+
try {
|
27 |
+
const url = new URL(request.url);
|
28 |
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
29 |
+
|
30 |
+
// 提取路径参数
|
31 |
+
const owner = pathParts[2] || url.searchParams.get('owner'); // 仓库所有者
|
32 |
+
const repo = pathParts[3] || url.searchParams.get('repo'); // 仓库名称
|
33 |
+
const operation = pathParts[4] || url.searchParams.get('op'); // 操作类型: raw, upload, tree, delete
|
34 |
+
const ref = pathParts[5] || url.searchParams.get('ref') || 'main';
|
35 |
+
|
36 |
+
if (!owner || !repo) {
|
37 |
+
return new Response(JSON.stringify({ error: '缺少仓库所有者或仓库名称' }), {
|
38 |
+
status: 400,
|
39 |
+
headers: { 'Content-Type': 'application/json' }
|
40 |
+
});
|
41 |
+
}
|
42 |
+
// Hugging Face API 基础 URL
|
43 |
+
const hfApiBaseUrl = 'https://huggingface.co/api/datasets';
|
44 |
+
|
45 |
+
// 处理 GET 请求 - 获取文件内容或列出文件
|
46 |
+
|
47 |
+
|
48 |
+
if (operation === 'raw' && request.method === 'GET') {
|
49 |
+
const path = pathParts.length > 6 ? pathParts.slice(6).join('/') : '';
|
50 |
+
//这里没有错..getraw就是没有api
|
51 |
+
const hfUrl = `https://huggingface.co/datasets/${owner}/${repo}/raw/${ref}/${path}`;
|
52 |
+
|
53 |
+
const response = await fetch(hfUrl, {
|
54 |
+
headers: {
|
55 |
+
'Authorization': `Bearer ${hfToken}`,
|
56 |
+
'User-Agent': 'Cloudflare-Worker'
|
57 |
+
}
|
58 |
+
});
|
59 |
+
|
60 |
+
return new Response(await response.text(), {
|
61 |
+
status: response.status,
|
62 |
+
headers: response.headers
|
63 |
+
});
|
64 |
+
}
|
65 |
+
|
66 |
+
if (operation === 'tree' && request.method === 'GET') {
|
67 |
+
const path = pathParts.length > 6 ? pathParts.slice(6).join('/') : '';
|
68 |
+
// 4. 列出文件
|
69 |
+
const hfUrl = `${hfApiBaseUrl}/${owner}/${repo}/tree/${ref}/${path}`;
|
70 |
+
|
71 |
+
const response = await fetch(hfUrl, {
|
72 |
+
headers: {
|
73 |
+
'Authorization': `Bearer ${hfToken}`,
|
74 |
+
'User-Agent': 'Cloudflare-Worker'
|
75 |
+
}
|
76 |
+
});
|
77 |
+
|
78 |
+
const data = await response.json();
|
79 |
+
return new Response(JSON.stringify(data), {
|
80 |
+
status: response.status,
|
81 |
+
headers: { 'Content-Type': 'application/json' }
|
82 |
+
});
|
83 |
+
}
|
84 |
+
|
85 |
+
// 处理 POST 请求 - 上传文件
|
86 |
+
if (operation === 'commit' && (request.method === 'POST' || request.method === 'PUT' || request.method === 'DELETE')) {
|
87 |
+
const hfUrl = `${hfApiBaseUrl}/${owner}/${repo}/commit/${ref}`;
|
88 |
+
const body = await request.json();
|
89 |
+
const response = await fetch(hfUrl, {
|
90 |
+
method: 'POST',
|
91 |
+
headers: {
|
92 |
+
'Authorization': `Bearer ${hfToken}`,
|
93 |
+
'Content-Type': 'application/json',
|
94 |
+
'User-Agent': 'Cloudflare-Worker'
|
95 |
+
},
|
96 |
+
body: JSON.stringify(body)
|
97 |
+
});
|
98 |
+
|
99 |
+
const data = await response.json();
|
100 |
+
return new Response(JSON.stringify(data), {
|
101 |
+
status: response.status,
|
102 |
+
headers: { 'Content-Type': 'application/json' }
|
103 |
+
});
|
104 |
+
}
|
105 |
+
|
106 |
+
|
107 |
+
// 不支持的请求方法或路径
|
108 |
+
return new Response(JSON.stringify({ error: '不支持的请求方法或路径' }), {
|
109 |
+
status: 400,
|
110 |
+
headers: { 'Content-Type': 'application/json' }
|
111 |
+
});
|
112 |
+
} catch (error: any) {
|
113 |
+
console.error('Error:', error);
|
114 |
+
return new Response(JSON.stringify({ error: '服务器内部错误', details: error.message }), {
|
115 |
+
status: 500,
|
116 |
+
headers: { 'Content-Type': 'application/json' }
|
117 |
+
});
|
118 |
+
}
|
119 |
+
};
|
functions/api/huggingface.ts
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { authMiddleware } from "../utils/auth.js";
|
3 |
+
|
4 |
+
|
5 |
+
export const onRequest = async (context: RouteContext): Promise<Response> => {
|
6 |
+
const request = context.request;
|
7 |
+
const env = context.env as Env;
|
8 |
+
|
9 |
+
const authResponse = await authMiddleware(request, env);
|
10 |
+
if (authResponse) {
|
11 |
+
return authResponse;
|
12 |
+
}
|
13 |
+
|
14 |
+
try {
|
15 |
+
|
16 |
+
if (request.method === 'GET') {
|
17 |
+
|
18 |
+
}
|
19 |
+
|
20 |
+
if (request.method === 'POST') {
|
21 |
+
|
22 |
+
}
|
23 |
+
|
24 |
+
if (request.method === 'PUT') {
|
25 |
+
|
26 |
+
}
|
27 |
+
|
28 |
+
if (request.method === 'DELETE') {
|
29 |
+
|
30 |
+
}
|
31 |
+
|
32 |
+
// 不支持的请求方法
|
33 |
+
return new Response(JSON.stringify({ error: '不支持的请求方法' }), {
|
34 |
+
status: 405,
|
35 |
+
headers: { 'Content-Type': 'application/json' }
|
36 |
+
});
|
37 |
+
|
38 |
+
|
39 |
+
} catch (error) {
|
40 |
+
return new Response(JSON.stringify({ error: '服务器内部错误' }), {
|
41 |
+
status: 500,
|
42 |
+
headers: { 'Content-Type': 'application/json' }
|
43 |
+
});
|
44 |
+
}
|
45 |
+
};
|
functions/api/login.ts
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { generateToken } from '../utils/jwt.js';
|
2 |
+
|
3 |
+
export const onRequest = async (context: RouteContext): Promise<Response> => {
|
4 |
+
const request = context.request;
|
5 |
+
const env = context.env as Env;
|
6 |
+
try {
|
7 |
+
// 解析登录凭证
|
8 |
+
const credentials = await request.json() as LoginCredentials;
|
9 |
+
// 验证用户名和密码
|
10 |
+
if (credentials.username === env.USER_NAME && credentials.password === env.PASSWORD) {
|
11 |
+
// 生成JWT令牌
|
12 |
+
const token = await generateToken(credentials.username, env.JWT_SECRET);
|
13 |
+
return new Response(
|
14 |
+
JSON.stringify({
|
15 |
+
success: true,
|
16 |
+
token,
|
17 |
+
user: { username: credentials.username }
|
18 |
+
}),
|
19 |
+
{
|
20 |
+
status: 200,
|
21 |
+
headers: { 'Content-Type': 'application/json' }
|
22 |
+
}
|
23 |
+
);
|
24 |
+
}
|
25 |
+
|
26 |
+
// 登录失败: 无效的凭证
|
27 |
+
return new Response(
|
28 |
+
JSON.stringify({
|
29 |
+
success: false,
|
30 |
+
error: 'Invalid credentials'
|
31 |
+
}),
|
32 |
+
{
|
33 |
+
status: 401,
|
34 |
+
headers: { 'Content-Type': 'application/json' }
|
35 |
+
}
|
36 |
+
);
|
37 |
+
} catch (error) {
|
38 |
+
// 登录处理失败
|
39 |
+
console.error(`登录处理失败:`, error);
|
40 |
+
throw error;
|
41 |
+
}
|
42 |
+
}
|
functions/api/setting.ts
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { authMiddleware } from "../utils/auth.js";
|
3 |
+
|
4 |
+
export const onRequest = async (context: RouteContext): Promise<Response> => {
|
5 |
+
const request = context.request;
|
6 |
+
const env = context.env as Env;
|
7 |
+
|
8 |
+
const authResponse = await authMiddleware(request, env);
|
9 |
+
if (authResponse) {
|
10 |
+
return authResponse;
|
11 |
+
}
|
12 |
+
|
13 |
+
const KV_KEY = "settings"
|
14 |
+
|
15 |
+
try {
|
16 |
+
// GET 请求处理
|
17 |
+
if (request.method === 'GET') {
|
18 |
+
const settings = await env.KV.get(KV_KEY);
|
19 |
+
return new Response(settings || '{}', {
|
20 |
+
status: 200,
|
21 |
+
headers: { 'Content-Type': 'application/json' }
|
22 |
+
});
|
23 |
+
}
|
24 |
+
|
25 |
+
// POST 请求处理
|
26 |
+
if (request.method === 'POST') {
|
27 |
+
const data = await request.json();
|
28 |
+
// 存储账号数据
|
29 |
+
await env.KV.put(KV_KEY, JSON.stringify(data));
|
30 |
+
|
31 |
+
return new Response(JSON.stringify({ message: '保存成功' }), {
|
32 |
+
status: 200,
|
33 |
+
headers: { 'Content-Type': 'application/json' }
|
34 |
+
});
|
35 |
+
}
|
36 |
+
|
37 |
+
// 不支持的请求方法
|
38 |
+
return new Response(JSON.stringify({ error: '不支持的请求方法' }), {
|
39 |
+
status: 405,
|
40 |
+
headers: { 'Content-Type': 'application/json' }
|
41 |
+
});
|
42 |
+
|
43 |
+
} catch (error) {
|
44 |
+
return new Response(JSON.stringify({ error: '服务器内部错误' }), {
|
45 |
+
status: 500,
|
46 |
+
headers: { 'Content-Type': 'application/json' }
|
47 |
+
});
|
48 |
+
}
|
49 |
+
};
|
functions/types.d.ts
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
interface KVNamespace {
|
3 |
+
put: (key: string, value: string, options?: { expiration?: number, expirationTtl?: number, metadata?: object }) => Promise<void>;
|
4 |
+
get: (key: string) => Promise<string | null>;
|
5 |
+
delete: (key: string) => Promise<void>;
|
6 |
+
}
|
7 |
+
|
8 |
+
interface Env {
|
9 |
+
SEND_PASSWORD:string;
|
10 |
+
API_TOKEN: string; // API 访问令牌
|
11 |
+
JWT_SECRET: string; // JWT 密钥
|
12 |
+
USER_NAME: string; // 用户名
|
13 |
+
KV: KVNamespace;
|
14 |
+
ASSETS:any;
|
15 |
+
}
|
16 |
+
|
17 |
+
/**
|
18 |
+
* 登录凭证接口
|
19 |
+
*/
|
20 |
+
interface LoginCredentials {
|
21 |
+
/** 用户名 */
|
22 |
+
username: string;
|
23 |
+
/** 密码 */
|
24 |
+
password: string;
|
25 |
+
}
|
26 |
+
|
27 |
+
|
28 |
+
interface RouteContext {
|
29 |
+
request: Request;
|
30 |
+
functionPath: string;
|
31 |
+
waitUntil: (promise: Promise<any>) => void;
|
32 |
+
passThroughOnException: () => void;
|
33 |
+
next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
|
34 |
+
env: Env;
|
35 |
+
params: any;
|
36 |
+
data: any;
|
37 |
+
}
|
functions/utils/auth.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { verifyToken } from './jwt.js';
|
2 |
+
|
3 |
+
/**
|
4 |
+
* 认证中间件
|
5 |
+
* @param request 请求对象
|
6 |
+
* @param env 环境变量
|
7 |
+
* @param requestId 请求ID
|
8 |
+
* @returns 如果认证失败返回错误响应,否则返回 null
|
9 |
+
*/
|
10 |
+
export async function authMiddleware(request: Request, env: Env): Promise<Response | null> {
|
11 |
+
const isValid = await verifyToken(request, env.JWT_SECRET);
|
12 |
+
if (!isValid) {
|
13 |
+
return new Response(
|
14 |
+
JSON.stringify({ error: 'Unauthorized' }),
|
15 |
+
{
|
16 |
+
status: 401,
|
17 |
+
headers: { 'Content-Type': 'application/json' }
|
18 |
+
}
|
19 |
+
);
|
20 |
+
}
|
21 |
+
|
22 |
+
return null;
|
23 |
+
}
|
24 |
+
|
25 |
+
export async function authApiToken(request: Request, env: Env): Promise<Response | null> {
|
26 |
+
// 验证API令牌
|
27 |
+
const authHeader = request.headers.get('Authorization');
|
28 |
+
if (authHeader !== `Bearer ${env.API_TOKEN}`) {
|
29 |
+
return new Response(
|
30 |
+
JSON.stringify({ error: 'Unauthorized' }),
|
31 |
+
{
|
32 |
+
status: 401,
|
33 |
+
headers: { 'Content-Type': 'application/json' }
|
34 |
+
}
|
35 |
+
);
|
36 |
+
}
|
37 |
+
|
38 |
+
return null;
|
39 |
+
}
|
40 |
+
|
41 |
+
|
functions/utils/jwt.ts
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* 生成 JWT token
|
3 |
+
* @param username 用户名
|
4 |
+
* @param secret 密钥
|
5 |
+
* @returns 生成的 token 字符串
|
6 |
+
*/
|
7 |
+
export async function generateToken(username: string, secret: string): Promise<string> {
|
8 |
+
// JWT 头部信息
|
9 |
+
const header = { alg: 'HS256', typ: 'JWT' };
|
10 |
+
// JWT 载荷信息
|
11 |
+
const payload = {
|
12 |
+
sub: username,
|
13 |
+
exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60), //30天后过期
|
14 |
+
iat: Math.floor(Date.now() / 1000) // 签发时间
|
15 |
+
};
|
16 |
+
|
17 |
+
const encodedHeader = btoa(JSON.stringify(header));
|
18 |
+
const encodedPayload = btoa(JSON.stringify(payload));
|
19 |
+
const signature = await createHmacSignature(
|
20 |
+
`${encodedHeader}.${encodedPayload}`,
|
21 |
+
secret
|
22 |
+
);
|
23 |
+
|
24 |
+
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
25 |
+
}
|
26 |
+
|
27 |
+
/**
|
28 |
+
* 验证 JWT token
|
29 |
+
* @param request 请求对象
|
30 |
+
* @param secret 密钥
|
31 |
+
* @returns 验证是否通过
|
32 |
+
*/
|
33 |
+
export async function verifyToken(request: Request, secret: string): Promise<boolean> {
|
34 |
+
const authHeader = request.headers.get('Authorization');
|
35 |
+
if (!authHeader?.startsWith('Bearer ')) {
|
36 |
+
return false;
|
37 |
+
}
|
38 |
+
|
39 |
+
const token = authHeader.split(' ')[1];
|
40 |
+
try {
|
41 |
+
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
42 |
+
const expectedSignature = await createHmacSignature(
|
43 |
+
`${headerB64}.${payloadB64}`,
|
44 |
+
secret
|
45 |
+
);
|
46 |
+
|
47 |
+
if (signatureB64 !== expectedSignature) {
|
48 |
+
return false;
|
49 |
+
}
|
50 |
+
|
51 |
+
const payload = JSON.parse(atob(payloadB64));
|
52 |
+
const now = Math.floor(Date.now() / 1000);
|
53 |
+
|
54 |
+
return payload.exp > now;
|
55 |
+
} catch (error) {
|
56 |
+
console.error('Token verification failed:', error);
|
57 |
+
return false;
|
58 |
+
}
|
59 |
+
}
|
60 |
+
|
61 |
+
/**
|
62 |
+
* 创建 HMAC 签名
|
63 |
+
* @param message 需要签名的消息
|
64 |
+
* @param secret 密钥
|
65 |
+
* @returns 签名字符串
|
66 |
+
*/
|
67 |
+
async function createHmacSignature(message: string, secret: string): Promise<string> {
|
68 |
+
const encoder = new TextEncoder();
|
69 |
+
const keyData = encoder.encode(secret);
|
70 |
+
const messageData = encoder.encode(message);
|
71 |
+
|
72 |
+
const cryptoKey = await crypto.subtle.importKey(
|
73 |
+
'raw',
|
74 |
+
keyData,
|
75 |
+
{ name: 'HMAC', hash: 'SHA-256' },
|
76 |
+
false,
|
77 |
+
['sign']
|
78 |
+
);
|
79 |
+
|
80 |
+
const signature = await crypto.subtle.sign(
|
81 |
+
'HMAC',
|
82 |
+
cryptoKey,
|
83 |
+
messageData
|
84 |
+
);
|
85 |
+
|
86 |
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
87 |
+
}
|
index.html
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>微软邮箱管理平台</title>
|
7 |
+
</head>
|
8 |
+
<body>
|
9 |
+
<div id="app"></div>
|
10 |
+
<script type="module" src="/src/main.ts"></script>
|
11 |
+
</body>
|
12 |
+
</html>
|
index.ts
ADDED
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Context, Hono } from 'hono'
|
2 |
+
import * as dotenv from 'dotenv'
|
3 |
+
import { cors } from "hono/cors";
|
4 |
+
import { compress } from "hono/compress";
|
5 |
+
import { prettyJSON } from "hono/pretty-json";
|
6 |
+
import { trimTrailingSlash } from "hono/trailing-slash";
|
7 |
+
import { serve } from '@hono/node-server'
|
8 |
+
import { createStorage } from "unstorage";
|
9 |
+
import cloudflareKVHTTPDriver from "unstorage/drivers/cloudflare-kv-http";
|
10 |
+
import { serveStatic } from '@hono/node-server/serve-static'
|
11 |
+
|
12 |
+
import path from 'path'
|
13 |
+
import { fileURLToPath } from 'url'
|
14 |
+
import { dirname } from 'path'
|
15 |
+
|
16 |
+
// 导入所有路由处理函数
|
17 |
+
import { onRequest as handleAccount } from './functions/api/account.js'
|
18 |
+
import { onRequest as handleLogin } from './functions/api/login.js'
|
19 |
+
import { onRequest as handleSetting } from './functions/api/setting.js'
|
20 |
+
|
21 |
+
|
22 |
+
dotenv.config({ path: ['.env', '.env.local'], override: true });
|
23 |
+
const isDev = process.env.NODE_ENV === 'development'
|
24 |
+
|
25 |
+
const app = new Hono<{ Bindings: Env }>()
|
26 |
+
app.use(compress());
|
27 |
+
app.use(prettyJSON());
|
28 |
+
app.use(trimTrailingSlash());
|
29 |
+
app.use('*', cors({
|
30 |
+
origin: '*',
|
31 |
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
32 |
+
allowHeaders: ['Content-Type', 'Authorization'],
|
33 |
+
exposeHeaders: ['Content-Length'],
|
34 |
+
credentials: true,
|
35 |
+
}));
|
36 |
+
|
37 |
+
|
38 |
+
const storage = createStorage({
|
39 |
+
driver: cloudflareKVHTTPDriver({
|
40 |
+
accountId: process.env.CLOUDFLARE_ACCOUNT_ID || "",
|
41 |
+
namespaceId: process.env.CLOUDFLARE_NAMESPACE_ID || "",
|
42 |
+
apiToken: process.env.CLOUDFLARE_API_TOKEN || "",
|
43 |
+
}),
|
44 |
+
});
|
45 |
+
|
46 |
+
var kv: KVNamespace = {
|
47 |
+
get: async (key: string) => {
|
48 |
+
const value = await storage.getItemRaw(key);
|
49 |
+
return value as string;
|
50 |
+
},
|
51 |
+
put: async (key: string, value: string) => {
|
52 |
+
await storage.setItem(key, value);
|
53 |
+
},
|
54 |
+
delete:async(key:string)=>{
|
55 |
+
await storage.removeItem(key);
|
56 |
+
}
|
57 |
+
};
|
58 |
+
|
59 |
+
app.use('*', async (c, next) => {
|
60 |
+
c.env.KV = kv;
|
61 |
+
await next()
|
62 |
+
})
|
63 |
+
|
64 |
+
|
65 |
+
const scriptPath = fileURLToPath(import.meta.url)
|
66 |
+
const scriptDir = dirname(scriptPath)
|
67 |
+
const rootDir = isDev ? dirname(scriptPath) : dirname(scriptDir)
|
68 |
+
const currentDir = process.cwd();
|
69 |
+
let staticPath = path.relative(currentDir, rootDir);
|
70 |
+
if (!isDev) {
|
71 |
+
staticPath = path.relative(currentDir, path.join(rootDir, "dist"))
|
72 |
+
}
|
73 |
+
console.log('Script dir:', scriptDir)
|
74 |
+
console.log('Root dir:', rootDir)
|
75 |
+
console.log('Current dir:', currentDir);
|
76 |
+
console.log('Relative path for static files:', staticPath || '.');
|
77 |
+
|
78 |
+
|
79 |
+
|
80 |
+
const createContext = (c: Context) => {
|
81 |
+
const eventContext: RouteContext = {
|
82 |
+
request: c.req.raw,
|
83 |
+
functionPath: c.req.path,
|
84 |
+
waitUntil: (promise: Promise<any>) => {
|
85 |
+
if (c.executionCtx?.waitUntil) {
|
86 |
+
c.executionCtx.waitUntil(promise);
|
87 |
+
}
|
88 |
+
},
|
89 |
+
passThroughOnException: () => {
|
90 |
+
if (c.executionCtx?.passThroughOnException) {
|
91 |
+
c.executionCtx.passThroughOnException();
|
92 |
+
}
|
93 |
+
},
|
94 |
+
next: async (input?: Request | string, init?: RequestInit) => {
|
95 |
+
if (typeof input === 'string') {
|
96 |
+
return fetch(input, init);
|
97 |
+
} else if (input instanceof Request) {
|
98 |
+
return fetch(input);
|
99 |
+
}
|
100 |
+
return new Response('Not Found', { status: 404 });
|
101 |
+
},
|
102 |
+
env: {
|
103 |
+
...c.env,
|
104 |
+
ASSETS: {
|
105 |
+
fetch: fetch.bind(globalThis)
|
106 |
+
}
|
107 |
+
},
|
108 |
+
params: c.req.param(),
|
109 |
+
// 可以从 c.get() 获取数据,或者传入空对象
|
110 |
+
data: c.get('data') || {}
|
111 |
+
};
|
112 |
+
return eventContext;
|
113 |
+
}
|
114 |
+
|
115 |
+
app.all('/api/*', async (c) => {
|
116 |
+
try {
|
117 |
+
const context = createContext(c);
|
118 |
+
const path = c.req.path;
|
119 |
+
// 根据路径匹配对应的处理函数
|
120 |
+
let response: Response;
|
121 |
+
switch (path) {
|
122 |
+
case '/api/account':
|
123 |
+
response = await handleAccount(context);
|
124 |
+
break;
|
125 |
+
case '/api/login':
|
126 |
+
response = await handleLogin(context);
|
127 |
+
break;
|
128 |
+
case '/api/setting':
|
129 |
+
response = await handleSetting(context);
|
130 |
+
break;
|
131 |
+
default:
|
132 |
+
return c.json({ error: 'Route not found' }, 404);
|
133 |
+
}
|
134 |
+
return response;
|
135 |
+
} catch (error) {
|
136 |
+
return c.json({ error: (error as Error).message }, 500);
|
137 |
+
}
|
138 |
+
})
|
139 |
+
|
140 |
+
|
141 |
+
// 中间件配置
|
142 |
+
app.get('/*', serveStatic({
|
143 |
+
root: staticPath,
|
144 |
+
rewriteRequestPath: (path) => {
|
145 |
+
return path === '/' ? '/index.html' : path;
|
146 |
+
},
|
147 |
+
onFound: async (path, c) => {
|
148 |
+
console.log('Found:', path)
|
149 |
+
},
|
150 |
+
onNotFound: async (path, c) => {
|
151 |
+
console.log('Not Found:', path)
|
152 |
+
}
|
153 |
+
}))
|
154 |
+
|
155 |
+
|
156 |
+
// 启动服务器
|
157 |
+
const port = parseInt(process.env.PORT || '8788')
|
158 |
+
serve({
|
159 |
+
fetch: (request: Request, env) => app.fetch(request, { ...env, ...process.env }),
|
160 |
+
port
|
161 |
+
}, () => {
|
162 |
+
console.log(`Server running at http://localhost:${port}`)
|
163 |
+
})
|
164 |
+
|
165 |
+
export default app
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "msmail",
|
3 |
+
"version": "0.0.0",
|
4 |
+
"private": true,
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite --host --port 5009",
|
8 |
+
"dev:server": "cross-env NODE_ENV=development tsx watch --no-cache index.ts",
|
9 |
+
"dev:pages": "wrangler pages dev dist",
|
10 |
+
"build": "vue-tsc -b && vite build",
|
11 |
+
"build:server": "npm run build && tsc -p ./tsconfig.functions.json",
|
12 |
+
"preview": "npm run build && wrangler pages dev ./dist",
|
13 |
+
"deploy": "npm run build && wrangler pages deploy ./dist",
|
14 |
+
"test": "vitest",
|
15 |
+
"cf-typegen": "wrangler types"
|
16 |
+
},
|
17 |
+
"dependencies": {
|
18 |
+
"@hono/node-server": "^1.13.8",
|
19 |
+
"@monaco-editor/loader": "^1.5.0",
|
20 |
+
"@tailwindcss/vite": "^4.0.14",
|
21 |
+
"dotenv": "^16.4.7",
|
22 |
+
"hono": "^4.7.4",
|
23 |
+
"monaco-editor": "^0.52.2",
|
24 |
+
"pinia": "^3.0.1",
|
25 |
+
"tailwindcss": "^4.0.14",
|
26 |
+
"tdesign-vue-next": "^1.11.4",
|
27 |
+
"unstorage": "^1.15.0",
|
28 |
+
"vue": "^3.5.13",
|
29 |
+
"vue-router": "^4.5.0"
|
30 |
+
},
|
31 |
+
"devDependencies": {
|
32 |
+
"@cloudflare/vitest-pool-workers": "^0.7.5",
|
33 |
+
"@cloudflare/workers-types": "^4.20250313.0",
|
34 |
+
"@types/node": "^22.10.2",
|
35 |
+
"@vitejs/plugin-vue": "^5.2.1",
|
36 |
+
"@vue/tsconfig": "^0.7.0",
|
37 |
+
"concurrently": "^8.2.2",
|
38 |
+
"cross-env": "^7.0.3",
|
39 |
+
"tsx": "^4.7.1",
|
40 |
+
"typescript": "^5.5.2",
|
41 |
+
"unplugin-auto-import": "^19.0.0",
|
42 |
+
"unplugin-vue-components": "^28.0.0",
|
43 |
+
"vite": "^6.2.0",
|
44 |
+
"vitest": "~3.0.7",
|
45 |
+
"vue-tsc": "^2.2.4",
|
46 |
+
"wrangler": "^4.0.0"
|
47 |
+
}
|
48 |
+
}
|
public/vite.svg
ADDED
|
src/App.vue
ADDED
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { useRoute, useRouter } from 'vue-router';
|
3 |
+
import { ref } from 'vue';
|
4 |
+
import logo from './assets/logo.png'
|
5 |
+
const router = useRouter();
|
6 |
+
const route = useRoute();
|
7 |
+
const menuVisible = ref(false);
|
8 |
+
|
9 |
+
const menu = [
|
10 |
+
{
|
11 |
+
name: '仓库',
|
12 |
+
path: '/repo',
|
13 |
+
icon: 'system-log'
|
14 |
+
},
|
15 |
+
{
|
16 |
+
name: '账号',
|
17 |
+
path: '/account',
|
18 |
+
icon: 'user-list'
|
19 |
+
},
|
20 |
+
{
|
21 |
+
name: '设置',
|
22 |
+
path: '/setting',
|
23 |
+
icon: 'setting-1'
|
24 |
+
}
|
25 |
+
];
|
26 |
+
|
27 |
+
const logout = () => {
|
28 |
+
window.localStorage.removeItem('isAuthenticated');
|
29 |
+
router.push('/login');
|
30 |
+
};
|
31 |
+
|
32 |
+
const toggleMenu = () => {
|
33 |
+
menuVisible.value = !menuVisible.value;
|
34 |
+
};
|
35 |
+
|
36 |
+
|
37 |
+
//解析token
|
38 |
+
const parseToken = () => {
|
39 |
+
try {
|
40 |
+
const token = localStorage.getItem('token') as string;
|
41 |
+
const [, payload] = token.split('.');
|
42 |
+
const data = JSON.parse(atob(payload));
|
43 |
+
return data;
|
44 |
+
} catch (error) {
|
45 |
+
return null;
|
46 |
+
}
|
47 |
+
};
|
48 |
+
const jwtToken = parseToken();
|
49 |
+
|
50 |
+
</script>
|
51 |
+
|
52 |
+
<template>
|
53 |
+
<template v-if="route.path === '/login'">
|
54 |
+
<router-view />
|
55 |
+
</template>
|
56 |
+
<t-layout v-else class="h-screen">
|
57 |
+
<!-- 移动端抽屉菜单 -->
|
58 |
+
<t-drawer v-model:visible="menuVisible" placement="left" size="232" :footer="false" :header="false"
|
59 |
+
:close-on-overlay-click="true" class="lg:hidden">
|
60 |
+
<t-menu :value="route.path" theme="dark" class="h-full bg-transparent ">
|
61 |
+
<template #logo>
|
62 |
+
<router-link to="/" class="flex items-center gap-2 p-4">
|
63 |
+
<img :src="logo" alt="logo" class="w-8 h-8" />
|
64 |
+
<h1 class="text-xl font-bold text-white">SEED LOG</h1>
|
65 |
+
</router-link>
|
66 |
+
</template>
|
67 |
+
<t-menu-item v-for="item in menu" :key="item.path" :value="item.path" :to="item.path"
|
68 |
+
@click="menuVisible = false" class="menu-item mx-4 my-2 rounded-xl">
|
69 |
+
<template #icon>
|
70 |
+
<t-icon :name="item.icon" />
|
71 |
+
</template>
|
72 |
+
{{ item.name }}
|
73 |
+
</t-menu-item>
|
74 |
+
</t-menu>
|
75 |
+
</t-drawer>
|
76 |
+
|
77 |
+
<!-- 桌面端侧边栏 -->
|
78 |
+
<t-aside class="sidebar backdrop-blur-lg hidden lg:block">
|
79 |
+
<t-menu :value="route.path" theme="dark" class="bg-transparent">
|
80 |
+
<template #logo>
|
81 |
+
<router-link to="/" class="flex items-center gap-2">
|
82 |
+
<img :src="logo" alt="logo" class="w-10 h-10 transition-transform hover:scale-110" />
|
83 |
+
<h1 class="text-2xl font-bold text-white tracking-wider">SEED LOG</h1>
|
84 |
+
</router-link>
|
85 |
+
</template>
|
86 |
+
<t-menu-item v-for="item in menu" :key="item.path" :value="item.path" :to="item.path"
|
87 |
+
class="menu-item mx-4 my-2 rounded-xl">
|
88 |
+
<template #icon>
|
89 |
+
<t-icon :name="item.icon" class="text-xl" />
|
90 |
+
</template>
|
91 |
+
<span class="font-medium">{{ item.name }}</span>
|
92 |
+
</t-menu-item>
|
93 |
+
</t-menu>
|
94 |
+
</t-aside>
|
95 |
+
|
96 |
+
|
97 |
+
<t-layout class="w-full">
|
98 |
+
<t-header class="header backdrop-blur-xl border-b border-gray-100">
|
99 |
+
<div class="flex items-center justify-between h-full px-2 sm:px-4 lg:px-8">
|
100 |
+
<!-- 移动端菜单按钮和标题 -->
|
101 |
+
<div class="flex items-center gap-2 sm:gap-4 ">
|
102 |
+
<div class="lg:hidden">
|
103 |
+
<t-button theme="default" variant="text" class=" min-w-[40px]" @click="toggleMenu">
|
104 |
+
<t-icon name="menu" size="20px" sm:size="24px" />
|
105 |
+
</t-button>
|
106 |
+
</div>
|
107 |
+
<h1
|
108 |
+
class="text-base sm:text-xl lg:text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent truncate max-w-[150px] sm:max-w-full">
|
109 |
+
{{ jwtToken?.sub }}
|
110 |
+
</h1>
|
111 |
+
</div>
|
112 |
+
<div class="flex items-center gap-2 sm:gap-4">
|
113 |
+
<t-button theme="danger" @click="logout" class="logout-btn text-sm sm:text-base py-1 px-2 sm:px-4">
|
114 |
+
<template #icon>
|
115 |
+
<t-icon name="logout" class="text-sm sm:text-base" />
|
116 |
+
</template>
|
117 |
+
<span>退出</span>
|
118 |
+
</t-button>
|
119 |
+
</div>
|
120 |
+
</div>
|
121 |
+
</t-header>
|
122 |
+
|
123 |
+
<!-- 内容区域 -->
|
124 |
+
<t-content class="content bg-gray-50/30 flex-1 overflow-y-auto w-full">
|
125 |
+
<router-view v-slot="{ Component }">
|
126 |
+
<transition name="fade-slide" mode="out-in">
|
127 |
+
<keep-alive>
|
128 |
+
<component :is="Component" />
|
129 |
+
</keep-alive>
|
130 |
+
</transition>
|
131 |
+
</router-view>
|
132 |
+
</t-content>
|
133 |
+
|
134 |
+
<!-- 页脚 -->
|
135 |
+
<t-footer class="footer backdrop-blur-sm py-4 text-center text-sm text-gray-600">
|
136 |
+
<span class="opacity-75">© 微软邮箱管理系统</span>
|
137 |
+
</t-footer>
|
138 |
+
</t-layout>
|
139 |
+
|
140 |
+
</t-layout>
|
141 |
+
</template>
|
142 |
+
|
143 |
+
<style scoped>
|
144 |
+
@reference "./assets/base.css";
|
145 |
+
|
146 |
+
.sidebar {
|
147 |
+
@apply bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800;
|
148 |
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
149 |
+
}
|
150 |
+
|
151 |
+
.header {
|
152 |
+
@apply bg-white/80 sticky top-0 z-10;
|
153 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
154 |
+
}
|
155 |
+
|
156 |
+
.menu-item {
|
157 |
+
@apply transition-all duration-300 hover:bg-white/15 active:scale-95;
|
158 |
+
@apply flex items-center gap-3 px-4 py-3;
|
159 |
+
}
|
160 |
+
|
161 |
+
.logout-btn {
|
162 |
+
@apply transition-transform hover:scale-105 active:scale-95;
|
163 |
+
}
|
164 |
+
|
165 |
+
.fade-slide-enter-active,
|
166 |
+
.fade-slide-leave-active {
|
167 |
+
transition: all 0.3s ease;
|
168 |
+
}
|
169 |
+
|
170 |
+
.fade-slide-enter-from {
|
171 |
+
opacity: 0;
|
172 |
+
transform: translateY(20px);
|
173 |
+
}
|
174 |
+
|
175 |
+
.fade-slide-leave-to {
|
176 |
+
opacity: 0;
|
177 |
+
transform: translateY(-20px);
|
178 |
+
}
|
179 |
+
|
180 |
+
/* :deep(.t-default-menu.t-menu--dark){
|
181 |
+
@apply bg-transparent;
|
182 |
+
} */
|
183 |
+
|
184 |
+
:deep(.t-drawer__body) {
|
185 |
+
@apply p-0;
|
186 |
+
}
|
187 |
+
|
188 |
+
:deep(.t-drawer__body) {
|
189 |
+
@apply p-0;
|
190 |
+
}
|
191 |
+
|
192 |
+
:deep(::-webkit-scrollbar) {
|
193 |
+
@apply w-2;
|
194 |
+
}
|
195 |
+
|
196 |
+
:deep(::-webkit-scrollbar-thumb) {
|
197 |
+
@apply bg-gray-400/30 rounded-full transition-colors hover:bg-gray-400/50;
|
198 |
+
}
|
199 |
+
|
200 |
+
:deep(::-webkit-scrollbar-track) {
|
201 |
+
@apply bg-transparent;
|
202 |
+
}
|
203 |
+
|
204 |
+
.content {
|
205 |
+
background-image: radial-gradient(circle at 50% 50%,
|
206 |
+
rgba(255, 255, 255, 0.8) 0%,
|
207 |
+
rgba(240, 240, 250, 0.6) 100%);
|
208 |
+
}
|
209 |
+
|
210 |
+
.footer {
|
211 |
+
@apply bg-white/60;
|
212 |
+
}
|
213 |
+
|
214 |
+
/* 添加移动端响应式样式 */
|
215 |
+
@media (max-width: 1024px) {
|
216 |
+
.sidebar {
|
217 |
+
display: none;
|
218 |
+
}
|
219 |
+
}
|
220 |
+
|
221 |
+
.drawer-menu {
|
222 |
+
@apply bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800;
|
223 |
+
}
|
224 |
+
</style>
|
src/assets/base.css
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
@import "tailwindcss";
|
2 |
+
@import "tailwindcss/preflight" layer(base);
|
3 |
+
@import "tailwindcss/utilities" layer(utilities);
|
src/assets/logo.png
ADDED
![]() |
src/assets/main.css
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
@import './base.css';
|
2 |
+
|
src/assets/vue.svg
ADDED
|
src/components/MonacoEditor.vue
ADDED
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<template>
|
2 |
+
<div ref="editorContainer" class="h-full"></div>
|
3 |
+
</template>
|
4 |
+
|
5 |
+
<script setup lang="ts">
|
6 |
+
import { ref, onMounted, watch, onBeforeUnmount } from 'vue';
|
7 |
+
import monaco from '@monaco-editor/loader';
|
8 |
+
|
9 |
+
const props = defineProps<{
|
10 |
+
value: string,
|
11 |
+
originalValue?: string,
|
12 |
+
language?: string,
|
13 |
+
options?: Record<string, any>,
|
14 |
+
}>();
|
15 |
+
const emit = defineEmits(['update:value']);
|
16 |
+
|
17 |
+
const editorContainer = ref<HTMLElement>();
|
18 |
+
let editorInstance: any = null;
|
19 |
+
let monacoInstance: any = null;
|
20 |
+
let editorModel: any = null;
|
21 |
+
|
22 |
+
// 创建编辑器的函数
|
23 |
+
const createEditor = () => {
|
24 |
+
if (!editorContainer.value || !monacoInstance) return;
|
25 |
+
|
26 |
+
// 如果已存在编辑器实例,先销毁
|
27 |
+
if (editorInstance) {
|
28 |
+
editorInstance.dispose();
|
29 |
+
editorInstance = null;
|
30 |
+
}
|
31 |
+
|
32 |
+
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
33 |
+
const commonOptions = {
|
34 |
+
language: props.language || 'json',
|
35 |
+
minimap: { enabled: !isMobile },
|
36 |
+
fontSize: isMobile ? 16 : 14,
|
37 |
+
lineNumbers: isMobile ? 'off' as const : 'on' as const,
|
38 |
+
scrollBeyondLastLine: false,
|
39 |
+
automaticLayout: true,
|
40 |
+
...props.options
|
41 |
+
};
|
42 |
+
|
43 |
+
// 每次都创建新模型,确保内容刷新
|
44 |
+
if (editorModel) {
|
45 |
+
editorModel.dispose();
|
46 |
+
}
|
47 |
+
editorModel = monacoInstance.editor.createModel(props.value, props.language);
|
48 |
+
|
49 |
+
if (props.originalValue !== undefined) {
|
50 |
+
// 创建差异编辑器
|
51 |
+
const originalModel = monacoInstance.editor.createModel(props.originalValue, props.language);
|
52 |
+
editorInstance = monacoInstance.editor.createDiffEditor(editorContainer.value, {
|
53 |
+
...commonOptions,
|
54 |
+
readOnly: false,
|
55 |
+
renderSideBySide: true,
|
56 |
+
ignoreTrimWhitespace: false,
|
57 |
+
renderOverviewRuler: true,
|
58 |
+
diffWordWrap: 'on',
|
59 |
+
});
|
60 |
+
|
61 |
+
editorInstance.setModel({
|
62 |
+
original: originalModel,
|
63 |
+
modified: editorModel
|
64 |
+
});
|
65 |
+
const modifiedEditor = editorInstance.getModifiedEditor();
|
66 |
+
modifiedEditor.onDidChangeModelContent(() => {
|
67 |
+
emit('update:value', modifiedEditor.getValue());
|
68 |
+
});
|
69 |
+
} else {
|
70 |
+
// 创建普通编辑器
|
71 |
+
editorInstance = monacoInstance.editor.create(editorContainer.value, {
|
72 |
+
...commonOptions,
|
73 |
+
model: editorModel
|
74 |
+
});
|
75 |
+
editorInstance.onDidChangeModelContent(() => {
|
76 |
+
emit('update:value', editorInstance.getValue());
|
77 |
+
});
|
78 |
+
}
|
79 |
+
};
|
80 |
+
|
81 |
+
onMounted(() => {
|
82 |
+
monaco.init().then(instance => {
|
83 |
+
monacoInstance = instance;
|
84 |
+
createEditor();
|
85 |
+
});
|
86 |
+
});
|
87 |
+
|
88 |
+
// 监听 value, originalValue 和 language 的变化
|
89 |
+
watch([() => props.value, () => props.originalValue, () => props.language], ([newVal, newOriginalVal, newLanguage], [oldVal, oldOriginalVal]) => {
|
90 |
+
// 判断是否需要重建编辑器
|
91 |
+
const modeChanged =
|
92 |
+
(oldOriginalVal === undefined && newOriginalVal !== undefined) ||
|
93 |
+
(oldOriginalVal !== undefined && newOriginalVal === undefined);
|
94 |
+
|
95 |
+
if (modeChanged || oldVal === undefined) {
|
96 |
+
// 编辑器模式变化或首次加载,重新创建
|
97 |
+
createEditor();
|
98 |
+
} else if (props.language && editorModel && editorModel.getLanguageId() !== props.language) {
|
99 |
+
// 语言改变时,更新模型的语言
|
100 |
+
monacoInstance.editor.setModelLanguage(editorModel, props.language);
|
101 |
+
} else if (editorInstance) {
|
102 |
+
if (props.originalValue !== undefined) {
|
103 |
+
// 差异模式下更新内容
|
104 |
+
const model = editorInstance.getModel();
|
105 |
+
if (model && model.original && model.original.getValue() !== newOriginalVal) {
|
106 |
+
model.original.setValue(newOriginalVal || '');
|
107 |
+
}
|
108 |
+
if (model && model.modified && model.modified.getValue() !== newVal) {
|
109 |
+
model.modified.setValue(newVal || '');
|
110 |
+
}
|
111 |
+
} else {
|
112 |
+
// 普通模式下更新内容
|
113 |
+
if (editorInstance.getValue() !== newVal) {
|
114 |
+
editorInstance.setValue(newVal || '');
|
115 |
+
}
|
116 |
+
}
|
117 |
+
}
|
118 |
+
}, { deep: true });
|
119 |
+
|
120 |
+
onBeforeUnmount(() => {
|
121 |
+
if (editorInstance) {
|
122 |
+
editorInstance.dispose();
|
123 |
+
}
|
124 |
+
if (editorModel) {
|
125 |
+
editorModel.dispose();
|
126 |
+
}
|
127 |
+
editorInstance = null;
|
128 |
+
editorModel = null;
|
129 |
+
});
|
130 |
+
</script>
|
131 |
+
|
132 |
+
<style scoped>
|
133 |
+
:deep(.monaco-diff-editor),
|
134 |
+
:deep(.monaco-editor) {
|
135 |
+
height: 100%;
|
136 |
+
}
|
137 |
+
</style>
|
src/components/RepoHeader.vue
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { computed } from 'vue';
|
3 |
+
import { FolderIcon } from 'tdesign-icons-vue-next';
|
4 |
+
import type { Account } from '../services/accountApi';
|
5 |
+
|
6 |
+
const props = defineProps<{
|
7 |
+
selectedAccount: number | '';
|
8 |
+
currentPath: string;
|
9 |
+
accounts: Account[];
|
10 |
+
isNewFile?: boolean;
|
11 |
+
}>();
|
12 |
+
|
13 |
+
const emit = defineEmits<{
|
14 |
+
(e: 'pathClick', path: string): void;
|
15 |
+
(e: 'rootClick'): void;
|
16 |
+
(e: 'update:selectedAccount', value: number): void;
|
17 |
+
}>();
|
18 |
+
|
19 |
+
const pathBreadcrumbs = computed(() => {
|
20 |
+
const { currentPath, isNewFile } = props;
|
21 |
+
if (isNewFile) {
|
22 |
+
// For new files, show "新建文件" at the end of the breadcrumb
|
23 |
+
const paths = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
24 |
+
const breadcrumbs = paths.map((path: string, index: number) => ({
|
25 |
+
text: path,
|
26 |
+
path: paths.slice(0, index + 1).join('/')
|
27 |
+
}));
|
28 |
+
|
29 |
+
return breadcrumbs;
|
30 |
+
}
|
31 |
+
|
32 |
+
// Regular file browsing
|
33 |
+
if (!currentPath || typeof currentPath !== 'string') return [];
|
34 |
+
const paths = currentPath.split('/').filter(Boolean);
|
35 |
+
if (paths.length === 0) return [];
|
36 |
+
return paths.map((path: string, index: number) => ({
|
37 |
+
text: path,
|
38 |
+
path: paths.slice(0, index + 1).join('/')
|
39 |
+
}));
|
40 |
+
});
|
41 |
+
</script>
|
42 |
+
|
43 |
+
<template>
|
44 |
+
<div class="header-section bg-white p-4 rounded-lg shadow-sm">
|
45 |
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
46 |
+
<div class="flex flex-col md:flex-row md:items-center gap-3">
|
47 |
+
<t-select :value="selectedAccount" class="w-full md:w-64" placeholder="选择仓库"
|
48 |
+
@update:value="(val: number) => emit('update:selectedAccount', val)">
|
49 |
+
<t-option v-for="acc in accounts" :key="acc.id" :value="acc.id" :label="`${acc.owner}/${acc.repo}`" />
|
50 |
+
</t-select>
|
51 |
+
|
52 |
+
<t-breadcrumb v-if="currentPath && pathBreadcrumbs.length > 0 || isNewFile" class="mt-3">
|
53 |
+
<t-breadcrumb-item @click="emit('rootClick')" class="cursor-pointer hover:text-blue-500">
|
54 |
+
<span class="flex items-center gap-1">
|
55 |
+
<FolderIcon /> 根目录
|
56 |
+
</span>
|
57 |
+
</t-breadcrumb-item>
|
58 |
+
<t-breadcrumb-item v-for="item in pathBreadcrumbs" :key="item.path" @click="emit('pathClick', item.path)"
|
59 |
+
class="cursor-pointer hover:text-blue-500">
|
60 |
+
{{ item.text }}
|
61 |
+
</t-breadcrumb-item>
|
62 |
+
</t-breadcrumb>
|
63 |
+
</div>
|
64 |
+
<slot></slot>
|
65 |
+
</div>
|
66 |
+
</div>
|
67 |
+
</template>
|
src/main.ts
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// 引入组件库的少量全局样式变量
|
2 |
+
import 'tdesign-vue-next/es/style/index.css';
|
3 |
+
import './assets/main.css'
|
4 |
+
|
5 |
+
import { createApp } from 'vue'
|
6 |
+
import { createPinia } from 'pinia'
|
7 |
+
|
8 |
+
import App from './App.vue'
|
9 |
+
import router from './router'
|
10 |
+
|
11 |
+
const app = createApp(App)
|
12 |
+
|
13 |
+
app.use(createPinia())
|
14 |
+
app.use(router)
|
15 |
+
|
16 |
+
app.mount('#app')
|
src/router/index.ts
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createRouter, createWebHistory } from 'vue-router'
|
2 |
+
|
3 |
+
|
4 |
+
const router = createRouter({
|
5 |
+
history: createWebHistory(import.meta.env.BASE_URL),
|
6 |
+
routes: [
|
7 |
+
{
|
8 |
+
path: '/',
|
9 |
+
redirect: '/repo',
|
10 |
+
meta: { requiresAuth: true }
|
11 |
+
},
|
12 |
+
{
|
13 |
+
path: '/login',
|
14 |
+
name: 'Login',
|
15 |
+
component: () => import('../views/LoginView.vue'),
|
16 |
+
meta: { requiresAuth: false }
|
17 |
+
},
|
18 |
+
{
|
19 |
+
path: '/repo',
|
20 |
+
name: 'Repo',
|
21 |
+
component: () => import('../views/RepoView.vue'),
|
22 |
+
meta: { requiresAuth: true }
|
23 |
+
},
|
24 |
+
{
|
25 |
+
path: '/content',
|
26 |
+
name: 'Content',
|
27 |
+
component: () => import('../views/ContentView.vue'),
|
28 |
+
meta: { requiresAuth: true }
|
29 |
+
},
|
30 |
+
{
|
31 |
+
path: '/setting',
|
32 |
+
name: 'Setting',
|
33 |
+
component: () => import('../views/SettingView.vue'),
|
34 |
+
meta: { requiresAuth: true }
|
35 |
+
},
|
36 |
+
{
|
37 |
+
path: '/account',
|
38 |
+
name: 'Account',
|
39 |
+
component: () => import('../views/AccountView.vue'),
|
40 |
+
meta: { requiresAuth: true }
|
41 |
+
},
|
42 |
+
],
|
43 |
+
})
|
44 |
+
// 添加路由守卫
|
45 |
+
router.beforeEach((to, from, next) => {
|
46 |
+
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
47 |
+
if (to.meta.requiresAuth && !isAuthenticated) {
|
48 |
+
next('/login')
|
49 |
+
} else if (to.path === '/login' && isAuthenticated) {
|
50 |
+
next('/')
|
51 |
+
} else {
|
52 |
+
next()
|
53 |
+
}
|
54 |
+
})
|
55 |
+
|
56 |
+
export default router
|
src/services/accountApi.ts
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { API_BASE_URL, getHeaders, handleResponse } from './util';
|
2 |
+
|
3 |
+
export interface Account {
|
4 |
+
id: number,
|
5 |
+
owner: string,
|
6 |
+
repo: string,
|
7 |
+
ref: string,
|
8 |
+
type: "github" | "hf",
|
9 |
+
token: string,
|
10 |
+
}
|
11 |
+
|
12 |
+
export const accountApi = {
|
13 |
+
async post(accounts: Account[]) {
|
14 |
+
const response = await fetch(
|
15 |
+
`${API_BASE_URL}/api/account`,
|
16 |
+
{
|
17 |
+
headers: getHeaders(),
|
18 |
+
method: 'POST',
|
19 |
+
body: JSON.stringify(accounts)
|
20 |
+
}
|
21 |
+
);
|
22 |
+
return handleResponse(response);
|
23 |
+
},
|
24 |
+
|
25 |
+
async get(): Promise<Account[]> {
|
26 |
+
const response = await fetch(
|
27 |
+
`${API_BASE_URL}/api/account`,
|
28 |
+
{
|
29 |
+
headers: getHeaders()
|
30 |
+
}
|
31 |
+
);
|
32 |
+
return handleResponse(response);
|
33 |
+
},
|
34 |
+
|
35 |
+
}
|
src/services/repoApi.ts
ADDED
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import router from '../router';
|
2 |
+
import { API_BASE_URL, getHeaders, handleResponse } from './util';
|
3 |
+
|
4 |
+
export interface Account {
|
5 |
+
owner: string;
|
6 |
+
repo: string;
|
7 |
+
ref: string;
|
8 |
+
type: "github" | "hf";
|
9 |
+
token: string;
|
10 |
+
}
|
11 |
+
|
12 |
+
export interface RepoContent {
|
13 |
+
name: string;
|
14 |
+
path: string;
|
15 |
+
sha: string;
|
16 |
+
size: number;
|
17 |
+
url: string;
|
18 |
+
html_url: string;
|
19 |
+
git_url: string;
|
20 |
+
download_url: string | null;
|
21 |
+
type: "file" | "dir";
|
22 |
+
content?: string;
|
23 |
+
encoding?: string;
|
24 |
+
}
|
25 |
+
|
26 |
+
// Base interface for repo operations
|
27 |
+
interface IRepoApi {
|
28 |
+
getContents(account: Account, path: string): Promise<RepoContent[]>;
|
29 |
+
getFileContent(account: Account, path: string): Promise<RepoContent>;
|
30 |
+
updateFile(account: Account, path: string, content: string, sha: string, message?: string): Promise<any>;
|
31 |
+
deleteFile(account: Account, path: string, sha: string, message?: string): Promise<any>;
|
32 |
+
createFile(account: Account, path: string, content: string, message?: string): Promise<any>;
|
33 |
+
}
|
34 |
+
|
35 |
+
// GitHub implementation
|
36 |
+
class GitHubRepoApi implements IRepoApi {
|
37 |
+
async getContents(account: Account, path: string = ''): Promise<RepoContent[]> {
|
38 |
+
const response = await fetch(
|
39 |
+
`${API_BASE_URL}/api/github/${account.owner}/${account.repo}/${path}?ref=${account.ref}`,
|
40 |
+
{
|
41 |
+
headers: {
|
42 |
+
Authorization: `Bearer ${account.token}`
|
43 |
+
}
|
44 |
+
}
|
45 |
+
);
|
46 |
+
return await handleResponse(response);
|
47 |
+
}
|
48 |
+
|
49 |
+
async getFileContent(account: Account, path: string): Promise<RepoContent> {
|
50 |
+
const response = await fetch(
|
51 |
+
`${API_BASE_URL}/api/github/${account.owner}/${account.repo}/${path}?ref=${account.ref}`,
|
52 |
+
{
|
53 |
+
headers: {
|
54 |
+
'Content-Type': 'application/json',
|
55 |
+
Authorization: `Bearer ${account.token}`
|
56 |
+
}
|
57 |
+
}
|
58 |
+
);
|
59 |
+
return await handleResponse(response);
|
60 |
+
}
|
61 |
+
|
62 |
+
async updateFile(account: Account, path: string, content: string, sha: string, message?: string) {
|
63 |
+
|
64 |
+
const encoder = new TextEncoder();
|
65 |
+
const bytes = encoder.encode(content);
|
66 |
+
const base64Content = btoa(String.fromCharCode.apply(null, [...new Uint8Array(bytes)]));
|
67 |
+
|
68 |
+
const response = await fetch(
|
69 |
+
`${API_BASE_URL}/api/github/${account.owner}/${account.repo}/${path}`,
|
70 |
+
{
|
71 |
+
method: 'PUT',
|
72 |
+
headers: {
|
73 |
+
'Content-Type': 'application/json',
|
74 |
+
Authorization: `Bearer ${account.token}`
|
75 |
+
},
|
76 |
+
body: JSON.stringify({ branch: account.ref, content: base64Content, message, sha })
|
77 |
+
}
|
78 |
+
);
|
79 |
+
return handleResponse(response);
|
80 |
+
}
|
81 |
+
|
82 |
+
async deleteFile(account: Account, path: string, sha: string, message?: string) {
|
83 |
+
const response = await fetch(
|
84 |
+
`${API_BASE_URL}/api/github/${account.owner}/${account.repo}/${path}`,
|
85 |
+
{
|
86 |
+
method: 'DELETE',
|
87 |
+
headers: {
|
88 |
+
'Content-Type': 'application/json',
|
89 |
+
Authorization: `Bearer ${account.token}`
|
90 |
+
},
|
91 |
+
body: JSON.stringify({ branch: account.ref, sha, message })
|
92 |
+
}
|
93 |
+
);
|
94 |
+
return handleResponse(response);
|
95 |
+
}
|
96 |
+
|
97 |
+
async createFile(account: Account, path: string, content: string, message?: string) {
|
98 |
+
const encoder = new TextEncoder();
|
99 |
+
const bytes = encoder.encode(content);
|
100 |
+
const base64Content = btoa(String.fromCharCode.apply(null, [...new Uint8Array(bytes)]));
|
101 |
+
const response = await fetch(
|
102 |
+
`${API_BASE_URL}/api/github/${account.owner}/${account.repo}/${path}`,
|
103 |
+
{
|
104 |
+
method: 'POST',
|
105 |
+
headers: {
|
106 |
+
'Content-Type': 'application/json',
|
107 |
+
Authorization: `Bearer ${account.token}`
|
108 |
+
},
|
109 |
+
body: JSON.stringify({ branch: account.ref, content: base64Content, message })
|
110 |
+
}
|
111 |
+
);
|
112 |
+
return handleResponse(response);
|
113 |
+
}
|
114 |
+
}
|
115 |
+
|
116 |
+
// HuggingFace implementation
|
117 |
+
class HuggingFaceRepoApi implements IRepoApi {
|
118 |
+
|
119 |
+
async getContents(account: Account, path: string = ''): Promise<RepoContent[]> {
|
120 |
+
const response = await fetch(
|
121 |
+
`${API_BASE_URL}/api/hf/${account.owner}/${account.repo}/tree/${account.ref}/${path}`,
|
122 |
+
{
|
123 |
+
method: 'GET',
|
124 |
+
headers: {
|
125 |
+
Authorization: `Bearer ${account.token}`
|
126 |
+
}
|
127 |
+
}
|
128 |
+
);
|
129 |
+
let result = await handleResponse(response);
|
130 |
+
|
131 |
+
return result.map((item: any) => {
|
132 |
+
item.sha = item.oid;
|
133 |
+
item.name = item.path.split('/').pop() || '';
|
134 |
+
return item;
|
135 |
+
});
|
136 |
+
}
|
137 |
+
|
138 |
+
async getFileContent(account: Account, path: string): Promise<RepoContent> {
|
139 |
+
const response = await fetch(
|
140 |
+
`${API_BASE_URL}/api/hf/${account.owner}/${account.repo}/raw/${account.ref}/${path}`,
|
141 |
+
{
|
142 |
+
method: 'GET',
|
143 |
+
headers: {
|
144 |
+
Authorization: `Bearer ${account.token}`
|
145 |
+
}
|
146 |
+
}
|
147 |
+
);
|
148 |
+
|
149 |
+
if (response.status === 401) {
|
150 |
+
localStorage.removeItem('isAuthenticated');
|
151 |
+
localStorage.removeItem('token');
|
152 |
+
router.push('/login');
|
153 |
+
throw new Error('认证失败,请重新登录');
|
154 |
+
}
|
155 |
+
|
156 |
+
return {
|
157 |
+
content: await response.text(),
|
158 |
+
encoding: 'utf-8',
|
159 |
+
name: '',
|
160 |
+
path: path,
|
161 |
+
sha: '',
|
162 |
+
size: 0,
|
163 |
+
url: '',
|
164 |
+
html_url: '',
|
165 |
+
git_url: '',
|
166 |
+
download_url: null,
|
167 |
+
type: 'file'
|
168 |
+
};
|
169 |
+
}
|
170 |
+
|
171 |
+
async updateFile(account: Account, path: string, content: string, sha: string, message?: string) {
|
172 |
+
const response = await fetch(
|
173 |
+
`${API_BASE_URL}/api/hf/${account.owner}/${account.repo}/commit/${account.ref}/${path}`,
|
174 |
+
{
|
175 |
+
method: 'PUT',
|
176 |
+
headers: {
|
177 |
+
'Content-Type': 'application/json',
|
178 |
+
Authorization: `Bearer ${account.token}`
|
179 |
+
},
|
180 |
+
body: JSON.stringify({
|
181 |
+
"description": "",
|
182 |
+
"summary": message,
|
183 |
+
"files": [
|
184 |
+
{
|
185 |
+
"content": content,
|
186 |
+
"encoding": "utf-8",
|
187 |
+
"path": path
|
188 |
+
}
|
189 |
+
]
|
190 |
+
})
|
191 |
+
}
|
192 |
+
);
|
193 |
+
const result = await handleResponse(response);
|
194 |
+
return {
|
195 |
+
content: {
|
196 |
+
sha: result.commitOid
|
197 |
+
}
|
198 |
+
};
|
199 |
+
}
|
200 |
+
|
201 |
+
async deleteFile(account: Account, path: string, sha: string, message?: string) {
|
202 |
+
const response = await fetch(
|
203 |
+
`${API_BASE_URL}/api/hf/${account.owner}/${account.repo}/commit/${account.ref}/${path}`,
|
204 |
+
{
|
205 |
+
method: 'DELETE',
|
206 |
+
headers: {
|
207 |
+
'Content-Type': 'application/json',
|
208 |
+
Authorization: `Bearer ${account.token}`
|
209 |
+
},
|
210 |
+
body: JSON.stringify({
|
211 |
+
"description": "",
|
212 |
+
"summary": message,
|
213 |
+
"deletedFiles": [
|
214 |
+
{
|
215 |
+
"path": path
|
216 |
+
}
|
217 |
+
]
|
218 |
+
})
|
219 |
+
}
|
220 |
+
);
|
221 |
+
return handleResponse(response);
|
222 |
+
}
|
223 |
+
|
224 |
+
async createFile(account: Account, path: string, content: string, message?: string) {
|
225 |
+
const response = await fetch(
|
226 |
+
`${API_BASE_URL}/api/hf/${account.owner}/${account.repo}/commit/${account.ref}/${path}`,
|
227 |
+
{
|
228 |
+
method: 'POST',
|
229 |
+
headers: {
|
230 |
+
'Content-Type': 'application/json',
|
231 |
+
Authorization: `Bearer ${account.token}`
|
232 |
+
},
|
233 |
+
body: JSON.stringify({
|
234 |
+
"description": "",
|
235 |
+
"summary": message,
|
236 |
+
"files": [
|
237 |
+
{
|
238 |
+
"content": content,
|
239 |
+
"encoding": "utf-8",
|
240 |
+
"path": path
|
241 |
+
}
|
242 |
+
]
|
243 |
+
})
|
244 |
+
}
|
245 |
+
);
|
246 |
+
const result = await handleResponse(response);
|
247 |
+
return {
|
248 |
+
content: {
|
249 |
+
sha: result.commitOid
|
250 |
+
}
|
251 |
+
};
|
252 |
+
}
|
253 |
+
}
|
254 |
+
|
255 |
+
// Factory to get the appropriate repo API implementation
|
256 |
+
class RepoApiFactory {
|
257 |
+
private static githubInstance: GitHubRepoApi;
|
258 |
+
private static huggingFaceInstance: HuggingFaceRepoApi;
|
259 |
+
|
260 |
+
static getRepoApi(type: "github" | "hf"): IRepoApi {
|
261 |
+
switch (type) {
|
262 |
+
case "github":
|
263 |
+
if (!this.githubInstance) {
|
264 |
+
this.githubInstance = new GitHubRepoApi();
|
265 |
+
}
|
266 |
+
return this.githubInstance;
|
267 |
+
case "hf":
|
268 |
+
if (!this.huggingFaceInstance) {
|
269 |
+
this.huggingFaceInstance = new HuggingFaceRepoApi();
|
270 |
+
}
|
271 |
+
return this.huggingFaceInstance;
|
272 |
+
default:
|
273 |
+
throw new Error(`Unsupported repository type: ${type}`);
|
274 |
+
}
|
275 |
+
}
|
276 |
+
}
|
277 |
+
|
278 |
+
// Exported API object that uses the factory
|
279 |
+
export const repoApi = {
|
280 |
+
async getContents(account: Account, path: string = '') {
|
281 |
+
const api = RepoApiFactory.getRepoApi(account.type);
|
282 |
+
return api.getContents(account, path);
|
283 |
+
},
|
284 |
+
|
285 |
+
async getFileContent(account: Account, path: string): Promise<RepoContent> {
|
286 |
+
const api = RepoApiFactory.getRepoApi(account.type);
|
287 |
+
return api.getFileContent(account, path);
|
288 |
+
},
|
289 |
+
|
290 |
+
async updateFile(account: Account, path: string, content: string, sha: string, message?: string) {
|
291 |
+
const api = RepoApiFactory.getRepoApi(account.type);
|
292 |
+
return api.updateFile(account, path, content, sha, message);
|
293 |
+
},
|
294 |
+
|
295 |
+
async deleteFile(account: Account, path: string, sha: string, message?: string) {
|
296 |
+
const api = RepoApiFactory.getRepoApi(account.type);
|
297 |
+
return api.deleteFile(account, path, sha, message);
|
298 |
+
},
|
299 |
+
|
300 |
+
async createFile(account: Account, path: string, content: string, message?: string) {
|
301 |
+
const api = RepoApiFactory.getRepoApi(account.type);
|
302 |
+
return api.createFile(account, path, content, message);
|
303 |
+
}
|
304 |
+
};
|
src/services/settingApi.ts
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { API_BASE_URL, getHeaders, handleResponse } from './util';
|
2 |
+
|
3 |
+
export interface Settings {
|
4 |
+
/** 飞书配置 */
|
5 |
+
feishu: {
|
6 |
+
/** 飞书应用ID */
|
7 |
+
app_id: string;
|
8 |
+
/** 飞书应用密钥 */
|
9 |
+
app_secret: string;
|
10 |
+
/** 飞书应用验证Token */
|
11 |
+
verification_token: string;
|
12 |
+
/** 飞书应用加密Key */
|
13 |
+
encrypt_key: string;
|
14 |
+
/** 飞书机器人接收ID */
|
15 |
+
receive_id: string;
|
16 |
+
}
|
17 |
+
}
|
18 |
+
|
19 |
+
|
20 |
+
export const settingApi = {
|
21 |
+
async update(settings: Settings) {
|
22 |
+
const response = await fetch(
|
23 |
+
`${API_BASE_URL}/api/setting`,
|
24 |
+
{
|
25 |
+
headers: getHeaders(),
|
26 |
+
method: 'POST',
|
27 |
+
body: JSON.stringify(settings)
|
28 |
+
}
|
29 |
+
);
|
30 |
+
return handleResponse(response);
|
31 |
+
},
|
32 |
+
|
33 |
+
async get(): Promise<Settings> {
|
34 |
+
const response = await fetch(
|
35 |
+
`${API_BASE_URL}/api/setting`,
|
36 |
+
{
|
37 |
+
headers: getHeaders()
|
38 |
+
}
|
39 |
+
);
|
40 |
+
return handleResponse(response);
|
41 |
+
},
|
42 |
+
}
|
src/services/userApi.ts
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { API_BASE_URL, handleResponse } from './util';
|
2 |
+
|
3 |
+
export interface LoginResponse {
|
4 |
+
success: boolean;
|
5 |
+
token: string;
|
6 |
+
message?: string;
|
7 |
+
}
|
8 |
+
|
9 |
+
export const userApi = {
|
10 |
+
async login(username: string, password: string): Promise<LoginResponse> {
|
11 |
+
const response = await fetch(`${API_BASE_URL}/api/login`, {
|
12 |
+
method: 'POST',
|
13 |
+
headers: {
|
14 |
+
'Content-Type': 'application/json',
|
15 |
+
},
|
16 |
+
body: JSON.stringify({ username, password }),
|
17 |
+
});
|
18 |
+
return handleResponse(response);
|
19 |
+
}
|
20 |
+
};
|
21 |
+
|
22 |
+
|
src/services/util.ts
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import router from "../router";
|
2 |
+
|
3 |
+
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
4 |
+
|
5 |
+
export function getHeaders() {
|
6 |
+
return {
|
7 |
+
'Content-Type': 'application/json',
|
8 |
+
Authorization: `Bearer ${localStorage.getItem('token')}`
|
9 |
+
};
|
10 |
+
}
|
11 |
+
export async function handleResponse(response: Response) {
|
12 |
+
if (response.status === 401) {
|
13 |
+
localStorage.removeItem('isAuthenticated');
|
14 |
+
localStorage.removeItem('token');
|
15 |
+
router.push('/login');
|
16 |
+
throw new Error('认证失败,请重新登录');
|
17 |
+
}
|
18 |
+
if (!response.ok) {
|
19 |
+
const contentType = response.headers.get('content-type');
|
20 |
+
if (contentType && contentType.includes('application/json')) {
|
21 |
+
const errorData = await response.json();
|
22 |
+
throw new Error(errorData.error || '请求失败');
|
23 |
+
} else {
|
24 |
+
const errorText = await response.text();
|
25 |
+
throw new Error(errorText || '请求失败');
|
26 |
+
}
|
27 |
+
}
|
28 |
+
return response.json();
|
29 |
+
}
|
src/stores/accountStorage.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineStore } from 'pinia'
|
2 |
+
import { accountApi, type Account } from '../services/accountApi'
|
3 |
+
|
4 |
+
export const useAccountStore = defineStore('server', {
|
5 |
+
state: () => ({
|
6 |
+
accounts: [] as Account[],
|
7 |
+
initialized: false
|
8 |
+
}),
|
9 |
+
|
10 |
+
actions: {
|
11 |
+
async fetchAccounts(force = false) {
|
12 |
+
if (!force && this.initialized) return;
|
13 |
+
try {
|
14 |
+
const data = await accountApi.get();
|
15 |
+
this.accounts = Array.isArray(data) ? data : [];
|
16 |
+
this.initialized = true;
|
17 |
+
} catch (error) {
|
18 |
+
console.error('Failed to fetch accounts:', error);
|
19 |
+
this.accounts = [];
|
20 |
+
}
|
21 |
+
},
|
22 |
+
|
23 |
+
updateAccounts(newAccounts: Account[]) {
|
24 |
+
this.accounts = Array.isArray(newAccounts) ? newAccounts : [];
|
25 |
+
}
|
26 |
+
}
|
27 |
+
})
|
src/style.css
ADDED
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
:root {
|
2 |
+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
3 |
+
line-height: 1.5;
|
4 |
+
font-weight: 400;
|
5 |
+
|
6 |
+
color-scheme: light dark;
|
7 |
+
color: rgba(255, 255, 255, 0.87);
|
8 |
+
background-color: #242424;
|
9 |
+
|
10 |
+
font-synthesis: none;
|
11 |
+
text-rendering: optimizeLegibility;
|
12 |
+
-webkit-font-smoothing: antialiased;
|
13 |
+
-moz-osx-font-smoothing: grayscale;
|
14 |
+
}
|
15 |
+
|
16 |
+
a {
|
17 |
+
font-weight: 500;
|
18 |
+
color: #646cff;
|
19 |
+
text-decoration: inherit;
|
20 |
+
}
|
21 |
+
a:hover {
|
22 |
+
color: #535bf2;
|
23 |
+
}
|
24 |
+
|
25 |
+
body {
|
26 |
+
margin: 0;
|
27 |
+
display: flex;
|
28 |
+
place-items: center;
|
29 |
+
min-width: 320px;
|
30 |
+
min-height: 100vh;
|
31 |
+
}
|
32 |
+
|
33 |
+
h1 {
|
34 |
+
font-size: 3.2em;
|
35 |
+
line-height: 1.1;
|
36 |
+
}
|
37 |
+
|
38 |
+
button {
|
39 |
+
border-radius: 8px;
|
40 |
+
border: 1px solid transparent;
|
41 |
+
padding: 0.6em 1.2em;
|
42 |
+
font-size: 1em;
|
43 |
+
font-weight: 500;
|
44 |
+
font-family: inherit;
|
45 |
+
background-color: #1a1a1a;
|
46 |
+
cursor: pointer;
|
47 |
+
transition: border-color 0.25s;
|
48 |
+
}
|
49 |
+
button:hover {
|
50 |
+
border-color: #646cff;
|
51 |
+
}
|
52 |
+
button:focus,
|
53 |
+
button:focus-visible {
|
54 |
+
outline: 4px auto -webkit-focus-ring-color;
|
55 |
+
}
|
56 |
+
|
57 |
+
.card {
|
58 |
+
padding: 2em;
|
59 |
+
}
|
60 |
+
|
61 |
+
#app {
|
62 |
+
max-width: 1280px;
|
63 |
+
margin: 0 auto;
|
64 |
+
padding: 2rem;
|
65 |
+
text-align: center;
|
66 |
+
}
|
67 |
+
|
68 |
+
@media (prefers-color-scheme: light) {
|
69 |
+
:root {
|
70 |
+
color: #213547;
|
71 |
+
background-color: #ffffff;
|
72 |
+
}
|
73 |
+
a:hover {
|
74 |
+
color: #747bff;
|
75 |
+
}
|
76 |
+
button {
|
77 |
+
background-color: #f9f9f9;
|
78 |
+
}
|
79 |
+
}
|
src/views/AccountView.vue
ADDED
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
3 |
+
import { MessagePlugin } from 'tdesign-vue-next';
|
4 |
+
import { accountApi } from '../services/accountApi';
|
5 |
+
import { useAccountStore } from '../stores/accountStorage';
|
6 |
+
import MonacoEditor from '../components/MonacoEditor.vue';
|
7 |
+
const accountStore = useAccountStore();
|
8 |
+
const accountsText = ref<string>('');
|
9 |
+
const loading = ref(false);
|
10 |
+
const showDiff = ref(false);
|
11 |
+
const originalText = ref('');
|
12 |
+
|
13 |
+
const fetchAccounts = async (force: boolean) => {
|
14 |
+
try {
|
15 |
+
loading.value = true;
|
16 |
+
accountsText.value = "";
|
17 |
+
await accountStore.fetchAccounts(force);
|
18 |
+
const formattedData = JSON.stringify(accountStore.accounts, null, 2);
|
19 |
+
accountsText.value = formattedData;
|
20 |
+
originalText.value = formattedData;
|
21 |
+
} catch (error) {
|
22 |
+
MessagePlugin.error('获取账号数据失败');
|
23 |
+
} finally {
|
24 |
+
loading.value = false;
|
25 |
+
}
|
26 |
+
};
|
27 |
+
|
28 |
+
|
29 |
+
const handleSave = async () => {
|
30 |
+
loading.value = true;
|
31 |
+
try {
|
32 |
+
if (accountsText.value === '') {
|
33 |
+
MessagePlugin.error('账号数据不能为空');
|
34 |
+
return;
|
35 |
+
}
|
36 |
+
if (originalText.value === accountsText.value) {
|
37 |
+
MessagePlugin.success('数据未修改');
|
38 |
+
return;
|
39 |
+
}
|
40 |
+
const accounts = JSON.parse(accountsText.value);
|
41 |
+
const result = await accountApi.post(accounts);
|
42 |
+
if (result.error) {
|
43 |
+
MessagePlugin.error(`${result.error}`);
|
44 |
+
return;
|
45 |
+
}
|
46 |
+
accountStore.updateAccounts(accounts || []);
|
47 |
+
MessagePlugin.success('保存成功');
|
48 |
+
} catch (error) {
|
49 |
+
if (error instanceof SyntaxError) {
|
50 |
+
MessagePlugin.error('JSON格式错误');
|
51 |
+
} else {
|
52 |
+
MessagePlugin.error('保存失败');
|
53 |
+
}
|
54 |
+
} finally {
|
55 |
+
loading.value = false;
|
56 |
+
}
|
57 |
+
};
|
58 |
+
|
59 |
+
const handleReload = async () => {
|
60 |
+
loading.value = true;
|
61 |
+
try {
|
62 |
+
await fetchAccounts(true);
|
63 |
+
MessagePlugin.success('重新加载成功');
|
64 |
+
} catch (error) {
|
65 |
+
MessagePlugin.error('重新加载失败');
|
66 |
+
} finally {
|
67 |
+
loading.value = false;
|
68 |
+
}
|
69 |
+
};
|
70 |
+
|
71 |
+
// 定义按键事件处理函数
|
72 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
73 |
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
74 |
+
e.preventDefault();
|
75 |
+
handleSave();
|
76 |
+
}
|
77 |
+
};
|
78 |
+
|
79 |
+
|
80 |
+
// 在显示差异按钮点击处理中添加
|
81 |
+
const toggleDiff = () => {
|
82 |
+
showDiff.value = !showDiff.value;
|
83 |
+
|
84 |
+
if (showDiff.value) {
|
85 |
+
// 进入差异模式时,确保有原始文本作为比较
|
86 |
+
if (originalText.value === '') {
|
87 |
+
originalText.value = accountsText.value;
|
88 |
+
}
|
89 |
+
}
|
90 |
+
};
|
91 |
+
// 在 AccountView.vue 中添加
|
92 |
+
watch(showDiff, (newVal) => {
|
93 |
+
if (newVal && originalText.value === accountsText.value) {
|
94 |
+
// 如果开启差异模式但两个文本相同,可以考虑提示用户
|
95 |
+
MessagePlugin.info('当前没有差异可以显示');
|
96 |
+
}
|
97 |
+
}, { immediate: true });
|
98 |
+
|
99 |
+
onMounted(async () => {
|
100 |
+
// 注册全局按键监听
|
101 |
+
window.addEventListener('keydown', handleKeyDown);
|
102 |
+
await fetchAccounts(false);
|
103 |
+
});
|
104 |
+
|
105 |
+
onUnmounted(() => {
|
106 |
+
// 注销全局按键监听
|
107 |
+
window.removeEventListener('keydown', handleKeyDown);
|
108 |
+
});
|
109 |
+
</script>
|
110 |
+
|
111 |
+
<template>
|
112 |
+
<div class="account-container h-full p-2 md:p-5">
|
113 |
+
<t-card bordered class="h-full">
|
114 |
+
<template #content>
|
115 |
+
<div class=" flex flex-col h-full">
|
116 |
+
<div class="flex justify-end items-center mb-4 gap-4">
|
117 |
+
<div class="flex gap-2">
|
118 |
+
<t-button variant="outline" @click="handleReload">
|
119 |
+
重新加载
|
120 |
+
</t-button>
|
121 |
+
<t-button variant="outline" @click="toggleDiff">
|
122 |
+
{{ showDiff ? '隐藏对比' : '显示对比' }}
|
123 |
+
</t-button>
|
124 |
+
<t-button theme="primary" @click="handleSave" :loading="loading">
|
125 |
+
保存账号
|
126 |
+
</t-button>
|
127 |
+
</div>
|
128 |
+
</div>
|
129 |
+
|
130 |
+
<div class="editor-container flex-1">
|
131 |
+
<MonacoEditor v-model:value="accountsText" :original-value="showDiff ? originalText : undefined"
|
132 |
+
language="json" :options="{ tabSize: 2 }" />
|
133 |
+
</div>
|
134 |
+
</div>
|
135 |
+
</template>
|
136 |
+
</t-card>
|
137 |
+
</div>
|
138 |
+
</template>
|
139 |
+
|
140 |
+
<style scoped>
|
141 |
+
.account-container {
|
142 |
+
width: 100%;
|
143 |
+
}
|
144 |
+
|
145 |
+
:deep(.t-card__body) {
|
146 |
+
height: 100%;
|
147 |
+
}
|
148 |
+
|
149 |
+
.editor-container {
|
150 |
+
border: 1px solid var(--td-component-border);
|
151 |
+
}
|
152 |
+
</style>
|
src/views/ContentView.vue
ADDED
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
3 |
+
import { useRoute, useRouter } from 'vue-router';
|
4 |
+
import { MessagePlugin } from 'tdesign-vue-next';
|
5 |
+
import { repoApi, type Account } from '../services/repoApi';
|
6 |
+
import { useAccountStore } from '../stores/accountStorage';
|
7 |
+
import MonacoEditor from '../components/MonacoEditor.vue';
|
8 |
+
import RepoHeader from '../components/RepoHeader.vue';
|
9 |
+
|
10 |
+
const route = useRoute();
|
11 |
+
const router = useRouter();
|
12 |
+
|
13 |
+
const contentText = ref<string>('');
|
14 |
+
const loading = ref(false);
|
15 |
+
const showDiff = ref(false);
|
16 |
+
const originalText = ref('');
|
17 |
+
const fileSha = ref('');
|
18 |
+
const selectedAccount = ref<number | ''>('');
|
19 |
+
const currentPath = ref('');
|
20 |
+
const newFileName = ref('');
|
21 |
+
const store = useAccountStore();
|
22 |
+
const isNewFile = ref(false);
|
23 |
+
|
24 |
+
// Compute full path by combining directory path with new file name
|
25 |
+
const fullPath = computed(() => {
|
26 |
+
if (!isNewFile.value) return currentPath.value;
|
27 |
+
const basePath = route.query.path as string || '';
|
28 |
+
return basePath ? `${basePath}/${newFileName.value}` : newFileName.value;
|
29 |
+
});
|
30 |
+
|
31 |
+
// 提交对话框状态
|
32 |
+
const showCommitDialog = ref(false);
|
33 |
+
const commitMessage = ref('');
|
34 |
+
|
35 |
+
const decodeContent = (base64Content: string, contentType: 'text' | 'binary' = 'text'): string | Uint8Array => {
|
36 |
+
// 步骤1: 使用atob解码Base64字符串,获取原始二进制数据
|
37 |
+
const binaryContent = atob(base64Content);
|
38 |
+
|
39 |
+
if (contentType === 'binary') {
|
40 |
+
// 对于二进制文件,返回Uint8Array
|
41 |
+
const bytes = new Uint8Array(binaryContent.length);
|
42 |
+
for (let i = 0; i < binaryContent.length; i++) {
|
43 |
+
bytes[i] = binaryContent.charCodeAt(i);
|
44 |
+
}
|
45 |
+
return bytes;
|
46 |
+
} else {
|
47 |
+
// 对于文本内容,处理UTF-8编码
|
48 |
+
try {
|
49 |
+
// 方法1: 使用TextDecoder (推荐,更可靠)
|
50 |
+
const bytes = new Uint8Array(binaryContent.length);
|
51 |
+
for (let i = 0; i < binaryContent.length; i++) {
|
52 |
+
bytes[i] = binaryContent.charCodeAt(i);
|
53 |
+
}
|
54 |
+
return new TextDecoder('utf-8').decode(bytes);
|
55 |
+
} catch (e) {
|
56 |
+
// 方法2: 兼容性方案
|
57 |
+
return decodeURIComponent(escape(binaryContent));
|
58 |
+
}
|
59 |
+
}
|
60 |
+
};
|
61 |
+
|
62 |
+
const fetchContent = async () => {
|
63 |
+
const account = store.accounts.find(acc => acc.id === selectedAccount.value);
|
64 |
+
if (!account) {
|
65 |
+
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`);
|
66 |
+
return;
|
67 |
+
}
|
68 |
+
try {
|
69 |
+
loading.value = true;
|
70 |
+
contentText.value = "";
|
71 |
+
originalText.value = "";
|
72 |
+
const result = await repoApi.getFileContent(account, currentPath.value);
|
73 |
+
if (result.content) {
|
74 |
+
// 不移除换行符,保持原始格式
|
75 |
+
contentText.value = result.encoding && result.encoding == "base64" ? decodeContent(result.content) as string : result.content;
|
76 |
+
originalText.value = contentText.value;
|
77 |
+
fileSha.value = result.sha;
|
78 |
+
}
|
79 |
+
} catch (error) {
|
80 |
+
MessagePlugin.error('获取文件内容失败');
|
81 |
+
} finally {
|
82 |
+
loading.value = false;
|
83 |
+
}
|
84 |
+
};
|
85 |
+
|
86 |
+
const handleSave = () => {
|
87 |
+
|
88 |
+
|
89 |
+
// For new files, we need a file name
|
90 |
+
if (isNewFile.value && !newFileName.value) {
|
91 |
+
MessagePlugin.error('请输入文件名');
|
92 |
+
return;
|
93 |
+
}
|
94 |
+
|
95 |
+
if (contentText.value === '') {
|
96 |
+
MessagePlugin.error('内容不能为空');
|
97 |
+
return;
|
98 |
+
}
|
99 |
+
|
100 |
+
// For existing files, check if content was modified
|
101 |
+
if (fileSha.value && originalText.value === contentText.value) {
|
102 |
+
MessagePlugin.success('内容未修改');
|
103 |
+
return;
|
104 |
+
}
|
105 |
+
|
106 |
+
showCommitDialog.value = true;
|
107 |
+
};
|
108 |
+
|
109 |
+
const handleConfirmSave = async () => {
|
110 |
+
|
111 |
+
|
112 |
+
// Use the full path for new files, or the route path for existing files
|
113 |
+
const path = isNewFile.value ? fullPath.value : (route.query.path as string);
|
114 |
+
if (!path) {
|
115 |
+
MessagePlugin.error('请输入文件名');
|
116 |
+
return;
|
117 |
+
}
|
118 |
+
|
119 |
+
loading.value = true;
|
120 |
+
try {
|
121 |
+
const account = store.accounts.find(acc => acc.id === selectedAccount.value);
|
122 |
+
|
123 |
+
if (!account) {
|
124 |
+
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`);
|
125 |
+
return;
|
126 |
+
}
|
127 |
+
|
128 |
+
let response;
|
129 |
+
if (isNewFile.value) {
|
130 |
+
// Create new file
|
131 |
+
response = await repoApi.createFile(
|
132 |
+
account,
|
133 |
+
path,
|
134 |
+
contentText.value,
|
135 |
+
commitMessage.value.trim() || '创建文件'
|
136 |
+
);
|
137 |
+
} else {
|
138 |
+
// Update existing file
|
139 |
+
response = await repoApi.updateFile(
|
140 |
+
account,
|
141 |
+
path,
|
142 |
+
contentText.value,
|
143 |
+
fileSha.value,
|
144 |
+
commitMessage.value.trim() || '更新文件'
|
145 |
+
);
|
146 |
+
}
|
147 |
+
|
148 |
+
// Update fileSha with the new value from response
|
149 |
+
if (response && response.content && response.content.sha) {
|
150 |
+
fileSha.value = response.content.sha;
|
151 |
+
}
|
152 |
+
|
153 |
+
MessagePlugin.success(isNewFile.value ? '创建成功' : '保存成功');
|
154 |
+
originalText.value = contentText.value;
|
155 |
+
showCommitDialog.value = false;
|
156 |
+
commitMessage.value = '';
|
157 |
+
|
158 |
+
// If this was a new file, update the URL to include the path
|
159 |
+
if (isNewFile.value) {
|
160 |
+
router.replace({
|
161 |
+
path: '/content',
|
162 |
+
query: {
|
163 |
+
...route.query,
|
164 |
+
path,
|
165 |
+
newFile: undefined
|
166 |
+
}
|
167 |
+
});
|
168 |
+
}
|
169 |
+
} catch (error) {
|
170 |
+
console.error(error);
|
171 |
+
MessagePlugin.error('保存失败');
|
172 |
+
} finally {
|
173 |
+
loading.value = false;
|
174 |
+
}
|
175 |
+
};
|
176 |
+
|
177 |
+
const handleCancelSave = () => {
|
178 |
+
showCommitDialog.value = false;
|
179 |
+
commitMessage.value = '';
|
180 |
+
};
|
181 |
+
|
182 |
+
// 删除对话框状态
|
183 |
+
const showDeleteDialog = ref(false);
|
184 |
+
|
185 |
+
const handleDelete = () => {
|
186 |
+
|
187 |
+
|
188 |
+
showDeleteDialog.value = true;
|
189 |
+
};
|
190 |
+
|
191 |
+
const handleConfirmDelete = async () => {
|
192 |
+
|
193 |
+
|
194 |
+
loading.value = true;
|
195 |
+
try {
|
196 |
+
const account = store.accounts.find(acc => acc.id === selectedAccount.value);
|
197 |
+
|
198 |
+
if (!account) {
|
199 |
+
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`);
|
200 |
+
return;
|
201 |
+
}
|
202 |
+
|
203 |
+
await repoApi.deleteFile(
|
204 |
+
account,
|
205 |
+
currentPath.value as string,
|
206 |
+
fileSha.value,
|
207 |
+
'删除文件'
|
208 |
+
);
|
209 |
+
MessagePlugin.success('删除成功');
|
210 |
+
showDeleteDialog.value = false;
|
211 |
+
router.back();
|
212 |
+
} catch (error) {
|
213 |
+
MessagePlugin.error('删除失败');
|
214 |
+
} finally {
|
215 |
+
loading.value = false;
|
216 |
+
}
|
217 |
+
};
|
218 |
+
|
219 |
+
const handleCancelDelete = () => {
|
220 |
+
showDeleteDialog.value = false;
|
221 |
+
};
|
222 |
+
|
223 |
+
// 定义按键事件处理函数
|
224 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
225 |
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
226 |
+
e.preventDefault();
|
227 |
+
handleSave();
|
228 |
+
}
|
229 |
+
};
|
230 |
+
|
231 |
+
// 在显示差异按钮点击处理中添加
|
232 |
+
const toggleDiff = () => {
|
233 |
+
showDiff.value = !showDiff.value;
|
234 |
+
|
235 |
+
if (showDiff.value) {
|
236 |
+
// 进入差异模式时,确保有原始文本作为比较
|
237 |
+
if (originalText.value === '') {
|
238 |
+
originalText.value = contentText.value;
|
239 |
+
}
|
240 |
+
}
|
241 |
+
};
|
242 |
+
|
243 |
+
// 在 AccountView.vue 中添加
|
244 |
+
watch(showDiff, (newVal) => {
|
245 |
+
if (newVal && originalText.value === contentText.value) {
|
246 |
+
// 如果开启差异模式但两个文本相同,可以考虑提示用户
|
247 |
+
MessagePlugin.info('当前没有差异可以显示');
|
248 |
+
}
|
249 |
+
}, { immediate: true });
|
250 |
+
|
251 |
+
const handlePathClick = (path: string) => {
|
252 |
+
router.push({
|
253 |
+
path: '/repo',
|
254 |
+
query: {
|
255 |
+
id: selectedAccount.value,
|
256 |
+
path
|
257 |
+
}
|
258 |
+
});
|
259 |
+
};
|
260 |
+
|
261 |
+
const handleRootClick = () => {
|
262 |
+
router.push({
|
263 |
+
path: '/repo',
|
264 |
+
query: {
|
265 |
+
id: selectedAccount.value,
|
266 |
+
}
|
267 |
+
});
|
268 |
+
};
|
269 |
+
|
270 |
+
const handleAccountChange = (val: number) => {
|
271 |
+
selectedAccount.value = val;
|
272 |
+
currentPath.value = ''; // Reset path
|
273 |
+
router.push({
|
274 |
+
path: '/repo',
|
275 |
+
query: {
|
276 |
+
id: selectedAccount.value,
|
277 |
+
}
|
278 |
+
});
|
279 |
+
}
|
280 |
+
|
281 |
+
const getLanguageFromPath = computed(() => {
|
282 |
+
const path = route.query.path as string;
|
283 |
+
if (!path) return 'plaintext';
|
284 |
+
|
285 |
+
const ext = path.split('.').pop()?.toLowerCase() || '';
|
286 |
+
|
287 |
+
// Map file extensions to Monaco editor languages
|
288 |
+
const languageMap: Record<string, string> = {
|
289 |
+
'js': 'javascript',
|
290 |
+
"mjs": 'javascript',
|
291 |
+
'ts': 'typescript',
|
292 |
+
'json': 'json',
|
293 |
+
'md': 'markdown',
|
294 |
+
'yml': 'yaml',
|
295 |
+
'yaml': 'yaml',
|
296 |
+
'py': 'python',
|
297 |
+
'html': 'html',
|
298 |
+
'css': 'css',
|
299 |
+
'scss': 'scss',
|
300 |
+
'less': 'less',
|
301 |
+
'xml': 'xml',
|
302 |
+
'sh': 'shell',
|
303 |
+
'bash': 'shell',
|
304 |
+
'vue': 'vue',
|
305 |
+
'jsx': 'javascript',
|
306 |
+
'tsx': 'typescript',
|
307 |
+
'gitignore': 'plaintext',
|
308 |
+
'env': 'plaintext',
|
309 |
+
'txt': 'plaintext',
|
310 |
+
"rs": 'rust',
|
311 |
+
'go': 'go',
|
312 |
+
};
|
313 |
+
|
314 |
+
return languageMap[ext] || 'plaintext';
|
315 |
+
});
|
316 |
+
|
317 |
+
// 监听路由变化同步本地状态
|
318 |
+
watch(() => route.query, async (query) => {
|
319 |
+
// Only respond to query changes when on the repo route
|
320 |
+
if (route.path !== '/content') return;
|
321 |
+
const { id, path, newFile } = query;
|
322 |
+
isNewFile.value = !!newFile;
|
323 |
+
if (id) {
|
324 |
+
await store.fetchAccounts();
|
325 |
+
const account = store.accounts.find(acc => acc.id === Number(id));
|
326 |
+
if (account) {
|
327 |
+
selectedAccount.value = account.id;
|
328 |
+
}
|
329 |
+
currentPath.value = path as string;
|
330 |
+
|
331 |
+
if (isNewFile.value) {
|
332 |
+
contentText.value = '';
|
333 |
+
originalText.value = '';
|
334 |
+
newFileName.value = ''; // Reset new file name when entering new file mode
|
335 |
+
return;
|
336 |
+
}
|
337 |
+
if (path) {
|
338 |
+
await fetchContent();
|
339 |
+
}
|
340 |
+
}
|
341 |
+
}, { immediate: true });
|
342 |
+
|
343 |
+
onMounted(() => {
|
344 |
+
window.addEventListener('keydown', handleKeyDown);
|
345 |
+
|
346 |
+
|
347 |
+
|
348 |
+
});
|
349 |
+
|
350 |
+
onUnmounted(() => {
|
351 |
+
// 注销全局按键监听
|
352 |
+
window.removeEventListener('keydown', handleKeyDown);
|
353 |
+
});
|
354 |
+
</script>
|
355 |
+
|
356 |
+
<template>
|
357 |
+
<div class="content-container h-full p-2 md:p-5">
|
358 |
+
<div class="flex flex-col gap-4 h-full">
|
359 |
+
<RepoHeader :selected-account="selectedAccount" :current-path="isNewFile ? fullPath : currentPath"
|
360 |
+
:is-new-file="isNewFile" :accounts="store.accounts" @path-click="handlePathClick"
|
361 |
+
@root-click="handleRootClick" @update:selected-account="handleAccountChange">
|
362 |
+
<div class="flex items-center justify-between w-full">
|
363 |
+
<div>
|
364 |
+
<t-input v-if="isNewFile" v-model="newFileName" placeholder="请输入文件名" class="w-20" />
|
365 |
+
</div>
|
366 |
+
<div class="flex gap-2">
|
367 |
+
<t-button variant="outline" @click="toggleDiff">
|
368 |
+
{{ showDiff ? '隐藏对比' : '显示对比' }}
|
369 |
+
</t-button>
|
370 |
+
<t-button theme="primary" @click="handleSave" :loading="loading">
|
371 |
+
保存
|
372 |
+
</t-button>
|
373 |
+
<t-button v-if="!isNewFile" theme="danger" @click="handleDelete" :loading="loading">
|
374 |
+
删除
|
375 |
+
</t-button>
|
376 |
+
</div>
|
377 |
+
</div>
|
378 |
+
</RepoHeader>
|
379 |
+
<t-card bordered class="h-full">
|
380 |
+
<template #content>
|
381 |
+
<div class="flex flex-col h-full">
|
382 |
+
<div class="editor-container flex-1">
|
383 |
+
<MonacoEditor v-model:value="contentText"
|
384 |
+
:original-value="showDiff ? originalText : undefined" :language="getLanguageFromPath"
|
385 |
+
:options="{ tabSize: 2 }" />
|
386 |
+
</div>
|
387 |
+
</div>
|
388 |
+
</template>
|
389 |
+
</t-card>
|
390 |
+
</div>
|
391 |
+
|
392 |
+
<t-dialog v-model:visible="showCommitDialog" header="提交更改" :confirm-on-enter="true" @confirm="handleConfirmSave"
|
393 |
+
@close="handleCancelSave">
|
394 |
+
<template #body>
|
395 |
+
<div class="flex flex-col gap-2">
|
396 |
+
<div class="mb-2">请输入提交信息:</div>
|
397 |
+
<t-input v-model:value="commitMessage" placeholder="描述此次更改的内容" :autofocus="true" />
|
398 |
+
</div>
|
399 |
+
</template>
|
400 |
+
<template #footer>
|
401 |
+
<t-button theme="default" @click="handleCancelSave">
|
402 |
+
取消
|
403 |
+
</t-button>
|
404 |
+
<t-button theme="primary" @click="handleConfirmSave" :loading="loading">
|
405 |
+
确认
|
406 |
+
</t-button>
|
407 |
+
</template>
|
408 |
+
</t-dialog>
|
409 |
+
|
410 |
+
<t-dialog v-model:visible="showDeleteDialog" header="确认删除" :confirm-on-enter="true"
|
411 |
+
@confirm="handleConfirmDelete" @close="handleCancelDelete">
|
412 |
+
<template #body>
|
413 |
+
<div class="p-2">
|
414 |
+
确定要删除此文件吗?此操作不可恢复。
|
415 |
+
</div>
|
416 |
+
</template>
|
417 |
+
<template #footer>
|
418 |
+
<t-button theme="default" @click="handleCancelDelete">
|
419 |
+
取消
|
420 |
+
</t-button>
|
421 |
+
<t-button theme="primary" @click="handleConfirmDelete" :loading="loading">
|
422 |
+
确认
|
423 |
+
</t-button>
|
424 |
+
</template>
|
425 |
+
</t-dialog>
|
426 |
+
</div>
|
427 |
+
</template>
|
428 |
+
|
429 |
+
<style scoped>
|
430 |
+
.content-container {
|
431 |
+
width: 100%;
|
432 |
+
}
|
433 |
+
|
434 |
+
:deep(.t-card__body) {
|
435 |
+
height: 100%;
|
436 |
+
}
|
437 |
+
|
438 |
+
.editor-container {
|
439 |
+
border: 1px solid var(--td-component-border);
|
440 |
+
}
|
441 |
+
</style>
|
src/views/LoginView.vue
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref } from 'vue'
|
3 |
+
import { useRouter } from 'vue-router'
|
4 |
+
import { MessagePlugin } from 'tdesign-vue-next'
|
5 |
+
import logo from '../assets/logo.png'
|
6 |
+
import { userApi } from '../services/userApi'
|
7 |
+
|
8 |
+
const router = useRouter()
|
9 |
+
const username = ref('')
|
10 |
+
const password = ref('')
|
11 |
+
const loading = ref(false)
|
12 |
+
|
13 |
+
|
14 |
+
const handleLogin = async () => {
|
15 |
+
if (!username.value || !password.value) {
|
16 |
+
MessagePlugin.warning('请输入用户名和密码')
|
17 |
+
return
|
18 |
+
}
|
19 |
+
|
20 |
+
loading.value = true
|
21 |
+
try {
|
22 |
+
const data = await userApi.login(username.value, password.value)
|
23 |
+
|
24 |
+
if (data.success) {
|
25 |
+
localStorage.setItem('token', data.token)
|
26 |
+
localStorage.setItem('isAuthenticated', 'true')
|
27 |
+
|
28 |
+
MessagePlugin.success('登录成功')
|
29 |
+
router.push('/')
|
30 |
+
} else {
|
31 |
+
MessagePlugin.error(data.message || '登录失败')
|
32 |
+
}
|
33 |
+
} catch (error) {
|
34 |
+
MessagePlugin.error('网络错误,请稍后重试')
|
35 |
+
console.error('Login error:', error)
|
36 |
+
} finally {
|
37 |
+
loading.value = false
|
38 |
+
}
|
39 |
+
}
|
40 |
+
</script>
|
41 |
+
|
42 |
+
|
43 |
+
<template>
|
44 |
+
<div
|
45 |
+
class="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
|
46 |
+
<div class="relative w-full max-w-md">
|
47 |
+
<!-- 装饰背景 -->
|
48 |
+
<div
|
49 |
+
class="absolute -top-4 -left-4 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob">
|
50 |
+
</div>
|
51 |
+
<div
|
52 |
+
class="absolute -bottom-8 -right-4 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-2000">
|
53 |
+
</div>
|
54 |
+
<div
|
55 |
+
class="absolute -bottom-8 left-20 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-4000">
|
56 |
+
</div>
|
57 |
+
|
58 |
+
<!-- 登录卡片 -->
|
59 |
+
<div class="relative bg-white/80 backdrop-blur-lg rounded-2xl shadow-xl p-8 m-4">
|
60 |
+
<!-- Logo和标题 -->
|
61 |
+
<div class="flex flex-col items-center mb-8">
|
62 |
+
<img :src="logo" alt="logo" class="w-16 h-16 mb-4" />
|
63 |
+
<h2 class="text-2xl font-bold text-gray-800">微软账号管理</h2>
|
64 |
+
<!-- <p class="text-gray-500 mt-2">登录以继续使用</p> -->
|
65 |
+
</div>
|
66 |
+
|
67 |
+
<!-- 登录表单 -->
|
68 |
+
<form @submit.prevent="handleLogin" class="space-y-6">
|
69 |
+
<div class="space-y-2">
|
70 |
+
<t-input v-model="username" size="large" placeholder="请输入用户名" :autofocus="true" class="w-full">
|
71 |
+
<template #prefix-icon>
|
72 |
+
<t-icon name="user" />
|
73 |
+
</template>
|
74 |
+
</t-input>
|
75 |
+
</div>
|
76 |
+
|
77 |
+
<div class="space-y-2">
|
78 |
+
<t-input v-model="password" type="password" size="large" placeholder="请输入密码" class="w-full">
|
79 |
+
<template #prefix-icon>
|
80 |
+
<t-icon name="lock-on" />
|
81 |
+
</template>
|
82 |
+
</t-input>
|
83 |
+
</div>
|
84 |
+
|
85 |
+
<!-- <div class="flex items-center justify-between text-sm">
|
86 |
+
<t-checkbox>记住我</t-checkbox>
|
87 |
+
<a href="#" class="text-blue-600 hover:text-blue-700 transition-colors">
|
88 |
+
忘记密码?
|
89 |
+
</a>
|
90 |
+
</div> -->
|
91 |
+
|
92 |
+
<t-button type="submit" theme="primary" :loading="loading" size="large" class="w-full" :disabled="loading">
|
93 |
+
{{ loading ? '登录中...' : '登录' }}
|
94 |
+
</t-button>
|
95 |
+
</form>
|
96 |
+
|
97 |
+
<!-- 额外信息 -->
|
98 |
+
<div class="mt-6 text-center text-sm text-gray-500">
|
99 |
+
测试账号: admin / password
|
100 |
+
</div>
|
101 |
+
</div>
|
102 |
+
</div>
|
103 |
+
</div>
|
104 |
+
</template>
|
105 |
+
|
106 |
+
<style scoped>
|
107 |
+
.animate-blob {
|
108 |
+
animation: blob 7s infinite;
|
109 |
+
}
|
110 |
+
|
111 |
+
.animation-delay-2000 {
|
112 |
+
animation-delay: 2s;
|
113 |
+
}
|
114 |
+
|
115 |
+
.animation-delay-4000 {
|
116 |
+
animation-delay: 4s;
|
117 |
+
}
|
118 |
+
|
119 |
+
@keyframes blob {
|
120 |
+
0% {
|
121 |
+
transform: translate(0px, 0px) scale(1);
|
122 |
+
}
|
123 |
+
|
124 |
+
33% {
|
125 |
+
transform: translate(30px, -50px) scale(1.1);
|
126 |
+
}
|
127 |
+
|
128 |
+
66% {
|
129 |
+
transform: translate(-20px, 20px) scale(0.9);
|
130 |
+
}
|
131 |
+
|
132 |
+
100% {
|
133 |
+
transform: translate(0px, 0px) scale(1);
|
134 |
+
}
|
135 |
+
}
|
136 |
+
</style>
|
src/views/RepoView.vue
ADDED
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { type TableProps } from 'tdesign-vue-next';
|
3 |
+
import { computed, ref, watch, onMounted } from 'vue';
|
4 |
+
import { useRoute, useRouter } from 'vue-router';
|
5 |
+
import { MessagePlugin } from 'tdesign-vue-next';
|
6 |
+
import { useAccountStore } from '../stores/accountStorage';
|
7 |
+
import { repoApi, type RepoContent, type Account } from '../services/repoApi';
|
8 |
+
import { FolderIcon, FileIcon, CodeIcon, ImageIcon } from 'tdesign-icons-vue-next';
|
9 |
+
import RepoHeader from '../components/RepoHeader.vue';
|
10 |
+
|
11 |
+
const router = useRouter();
|
12 |
+
const route = useRoute();
|
13 |
+
|
14 |
+
// Local state
|
15 |
+
const loading = ref(false);
|
16 |
+
const allFiles = ref<RepoContent[]>([]);
|
17 |
+
const selectedAccount = ref<number>(0);
|
18 |
+
const currentPath = ref('');
|
19 |
+
const store = useAccountStore();
|
20 |
+
|
21 |
+
// Sort files with directories first then files
|
22 |
+
const sortedFiles = computed(() => {
|
23 |
+
// First separate directories and files
|
24 |
+
const dirs = allFiles.value.filter(item => item.type === 'dir');
|
25 |
+
const files = allFiles.value.filter(item => item.type === 'file');
|
26 |
+
|
27 |
+
// Sort directories and files alphabetically by name
|
28 |
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
29 |
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
30 |
+
|
31 |
+
// Combine with directories first
|
32 |
+
return [...dirs, ...files];
|
33 |
+
});
|
34 |
+
|
35 |
+
// Helper function to determine file icon based on file extension
|
36 |
+
const getFileIcon = (filename: string) => {
|
37 |
+
const extension = filename.split('.').pop()?.toLowerCase();
|
38 |
+
|
39 |
+
if (!extension) return FileIcon;
|
40 |
+
|
41 |
+
const codeExtensions = ['js', 'ts', 'py', 'java', 'c', 'cpp', 'cs', 'go', 'php', 'html', 'css', 'vue', 'jsx', 'tsx'];
|
42 |
+
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'];
|
43 |
+
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'yml', 'yaml'];
|
44 |
+
|
45 |
+
if (codeExtensions.includes(extension)) return CodeIcon;
|
46 |
+
if (imageExtensions.includes(extension)) return ImageIcon;
|
47 |
+
// if (textExtensions.includes(extension)) return TextIcon;
|
48 |
+
// if (extension === 'pdf') return PdfIcon;
|
49 |
+
|
50 |
+
return FileIcon;
|
51 |
+
};
|
52 |
+
|
53 |
+
const columns = ref<TableProps['columns']>([
|
54 |
+
{
|
55 |
+
colKey: 'name',
|
56 |
+
title: '文件名',
|
57 |
+
width: 300,
|
58 |
+
cell: (h, { row }) => {
|
59 |
+
const IconComponent = row.type === 'dir' ? FolderIcon : getFileIcon(row.name);
|
60 |
+
return h('div', { class: 'flex items-center gap-2' }, [
|
61 |
+
h(IconComponent, {
|
62 |
+
class: row.type === 'dir' ? 'text-blue-500' : 'text-gray-600',
|
63 |
+
style: { fontSize: '1.25rem' }
|
64 |
+
}),
|
65 |
+
h('span', row.name)
|
66 |
+
]);
|
67 |
+
}
|
68 |
+
},
|
69 |
+
{ colKey: 'type', title: '类型', width: 100 },
|
70 |
+
{
|
71 |
+
colKey: 'size',
|
72 |
+
title: '大小',
|
73 |
+
width: 100,
|
74 |
+
cell: (h, { row }) => {
|
75 |
+
if (row.type === 'dir') return '-';
|
76 |
+
// Format file size
|
77 |
+
const size = Number(row.size);
|
78 |
+
if (size < 1024) return `${size} B`;
|
79 |
+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
80 |
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
81 |
+
}
|
82 |
+
},
|
83 |
+
{ colKey: 'sha', title: 'SHA', width: 200 }
|
84 |
+
]);
|
85 |
+
|
86 |
+
const handleRowClick = (e: any) => {
|
87 |
+
const item: RepoContent = e.row;
|
88 |
+
if (item.type === 'dir') {
|
89 |
+
currentPath.value = item.path;
|
90 |
+
fetchRepo();
|
91 |
+
} else if (item.type === 'file') {
|
92 |
+
router.push({
|
93 |
+
path: '/content',
|
94 |
+
query: {
|
95 |
+
id: selectedAccount.value,
|
96 |
+
path: item.path
|
97 |
+
}
|
98 |
+
});
|
99 |
+
}
|
100 |
+
};
|
101 |
+
|
102 |
+
const fetchRepo = async () => {
|
103 |
+
if (!selectedAccount.value) return;
|
104 |
+
|
105 |
+
loading.value = true;
|
106 |
+
try {
|
107 |
+
const account = store.accounts.find(acc => acc.id === selectedAccount.value);
|
108 |
+
if (!account) {
|
109 |
+
MessagePlugin.error(`未找到账户信息${selectedAccount.value}`);
|
110 |
+
return;
|
111 |
+
}
|
112 |
+
const result = await repoApi.getContents(account, currentPath.value);
|
113 |
+
allFiles.value = Array.isArray(result) ? result : [result];
|
114 |
+
} catch (error) {
|
115 |
+
MessagePlugin.error('获取仓库内容失败');
|
116 |
+
} finally {
|
117 |
+
loading.value = false;
|
118 |
+
}
|
119 |
+
};
|
120 |
+
|
121 |
+
const handleBreadcrumbClick = (path: string) => {
|
122 |
+
currentPath.value = path;
|
123 |
+
fetchRepo();
|
124 |
+
};
|
125 |
+
|
126 |
+
const handleRootClick = () => {
|
127 |
+
currentPath.value = '';
|
128 |
+
fetchRepo();
|
129 |
+
};
|
130 |
+
|
131 |
+
const handleAccountChange = (val: number) => {
|
132 |
+
selectedAccount.value = val;
|
133 |
+
currentPath.value = ''; // Reset path
|
134 |
+
router.push({
|
135 |
+
path: '/repo',
|
136 |
+
query: {
|
137 |
+
id: selectedAccount.value,
|
138 |
+
}
|
139 |
+
});
|
140 |
+
}
|
141 |
+
|
142 |
+
// Watch route query params and sync with local state
|
143 |
+
watch(() => route.query, async (query) => {
|
144 |
+
// Only respond to query changes when on the repo route
|
145 |
+
if (route.path !== '/repo') return;
|
146 |
+
await store.fetchAccounts();
|
147 |
+
const { id, path } = query;
|
148 |
+
|
149 |
+
if (id) {
|
150 |
+
|
151 |
+
selectedAccount.value = Number(id);
|
152 |
+
currentPath.value = path ? path as string : '';
|
153 |
+
}
|
154 |
+
else {
|
155 |
+
|
156 |
+
if (store.accounts.length > 0) {
|
157 |
+
selectedAccount.value = store.accounts[0].id;
|
158 |
+
}
|
159 |
+
currentPath.value = '';
|
160 |
+
}
|
161 |
+
// Fetch data after updating both account and path
|
162 |
+
await fetchRepo();
|
163 |
+
}, { immediate: true });
|
164 |
+
|
165 |
+
|
166 |
+
onMounted(async () => {
|
167 |
+
|
168 |
+
});
|
169 |
+
|
170 |
+
|
171 |
+
|
172 |
+
const handleNewFile = () => {
|
173 |
+
if (!selectedAccount.value) {
|
174 |
+
MessagePlugin.error('请先选择仓库');
|
175 |
+
return;
|
176 |
+
}
|
177 |
+
let path = currentPath.value;
|
178 |
+
router.push({
|
179 |
+
path: '/content',
|
180 |
+
query: {
|
181 |
+
id: selectedAccount.value,
|
182 |
+
path: path,
|
183 |
+
newFile: 1,
|
184 |
+
}
|
185 |
+
});
|
186 |
+
};
|
187 |
+
</script>
|
188 |
+
|
189 |
+
<template>
|
190 |
+
<div class="repository-browser w-full flex flex-col p-3 md:p-5 gap-3 md:gap-5 bg-gray-50">
|
191 |
+
<RepoHeader :selected-account="selectedAccount" :current-path="currentPath" :accounts="store.accounts"
|
192 |
+
@path-click="handleBreadcrumbClick" @root-click="handleRootClick"
|
193 |
+
@update:selected-account="handleAccountChange">
|
194 |
+
<div class="flex gap-2 items-center">
|
195 |
+
<t-button theme="primary" @click="handleNewFile" class="flex items-center gap-1">
|
196 |
+
<template #icon>
|
197 |
+
<FileIcon />
|
198 |
+
</template>
|
199 |
+
新建文件
|
200 |
+
</t-button>
|
201 |
+
</div>
|
202 |
+
</RepoHeader>
|
203 |
+
|
204 |
+
<div class="content-section flex-1 bg-white rounded-lg shadow-sm">
|
205 |
+
<t-table :data="sortedFiles" :loading="loading" :columns="columns" row-key="sha" hover stripe size="medium"
|
206 |
+
class="min-w-full" @row-click="handleRowClick" row-class-name="hover:bg-blue-50 cursor-pointer" />
|
207 |
+
</div>
|
208 |
+
</div>
|
209 |
+
</template>
|
210 |
+
|
211 |
+
<style scoped>
|
212 |
+
.repository-browser {
|
213 |
+
min-height: 600px;
|
214 |
+
}
|
215 |
+
|
216 |
+
/* Add subtle hover animations */
|
217 |
+
.t-table__row:hover {
|
218 |
+
transition: all 0.2s ease;
|
219 |
+
}
|
220 |
+
|
221 |
+
/* Additional responsive styles */
|
222 |
+
@media (max-width: 640px) {
|
223 |
+
.t-table {
|
224 |
+
font-size: 0.875rem;
|
225 |
+
}
|
226 |
+
}
|
227 |
+
</style>
|
src/views/SettingView.vue
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script setup lang="ts">
|
2 |
+
import { ref, onMounted } from 'vue';
|
3 |
+
import { MessagePlugin } from 'tdesign-vue-next';
|
4 |
+
import { settingApi, type Settings } from '../services/settingApi';
|
5 |
+
|
6 |
+
const settings = ref<Settings>({
|
7 |
+
feishu: {
|
8 |
+
app_id: '',
|
9 |
+
app_secret: '',
|
10 |
+
verification_token: '',
|
11 |
+
encrypt_key: '',
|
12 |
+
receive_id: ''
|
13 |
+
}
|
14 |
+
});
|
15 |
+
|
16 |
+
const loading = ref(false);
|
17 |
+
|
18 |
+
onMounted(async () => {
|
19 |
+
try {
|
20 |
+
const data = await settingApi.get();
|
21 |
+
// 合并默认值和获取的数据
|
22 |
+
settings.value = {
|
23 |
+
feishu: {
|
24 |
+
...settings.value.feishu,
|
25 |
+
...data.feishu
|
26 |
+
}
|
27 |
+
};
|
28 |
+
} catch (error) {
|
29 |
+
MessagePlugin.error('获取设置失败');
|
30 |
+
}
|
31 |
+
});
|
32 |
+
|
33 |
+
const handleSave = async () => {
|
34 |
+
loading.value = true;
|
35 |
+
try {
|
36 |
+
if (!settings.value) {
|
37 |
+
return
|
38 |
+
}
|
39 |
+
await settingApi.update(settings.value);
|
40 |
+
MessagePlugin.success('保存成功');
|
41 |
+
} catch (error) {
|
42 |
+
MessagePlugin.error('保存失败');
|
43 |
+
} finally {
|
44 |
+
loading.value = false;
|
45 |
+
}
|
46 |
+
};
|
47 |
+
</script>
|
48 |
+
|
49 |
+
<template>
|
50 |
+
<div class="setting-container p-2 md:p-5">
|
51 |
+
|
52 |
+
<t-form :data="settings" @submit="handleSave">
|
53 |
+
<t-card bordered>
|
54 |
+
<t-divider>飞书配置</t-divider>
|
55 |
+
<t-form-item label="应用ID" name="feishu.app_id">
|
56 |
+
<t-input v-model="settings.feishu.app_id" placeholder="请输入飞书应用ID" />
|
57 |
+
</t-form-item>
|
58 |
+
<t-form-item label="应用密钥" name="feishu.app_secret">
|
59 |
+
<t-input v-model="settings.feishu.app_secret" type="password" placeholder="请输入飞书应用密钥" />
|
60 |
+
</t-form-item>
|
61 |
+
<t-form-item label="验证Token" name="feishu.verification_token">
|
62 |
+
<t-input v-model="settings.feishu.verification_token" placeholder="请输入飞书应用验证Token" />
|
63 |
+
</t-form-item>
|
64 |
+
<t-form-item label="加密Key" name="feishu.encrypt_key">
|
65 |
+
<t-input v-model="settings.feishu.encrypt_key" placeholder="请输入飞书应用加密Key" />
|
66 |
+
</t-form-item>
|
67 |
+
<t-form-item label="接收ID" name="feishu.receive_id">
|
68 |
+
<t-input v-model="settings.feishu.receive_id" placeholder="请输入飞书机器人接收ID" />
|
69 |
+
</t-form-item>
|
70 |
+
|
71 |
+
|
72 |
+
<t-form-item class="flex justify-center">
|
73 |
+
<t-button theme="primary" type="submit" :loading="loading">保存设置</t-button>
|
74 |
+
</t-form-item>
|
75 |
+
</t-card>
|
76 |
+
|
77 |
+
|
78 |
+
</t-form>
|
79 |
+
</div>
|
80 |
+
</template>
|
81 |
+
|
82 |
+
<style scoped></style>
|
src/vite-env.d.ts
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
/// <reference types="vite/client" />
|
test/index.spec.ts
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// test/index.spec.ts
|
2 |
+
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
3 |
+
import { describe, it, expect } from 'vitest';
|
4 |
+
import worker from '../src/index';
|
5 |
+
|
6 |
+
// For now, you'll need to do something like this to get a correctly-typed
|
7 |
+
// `Request` to pass to `worker.fetch()`.
|
8 |
+
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
9 |
+
|
10 |
+
describe('Hello World worker', () => {
|
11 |
+
it('responds with Hello World! (unit style)', async () => {
|
12 |
+
const request = new IncomingRequest('http://example.com');
|
13 |
+
// Create an empty context to pass to `worker.fetch()`.
|
14 |
+
const ctx = createExecutionContext();
|
15 |
+
const response = await worker.fetch(request, env, ctx);
|
16 |
+
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
17 |
+
await waitOnExecutionContext(ctx);
|
18 |
+
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
19 |
+
});
|
20 |
+
|
21 |
+
it('responds with Hello World! (integration style)', async () => {
|
22 |
+
const response = await SELF.fetch('https://example.com');
|
23 |
+
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
24 |
+
});
|
25 |
+
});
|
test/tsconfig.json
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"extends": "../tsconfig.json",
|
3 |
+
"compilerOptions": {
|
4 |
+
"types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers"]
|
5 |
+
},
|
6 |
+
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
|
7 |
+
"exclude": []
|
8 |
+
}
|
tsconfig.app.json
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
3 |
+
"compilerOptions": {
|
4 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
5 |
+
|
6 |
+
/* Linting */
|
7 |
+
"strict": true,
|
8 |
+
"noUnusedLocals": false,
|
9 |
+
"noUnusedParameters": false,
|
10 |
+
"noFallthroughCasesInSwitch": true,
|
11 |
+
"noUncheckedSideEffectImports": true
|
12 |
+
},
|
13 |
+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
14 |
+
}
|
tsconfig.functions.json
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
4 |
+
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
5 |
+
"target": "ESNext",
|
6 |
+
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
7 |
+
"lib": [
|
8 |
+
"esnext"
|
9 |
+
],
|
10 |
+
/* Specify what JSX code is generated. */
|
11 |
+
"jsx": "react-jsx",
|
12 |
+
/* Specify what module code is generated. */
|
13 |
+
"module": "ESNext",
|
14 |
+
/* Specify how TypeScript looks up a file from a given module specifier. */
|
15 |
+
"moduleResolution": "node",
|
16 |
+
/* Specify type package names to be included without being referenced in a source file. */
|
17 |
+
"types": [
|
18 |
+
//"@cloudflare/workers-types/2023-07-01",
|
19 |
+
"node"
|
20 |
+
],
|
21 |
+
/* Enable importing .json files */
|
22 |
+
"resolveJsonModule": true,
|
23 |
+
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
24 |
+
"allowJs": true,
|
25 |
+
/* Enable error reporting in type-checked JavaScript files. */
|
26 |
+
"checkJs": false,
|
27 |
+
|
28 |
+
/* Ensure that each file can be safely transpiled without relying on other imports. */
|
29 |
+
"isolatedModules": true,
|
30 |
+
/* Allow 'import x from y' when a module doesn't have a default export. */
|
31 |
+
"allowSyntheticDefaultImports": true,
|
32 |
+
/* Ensure that casing is correct in imports. */
|
33 |
+
"forceConsistentCasingInFileNames": true,
|
34 |
+
/* Enable all strict type-checking options. */
|
35 |
+
"strict": true,
|
36 |
+
/* Skip type checking all .d.ts files. */
|
37 |
+
"skipLibCheck": true,
|
38 |
+
"outDir": "./dist-server", // 输出到 dist
|
39 |
+
"noEmit": false, //允许编译器生成 JavaScript 输出文件
|
40 |
+
},
|
41 |
+
"exclude": [
|
42 |
+
"test"
|
43 |
+
],
|
44 |
+
"include": [
|
45 |
+
"worker-configuration.d.ts",
|
46 |
+
"functions/types.d.ts",
|
47 |
+
"index.ts",
|
48 |
+
"functions/**/*.ts"
|
49 |
+
]
|
50 |
+
}
|
tsconfig.json
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"files": [],
|
3 |
+
"references": [
|
4 |
+
{ "path": "./tsconfig.app.json" },
|
5 |
+
{ "path": "./tsconfig.node.json" },
|
6 |
+
{ "path": "./tsconfig.functions.json" }
|
7 |
+
]
|
8 |
+
}
|
tsconfig.node.json
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
4 |
+
"target": "ES2022",
|
5 |
+
"lib": ["ES2023"],
|
6 |
+
"module": "ESNext",
|
7 |
+
"skipLibCheck": true,
|
8 |
+
|
9 |
+
/* Bundler mode */
|
10 |
+
"moduleResolution": "bundler",
|
11 |
+
"allowImportingTsExtensions": true,
|
12 |
+
"isolatedModules": true,
|
13 |
+
"moduleDetection": "force",
|
14 |
+
"noEmit": true,
|
15 |
+
|
16 |
+
/* Linting */
|
17 |
+
"strict": true,
|
18 |
+
"noUnusedLocals": true,
|
19 |
+
"noUnusedParameters": true,
|
20 |
+
"noFallthroughCasesInSwitch": true,
|
21 |
+
"noUncheckedSideEffectImports": true
|
22 |
+
},
|
23 |
+
"include": ["vite.config.ts"]
|
24 |
+
}
|