fix upgrade bug

v3
veypi 4 months ago
parent 89e7caa7b0
commit 89a2ea17e2

@ -17,16 +17,13 @@ import (
)
var cliOpts = &struct {
Host string `json:"host" short:"h`
Port int `json:"port" short:"p"`
LoggerPath string `json:"logger_path,omitempty"`
LoggerLevel string `json:"logger_level,omitempty"`
Host string `json:"host"`
Port int `json:"port" short:"p"`
*cfg.Options
}{
Host: "0.0.0.0",
Port: 4000,
LoggerLevel: "debug",
Options: cfg.Config,
Host: "0.0.0.0",
Port: 4000,
Options: cfg.Config,
}
var (
@ -50,7 +47,6 @@ func main() {
}
func runWeb() error {
logv.SetLevel(logv.AssertFuncErr(logv.ParseLevel(cliOpts.LoggerLevel)))
server, err := vigo.New(vigo.WithHost(cliOpts.Host), vigo.WithPort(cliOpts.Port))
if err != nil {
return err

File diff suppressed because one or more lines are too long

@ -89,7 +89,7 @@
<script setup>
goLogin = () => {
$router.push('/login');
$router.push('/app');
}
</script>

@ -409,15 +409,15 @@
</div>
</div>
<!-- 用户名登录 (使用 v-form) -->
<v-form v-if="loginType === 'username'" :data="signInForm" :items="signInUsernameItems" @submit="handleSignIn">
<div vslot="actions" style="width: 100%;">
<a href="#" class="forgot-password"
style="display: block; text-align: right; margin-bottom: 15px;">忘记密码?</a>
<div class="error-message" v-if="signInError">{{ signInError }}</div>
<v-btn round block size="lg" :loading="signInLoading" type="submit">登 录</v-btn>
</div>
</v-form>
<!-- 用户名登录 (使用 v-input) -->
<div v-if="loginType === 'username'" class="input-group">
<v-input v:value="signInForm.username" placeholder="用户名" :validate="validateUsername"></v-input>
<v-input type="password" v:value="signInForm.password" placeholder="密码"></v-input>
<a href="#" class="forgot-password">忘记密码?</a>
<div class="error-message" v-if="signInError">{{ signInError }}</div>
<v-btn round block size="lg" :loading="signInLoading" :click="handleSignIn">登 录</v-btn>
</div>
<!-- 手机号登录 (保持自定义布局,使用 v-input validate) -->
<div v-if="loginType === 'phone'" class="input-group">
@ -501,12 +501,6 @@
return true;
};
// v-form 配置项 (用户名登录)
signInUsernameItems = [
{name: 'username', placeholder: '用户名', required: true, validate: validateUsername},
{name: 'password', placeholder: '密码', type: 'password', required: true, validate: validatePassword}
];
// 常用国家/地区代码 - 转换为 v-select 格式
regions = [
{value: '+86', label: '+86 中国'},
@ -659,9 +653,13 @@
$message.success('注册成功!');
signUpForm = {username: '', phone: '', verifyCode: '', password: '', region: '+86'};
signUpLoading = false;
switchToSignIn();
} catch (error) {
signUpError = error.message || '注册失败,请重试。';
if (signUpError.indexOf("Duplicate entry") >= 0) {
signUpError = '用户名重复'
}
$message.warning(signUpError);
} finally {
signUpLoading = false;
@ -676,9 +674,8 @@
try {
let loginData = {};
if (loginType === 'username') {
// v-form 提交时已经通过了基本验证,但 double check 也没坏处
if (validateUsername(signInForm.username) !== true) return;
if (validatePassword(signInForm.password) !== true) return;
// if (validatePassword(signInForm.password) !== true) return;
loginData = {username: signInForm.username, code: btoa(signInForm.password), type: 'username'};
} else {
if (validatePhone(signInForm.phone) !== true) return;
@ -687,9 +684,17 @@
}
signInLoading = true;
await $axios.post('/api/token', loginData, {noretry: true});
$message.success('登录成功!');
$router.push(redirect);
const loginResponse = await $axios.post('/api/user/login', {
username: signInForm.username,
code: btoa(signInForm.password),
}, {noretry: true});
if (loginResponse && typeof loginResponse === 'string') {
localStorage.setItem('refresh', loginResponse)
window.location.href = redirect
} else {
console.warn('登录失败,服务器返回异常数据', loginResponse);
$message.warning('服务器异常');
}
} catch (error) {
signInError = error.message || '登录失败,请重试';
$message.warning(signInError);

@ -4,7 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>oa</title>
<script type="module" key='vhtml' src="/assets/vhtml.min.js"></script>
<!-- <script type="module" key='vhtml' src="/assets/vhtml.min.js"></script> -->
<script type="module" key='vhtml' src="/vhtml/v.js"></script>
<link rel="stylesheet" href="/assets/common.css">
<link rel="stylesheet" href="/v/common.css">
<link href="/assets/libs/animate/animate.min.css" rel="stylesheet">

File diff suppressed because one or more lines are too long

@ -0,0 +1,293 @@
function CamelToKebabCase(str) {
// 首先将字符串的第一个字符转换为小写,避免在首字符前加上'-'
if (str.length === 0) return '';
let firstChar = str.charAt(0).toLowerCase();
// 对剩余部分应用原逻辑:找到每个大写字母,并替换为连字符加上该字母的小写形式
let rest = str.slice(1).replace(/([A-Z])/g, function(match, p1) {
return '-' + p1.toLowerCase();
});
return firstChar + rest;
}
const outerClickList = []
const globalClick = document.addEventListener('click', (event) => {
outerClickList.forEach((item) => {
if (item?.dom instanceof Element && typeof item?.callback === 'function') {
if (!item.dom.contains(event.target)) {
item.callback(event)
}
}
})
})
const AddClicker = (dom, typ, callback) => {
if (typ === 'outer') {
let idx = outerClickList.length
outerClickList.push({ dom, callback })
return () => {
outerClickList[idx] = null
}
}
}
const EventsList = [
// 窗口和框架事件
'load',
'unload',
'beforeunload',
'resize',
'scroll',
// 表单事件
'submit',
'reset',
'input',
'change',
'focus',
'blur',
// 键盘事件
'keydown',
'keypress',
'keyup',
// 鼠标事件
'click',
'dblclick',
'contextmenu',
'mousedown',
'mouseup',
'mousemove',
'mouseover',
'mouseout',
'mouseenter',
'mouseleave',
// 触摸事件
'touchstart',
'touchmove',
'touchend',
'touchcancel',
// 拖拽事件
'drag',
'dragstart',
'dragend',
'dragover',
'dragenter',
'dragleave',
'drop',
// 剪贴板事件
'copy',
'cut',
'paste',
// 动画事件
'animationstart',
'animationend',
'animationiteration',
// 过渡事件
'transitionend',
// 文件操作事件
'abort',
'error',
'loadstart',
'progress',
// 音视频事件
'play',
'pause',
'ended',
'volumechange',
'timeupdate',
'loadeddata',
'waiting',
'playing',
// 网络状态事件
'online',
'offline',
// 存储事件
'storage',
// 页面可见性事件
'visibilitychange'
];
function BindInputDomValue(dom, data, key, watch) {
const element = typeof dom === 'string' ? document.querySelector(dom) : dom;
if (!element) {
console.error('DOM元素未找到');
return;
}
// 根据元素类型进行双向绑定
const elementType = element.type || element.tagName.toLowerCase();
switch (elementType) {
// 文本输入类
case 'text':
case 'password':
case 'email':
case 'tel':
case 'url':
case 'search':
case 'number':
case 'range':
case 'color':
case 'date':
case 'time':
case 'datetime-local':
case 'month':
case 'week':
case 'hidden':
case 'textarea':
watch(() => data[key], (value) => {
if (value === undefined) {
element.value = ''
} else {
element.value = value
}
})
element.addEventListener('input', function() {
data[key] = this.value;
});
break;
case 'checkbox':
watch(function() {
element.checked = !!data[key];
});
element.addEventListener('change', function() {
data[key] = this.checked;
console.log(data, data[key])
});
break;
// 单选框
case 'radio':
// 初始化
watch(() => {
element.checked = element.value === data[key];
})
element.addEventListener('change', function() {
if (this.checked) {
data[key] = this.value;
}
});
break;
// 下拉选择框
case 'select-one':
case 'select-multiple':
watch(() => {
let newValue = data[key]
if (element.multiple) {
const values = Array.isArray(newValue) ? newValue : [];
for (let i = 0; i < element.options.length; i++) {
element.options[i].selected = values.includes(element.options[i].value);
}
} else {
element.value = newValue || '';
}
});
// 监听变化
element.addEventListener('change', function() {
if (this.multiple) {
// 多选
const selectedValues = [];
for (let i = 0; i < this.options.length; i++) {
if (this.options[i].selected) {
selectedValues.push(this.options[i].value);
}
}
data[key] = selectedValues;
} else {
// 单选
data[key] = this.value;
}
});
break;
default:
console.warn(`${elementType} not support v!bind only for input element`, element);
return false
}
return true
}
function SetAttr(dom, key, value) {
// 属性名映射表
const propertyMap = {
'htmlfor': 'htmlFor',
'readonly': 'readOnly',
'maxlength': 'maxLength',
'minlength': 'minLength',
'cellspacing': 'cellSpacing',
'cellpadding': 'cellPadding',
'rowspan': 'rowSpan',
'colspan': 'colSpan',
'tabindex': 'tabIndex',
'usemap': 'useMap',
'frameborder': 'frameBorder',
'contenteditable': 'contentEditable',
'spellcheck': 'spellcheck',
'innerhtml': 'innerHTML',
'innertext': 'innerText',
'autocapitalize': 'autocapitalize',
};
// 需要使用 DOM 属性设置的属性
const domProperties = new Set([
'innerHTML', 'innerText', 'outerHTML', 'textContent',
'value', 'checked', 'selected', 'disabled', 'readOnly',
'maxLength', 'minLength', 'htmlFor',
'tabIndex', 'scrollTop', 'scrollLeft', 'scrollWidth', 'scrollHeight',
'clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight',
'style', 'dataset'
]);
// 布尔属性
const booleanAttributes = new Set([
'checked', 'selected', 'disabled', 'readonly', 'required',
'hidden', 'autofocus', 'multiple', 'novalidate'
]);
// 转换属性名
const lowerKey = key.toLowerCase();
const mappedKey = propertyMap[lowerKey] || key;
// 设置属性的策略:
if (domProperties.has(mappedKey)) {
// DOM 属性
if (value === undefined) {
dom[mappedKey] = ''
} else {
dom[mappedKey] = value;
}
} else if (booleanAttributes.has(lowerKey)) {
// 布尔属性
if (value) {
dom.setAttribute(lowerKey, '');
} else {
dom.removeAttribute(lowerKey);
}
} else {
// 其他属性使用 setAttribute
if (value === undefined) {
dom.removeAttribute(key);
} else {
dom.setAttribute(key, value);
}
}
}
export default { CamelToKebabCase, EventsList, BindInputDomValue, SetAttr, AddClicker }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,138 @@
/*
* vbus.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
class EventBus {
constructor() {
// 存储事件监听器的对象
this.events = {};
}
/**
* 订阅事件
* @param {string} eventName - 事件名称
* @param {Function} callback - 回调函数
* @param {Object} context - 执行上下文可选
* @returns {Function} 取消订阅的函数
*/
on(eventName, callback, context = null) {
if (typeof callback !== 'function') {
throw new Error('回调函数必须是一个函数');
}
if (!this.events[eventName]) {
this.events[eventName] = [];
}
const listener = { callback, context };
this.events[eventName].push(listener);
// 返回取消订阅的函数
return () => this.off(eventName, callback, context);
}
/**
* 一次性事件监听
* @param {string} eventName - 事件名称
* @param {Function} callback - 回调函数
* @param {Object} context - 执行上下文可选
* @returns {Function} 取消订阅的函数
*/
once(eventName, callback, context = null) {
const onceWrapper = (...args) => {
this.off(eventName, onceWrapper, context);
callback.apply(context, args);
};
return this.on(eventName, onceWrapper, context);
}
/**
* 取消事件订阅
* @param {string} eventName - 事件名称
* @param {Function} callback - 要移除的回调函数可选
* @param {Object} context - 执行上下文可选
*/
off(eventName, callback = null, context = null) {
if (!this.events[eventName]) {
return;
}
// 如果没有指定回调函数,移除该事件的所有监听器
if (!callback) {
delete this.events[eventName];
return;
}
// 移除特定的监听器
this.events[eventName] = this.events[eventName].filter(listener => {
return !(listener.callback === callback && listener.context === context);
});
// 如果该事件没有监听器了,删除该事件
if (this.events[eventName].length === 0) {
delete this.events[eventName];
}
}
/**
* 触发事件
* @param {string} eventName - 事件名称
* @param {...any} args - 传递给回调函数的参数
*/
emit(eventName, ...args) {
if (!this.events[eventName]) {
return;
}
// 复制监听器数组,避免在执行过程中修改原数组导致的问题
const listeners = [...this.events[eventName]];
listeners.forEach(listener => {
try {
listener.callback.apply(listener.context, args);
} catch (error) {
console.error(`事件 "${eventName}" 的监听器执行出错:`, error);
}
});
}
/**
* 获取事件的监听器数量
* @param {string} eventName - 事件名称
* @returns {number} 监听器数量
*/
listenerCount(eventName) {
return this.events[eventName] ? this.events[eventName].length : 0;
}
/**
* 获取所有事件名称
* @returns {string[]} 事件名称数组
*/
eventNames() {
return Object.keys(this.events);
}
/**
* 移除所有事件监听器
*/
removeAllListeners() {
this.events = {};
}
/**
* 检查是否有某个事件的监听器
* @param {string} eventName - 事件名称
* @returns {boolean} 是否有监听器
*/
hasListeners(eventName) {
return this.listenerCount(eventName) > 0;
}
}
export default EventBus;

@ -0,0 +1,566 @@
/*
* vcss.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the GPL license.
* simple css parser
*/
class CSSParser {
constructor() {
this.scopeAttribute = '';
this.scopeBody = ''
this.scopedKeyframes = new Map(); // 存储已处理的keyframe映射
}
/**
* 解析CSS文本并添加作用域
* @param {string} cssText - CSS文本
* @param {string} scope - 作用域标识符
* @returns {string} - 处理后的CSS文本
*/
parse(cssText, scope) {
this.scopeAttribute = `[vrefof="${scope}"]`;
this.scopeBody = `[vref="${scope}"]`
this.scopedKeyframes.clear();
this.scopeSuffix = scope.replace(/[^a-zA-Z0-9]/g, ''); // 清理作用域后缀
// 移除注释
cssText = this.removeComments(cssText);
// 第一遍收集所有keyframes名称
this.collectKeyframes(cssText);
// 第二遍解析CSS规则并替换动画名称
return this.parseRules(cssText);
}
/**
* 收集所有keyframes名称
* @param {string} cssText
*/
collectKeyframes(cssText) {
const keyframeRegex = /@keyframes\s+([^\s{]+)/gi;
let match;
while ((match = keyframeRegex.exec(cssText)) !== null) {
const originalName = match[1];
const scopedName = originalName + '-' + this.scopeSuffix;
this.scopedKeyframes.set(originalName, scopedName);
}
}
/**
* 移除CSS注释
* @param {string} cssText
* @returns {string}
*/
removeComments(cssText) {
return cssText.replace(/\/\*[\s\S]*?\*\//g, '');
}
/**
* 解析CSS规则
* @param {string} cssText
* @returns {string}
*/
parseRules(cssText) {
let result = '';
let i = 0;
while (i < cssText.length) {
// 跳过空白字符
while (i < cssText.length && /\s/.test(cssText[i])) {
result += cssText[i];
i++;
}
if (i >= cssText.length) break;
// 检查是否是@规则
if (cssText[i] === '@') {
const atRule = this.parseAtRule(cssText, i);
result += atRule.content;
i = atRule.endIndex;
} else {
// 普通CSS规则
const rule = this.parseNormalRule(cssText, i);
result += rule.content;
i = rule.endIndex;
}
}
return result;
}
/**
* 解析@规则@media, @keyframes等
* @param {string} cssText
* @param {number} startIndex
* @returns {object}
*/
parseAtRule(cssText, startIndex) {
let i = startIndex;
let atRuleContent = '';
// 读取@规则名称和参数
while (i < cssText.length && cssText[i] !== '{') {
atRuleContent += cssText[i];
i++;
}
if (i >= cssText.length) {
return { content: atRuleContent, endIndex: i };
}
// 检查是否是@keyframes或@media
const atRuleName = atRuleContent.toLowerCase().trim();
if (atRuleName.startsWith('@keyframes')) {
return this.parseKeyframes(cssText, startIndex);
} else if (atRuleName.startsWith('@media')) {
return this.parseMedia(cssText, startIndex);
} else if (atRuleName.startsWith('@supports')) {
return this.parseSupports(cssText, startIndex);
} else {
// 其他@规则,查找匹配的大括号
const braceContent = this.findMatchingBrace(cssText, i);
return {
content: atRuleContent + braceContent.content,
endIndex: braceContent.endIndex
};
}
}
/**
* 解析@keyframes规则
* @param {string} cssText
* @param {number} startIndex
* @returns {object}
*/
parseKeyframes(cssText, startIndex) {
let i = startIndex;
let result = '';
// 读取@keyframes声明
while (i < cssText.length && cssText[i] !== '{') {
result += cssText[i];
i++;
}
if (i >= cssText.length) {
return { content: result, endIndex: i };
}
// 处理keyframes内容
const braceContent = this.findMatchingBrace(cssText, i);
const keyframesContent = braceContent.content;
// 为keyframes名称添加作用域
const keyframeName = this.extractKeyframeName(result);
const scopedKeyframeName = this.scopedKeyframes.get(keyframeName);
if (scopedKeyframeName) {
result = result.replace(keyframeName, scopedKeyframeName);
}
result += keyframesContent;
return {
content: result,
endIndex: braceContent.endIndex
};
}
/**
* 提取keyframe名称
* @param {string} keyframeDeclaration
* @returns {string}
*/
extractKeyframeName(keyframeDeclaration) {
const match = keyframeDeclaration.match(/@keyframes\s+([^\s{]+)/i);
return match ? match[1] : '';
}
/**
* 解析@media规则
* @param {string} cssText
* @param {number} startIndex
* @returns {object}
*/
parseMedia(cssText, startIndex) {
let i = startIndex;
let result = '';
// 读取@media声明
while (i < cssText.length && cssText[i] !== '{') {
result += cssText[i];
i++;
}
if (i >= cssText.length) {
return { content: result, endIndex: i };
}
result += '{';
i++; // 跳过开始的 '{'
// 解析@media内部的CSS规则
let braceLevel = 1;
let innerCss = '';
while (i < cssText.length && braceLevel > 0) {
if (cssText[i] === '{') {
braceLevel++;
} else if (cssText[i] === '}') {
braceLevel--;
if (braceLevel === 0) {
break;
}
}
innerCss += cssText[i];
i++;
}
// 递归处理@media内部的CSS规则
const processedInnerCss = this.parseRules(innerCss);
result += processedInnerCss;
if (i < cssText.length && cssText[i] === '}') {
result += '}';
i++;
}
return {
content: result,
endIndex: i
};
}
/**
* 解析@supports规则
* @param {string} cssText
* @param {number} startIndex
* @returns {object}
*/
parseSupports(cssText, startIndex) {
return this.parseMedia(cssText, startIndex);
}
/**
* 解析普通CSS规则
* @param {string} cssText
* @param {number} startIndex
* @returns {object}
*/
parseNormalRule(cssText, startIndex) {
let i = startIndex;
let selector = '';
// 读取选择器
while (i < cssText.length && cssText[i] !== '{') {
selector += cssText[i];
i++;
}
if (i >= cssText.length) {
return { content: selector, endIndex: i };
}
// 处理选择器添加作用域
const scopedSelector = this.addScopeToSelector(selector.trim());
// 读取CSS规则体并处理动画名称
const braceContent = this.findMatchingBrace(cssText, i);
const processedContent = this.processRuleContent(braceContent.content);
return {
content: scopedSelector + processedContent,
endIndex: braceContent.endIndex
};
}
/**
* 处理CSS规则内容替换动画名称
* @param {string} content
* @returns {string}
*/
processRuleContent(content) {
let processedContent = content;
// 处理 animation 属性
processedContent = processedContent.replace(
/animation\s*:\s*([^;]+);/gi,
(match, animationValue) => {
const processedValue = this.processAnimationValue(animationValue);
return `animation: ${processedValue};`;
}
);
// 处理 animation-name 属性
processedContent = processedContent.replace(
/animation-name\s*:\s*([^;]+);/gi,
(match, animationNames) => {
const processedNames = this.processAnimationNames(animationNames);
return `animation-name: ${processedNames};`;
}
);
return processedContent;
}
/**
* 处理animation属性值
* @param {string} animationValue
* @returns {string}
*/
processAnimationValue(animationValue) {
// animation 可能包含多个动画,用逗号分隔
const animations = animationValue.split(',').map(anim => anim.trim());
return animations.map(animation => {
const parts = animation.split(/\s+/);
// 第一个非时间、非数字、非关键字的值通常是动画名称
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// 跳过时间值 (如 0.3s, 200ms)
if (/^\d+(\.\d+)?(s|ms)$/.test(part)) continue;
// 跳过数字值 (如 iteration count)
if (/^\d+(\.\d+)?$/.test(part)) continue;
// 跳过CSS关键字
if (['ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear', 'infinite',
'normal', 'reverse', 'alternate', 'alternate-reverse', 'forwards',
'backwards', 'both', 'running', 'paused'].includes(part)) continue;
// 跳过贝塞尔曲线
if (part.startsWith('cubic-bezier(')) continue;
// 这应该是动画名称
const scopedName = this.scopedKeyframes.get(part);
if (scopedName) {
parts[i] = scopedName;
}
break;
}
return parts.join(' ');
}).join(', ');
}
/**
* 处理animation-name属性值
* @param {string} animationNames
* @returns {string}
*/
processAnimationNames(animationNames) {
return animationNames.split(',').map(name => {
const trimmedName = name.trim();
const scopedName = this.scopedKeyframes.get(trimmedName);
return scopedName || trimmedName;
}).join(', ');
}
/**
* 为选择器添加作用域
* @param {string} selector
* @returns {string}
*/
addScopeToSelector(selector) {
if (!selector.trim()) return selector;
// 分割多个选择器(用逗号分隔)
const selectors = selector.split(',').map(sel => sel.trim());
const scopedSelectors = selectors.map(sel => {
return this.addScopeToSingleSelector(sel);
});
return scopedSelectors.join(', ');
}
/**
* 为单个选择器添加作用域
* @param {string} selector
* @returns {string}
*/
addScopeToSingleSelector(selector) {
if (!selector.trim()) return selector;
// 处理伪元素选择器 - 在伪元素前添加作用域
if (selector.includes('::')) {
const parts = selector.split('::');
const mainPart = parts[0];
const pseudoElement = '::' + parts.slice(1).join('::');
// 为主要部分添加作用域
const scopedMain = this.addScopeToSelectorPart(mainPart);
return scopedMain + pseudoElement;
}
// 处理伪类选择器 - 在伪类前添加作用域
if (selector.includes(':') && !selector.includes('::')) {
const pseudoMatch = selector.match(/^([^:]+)(:.+)$/);
if (pseudoMatch) {
const mainPart = pseudoMatch[1];
const pseudoClass = pseudoMatch[2];
// 为主要部分添加作用域
const scopedMain = this.addScopeToSelectorPart(mainPart);
return scopedMain + pseudoClass;
}
}
// 处理特殊选择器
if (selector === '*' || selector.startsWith('@')) {
return selector;
}
// 处理复合选择器
return this.addScopeToSelectorPart(selector);
}
/**
* 为选择器部分添加作用域
* @param {string} selectorPart
* @returns {string}
*/
addScopeToSelectorPart(selectorPart) {
// 处理组合器选择器(>、+、~、空格)
const combinatorRegex = /(\s*[>+~]\s*|\s+)/;
if (combinatorRegex.test(selectorPart)) {
// 分割选择器,但保留组合器
const parts = selectorPart.split(combinatorRegex);
if (/^body(?:$|[:\[ ])/.test(parts[0])) {
parts[0] = this.scopeBody + parts[0].slice(4)
return parts.join('');
}
if (/^:root(?:$|[:\[ ])/.test(parts[0])) {
parts[0] = this.scopeBody + parts[0].slice(5)
return parts.join('');
}
// 只在最后一个选择器部分添加作用域
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].trim() && !combinatorRegex.test(parts[i])) {
let tag = parts[i].trim()
if (/^body(?:$|[:\[ ])/.test(tag)) {
parts[i] = this.scopeBody + tag.slice(4)
} else if (/^:root(?:$:[$:\[ ])/.test(tag)) {
parts[i] = this.scopeBody + tag.slice(5)
} else {
parts[i] = parts[i].trim() + this.scopeAttribute;
}
break;
}
}
return parts.join('');
}
selectorPart = selectorPart.trim()
let tag = selectorPart.trim()
if (/^body(?:$|[:\[ ])/.test(tag)) {
return this.scopeBody + tag.slice(4)
} else if (/^:root(?:$:[$:\[ ])/.test(tag)) {
return this.scopeBody + tag.slice(5)
} else {
return tag + this.scopeAttribute;
}
}
/**
* 查找匹配的大括号
* @param {string} cssText
* @param {number} startIndex
* @returns {object}
*/
findMatchingBrace(cssText, startIndex) {
let i = startIndex;
let content = '';
let braceLevel = 0;
while (i < cssText.length) {
content += cssText[i];
if (cssText[i] === '{') {
braceLevel++;
} else if (cssText[i] === '}') {
braceLevel--;
if (braceLevel === 0) {
i++;
break;
}
}
i++;
}
return {
content: content,
endIndex: i
};
}
}
// 使用示例
const parser = new CSSParser();
// 示例CSS
const cssText = `
body{}
.container::before, .a {
content: "";
animation-name: fadeIn, slideUp;
animation: slideIn 0.5s infinite alternate;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
font-size: 14px;
animation: slideIn 0.2s ease-out;
}
.item {
margin: 5px;
}
}
.box > .child {
color: green;
}
.box + .sibling {
margin-top: 20px;
}
`;
// 解析并添加作用域
// console.log(parser.parse(cssText, 'comasd-123'));
// 导出类
export default parser;
export { CSSParser }

@ -0,0 +1,70 @@
/*
* vdev.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
import DivSelectorPlugin from './vdevselect.js'
let isSetup = false;
let divSelector = null;
const postMessage = (typ, args) => {
if (!isSetup) return; // 未初始化前不发送消息
if (typeof args === 'string' || typeof args === 'number') {
args = { value: args }
}
window.parent.postMessage(Object.assign({ type: typ, from: 'vdev' }, args), '*')
}
// 延迟执行的初始化函数
const initializeVdev = () => {
// 在初始化时创建 divSelector 实例
divSelector = new DivSelectorPlugin();
postMessage('iframe-loaded')
divSelector.postMessage = postMessage
window.addEventListener('keyup', (event) => {
if (event.key === 'Escape') {
postMessage('key-esc')
}
})
setTimeout(() => {
if (window.$vhtml && window.$vhtml.$router) {
$vhtml.$router.onChange((url) => {
postMessage('url-change', url.fullPath)
})
}
}, 100)
window.addEventListener('message', (event) => {
const data = event.data;
console.log(data)
if (data.from != 'vhtml') {
return
}
switch (data.type) {
case 'reload':
window.location.reload()
break;
case 'magic':
if (divSelector && divSelector.isActive) {
divSelector.deactivate()
} else if (divSelector) {
divSelector.activate()
}
break
}
});
}
function setup() {
if (isSetup) return; // 防止重复初始化
isSetup = true;
initializeVdev();
}
export default setup

@ -0,0 +1,620 @@
/*
* vdev-select.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
const copyToClipboard = async (text) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text)
}
prompt('http环境无法自动复制请手动复制内容到剪贴板:', text)
return new Promise((resolve) => { })
}
/**
* Div Selector Plugin
* 用于选择页面div元素并高亮显示的插件
*/
class DivSelectorPlugin {
postMessage = (typ, args) => { }
constructor(options = {}) {
this.options = {
highlightColor: '#007bff',
overlayColor: 'rgba(0, 123, 255, 0.1)',
borderWidth: '2px',
zIndex: 10000,
showTagName: true,
...options
};
this.isActive = false;
this.selectedElement = null;
this.currentHoverElement = null;
this.overlay = null;
this.selectedOverlay = null;
this.tooltip = null;
this.selectedTooltip = null;
this.actionPanel = null;
this.originalCursor = '';
this.init();
}
init() {
this.createStyles();
this.bindEvents();
}
createStyles() {
const style = document.createElement('style');
style.textContent = `
.div-selector-overlay {
position: fixed;
pointer-events: none;
border: ${this.options.borderWidth} solid ${this.options.highlightColor};
background: ${this.options.overlayColor};
z-index: ${this.options.zIndex};
transition: all 0.2s ease;
box-sizing: border-box;
}
.div-selector-selected {
position: fixed;
pointer-events: none;
border: ${this.options.borderWidth} solid #28a745;
background: rgba(40, 167, 69, 0.1);
z-index: ${this.options.zIndex - 1};
box-sizing: border-box;
}
.div-selector-tooltip {
position: fixed;
background: rgba(51, 51, 51, 0.95);
cursor:pointer;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
z-index: ${this.options.zIndex + 1};
user-select: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(4px);
}
.div-selector-tooltip.hover {
background: rgba(0, 123, 255, 0.9);
}
.div-selector-tooltip.selected {
background: rgba(40, 167, 69, 0.9);
border-color: rgba(40, 167, 69, 0.3);
}
.div-selector-actions {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px;
z-index: ${this.options.zIndex + 2};
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
display: flex;
gap: 6px;
min-width: 30px;
backdrop-filter: blur(8px);
}
.div-selector-btn {
padding: 4px 6px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
text-align: left;
white-space: nowrap;
}
.div-selector-btn:hover {
background: #f8f9fa;
transform: translateX(2px);
}
.div-selector-btn.danger {
color: #dc3545;
border-color: #dc3545;
}
.div-selector-btn.danger:hover {
background: #dc3545;
color: white;
}
.div-selector-btn.primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.div-selector-btn.primary:hover {
background: #0056b3;
}
.div-selector-active {
cursor: crosshair !important;
}
`;
document.head.appendChild(style);
}
bindEvents() {
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.handleResize = this.handleResize.bind(this);
}
activate() {
if (this.isActive) return;
this.isActive = true;
this.originalCursor = document.body.style.cursor;
document.body.classList.add('div-selector-active');
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('click', this.handleClick);
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize);
this.createOverlay();
this.createTooltip();
}
deactivate() {
if (!this.isActive) return;
this.isActive = false;
document.body.style.cursor = this.originalCursor;
document.body.classList.remove('div-selector-active');
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('click', this.handleClick);
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
this.removeOverlay();
this.removeTooltip();
// this.clearSelection();
console.log('Div Selector Plugin deactivated.');
}
handleMouseMove(e) {
if (!this.isActive) return;
const element = document.elementFromPoint(e.clientX, e.clientY);
if (!element) return;
// 忽略插件自身的元素
if (this.isPluginElement(element)) return;
// 查找最近的div元素
const divElement = element.tagName === 'DIV' ? element : element.closest('div,[vref]');
if (!divElement || this.isPluginElement(divElement)) return;
// 如果已选中元素,不要高亮显示它
if (this.selectedElement === divElement) return;
this.currentHoverElement = divElement;
this.highlightElement(divElement);
this.updateHoverTooltip(divElement);
}
handleClick(e) {
if (!this.isActive) return;
const element = document.elementFromPoint(e.clientX, e.clientY);
if (!element) return;
// 忽略插件自身的元素
if (this.isPluginElement(element)) return;
e.preventDefault();
e.stopPropagation();
const divElement = element.tagName === 'DIV' ? element : element.closest('div');
if (!divElement || this.isPluginElement(divElement)) return;
this.selectElement(divElement);
this.deactivate()
}
handleScroll() {
if (this.selectedElement) {
this.updateSelectedOverlay();
this.updateSelectedTooltip();
this.updateActionPanelPosition();
}
if (this.currentHoverElement && !this.selectedElement) {
this.highlightElement(this.currentHoverElement);
this.updateHoverTooltip(this.currentHoverElement);
}
}
handleResize() {
if (this.selectedElement) {
this.updateSelectedOverlay();
this.updateSelectedTooltip();
this.updateActionPanelPosition();
}
}
isPluginElement(element) {
return element.closest('.div-selector-overlay, .div-selector-selected, .div-selector-tooltip, .div-selector-actions');
}
createOverlay() {
this.overlay = document.createElement('div');
this.overlay.className = 'div-selector-overlay';
this.overlay.style.display = 'none';
document.body.appendChild(this.overlay);
}
createSelectedOverlay() {
this.selectedOverlay = document.createElement('div');
this.selectedOverlay.className = 'div-selector-selected';
document.body.appendChild(this.selectedOverlay);
}
createTooltip() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'div-selector-tooltip hover';
this.tooltip.style.display = 'none';
document.body.appendChild(this.tooltip);
}
createSelectedTooltip() {
this.selectedTooltip = document.createElement('div');
this.selectedTooltip.className = 'div-selector-tooltip selected';
document.body.appendChild(this.selectedTooltip);
}
highlightElement(element) {
if (!this.overlay) return;
const rect = element.getBoundingClientRect();
this.overlay.style.display = 'block';
this.overlay.style.left = rect.left + 'px';
this.overlay.style.top = rect.top + 'px';
this.overlay.style.width = rect.width + 'px';
this.overlay.style.height = rect.height + 'px';
}
updateHoverTooltip(element) {
if (!this.tooltip) return;
const elementInfo = this.getElementInfo(element);
const rect = element.getBoundingClientRect();
this.tooltip.textContent = elementInfo;
this.tooltip.style.display = 'block';
// 计算tooltip位置 - 左上角,稍微偏移避免遮挡
let left = rect.left - 8;
let top = rect.top - this.tooltip.offsetHeight - 8;
// 确保不超出视窗
if (left < 0) left = 8;
if (top < 0) top = rect.top + 8;
this.tooltip.style.left = left + 'px';
this.tooltip.style.top = top + 'px';
}
selectElement(element) {
// 清除之前的选择
this.clearSelection();
this.selectedElement = element;
// 创建选中状态的覆盖层和tooltip
this.createSelectedOverlay();
this.createSelectedTooltip();
this.updateSelectedOverlay();
this.updateSelectedTooltip();
// 隐藏悬停效果
if (this.overlay) {
this.overlay.style.display = 'none';
}
if (this.tooltip) {
this.tooltip.style.display = 'none';
}
// 显示操作面板
this.showActionPanel();
console.log('Element selected:', element);
}
updateSelectedOverlay() {
if (!this.selectedOverlay || !this.selectedElement) return;
const rect = this.selectedElement.getBoundingClientRect();
this.selectedOverlay.style.left = rect.left + 'px';
this.selectedOverlay.style.top = rect.top + 'px';
this.selectedOverlay.style.width = rect.width + 'px';
this.selectedOverlay.style.height = rect.height + 'px';
}
updateSelectedTooltip() {
if (!this.selectedTooltip || !this.selectedElement) return;
const elementInfo = this.getElementInfo(this.selectedElement);
let vref = this.getFilePath(this.selectedElement)
const rect = this.selectedElement.getBoundingClientRect();
this.selectedTooltip.addEventListener('click', e => {
if (vref) {
this.postMessage('fs-open', vref)
}
e.preventDefault()
e.stopPropagation()
})
this.selectedTooltip.textContent = `${elementInfo}`;
// 计算tooltip位置 - 选中框的左上角
let left = rect.left - 8;
let top = rect.top - this.selectedTooltip.offsetHeight - 8;
// 确保不超出视窗
if (left < 0) left = 8;
if (top < 0) top = rect.top + 8;
this.selectedTooltip.style.left = left + 'px';
this.selectedTooltip.style.top = top + 'px';
}
showActionPanel() {
this.removeActionPanel();
const rect = this.selectedElement.getBoundingClientRect();
this.actionPanel = document.createElement('div');
this.actionPanel.className = 'div-selector-actions';
// 计算位置 - 放在右上角
const panelWidth = 180; // 预估宽度
const panelHeight = 40; // 预估高度
let left = rect.right - panelWidth;
let top = rect.top + 6;
// 确保不超出视窗
if (left + panelWidth > window.innerWidth) {
left = rect.left - panelWidth - 8;
}
if (left < 0) left = 8;
if (top + panelHeight > window.innerHeight) {
top = window.innerHeight - panelHeight - 8;
}
if (top < 0) top = 8;
this.actionPanel.style.left = left + 'px';
this.actionPanel.style.top = top + 'px';
// 创建操作按钮
const buttons = [
{ text: '📋', action: () => this.copySelector(this.selectedElement) },
// { text: '🔍', action: () => this.copyXPath(this.selectedElement) },
// { text: '👁️', action: () => this.viewStyles(this.selectedElement) },
// { text: '📍', action: () => this.clearSelection() },
{ text: 'x', action: () => this.clearSelection() },
];
buttons.forEach(btn => {
const button = document.createElement('button');
button.className = `div-selector-btn ${btn.class || ''}`;
button.textContent = btn.text;
button.onclick = btn.action;
this.actionPanel.appendChild(button);
});
document.body.appendChild(this.actionPanel);
}
updateActionPanelPosition() {
if (!this.actionPanel || !this.selectedElement) return;
const rect = this.selectedElement.getBoundingClientRect();
const panelWidth = this.actionPanel.offsetWidth;
const panelHeight = this.actionPanel.offsetHeight;
let left = rect.right - panelWidth + 8;
let top = rect.top - 8;
// 确保不超出视窗
if (left + panelWidth > window.innerWidth) {
left = rect.left - panelWidth - 8;
}
if (left < 0) left = 8;
if (top + panelHeight > window.innerHeight) {
top = window.innerHeight - panelHeight - 8;
}
if (top < 0) top = 8;
this.actionPanel.style.left = left + 'px';
this.actionPanel.style.top = top + 'px';
}
clearSelection() {
this.selectedElement = null;
if (this.selectedOverlay) {
this.selectedOverlay.remove();
this.selectedOverlay = null;
}
if (this.selectedTooltip) {
this.selectedTooltip.remove();
this.selectedTooltip = null;
}
this.removeActionPanel();
console.log('Selection cleared');
}
getElementInfo(element) {
const rect = element.getBoundingClientRect();
let vref = element.getAttribute('vref')
if (!vref) {
vref = element.closest('[vref]').getAttribute('vref')
vref += "." + element.tagName.toLowerCase();
}
return `${vref} (${Math.round(rect.width)}×${Math.round(rect.height)})`;
}
getFilePath(element) {
let vref = element.getAttribute('vref')
if (!vref) {
vref = element.closest('[vref]').getAttribute('vref')
}
if (vref) {
vref = '/ui' + vref + '.html'
}
return vref
}
copySelector(element) {
let vref = this.getFilePath(element)
copyToClipboard(vref).then(() => {
console.log('CSS Selector copied:', selector);
this.showNotification('📋 CSS选择器已复制到剪贴板');
});
}
copyXPath(element) {
const xpath = this.generateXPath(element);
copyToClipboard(xpath).then(() => {
console.log('XPath copied:', xpath);
this.showNotification('🔍 XPath已复制到剪贴板');
});
}
viewStyles(element) {
const styles = window.getComputedStyle(element);
const importantStyles = [
'display', 'position', 'width', 'height', 'margin', 'padding',
'background-color', 'color', 'font-size', 'border', 'z-index', 'opacity'
];
let styleInfo = '📊 重要样式属性:\n\n';
importantStyles.forEach(prop => {
styleInfo += `${prop}: ${styles.getPropertyValue(prop)}\n`;
});
alert(styleInfo);
}
showNotification(message) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
padding: 12px 18px;
border-radius: 8px;
z-index: ${this.options.zIndex + 10};
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
transform: translateX(100%);
transition: transform 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
// 动画显示
setTimeout(() => {
notification.style.transform = 'translateX(0)';
}, 100);
// 自动隐藏
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
setTimeout(() => {
notification.remove();
}, 300);
}, 2500);
}
generateXPath(element) {
if (element.id) {
return `//*[@id="${element.id}"]`;
}
const path = [];
while (element && element.nodeType === Node.ELEMENT_NODE) {
let index = 0;
let hasFollowingSiblings = false;
let hasPrecedingSiblings = false;
for (let sibling = element.previousSibling; sibling; sibling = sibling.previousSibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
hasPrecedingSiblings = true;
index++;
}
}
for (let sibling = element.nextSibling; sibling && !hasFollowingSiblings; sibling = sibling.nextSibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
hasFollowingSiblings = true;
}
}
const tagName = element.nodeName.toLowerCase();
const pathIndex = (hasPrecedingSiblings || hasFollowingSiblings) ? `[${index + 1}]` : '';
path.unshift(tagName + pathIndex);
element = element.parentNode;
if (element === document.body) break;
}
return path.length ? '/' + path.join('/') : null;
}
removeOverlay() {
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
}
removeTooltip() {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
}
removeActionPanel() {
if (this.actionPanel) {
this.actionPanel.remove();
this.actionPanel = null;
}
}
}
export default DivSelectorPlugin

@ -0,0 +1,89 @@
/*
* verror.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
class ErrorCollector {
constructor() {
this.errorQueue = [];
this.init();
}
init() {
// 监听未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', (event) => {
this.handleError({
type: 'unhandledrejection',
message: event.reason.message || String(event.reason),
stack: event.reason.stack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
promiseRejection: true
});
});
// 监听 JavaScript 错误
window.addEventListener('error', (event) => {
if (event.target === window) {
this.handleError({
type: 'javascript-error',
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error ? event.error.stack : null,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href
});
}
});
}
handleError(errorInfo) {
console.error('Error collected:', errorInfo);
this.queueError(errorInfo);
}
queueError(errorInfo) {
this.errorQueue.push(errorInfo);
// 限制队列大小
if (this.errorQueue.length > 100) {
this.errorQueue.shift();
}
// 尝试存储到 localStorage
try {
localStorage.setItem('errorQueue', JSON.stringify(this.errorQueue));
} catch (e) {
console.warn('Failed to store error queue in localStorage');
}
}
// 手动报告错误
reportError(error, context = {}) {
this.handleError({
type: 'manual-report',
message: error.message,
stack: error.stack,
context: context,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href
});
}
}
// 初始化错误收集器
const errorCollector = new ErrorCollector();
// 导出实例供其他模块使用
window.errorCollector = errorCollector;
export default errorCollector

@ -0,0 +1,322 @@
/*
* vget.js
* Copyright (C) 2024 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
import EventBus from './vbus.js';
import axios from './axios.min.js'
import vcss from './vcss.js'
import vproxy from './vproxy.js';
import vmessage from './vmessage.js'
async function FetchFile(url) {
return fetch(url).then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
}
var cacheUrl = {}
var pendingRequests = {};
let baseFile = ''
const envMap = {}
async function getEnv(scoped, temp) {
scoped = scoped || ''
if (!envMap[scoped]) {
let baseURL = scoped.startsWith('http') ? scoped : window.location.origin + scoped
envMap[scoped] = Object.assign({}, temp, {
scoped: scoped,
$G: vproxy.Wrap({}),
$bus: new EventBus(),
$axios: axios.create({
baseURL: baseURL,
}),
$message: vmessage,
$router: null,
$emit: null,
})
if (scoped === $vhtml.scoped || $vhtml.scoped === null) {
envMap[scoped].$router = $vhtml.$router
} else {
// 对于第三方组件,不配置路由
envMap[scoped].$router = { addRoutes: () => { }, beforeEnter: () => { } }
}
try {
await (await import(baseURL + '/env.js')).default(envMap[scoped])
} catch (e) {
console.warn('error loading ' + baseURL + '/env.js: ' + e)
}
}
return envMap[scoped]
}
/**
* @param {string} url
* @return {Promise<{heads:HTMLCollection, body: HTMLElement, setup?:Element, scripts:Element[]}, scripts:Element>}
*/
async function FetchUI(url, env, ignorescoped) {
if (!url || url === '/') {
url = '/'
}
if (!url.startsWith('http') && !url.startsWith('@')) {
if (!url.startsWith('/')) {
url = '/' + url
}
}
let scoped = env?.scoped
if (scoped && url.startsWith('/')) {
url = scoped + url
}
if (url.startsWith('@')) {
url = url.slice(1)
}
if (cacheUrl[url]) {
return Promise.resolve(cacheUrl[url])
}
if (pendingRequests[url]) {
return pendingRequests[url];
}
let tempenv = {}
const promise = fetch(url + "?random=" + Math.random())
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
for (const [key, value] of response.headers.entries()) {
if (key.startsWith('vhtml-')) {
tempenv[key.slice(6)] = value
}
}
let scoped = tempenv.scoped || ''
if (url.startsWith('http')) {
scoped = new URL(url).origin + scoped
tempenv.scoped = scoped
}
let packEnv = await getEnv(scoped, tempenv)
Object.assign(tempenv, packEnv)
// Object.seal(tempenv)
return response.text()
})
.then(txt => {
// if (baseFile === txt) {
// throw new Error(`HTTP error! status: 404`);
// }
if (baseFile == '') {
baseFile = txt
}
return ParseUI(txt, tempenv, url, ignorescoped)
}).then((parser) => {
cacheUrl[url] = parser
return parser
})
.catch(err => {
let errmsg = '404'
if (err.message !== 'HTTP error! status: 404') {
console.warn(err)
}
let dom404 = document.createElement('div')
dom404.style.cssText = `
backgound:#aaa;
height:100%;
width: 100%;
display:grid;
place-items: center;
`
dom404.innerHTML = `
<div style="width:20rem;height:15rem;border-radius:1rem;padding:1rem;background:#cfc0aa;display:grid;place-items:center;">
<div style="font-size:2rem">404</div>
<p>${url}</p>
</div>
`
let parser = {
heads: [],
body: dom404,
setup: '',
scripts: [],
styles: '',
txt: '',
tmp: '',
env: tempenv,
err: err,
}
cacheUrl[url] = parser
return parser
})
.finally(() => {
delete pendingRequests[url];
});
pendingRequests[url] = promise;
return promise;
}
function generateCompactUniqueString() {
// 获取当前时间戳,精确到毫秒
const timestamp = new Date().getTime();
let shortenedTimestamp = timestamp.toString(36);
if (shortenedTimestamp.length > 4) {
shortenedTimestamp = shortenedTimestamp.substring(shortenedTimestamp.length - 4);
}
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let randomPart = '';
for (let i = 0; i < 4; i++) {
randomPart += characters.charAt(Math.floor(Math.random() * characters.length));
}
// 组合随机部分和时间戳部分保证总长度为8位
return randomPart + shortenedTimestamp.padStart(4, '0');
}
function sync_ref_owner_id(dom, id) {
// 子组件根节点不设置data-v, 所以style class 不生效于body只能通过body {}设置样式
// dom.setAttribute('data-v-' + id, '')
Array.from(dom.childNodes).forEach((n) => {
if (n.nodeType === 1) {
n.setAttribute('vrefof', id)
sync_ref_owner_id(n, id)
}
})
}
async function ParseUI(txt, env, turl, ignorescoped) {
if (turl === undefined) {
turl = '#' + generateCompactUniqueString()
}
if (turl.endsWith('.html')) {
turl = turl.slice(0, -5)
}
let tmp = new DOMParser().parseFromString(txt, 'text/html')
if (tmp.body.hasAttribute('scoped') && !ignorescoped) {
throw new Error(`HTTP error! status: 404`);
}
let target = {
url: turl,
heads: [],
body: document.createElement('div'),
setup: undefined,
scripts: [],
styles: '',
txt: txt,
env: env,
tmp: tmp,
customAttrs: {},
}
target.heads = Array.from(tmp.querySelector('head')?.children)
// target.heads.forEach(h => {
// })
if (turl) {
tmp.querySelectorAll('style').forEach((s) => {
if (s.getAttribute('unscoped') === null) {
target.styles += vcss.parse(s.innerHTML, turl)
} else {
target.styles += s.innerHTML
}
})
if (target.styles) {
const style = document.createElement('style')
style.innerHTML = target.styles
style.setAttribute('vref', turl)
document.head.appendChild(style)
}
}
target.body.append(...tmp.querySelector('body').childNodes)
// target.body = tmp.querySelector('body')
target.body.querySelectorAll('script').forEach((s) => {
let sinner = s.innerHTML.trim()
if (sinner == '') {
s.remove()
return
}
if (s.hasAttribute('setup')) {
target.setup = s
} else if (!s.hasAttribute('no-vhtml')) {
target.scripts.push(s)
}
s.remove()
})
// target.body.classList = tmp.body.classList
Array.from(tmp.body.attributes).forEach((e) => {
if (/^[a-zA-Z]/.test(e.name)) {
target.body.setAttribute(e.name, e.value)
} else {
target.customAttrs[e.name] = e.value
}
})
target.body.setAttribute('vref', turl)
sync_ref_owner_id(target.body, turl)
if (!ignorescoped) {
await loadHeaders(target, env)
}
return target
}
async function loadHeaders(target, env) {
for (let h of target.heads) {
let nodeName = h.nodeName.toLowerCase()
if (nodeName === 'link') {
LoadLink(h, env)
} else if (nodeName === 'script') {
await LoadScript(h, env)
} else if (nodeName === 'title') {
target.title = h.innerText
} else {
}
}
}
/**
* @param {HTMLElement} dom
*/
function LoadScript(dom, env) {
let src = dom.getAttribute('src')
let key = dom.getAttribute('key')
let scoped = env?.scoped
if (scoped && src.startsWith('/')) {
src = scoped + src
}
if (src.startsWith('@')) {
src = src.slice(1)
}
if (src && document.querySelector(`script[src="${src}"]`)) {
return
}
if (key && document.querySelector(`script[key="${key}"]`)) {
return
}
let newDom = document.createElement('script')
newDom.src = src
newDom.key = key
newDom.type = dom.getAttribute('type') || 'text/javascript'
return new Promise((resolve, reject) => {
newDom.onload = () => {
resolve(newDom)
};
newDom.onerror = () => reject(new Error(`Failed to load script ${src}`));
document.head.appendChild(newDom)
})
}
async function LoadLink(dom, env) {
let src = dom.getAttribute('href')
let key = dom.getAttribute('key')
let scoped = env?.scoped
if (scoped && src.startsWith('/')) {
src = scoped + src
}
if (src.startsWith('@')) {
src = src.slice(1)
}
if (src && document.querySelector(`link[href="${src}"]`)) {
return
}
if (key && document.querySelector(`link[key="${key}"]`)) {
return
}
dom.setAttribute('href', src)
document.head.append(dom)
}
export default { FetchUI, FetchFile, LoadScript, LoadLink, ParseUI }

@ -0,0 +1,531 @@
/*
* vmessage.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
class Message {
constructor() {
this.container = null;
this.init();
}
/**
* 初始化容器
*/
init() {
this.container = document.createElement('div');
this.container.className = 'message-container';
document.body.appendChild(this.container);
// 添加基础样式
this.addBaseStyles();
}
/**
* 添加基础样式
*/
addBaseStyles() {
const style = document.createElement('style');
style.textContent = `
.message-container {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: 300px;
}
.message-item {
margin-bottom: 10px;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transform: translateY(100%);
opacity: 0;
transition: all 0.3s ease;
display: flex;
align-items: flex-start;
}
.message-item.show {
transform: translateY(0);
opacity: 1;
}
.message-icon {
margin-right: 10px;
font-size: 16px;
line-height: 1;
}
.message-content {
flex: 1;
font-size: 14px;
line-height: 1.4;
}
.message-close {
margin-left: 10px;
cursor: pointer;
font-size: 16px;
opacity: 0.7;
transition: opacity 0.2s;
}
.message-close:hover {
opacity: 1;
}
.message-success {
background-color: #f0f9eb;
border: 1px solid #e1f3d8;
color: var(--color-success, #67c23a);
}
.message-warning {
background-color: #fdf6ec;
border: 1px solid #faecd8;
color: #e6a23c;
}
.message-error {
background-color: #fef0f0;
border: 1px solid #fde2e2;
color: #f56c6c;
}
.message-info {
background-color: #edf2fc;
border: 1px solid #ebeef5;
color: #409eff;
}
.prompt-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.prompt-overlay.show {
opacity: 1;
}
.prompt-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
min-width: 400px;
max-width: 500px;
transform: scale(0.8);
transition: transform 0.3s ease;
}
.prompt-overlay.show .prompt-dialog {
transform: scale(1);
}
.prompt-header {
padding: 20px 20px 10px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
}
.prompt-title {
font-size: 18px;
font-weight: 500;
margin: 0;
}
.prompt-close {
cursor: pointer;
font-size: 20px;
color: #999;
border: none;
background: none;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.prompt-close:hover {
color: #666;
}
.prompt-body {
padding: 20px;
}
.prompt-content {
margin-bottom: 20px;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.prompt-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.prompt-input:focus {
outline: none;
border-color: #409eff;
}
.prompt-footer {
padding: 15px 20px;
text-align: right;
border-top: 1px solid #eee;
}
.prompt-btn {
padding: 8px 16px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
margin-left: 10px;
transition: all 0.2s;
}
.prompt-btn-cancel {
background: white;
color: #606266;
}
.prompt-btn-cancel:hover {
background: #f5f7fa;
border-color: #c0c4cc;
}
.prompt-btn-confirm {
background: #409eff;
color: white;
border-color: #409eff;
}
.prompt-btn-confirm:hover {
background: #66b1ff;
border-color: #66b1ff;
}
`;
document.head.appendChild(style);
}
/**
* 创建消息元素
* @param {string} type - 消息类型 (success, warning, error, info)
* @param {string} content - 消息内容
* @param {Object} options - 配置选项
*/
createMessage(type, content, options = {}) {
const {
duration = 3000,
showClose = true,
onClose = null
} = options;
const messageItem = document.createElement('div');
messageItem.className = `message-item message-${type}`;
// 消息图标
const icons = {
success: '✓',
warning: '⚠',
error: '✕',
info: ''
};
const icon = document.createElement('span');
icon.className = 'message-icon';
icon.textContent = icons[type] || icons.info;
// 消息内容
const contentEl = document.createElement('div');
contentEl.className = 'message-content';
contentEl.textContent = content;
messageItem.appendChild(icon);
messageItem.appendChild(contentEl);
// 关闭按钮
if (showClose) {
const closeBtn = document.createElement('span');
closeBtn.className = 'message-close';
closeBtn.innerHTML = '&times;';
closeBtn.onclick = () => {
this.removeMessage(messageItem);
if (onClose) onClose();
};
messageItem.appendChild(closeBtn);
}
this.container.appendChild(messageItem);
// 显示动画
setTimeout(() => {
messageItem.classList.add('show');
}, 10);
// 自动关闭
if (duration > 0) {
setTimeout(() => {
this.removeMessage(messageItem);
if (onClose) onClose();
}, duration);
}
return messageItem;
}
/**
* 移除消息
* @param {HTMLElement} messageItem - 消息元素
*/
removeMessage(messageItem) {
if (messageItem && messageItem.parentNode) {
messageItem.classList.remove('show');
setTimeout(() => {
if (messageItem.parentNode) {
messageItem.parentNode.removeChild(messageItem);
}
}, 300);
}
}
/**
* 成功消息
* @param {string} content - 消息内容
* @param {Object} options - 配置选项
*/
success(content, options = {}) {
return this.createMessage('success', content, options);
}
/**
* 警告消息
* @param {string} content - 消息内容
* @param {Object} options - 配置选项
*/
warning(content, options = {}) {
return this.createMessage('warning', content, options);
}
/**
* 错误消息
* @param {string} content - 消息内容
* @param {Object} options - 配置选项
*/
error(content, options = {}) {
return this.createMessage('error', content, options);
}
/**
* 信息消息
* @param {string} content - 消息内容
* @param {Object} options - 配置选项
*/
info(content, options = {}) {
return this.createMessage('info', content, options);
}
/**
* 显示提示框
* @param {string} content - 提示内容
* @param {Object} options - 配置选项
*/
_prompt(content, options = {}) {
return new Promise((resolve, reject) => {
const {
title = '提示',
type = 'confirm', // confirm, input
inputValue = '',
confirmText = '确定',
cancelText = '取消',
onConfirm = null,
onCancel = () => { resolve('') }
} = options;
// 创建遮罩层
const overlay = document.createElement('div');
overlay.className = 'prompt-overlay';
// 创建对话框
const dialog = document.createElement('div');
dialog.className = 'prompt-dialog';
// 头部
const header = document.createElement('div');
header.className = 'prompt-header';
const titleEl = document.createElement('h3');
titleEl.className = 'prompt-title';
titleEl.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.className = 'prompt-close';
closeBtn.innerHTML = '&times;';
header.appendChild(titleEl);
header.appendChild(closeBtn);
// 主体
const body = document.createElement('div');
body.className = 'prompt-body';
const contentEl = document.createElement('div');
contentEl.className = 'prompt-content';
contentEl.textContent = content;
body.appendChild(contentEl);
// 输入框如果是input类型
let inputEl = null;
if (type === 'input') {
inputEl = document.createElement('input');
inputEl.className = 'prompt-input';
inputEl.type = 'text';
inputEl.value = inputValue;
body.appendChild(inputEl);
}
// 底部按钮
const footer = document.createElement('div');
footer.className = 'prompt-footer';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'prompt-btn prompt-btn-cancel';
cancelBtn.textContent = cancelText;
const confirmBtn = document.createElement('button');
confirmBtn.className = 'prompt-btn prompt-btn-confirm';
confirmBtn.textContent = confirmText;
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
dialog.appendChild(header);
dialog.appendChild(body);
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// 显示动画
setTimeout(() => {
overlay.classList.add('show');
}, 10);
// 如果是input类型自动聚焦
if (inputEl) {
setTimeout(() => {
inputEl.focus();
}, 300);
}
// 事件处理
const closeDialog = (result = null) => {
overlay.classList.remove('show');
setTimeout(() => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, 300);
return result;
};
// 关闭按钮
closeBtn.onclick = () => {
closeDialog();
onCancel ? onCancel() : reject(new Error('cancelled'))
};
// 取消按钮
cancelBtn.onclick = () => {
closeDialog();
onCancel ? onCancel() : reject(new Error('cancelled'))
};
// 确认按钮
confirmBtn.onclick = () => {
const value = inputEl ? inputEl.value : true;
closeDialog();
resolve(value);
if (onConfirm) onConfirm(value);
};
// ESC键关闭
const handleEsc = (e) => {
if (e.key === 'Escape') {
closeDialog();
onCancel ? onCancel() : reject(new Error('cancelled'))
document.removeEventListener('keydown', handleEsc);
}
};
document.addEventListener('keydown', handleEsc);
// 点击遮罩层关闭
overlay.onclick = (e) => {
if (e.target === overlay) {
closeDialog();
onCancel ? onCancel() : reject(new Error('cancelled'))
}
};
});
}
/**
* 确认框
* @param {string} content - 确认内容
* @param {Object} options - 配置选项
*/
confirm(content, options = {}) {
return this._prompt(content, {
...options,
type: 'confirm'
});
}
/**
* 输入框
* @param {string} content - 提示内容
* @param {Object} options - 配置选项
*/
prompt(message, content, options = {}) {
return this._prompt(message, {
...options,
type: 'input',
inputValue: content,
});
}
}
// 创建单例实例
const message = new Message();
// 导出默认实例和类
export default message;
export { Message };

@ -0,0 +1,553 @@
/*
* proxy.js
* Copyright (C) 2024 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
/** @type {([boolean, ()=>void])[]} */
const callbackList = []
/** @type {number[]} */
const cacheUpdateList = []
// 界面更新响应频率40hz
const sync = () => {
let list = new Set(cacheUpdateList.splice(0))
let c = 0
for (let l of list) {
if (callbackList[l]) {
callbackList[l]()
c++
}
}
if (c > 0) {
console.log(`update ${c}`)
// sync()
}
return c
}
setInterval(sync, 25)
function GenUniqueID() {
const timestamp = performance.now().toString(36);
const random = Math.random().toString(36).substring(2, 5);
return `${timestamp}-${random}`;
}
function ForceUpdate() {
for (let c of callbackList) {
if (c) {
c()
}
}
}
window.$vupdate = (id) => {
console.log('update', id)
callbackList[id]()
}
function deepAccess(obj, seen = new Set()) {
if (obj && typeof obj === 'object' && !seen.has(obj)) {
seen.add(obj)
for (let key in obj) {
deepAccess(obj[key], seen)
}
}
return obj
}
/** @type {number[]} */
var listen_tags = []
/**
* @param {()=>void} callback
* @returns number
*/
function Watch(target, callback, options) {
let idx = callbackList.length
listen_tags.push(idx)
if (typeof callback === 'function') {
callbackList.push(() => {
callback(target())
})
} else {
callbackList.push(target)
}
let res
try {
res = target()
if (options && options.deep) {
deepAccess(res)
}
} catch (e) {
console.warn('running \n%s\n failed:', target, e)
} finally {
listen_tags.pop()
}
if (typeof callback === 'function') {
callback(res)
}
return idx
}
function Cancel(idx) {
if (idx < 0 || idx >= callbackList.length) {
return
}
callbackList[idx] = null
}
const isProxy = Symbol("isProxy")
const DataID = Symbol("DataID")
const DataBind = Symbol("bind")
const rootObj = Symbol("root")
const rootArg = Symbol("root arg")
function SetDataRoot(data, root) {
data[rootObj] = root
Object.keys(root).forEach(k => {
if (k in data) {
} else {
data[k] = rootArg
}
})
}
function isProxyType(v) {
if (!v || typeof v !== 'object') {
return false
}
if (v instanceof Node || v instanceof Date || v instanceof RegExp || v instanceof Event) {
return false
}
if (v.__noproxy) {
return false
}
if (v.constructor !== Object && v.constructor !== Array) {
return false
}
return true
}
// oldValue只集成值 不继承事件
function copyBind(oldValue, newValue) {
if (!oldValue || !oldValue[isProxy] || !isProxyType(newValue)) {
return newValue
}
let binds = oldValue[DataBind]
if (newValue[isProxy]) {
// 新值也是代理对象,继承旧值的事件绑定, 使用新的代理对象
if (newValue[DataID] === oldValue[DataID]) {
return newValue
}
for (let k in binds) {
if (newValue[DataBind][k]?.indexOf) {
const currentBinds = newValue[DataBind][k]
const bindSet = new Set(currentBinds)
for (let i of binds[k]) {
if (!bindSet.has(i)) {
currentBinds.push(i)
bindSet.add(i)
}
}
} else {
newValue[DataBind][k] = binds[k]
}
}
} else {
// 新值不是代理对象,继承值,使用旧的代理对象
if (Array.isArray(newValue) && Array.isArray(oldValue)) {
oldValue.length = 0
for (let i = 0; i < newValue.length; i++) {
oldValue.push(newValue[i])
}
return oldValue
}
Object.keys(oldValue).forEach(k => {
if (!newValue.hasOwnProperty(k)) {
delete oldValue[k]
}
})
Object.keys(newValue).forEach(k => {
if (oldValue[k]?.[isProxy]) {
oldValue[k] = copyBind(oldValue[k], newValue[k])
} else {
oldValue[k] = newValue[k]
}
})
return oldValue
}
for (let k in newValue) {
if (k in oldValue && oldValue[k]?.[isProxy]) {
newValue[k] = copyBind(oldValue[k], newValue[k])
}
}
return newValue
}
let stopChecking = false
function Wrap(data, root = undefined) {
const did = GenUniqueID()
let isArray = false
if (Object.prototype.toString.call(data) === '[object Array]') {
isArray = true
}
if (root) {
SetDataRoot(data, root)
}
// data[DataID] = did
const listeners = {}
const handler = {
/**
* @param {Object} target
* @param {string|symbol} key
*
* */
get(target, key, receiver) {
if (key === DataID) {
return did
} else if (key === isProxy) {
return true
} else if (key === DataBind) {
return listeners
}
const value = Reflect.get(target, key, receiver)
if (value === rootArg) {
return target[rootObj][key]
}
if (typeof key === 'symbol' && stopChecking) {
return value
} else if (typeof value === 'function') {
return value
}
let idx = -1
if (listen_tags.length > 0) {
let lkey = key
idx = listen_tags[listen_tags.length - 1]
if (isArray) {
lkey = ''
}
if (!listeners.hasOwnProperty(lkey)) {
listeners[lkey] = [idx]
} else if (listeners[lkey].indexOf(idx) == -1) {
listeners[lkey].push(idx)
}
}
if (window.vdev) {
console.log(`${did} get ${key.toString()}:|${value}| `)
}
if (isProxyType(value) && !value[isProxy]) {
let newValue = Wrap(value, undefined)
Reflect.set(target, key, newValue, receiver)
return newValue
}
return value;
},
set(target, key, newValue, receiver) {
const oldValue = Reflect.get(target, key, receiver)
if (oldValue === rootArg) {
target[rootObj][key] = newValue
return true
}
if (oldValue === newValue) {
return true
} else if (stopChecking) {
return Reflect.set(target, key, newValue, receiver)
}
let result = true
if (Array.isArray(newValue) && Array.isArray(oldValue)) {
stopChecking = true
oldValue.length = 0
for (let i = 0; i < newValue.length; i++) {
oldValue.push(newValue[i])
}
stopChecking = false
} else if (oldValue && oldValue[isProxy] && isProxyType(newValue)) {
// 监听对象只赋值可迭代属性
newValue = copyBind(oldValue, newValue)
result = Reflect.set(target, key, newValue, receiver);
} else {
result = Reflect.set(target, key, newValue, receiver);
}
if (result && listen_tags.length === 0) {
let lkey = key
if (isArray) {
lkey = ''
}
if (listeners[lkey]) {
let i = 0
if (window.vdev) {
console.log(`before set ${key} listeners:`, listeners[lkey], target)
}
while (i < listeners[lkey].length) {
let cb = listeners[lkey][i]
if (!callbackList[cb]) {
listeners[lkey].splice(i, 1);
} else {
i++
if (window.vdev) {
console.log(`${did} set ${key}:`, '\n', callbackList[cb], '\n', oldValue, newValue)
}
cacheUpdateList.push(cb)
}
}
}
}
return result;
},
deleteProperty(target, key) {
if (window.vdev) {
console.log(`del ${key}`)
}
const result = Reflect.deleteProperty(target, key);
if (result && listen_tags.length === 0) {
let lkey = key
if (isArray) {
lkey = ''
}
if (listeners[lkey]) {
let i = 0
while (i < listeners[lkey].length) {
let cb = listeners[lkey][i]
if (!callbackList[cb]) {
listeners[lkey].splice(i, 1);
} else {
i++
cacheUpdateList.push(cb)
if (window.vdev) {
console.log(`${did} del ${key}:`, '\n', callbackList[cb], '\n')
}
}
}
}
}
return result
},
};
let res = new Proxy(data, handler);
return res
}
const expose = {
'console': console,
'window': window,
'prompt': prompt.bind(window),
'alert': alert.bind(window),
'confirm': confirm.bind(window),
'RegExp': RegExp,
'document': document,
'Array': Array,
'Object': Object,
'Math': Math,
'Date': Date,
'JSON': JSON,
'Symbol': Symbol,
'Number': Number,
'eval': eval,
'isNaN': isNaN,
'parseInt': parseInt,
'parseFloat': parseFloat,
'setTimeout': setTimeout.bind(window),
'setInterval': setInterval.bind(window),
'clearTimeout': clearTimeout.bind(window),
'clearInterval': clearInterval.bind(window),
'encodeURIComponent': encodeURIComponent,
'btoa': btoa.bind(window),
'fetch': fetch.bind(window),
'TextDecoder': TextDecoder,
'history': history,
'requestAnimationFrame': requestAnimationFrame.bind(window),
}
function newProxy(data, env, tmpenv) {
const proxy = new Proxy(data, {
// 拦截所有属性,防止到 Proxy 对象以外的作用域链查找。
has(target, key) {
return true;
},
get(target, key, receiver) {
let v
if (key === '$data') {
v = data
} else if (key === '$env') {
v = env
} else if (key in target) {
v = Reflect.get(target, key, receiver);
} else if (key in env) {
v = env[key]
} else if (tmpenv && key in tmpenv) {
v = tmpenv[key]
} else if (key in expose) {
v = expose[key]
} else if (key in window) {
v = window[key]
}
return v
},
set(target, key, newValue, receiver) {
// code global variable set will work on "data"
return Reflect.set(target, key, newValue, receiver);
}
});
return proxy
}
// 运行dom属性绑定等小代码语句
// for code snapshot
function Run(originCode, data, env, tmpenv) {
let code = originCode.trim()
const cleanCode = code.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '').trim()
const isStatement = /^(var|let|const|if|for|while|switch|try|throw|class|function|return|debugger)\b/.test(cleanCode)
if (!isStatement && (code.indexOf('\n') === -1 || !cleanCode.includes(';'))) {
code = 'return ' + code
}
code = `
with (sandbox) {
${code}
}`
let res
try {
const fn = new Function('sandbox', code);
res = fn(newProxy(data, env, tmpenv))
} catch (error) {
console.warn(`Run error:`, originCode, '\n', error)
}
return res
}
const AsyncFunction = Object.getPrototypeOf(async function() { }).constructor
// 运行大段代码库
async function AsyncRun(originCode, data, env, tmpenv) {
let code = originCode.trim()
if (code.indexOf('\n') === -1) {
code = 'return ' + code
}
code = `
with (sandbox) {
${code}
}`
// try {
const fn = new AsyncFunction('sandbox', code);
return await fn(newProxy(data, env, tmpenv))
// } catch (error) {
// console.warn('AsyncRun error:', error, '\n', originCode)
// }
}
function resolvePath(relativePath, currentPath) {
// 如果相对路径已经是绝对路径,直接返回
if (relativePath.startsWith('/')) {
return relativePath;
}
// 获取当前路径的目录部分(去掉文件名)
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'));
// 分割路径段
const currentSegments = currentDir.split('/').filter(segment => segment !== '');
const relativeSegments = relativePath.split('/').filter(segment => segment !== '');
// 处理相对路径段
for (const segment of relativeSegments) {
if (segment === '..') {
// 返回上一级目录
if (currentSegments.length > 0) {
currentSegments.pop();
}
} else if (segment === '.') {
// 当前目录,不做任何操作
continue;
} else {
// 普通目录或文件名
currentSegments.push(segment);
}
}
// 构建绝对路径
return '/' + currentSegments.join('/');
}
async function ParseImport(code, data, env, src) {
data = data || {}
let scoped = env.scoped || ''
let codeCopy = code
let match;
src = src.startsWith('http') ? src : scoped + src
const awaitImportRegex = /await import\(['"]([^'"]+)['"]\)/gm;
while ((match = awaitImportRegex.exec(code)) !== null) {
let url = match[1]
if (!url.startsWith('http')) {
url = resolvePath(url, src)
url = window.location.origin + url
}
codeCopy = codeCopy.replace(match[0], `await import('${url}')`)
}
const importRegex = /^[\s/]*import\s+([\w{},\s]+)\s+from\s+['"]([^'"]+)['"][;\s]*$/gm;
// 提取所有匹配的模块路径
while ((match = importRegex.exec(code)) !== null) {
codeCopy = codeCopy.replace(match[0], '')
if (match[0].trim().startsWith('//')) {
continue
}
let url = match[2]
if (!url.startsWith('http') && !url.startsWith('@')) {
if (url.startsWith('/') && scoped) {
url = scoped + url
} else {
url = resolvePath(url, src)
}
}
if (url.startsWith('@')) {
url = url.slice(1)
}
if (!url.endsWith('.js')) {
url += '.js'
}
if (!url.startsWith('http')) {
url = window.location.origin + url
}
let packname = match[1].trim()
try {
let packs = null
if (/^\w+$/.test(packname)) {
packs = packname
} else if (/^{[\w\s,]+}$/.test(packname)) {
packs = packname.slice(1, -1).split(',').map(p => p.trim())
} else {
throw new Error('unsupported import: ' + match[0])
}
// window.$env = env
const module = await import(url)
if (typeof packs === 'string') {
if (module.default) {
data[packs] = module.default
} else {
data[packs] = module
}
} else {
packs.forEach((p) => {
if (p in module) {
data[p] = module[p]
} else if (p in module.default) {
data[p] = module.default[p]
}
})
}
} catch (error) {
console.error(`模块加载失败 (${match[0]}):`, error.message);
}
}
return codeCopy.trim()
}
export default {
Wrap, Watch, Cancel, ForceUpdate, SetDataRoot,
DataID, GenUniqueID, Run, AsyncRun, ParseImport
}

@ -0,0 +1,624 @@
import vproxy from './vproxy.js'
import vget from './vget.js'
// 解析URL字符串提取路径、查询参数和hash
function parseUrlString(urlString, scoped) {
let url
let path
// 判断是否为完整URL包含协议
if (urlString.startsWith('http://') || urlString.startsWith('https://')) {
url = new URL(urlString)
// 如果是外部URL返回null不处理外部链接
if (url.origin !== window.location.origin) {
return null
}
if (url.pathname.startsWith(scoped)) {
path = url.pathname.slice(scoped.length) // 去掉根路径
}
} else {
// 相对路径基于当前origin构建完整URL
url = new URL(urlString, window.location.href)
path = url.pathname
}
// 解析查询参数
const query = {}
url.searchParams.forEach((value, key) => {
query[key] = value
})
return {
path: path,
query,
hash: url.hash
}
}
class VRouter {
#routes = []
#history = []
#current = null
#scoped = ''
#listeners = []
#pageCache = new Map()
#node = null
#env = null
#originContent = []
#loaded = false
#vhtml = null
#routesByName = new Map() // 添加按名称索引的路由缓存
constructor() {
this.init()
}
get routes() { return this.#routes.slice() }
get history() { return this.#history.slice() }
get current() { return this.#current }
get query() { return this.#current?.query || {} }
get params() { return this.#current?.params || {} }
get scoped() { return this.#scoped }
onChange(fc) {
this.#listeners.push(fc)
}
addRoute(route) {
if (!route.path) throw new Error('Route must have a path')
if (route.path != '/' && route.path.endsWith('/')) {
route.path = route.path.slice(0, -1)
}
const routeConfig = {
path: route.path,
component: route.component,
name: route.name,
meta: route.meta || {},
children: route.children || [],
matcher: new RouteMatcher(route.path, route.name),
description: route.description || '',
layout: route.layout || '',
}
this.#routes.push(routeConfig)
// 如果有名称,添加到名称索引中
if (route.name) {
this.#routesByName.set(route.name, routeConfig)
}
// 递归处理子路由
if (route.children?.length > 0) {
route.children.forEach(child => {
const childPath = route.path + (child.path.startsWith('/') ? child.path : '/' + child.path)
const layout = child.layout || route.layout || ''
const meta = { ...route.meta, ...child.meta }
this.addRoute({ ...child, path: childPath, parent: routeConfig, layout, meta })
})
}
}
addRoutes(routes) {
routes.forEach(route => this.addRoute(route))
}
#notifyListeners(to, from) {
this.#listeners.forEach(listener => {
if (typeof listener === 'function') {
try {
listener(to, from)
} catch (error) {
console.error('Error in router listener:', error)
}
}
})
}
#setRouterPath(matchedRoute) {
const oldRoute = this.#current
this.#current = {
path: matchedRoute.path,
fullPath: matchedRoute.fullPath,
params: matchedRoute.params || {},
query: matchedRoute.query || {},
hash: new URL(matchedRoute.fullPath, window.location.origin).hash,
meta: matchedRoute.route?.meta || {},
description: matchedRoute.route?.description || '',
layout: matchedRoute.route?.layout || '',
name: matchedRoute.route?.name,
matched: matchedRoute.route ? [matchedRoute.route] : []
}
this.#history.push(this.#current)
if (this.#scoped && !matchedRoute.fullPath.startsWith('http')) {
history.pushState({}, '', this.#scoped + matchedRoute.fullPath)
} else {
history.pushState({}, '', matchedRoute.fullPath)
}
this.#notifyListeners(this.#current, oldRoute)
}
// 优化后的路由匹配方法,支持多种参数类型
matchRoute(to) {
// 处理不同类型的路由参数
const routeInfo = this.normalizeRouteTarget(to)
if (!routeInfo) return null
const { path, query, params, name } = routeInfo
// 如果是按名称匹配
if (name) {
const route = this.#routesByName.get(name)
if (!route) return null
// 构建带参数的路径
let resolvedPath = route.path
Object.entries(params).forEach(([key, value]) => {
resolvedPath = resolvedPath.replace(`:${key}`, value)
})
const match = route.matcher.match(resolvedPath)
if (match) {
return {
route,
params: { ...match.params, ...params },
matched: match.matched,
path: resolvedPath,
query,
name
}
}
return null
}
// 按路径匹配
for (const route of this.#routes) {
const match = route.matcher.match(path)
if (match && route.component) {
return {
route,
params: { ...match.params, ...params },
matched: match.matched,
description: route.description,
layout: route.layout,
path,
query,
name: route.name
}
}
}
return null
}
// 标准化路由目标参数
normalizeRouteTarget(to) {
let path, query = {}, params = {}, hash = '', name
if (typeof to === 'string') {
// 字符串类型解析可能包含的URL、query、hash
const parsed = parseUrlString(to, this.#scoped)
if (!parsed) return null // 外部URL或解析失败
path = parsed.path
query = { ...parsed.query }
hash = parsed.hash
} else if (to && typeof to === 'object') {
if (to.path) {
// {path} 类型path可能也包含query和hash
const parsed = parseUrlString(to.path, this.#scoped)
if (!parsed) return null
path = parsed.path
// 合并query参数对象中的query优先级更高
query = { ...parsed.query, ...(to.query || {}) }
hash = to.hash || parsed.hash
params = to.params || {}
} else if (to.name) {
// {name} 类型
name = to.name
query = to.query || {}
params = to.params || {}
hash = to.hash || ''
} else {
return null
}
} else {
return null
}
// 标准化路径
if (path && !path.startsWith('/')) {
path = '/' + path
}
if (this.#scoped) {
path = path.startsWith(this.#scoped) ? path.slice(this.#scoped.length) : path
}
if (!path.startsWith('/')) {
path = '/' + path
}
if (path != '/' && path.endsWith('/')) {
path = path.slice(0, -1)
}
return { path, query, params, hash, name }
}
matchTo(to) {
const matchResult = this.matchRoute(to)
if (!matchResult) return null
const { route, params, query, path, name } = matchResult
// 构建查询字符串
let search = ''
if (query && Object.keys(query).length > 0) {
search = '?' + Object.entries(query)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
}
const fullPath = (path || matchResult.path) + search
return {
route,
params,
query,
name: name || route.name,
path: path || matchResult.path,
fullPath,
matched: [route]
}
}
buildUrl(baseUrl, additionalQuery = {}) {
const url = new URL(baseUrl, window.location.origin)
Object.entries(additionalQuery).forEach(([key, value]) => {
url.searchParams.append(key, value)
})
return url
}
resolveRoutePath(route, params = {}) {
let path = route.component || route.path
Object.entries(params).forEach(([key, value]) => {
path = path.replace(`:${key}`, value)
})
if (path === '/' || path === '') path = '/index'
if (!path.startsWith('/')) path = '/' + path
if (path.endsWith('.html')) path = path.slice(0, -5)
if (path.endsWith('/')) path = path.slice(0, -1)
return path
}
async #navigateTo(matchedRoute) {
if (!matchedRoute) {
console.warn(`No route matched`)
return
}
const { route, params, query } = matchedRoute
const to = {
path: matchedRoute.path,
fullPath: matchedRoute.fullPath,
params,
query,
hash: new URL(matchedRoute.fullPath, window.location.origin).hash,
meta: route.meta,
description: route.description,
layout: route.layout,
name: route.name,
matched: [route]
}
if (this.beforeEnter) {
try {
let shouldContinue = true
const result = await this.beforeEnter(to, this.#current, (next) => {
if (next) {
shouldContinue = false
this.push(next)
}
})
if (result === false || !shouldContinue) return
} catch (error) {
console.error('Error in beforeEnter guard:', error)
return
}
}
const cacheKey = matchedRoute.fullPath
let page = this.#pageCache.get(cacheKey)
this.#setRouterPath(matchedRoute)
if (page) {
page.activate()
} else {
page = new Page(this.#vhtml, this.#node, matchedRoute)
await page.mount(this.#env, this.#originContent, to.layout)
this.#pageCache.set(cacheKey, page)
}
}
async push(to) {
const matchedRoute = this.matchTo(to)
if (!matchedRoute) {
const target = typeof to === 'string' ? to : (to.path || `name: ${to.name}`)
console.warn(`No route matched for ${target}`)
return
}
await this.#navigateTo(matchedRoute)
}
replace(to) {
this.push(to)
if (this.#history.length > 1) {
this.#history.splice(-2, 1)
}
}
go(n) { history.go(n) }
back() { history.back() }
forward() { history.forward() }
init() {
if (this.#loaded) return
this.#loaded = true
document.body.addEventListener('click', (event) => {
const linkElement = event.target.closest('a')
if (!linkElement) return
const href = linkElement.getAttribute('href')
if (!href || href.startsWith('http') || href.startsWith('#')) return
event.preventDefault()
const reload = linkElement.hasAttribute('reload')
if (reload) {
window.location.href = href
} else {
this.push(href)
}
}, true)
window.addEventListener('popstate', () => {
this.push(window.location.href)
})
}
ParseVrouter($vhtml, $node, env) {
this.#node = $node
this.#env = env
this.#scoped = env.scoped || ''
this.#originContent = Array.from($node.childNodes)
this.#vhtml = $vhtml
this.push(window.location.href)
}
}
// 优化后的路由匹配器
class RouteMatcher {
constructor(path, name) {
this.originalPath = path
this.name = name
this.keys = []
this.regexp = this.pathToRegexp(path)
}
pathToRegexp(path) {
const paramPattern = /:([^(/]+)/g
let regexpStr = path.replace(paramPattern, (match, key) => {
this.keys.push(key)
return `(?<${key}>[^/]+)`
})
// 处理 *path 形式的通配符
regexpStr = regexpStr.replace(/\*(\w+)/g, (match, key) => {
this.keys.push(key)
return `(?<${key}>.*)`
});
// 如果有未处理的*号,将其替换为允许匹配任意数量字符的正则表达式
regexpStr = regexpStr.replace(/\*/g, '.*')
return new RegExp(`^${regexpStr}$`)
}
// 优化的匹配方法,支持多种参数类型
match(target) {
let path
// 处理不同类型的输入
if (typeof target === 'string') {
path = target
} else if (target && typeof target === 'object') {
if (target.path) {
path = target.path
} else if (target.name && target.name === this.name) {
// 如果按名称匹配且名称相符,返回基本匹配
return {
path: this.originalPath,
params: target.params || {},
matched: this.originalPath
}
} else {
return null
}
} else {
return null
}
const match = this.regexp.exec(path)
if (!match) return null
const params = {}
this.keys.forEach(key => {
if (match.groups?.[key]) {
params[key] = match.groups[key]
}
})
return {
path: this.originalPath,
params,
matched: match[0]
}
}
}
const layoutCache = new Map()
class Page {
constructor(vhtml, node, matchedRoute) {
this.vhtml = vhtml
this.node = node
this.layoutDom = undefined
this.matchedRoute = matchedRoute
this.htmlPath = this.resolveHtmlPath(matchedRoute)
}
resolveHtmlPath(matchedRoute) {
let path = matchedRoute.route.component || matchedRoute.route.path
if (typeof path === 'function') {
path = path(matchedRoute.path)
}
Object.entries(matchedRoute.params).forEach(([key, value]) => {
path = path.replace(`:${key}`, value)
})
if (!path.startsWith('/')) path = '/' + path
if (path.endsWith('/')) path = path.slice(0, -1)
if (!path.endsWith('.html')) path = path + '.html'
return path
}
async mount(env, originContent, layout) {
const parser = await vget.FetchUI(this.htmlPath, env)
if (parser.err) {
console.warn(parser.err)
let dom = document.createElement('div')
Object.assign(dom.style, { width: '100%', height: '100%' })
dom.append(...originContent)
this.node.innerHTML = ''
this.node.append(dom)
this.vhtml.parseRef(this.htmlPath, dom, {}, env, null, true)
return
}
this.title = parser.title || ''
const slots = {}
const dom = document.createElement("div")
dom.setAttribute('vsrc', this.htmlPath)
slots[''] = [dom]
this.slots = slots
if (!layout) {
this.node.innerHTML = ''
this.node.append(dom)
this.vhtml.parseRef(this.htmlPath, dom, {}, env, null)
return
}
let layoutDom = layoutCache.get(layout)
if (!layoutDom) {
let layoutUrl = layout
if (!layoutUrl.startsWith('/')) {
layoutUrl = '/' + layout
}
if (!layoutUrl.endsWith('.html')) {
layoutUrl += '.html'
}
if (!layoutUrl.startsWith('/layout')) {
layoutUrl = '/layout' + layoutUrl
}
const layoutParser = await vget.FetchUI(layoutUrl, env)
if (layoutParser.err) {
console.warn(`get layout ${layoutUrl} failed.`, layoutParser.err)
this.node.innerHTML = ''
this.node.append(dom)
this.vhtml.parseRef(this.htmlPath, dom, {}, env, null)
return
}
layoutDom = layoutParser.body.cloneNode(true)
layoutCache.set(layout, layoutDom)
dom.$refData = vproxy.Wrap({})
layoutDom.$refSlots = vproxy.Wrap({ ...slots })
this.node.innerHTML = ''
this.node.append(layoutDom)
this.layoutDom = layoutDom
this.vhtml.parseRef('/layout/' + layout, layoutDom, {}, env, null, true)
} else {
this.layoutDom = layoutDom
this.activate()
}
}
activate() {
if (this.title) document.title = this.title
const layoutDom = this.layoutDom
if (layoutDom) {
layoutDom.querySelectorAll("vslot").forEach(e => {
if (e.closest('[vref]') === layoutDom && this.slots[e.getAttribute('name') || '']) {
e.innerHTML = ''
}
})
Object.keys(layoutDom.$refSlots).forEach(key => {
delete layoutDom.$refSlots[key]
})
Object.assign(layoutDom.$refSlots, this.slots)
if (!layoutDom.isConnected) {
this.node.innerHTML = ''
}
this.node.append(layoutDom)
} else {
this.node.innerHTML = ''
const dom = this.slots['']
if (dom instanceof Array) {
this.node.append(...dom)
} else {
this.node.append(dom)
}
}
}
}
const $router = new VRouter()
const DefaultRoutes = [
{
path: '/',
component: '/page/index.html',
name: 'home',
},
{
path: '/404',
component: '/page/404.html',
name: '404'
},
{
path: '*',
component: (path) => {
if (path.endsWith('.html')) return path
return '/page' + path + '.html'
},
}
]
export default { $router, DefaultRoutes }
Loading…
Cancel
Save