This commit is contained in:
2025-12-27 10:20:06 +08:00
parent ad2d73a61b
commit 3ca2820516
10 changed files with 1448 additions and 45 deletions

View File

@@ -10,7 +10,7 @@ import dotenv from 'dotenv';
dotenv.config();
const isDev = process.env.NODE_ENV === 'development';
let target = process.env.VITE_API_URL || 'http://localhost:51015';
let target = process.env.VITE_API_URL || 'http://localhost:51515';
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
let proxy = {
'/root/': apiProxy,

View File

@@ -27,6 +27,7 @@
"@kevisual/query": "^0.0.33",
"@kevisual/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1",
"@kevisual/router": "^0.0.49",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@uiw/react-md-editor": "^4.0.11",
@@ -34,6 +35,7 @@
"astro": "^5.16.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.19",
"es-toolkit": "^1.43.0",
"github-markdown-css": "^5.8.1",
@@ -53,6 +55,7 @@
"access": "public"
},
"devDependencies": {
"@kevisual/api": "^0.0.5",
"@kevisual/types": "^0.0.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

798
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
web/src/app.ts Normal file
View File

@@ -0,0 +1,3 @@
import { QueryRouterServer } from '@kevisual/router/browser'
import { use } from '@kevisual/context'
export const app = use('app', new QueryRouterServer())

257
web/src/apps/cv/index.css Normal file
View File

@@ -0,0 +1,257 @@
.cv-app {
min-height: 100vh;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
.cv-header {
height: 68px;
background: #ffffff;
padding: 0 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.cv-header h1 {
margin: 0;
font-size: 1.25rem;
color: #0a0a0a;
font-weight: 600;
}
.cv-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.cv-btn {
padding: 0.5rem 1rem;
border: 1px solid #e5e5e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.375rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cv-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.cv-btn:active {
transform: translateY(0);
}
.cv-btn-primary {
background: #0a0a0a;
color: white;
border-color: #0a0a0a;
}
.cv-btn-primary:hover {
background: #1a1a1a;
border-color: #1a1a1a;
}
.cv-btn-secondary {
background: white;
color: #525252;
border-color: #e5e5e5;
}
.cv-btn-secondary:hover {
background: #fafafa;
border-color: #d4d4d4;
}
.cv-btn-danger {
background: white;
color: #dc2626;
border-color: #fecaca;
}
.cv-btn-danger:hover {
background: #fef2f2;
border-color: #fca5a5;
}
.cv-editor-container {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.cv-preview-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.cv-preview {
background: white;
padding: 3rem;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
min-height: 800px;
}
/* Markdown 样式优化 */
.markdown-body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
font-size: 16px;
line-height: 1.6;
color: #0a0a0a;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: #0a0a0a;
}
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid #e5e5e5;
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid #e5e5e5;
padding-bottom: 0.3em;
}
.markdown-body h3 {
font-size: 1.25em;
}
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body ul,
.markdown-body ol {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
.markdown-body li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: #f5f5f5;
border-radius: 6px;
color: #0a0a0a;
}
.markdown-body pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f5f5f5;
border-radius: 6px;
margin-bottom: 16px;
border: 1px solid #e5e5e5;
}
.markdown-body blockquote {
margin: 0;
padding: 0 1em;
color: #525252;
border-left: 0.25em solid #e5e5e5;
margin-bottom: 16px;
}
.markdown-body strong {
font-weight: 600;
color: #0a0a0a;
}
.markdown-body em {
font-style: italic;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e5e5e5;
border: 0;
}
/* 打印样式 */
@media print {
.cv-header {
display: none;
}
.cv-app {
background: white;
}
.cv-preview {
box-shadow: none;
padding: 0;
}
.cv-preview-container {
padding: 0;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.cv-header {
height: auto;
min-height: 68px;
flex-direction: column;
align-items: stretch;
padding: 1rem;
}
.cv-actions {
justify-content: stretch;
}
.cv-btn {
flex: 1;
justify-content: center;
padding: 0.5rem;
}
.cv-btn span:not([class]) {
display: none;
}
.cv-preview {
padding: 1.5rem;
}
.cv-editor-container {
padding: 1rem;
}
}

174
web/src/apps/cv/index.tsx Normal file
View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react'
import MDEditor from '@uiw/react-md-editor'
import { ToastContainer } from 'react-toastify'
import { Save, Download, Printer, Eye, Edit, RotateCcw } from 'lucide-react'
import 'github-markdown-css/github-markdown-light.css'
import './index.css'
const defaultResume = `# 张三
**前端开发工程师** | 北京 | zhangsan@example.com | 138-0000-0000
## 个人简介
热爱前端开发拥有5年React和Vue开发经验。专注于构建高性能、可维护的Web应用。
## 工作经历
### 高级前端工程师 | ABC科技有限公司
*2021年6月 - 至今*
- 负责公司核心产品的前端架构设计和开发
- 使用React和TypeScript构建企业级管理系统
- 优化前端性能页面加载速度提升40%
- 带领5人前端团队制定代码规范和最佳实践
### 前端开发工程师 | XYZ互联网公司
*2019年3月 - 2021年5月*
- 参与电商平台的前端开发
- 使用Vue.js开发响应式Web应用
- 与后端团队协作实现RESTful API对接
## 技能
- **前端框架**: React, Vue.js, Next.js, Astro
- **编程语言**: TypeScript, JavaScript, HTML5, CSS3
- **工具**: Git, Webpack, Vite, Docker
- **其他**: Node.js, GraphQL, Tailwind CSS
## 教育背景
### 计算机科学与技术 | 本科
*北京某大学 | 2015年9月 - 2019年6月*
- 主修课程:数据结构、算法、计算机网络、数据库原理
- 优秀毕业生GPA 3.8/4.0
## 项目经验
### 企业级管理系统
*技术栈: React, TypeScript, Ant Design*
- 设计并实现模块化权限管理系统
- 开发数据可视化大屏,支持实时数据展示
- 编写单元测试代码覆盖率达到85%
### 电商平台前端
*技术栈: Vue.js, Vuex, Element UI*
- 实现购物车、订单管理等核心功能
- 优化首屏加载时间LCP从2.5s降至1.2s
- 响应式设计,完美支持移动端访问
## 语言能力
- 英语CET-6能够阅读英文技术文档
- 普通话:母语
`
export const AppProvider = () => {
return (
<div>
<App />
<ToastContainer />
</div>
)
}
export const App = () => {
const [markdown, setMarkdown] = useState<string | undefined>(defaultResume)
const [isPreviewMode, setIsPreviewMode] = useState(false)
// 从 localStorage 加载保存的简历
useEffect(() => {
const saved = localStorage.getItem('cv-content')
if (saved) {
setMarkdown(saved)
}
}, [])
// 保存到 localStorage
const handleSave = () => {
if (markdown) {
localStorage.setItem('cv-content', markdown)
alert('简历已保存!')
}
}
// 导出为 Markdown 文件
const handleExport = () => {
if (markdown) {
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'resume.md'
a.click()
URL.revokeObjectURL(url)
}
}
// 打印简历
const handlePrint = () => {
window.print()
}
// 重置为默认模板
const handleReset = () => {
if (confirm('确定要重置为默认模板吗?当前内容将丢失。')) {
setMarkdown(defaultResume)
localStorage.removeItem('cv-content')
}
}
return (
<div className="cv-app">
<header className="cv-header">
<h1>📄 </h1>
<div className="cv-actions">
<button onClick={handleSave} className="cv-btn cv-btn-primary">
<Save size={16} />
<span></span>
</button>
<button onClick={handleExport} className="cv-btn cv-btn-secondary">
<Download size={16} />
<span> MD</span>
</button>
<button onClick={handlePrint} className="cv-btn cv-btn-secondary">
<Printer size={16} />
<span></span>
</button>
<button onClick={() => setIsPreviewMode(!isPreviewMode)} className="cv-btn cv-btn-secondary">
{isPreviewMode ? <Edit size={16} /> : <Eye size={16} />}
<span>{isPreviewMode ? '编辑' : '预览'}</span>
</button>
<button onClick={handleReset} className="cv-btn cv-btn-danger">
<RotateCcw size={16} />
<span></span>
</button>
</div>
</header>
{isPreviewMode ? (
<div className="cv-preview-container">
<div className="cv-preview markdown-body">
<MDEditor.Markdown source={markdown} />
</div>
</div>
) : (
<div className="cv-editor-container" data-color-mode="light">
<MDEditor
value={markdown}
onChange={setMarkdown}
height={800}
preview="edit"
textareaProps={{
placeholder: '在这里输入你的简历内容(支持 Markdown 格式)...'
}}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { toast, ToastContainer } from 'react-toastify';
import { useStudioStore } from './store.ts';
import { useEffect, useState } from 'react';
import { Play } from 'lucide-react';
export const AppProvider = () => {
return <main className='w-full'>
<App />
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light" />
</main>
}
interface RouteItem {
id: string;
path?: string;
key?: string;
description?: string;
metadata?: Record<string, any>;
}
export const App = () => {
const { routes, getRoutes, run } = useStudioStore();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
useEffect(() => {
getRoutes();
}, []);
const toggleDescription = (id: string) => {
const newExpanded = new Set(expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedIds(newExpanded);
};
const toggleIdVisibility = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const newVisible = new Set(visibleIds);
if (newVisible.has(id)) {
newVisible.delete(id);
} else {
newVisible.add(id);
}
setVisibleIds(newVisible);
};
return (
<div className="max-w-5xl mx-auto p-6">
<div className="space-y-1">
{routes.map((route: RouteItem) => {
const isExpanded = expandedIds.has(route.id);
const isIdVisible = visibleIds.has(route.id);
const len = route.description?.length || 0;
const isLongDescription = len > 20;
return (
<div
key={route.id}
className="px-4 py-3 border-b border-gray-100 hover:bg-gray-50/50 transition-all duration-200"
>
<div className="flex flex-col gap-2.5">
{/* ID and Path/Key in one line */}
<div className="flex gap-2.5 flex-wrap items-center justify-between">
<div className="flex gap-2.5 flex-wrap items-center">
<span
onClick={(e) => toggleIdVisibility(e, route.id)}
className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold bg-gray-900 text-white cursor-pointer hover:bg-gray-700 transition-all duration-200 shadow-sm"
>
{isIdVisible ? route.id : 'id'}
</span>
{(route.path || route.key) && (
<div className="bg-gray-100 px-3 py-1.5 rounded-md font-mono text-sm text-gray-900 border border-gray-200">
{route.path}{route.key && ` / ${route.key}`}
</div>
)}
</div>
<button
className="inline-flex items-center justify-center p-1.5 rounded-md text-gray-500 hover:text-green-600 hover:bg-green-50 transition-all duration-200 cursor-pointer"
title="运行"
onClick={() => run(route)}
>
<Play size={14} strokeWidth={2.5} />
</button>
</div>
{/* Description with expand/collapse */}
{route.description && (
<div
className="cursor-pointer group"
>
<div
className={`text-gray-700 transition-colors duration-200 cursor-pointer ${isExpanded ? 'text-gray-900' : 'group-hover:text-gray-900'
}`}
>
{isExpanded ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{route.description}
</p>
) : (
<p className="text-sm leading-relaxed overflow-hidden text-ellipsis whitespace-nowrap">
{route.description}
</p>
)}
</div>
{isLongDescription && (
<p className="text-xs text-gray-400 mt-1 group-hover:text-gray-500 transition-colors duration-200"
onClick={() => toggleDescription(route.id)}
>
{isExpanded ? '点击收起' : '点击展开'}
</p>
)}
</div>
)}
{/* Metadata */}
{route.metadata && Object.keys(route.metadata).length > 0 && (
<div className="mt-0.5">
<span className="text-xs text-gray-500 mr-2 font-medium">Metadata:</span>
<div className="flex gap-1.5 flex-wrap">
{Object.entries(route.metadata).map(([k, v]) => (
<span
key={k}
className="inline-flex items-center px-2 py-1 rounded text-xs bg-white text-gray-700 border border-gray-200 shadow-sm"
>
<span className="font-semibold text-gray-900">{k}:</span> {String(v)}
</span>
))}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { QueryProxy } from '@kevisual/api'
// import { query } from '@/modules/query.ts'
import { Query } from '@kevisual/query';
import { toast } from 'react-toastify';
const url = localStorage.getItem('BROWSER_HELPER_URL') || 'http://localhost:52000/api/router';
const query = new Query({
url,
})
import { QueryRouterServer } from '@kevisual/router/src/route.ts'
const router = new QueryRouterServer();
const qp = new QueryProxy({ query, router });
type RouteItem = {
id: string;
path?: string;
key?: string;
description?: string;
metadata?: Record<string, any>;
}
interface StudioState {
routes: Array<RouteItem>;
getRoutes: () => Promise<void>;
run: (route: RouteItem) => Promise<void>;
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const useStudioStore = create<StudioState>((set) => ({
routes: [],
getRoutes: async () => {
await qp.init();
await sleep(200); // wait for routes to be registered
console.log('query proxy', qp.router);
const routes: any[] = await qp.listRoutes()
console.log('fetched routes', routes);
set({ routes });
},
run: async (route: RouteItem) => {
console.log('running route', route);
const res = await qp.run({ path: route.path, key: route.key });
console.log('route run result', res);
if (res.code !== 200) {
toast.error(`运行失败:${res.message || '未知错误'}`);
}
}
}));

8
web/src/pages/cv.astro Normal file
View File

@@ -0,0 +1,8 @@
---
import Html from '@/components/html.astro';
import { AppProvider } from '@/apps/cv/index.tsx';
---
<Html title='简历'>
<AppProvider client:only></AppProvider>
</Html>

View File

@@ -1,47 +1,8 @@
---
// import { query } from '@/modules/query.ts';
console.log('Hello from index.astro');
import '../styles/global.css';
import Html from '@/components/html.astro';
import { AppProvider } from '@/apps/studio/index.tsx';
---
<html lang='en'>
<head>
<title>My Homepage</title>
</head>
<body>
<h1 onclick="{onClick}">Welcome to my website!</h1>
<div class='bg-amber-50 w-20 h-20 rounded-full'></div>
<div id='root'></div>
<script type='importmap' data-vite-ignore is:inline>
{
"imports": {
"react": "https://esm.sh/react@19.1.0",
"react-dom": "https://esm.sh/react-dom@19.1.0/client.js",
"react-toastify": "https://esm.sh/react-toastify@11.0.5"
}
}
</script>
<script type='module' data-vite-ignore is:inline>
import { Button, message } from 'https://esm.sh/antd?standalone';
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
import { createRoot } from 'react-dom';
setTimeout(() => {
toast.loading('Hello from index.astro');
window.toast = toast;
console.log('message', toast);
}, 1000);
console.log('Hello from index.astro', Button);
const root = document.getElementById('root');
const render = createRoot(root);
const App = () => {
const button = React.createElement(Button, null, 'Hello');
const messageEl = React.createElement(ToastContainer, null, 'Hello');
const wrapperMessage = React.createElement('div', null, [button, messageEl]);
return wrapperMessage;
};
// render.render(React.createElement(Button, null, 'Hello'), root);
render.render(App(), root);
</script>
</body>
</html>
<Html title='Router Studio'>
<AppProvider client:only></AppProvider>
</Html>