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:$GITHUB_TOKEN@github.com/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 |
+
}
|