feat: 全新UI框架升级和页面重构

v3
veypi 4 months ago
parent 5654a7cd37
commit e5308f9471

@ -0,0 +1,24 @@
# Frontend TODOs and Missing APIs
## Missing APIs
### Dashboard Statistics
- **Endpoint**: `GET /api/stats/dashboard`
- **Description**: Returns summary statistics for the dashboard (e.g., total users, active orgs, api calls, revenue).
- **Reason**: The dashboard needs high-level metrics to display to the user immediately upon login. Currently using mock data in `ui/page/dashboard/index.html`.
## Future Improvements
### UI
- Add proper form validation for all inputs.
- Implement pagination for lists (Users, Orgs, OAuth Clients).
- Add search and filter functionality for lists.
- Improve error handling and user feedback messages.
- Add loading states for all async operations.
### Features
- Implement Organization editing (currently placeholder).
- Implement Organization member management (add/remove members).
- Implement User creation modal (currently placeholder).
- Implement User editing (currently placeholder).
- Implement detailed OAuth client management (scopes, secrets).

@ -1,33 +1,62 @@
import token from './token.js'
export default ($env) => {
token.wrapAxios($env.$axios)
$env.$G.token = token
let user = token.body()
$env.$G.user = user
import VBase from './vbase.js'
export default async ($env) => {
// Load i18n
try {
const langs = await (await fetch('/langs.json')).json()
$env.$i18n.load(langs)
} catch (e) {
console.error('Failed to load langs.json', e)
}
// Initialize VBase Service
const vbase = new VBase(''); // Relative path
$env.$vbase = vbase;
// Wrap Axios
vbase.wrapAxios($env.$axios);
// Router Guard
$env.$router.beforeEnter = async (to, from, next) => {
if (to.meta && to.meta.auth) {
if (token.isExpired()) {
await token.refresh()
const isAuth = to.meta && to.meta.auth;
const isGuest = to.meta && to.meta.guest;
const roles = to.meta && to.meta.roles; // Array of required roles
if (isAuth) {
if (vbase.isExpired()) {
try {
await vbase.refresh();
} catch (e) {
vbase.logout(to.fullPath);
return false;
}
}
if (token.isExpired()) {
token.logout(to.fullPath)
return false
if (!vbase.user) {
try {
await vbase.fetchUser();
} catch (e) {
vbase.logout(to.fullPath);
return false;
}
}
if (!token.check('app', '', 2)) {
next('/')
// Role Check
if (roles && roles.length > 0) {
const hasRole = roles.some(role => vbase.hasRole(role));
if (!hasRole) {
$env.$router.push('/403');
return false;
}
}
} else if (isGuest) {
if (!vbase.isExpired()) {
next('/');
return false;
}
} else {
next();
}
next();
};
$env.$axios.interceptors.response.use(function(response) {
return response?.data || response;
}, function(error) {
error = error?.response?.data || error?.response || error
return Promise.reject(error);
});
$env.$axios.get('/api/cfg').then(res => {
$env.$G.cfg = res
console.log(res)
})
}

@ -36,11 +36,11 @@
</style>
<body>
<div v-if="$G.user.name">
<div v-if="user && user.name">
<v-dropdown :items="dropdownItems" @command="handleCommand">
<div class="user-container">
<img :src="$G.user.icon" class="user-avatar" alt="用户头像">
<span class="user-name">{{ $G.user.name }}</span>
<img :src="user.icon" class="user-avatar" alt="用户头像">
<span class="user-name">{{ user.name }}</span>
</div>
</v-dropdown>
</div>
@ -51,6 +51,7 @@
</div>
</body>
<script setup>
user = $env.$vbase.user;
dropdownItems = [
{ label: "个人中心", value: "profile" },
{ label: "退出登录", value: "logout", divided: true }
@ -60,7 +61,7 @@
if (val === 'profile') {
$router.push('/profile');
} else if (val === 'logout') {
$G.token.logout();
$env.$vbase.logout();
}
};
</script>

@ -0,0 +1,58 @@
{
"en-US": {
"auth.email": "Email",
"auth.login": "Login",
"auth.logout": "Logout",
"auth.password": "Password",
"auth.register": "Register",
"auth.username": "Username",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.create": "Create",
"common.delete": "Delete",
"common.edit": "Edit",
"common.forbidden": "Forbidden",
"common.not_found": "Not Found",
"common.save": "Save",
"nav.dashboard": "Dashboard",
"nav.home": "Home",
"nav.oauth": "OAuth Apps",
"nav.org": "Organizations",
"nav.profile": "Profile",
"nav.users": "Users",
"org.create": "Create Organization",
"org.detail": "Organization Detail",
"org.info": "Information",
"org.members": "Members",
"org.name": "Organization Name",
"user.profile": "User Profile"
},
"zh-CN": {
"auth.email": "邮箱",
"auth.login": "登录",
"auth.logout": "登出",
"auth.password": "密码",
"auth.register": "注册",
"auth.username": "用户名",
"common.cancel": "取消",
"common.confirm": "确认",
"common.create": "创建",
"common.delete": "删除",
"common.edit": "编辑",
"common.forbidden": "禁止访问",
"common.not_found": "页面未找到",
"common.save": "保存",
"nav.dashboard": "仪表盘",
"nav.home": "首页",
"nav.oauth": "OAuth应用",
"nav.org": "组织管理",
"nav.profile": "个人中心",
"nav.users": "用户管理",
"org.create": "创建组织",
"org.detail": "组织详情",
"org.info": "基本信息",
"org.members": "成员列表",
"org.name": "组织名称",
"user.profile": "个人资料"
}
}

@ -2,113 +2,218 @@
<html>
<head>
<title>Auth Layout</title>
<meta name="description" content="Default Layout">
<title>VBase</title>
<style>
.layout-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
width: 100vw;
overflow: hidden;
background-color: var(--bg-color);
color: var(--text-color);
}
.header {
user-select: none;
height: 60px;
background: var(--color-primary);
color: var(--color-primary-text);
.sidebar-container {
height: 100%;
background-color: #fff;
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 var(--spacing-lg);
box-shadow: var(--shadow-sm);
flex-direction: column;
transition: width 0.3s;
}
.header-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--bg-color);
}
.header-title a {
color: inherit;
text-decoration: none;
.header {
height: 60px;
background-color: #fff;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
}
.main-container {
.header-left {
display: flex;
flex: 1;
overflow: hidden;
align-items: center;
gap: 20px;
}
.menu {
border-right: 1px solid var(--border-color);
background-color: var(--bg-color-secondary);
.header-right {
display: flex;
align-items: center;
gap: 15px;
}
.content {
.content-body {
flex: 1;
overflow-y: auto;
width: 100%;
height: 100%;
padding: var(--spacing-md);
padding: 20px;
}
.footer {
height: 40px;
background: var(--bg-color-secondary);
border-top: 1px solid var(--border-color);
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
color: var(--text-color-secondary);
font-weight: bold;
font-size: 18px;
color: var(--color-primary);
border-bottom: 1px solid var(--border-color);
}
.nav-link {
color: var(--color-primary-text);
text-decoration: none;
margin-left: var(--spacing-md);
font-size: var(--font-size-md);
opacity: 0.9;
transition: opacity var(--transition-base);
.org-switcher {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
transition: background 0.2s;
}
.org-switcher:hover {
background-color: var(--bg-color-tertiary);
}
.nav-link:hover {
opacity: 1;
.user-profile {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.breadcrumb {
color: var(--text-color-secondary);
font-size: 14px;
}
</style>
</head>
<body style="height: 100%;width: 100%;margin: 0;">
<body>
<div class="layout-container">
<header class="header">
<div class="header-title">
<a href="@/">首页</a>
</div>
<a class="nav-link ml-auto" href="/">应用权限管理</a>
<div vsrc='ico.html' class="ml-auto" style="margin-left: auto;"></div>
</header>
<div class="main-container">
<vslot v='user' class="menu" name='menu'>
<v-sidebar :items="menuItems" width="220px" style="height: 100%;"></v-sidebar>
</vslot>
<!-- Sidebar -->
<div class="sidebar-container" :style="{width: collapsed ? '64px' : '240px'}">
<v-sidebar :items="menuItems" :collapsed="collapsed" width="240px" collapsedWidth="64px">
<div vslot="header" class="logo">
<span v-if="!collapsed">VBase</span>
<span v-else>VB</span>
</div>
</v-sidebar>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<header class="header">
<div class="header-left">
<div @click="toggleCollapse" style="cursor: pointer;">
<i class="fas" :class="collapsed ? 'fa-indent' : 'fa-outdent'"></i>
</div>
<div class="breadcrumb">
{{ currentRouteName }}
</div>
</div>
<vslot class="content">
<div class="header-right">
<!-- Org Switcher -->
<div class="org-switcher" @click="openOrgSwitch" v-if="currentOrg">
<i class="fas fa-building"></i>
<span>{{ currentOrg.name }}</span>
<i class="fas fa-chevron-down" style="font-size: 12px;"></i>
</div>
<v-lang></v-lang>
<!-- User Profile -->
<div class="user-profile" @click="goToProfile">
<i class="fas fa-user-circle" style="font-size: 24px;"></i>
<span>{{ user ? user.nickname || user.username : 'User' }}</span>
</div>
<div @click="logout" style="cursor: pointer; color: var(--color-danger);" title="Logout">
<i class="fas fa-sign-out-alt"></i>
</div>
</div>
</header>
<!-- Page Content -->
<vslot class="content-body">
</vslot>
</div>
<footer class="footer">
Copyright © 2025 veypi. All Rights Reserved.
</footer>
</div>
</body>
<script setup>
menuItems = [
{ label: "HOME", path: "/" },
{ label: "应用管理", path: "/app" },
{ label: "个人中心", path: "/profile" },
{ label: "系统设置", path: "/settings" }
];
collapsed = false;
user = $env.$vbase.user;
currentOrg = $env.$vbase.currentOrg;
// Define Menu Items
menuItems = [
{label: $t('nav.dashboard'), icon: "<i class='fas fa-tachometer-alt'></i>", path: "/"},
{label: $t('nav.org'), icon: "<i class='fas fa-sitemap'></i>", path: "/org"},
{label: $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"},
// Admin only items would be filtered here ideally
{label: $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: $t('nav.oauth'), icon: "<i class='fas fa-key'></i>", path: "/oauth/apps"},
];
currentRouteName = "";
toggleCollapse = () => {
collapsed = !collapsed;
};
logout = () => {
$env.$vbase.logout();
};
goToProfile = () => {
$router.push('/profile');
};
openOrgSwitch = () => {
// Simple alert for now, should be a modal or dropdown
// In vhtml we can use $message or navigate to org list
$router.push('/org');
};
</script>
<script>
$watch(() => $env.$route.path, () => {
// Update breadcrumb or active item
// v-sidebar handles active state via path matching usually
// We can update title based on route name
// For now just simple mapping or rely on $route
// $data.currentRouteName = $env.$route.name || $env.$route.path
// Simple implementation:
const path = $env.$route.path;
const item = $data.menuItems.find(i => i.path === path);
$data.currentRouteName = item ? item.label : path;
});
// Watch global state for user/org changes
$watch(() => [$env.$vbase.user, $env.$i18n.locale], () => {
$data.user = $env.$vbase.user;
// Re-generate menu items when locale changes
$data.menuItems = [
{label: $t('nav.dashboard'), icon: "<i class='fas fa-tachometer-alt'></i>", path: "/"},
{label: $t('nav.org'), icon: "<i class='fas fa-sitemap'></i>", path: "/org"},
{label: $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"},
// Admin only items would be filtered here ideally
{label: $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: $t('nav.oauth'), icon: "<i class='fas fa-key'></i>", path: "/oauth/apps"},
];
});
$watch(() => $env.$vbase.currentOrg, () => {
$data.currentOrg = $env.$vbase.currentOrg;
});
</script>
</html>

@ -1,10 +1,35 @@
<!DOCTYPE html>
<html>
<body style="height: 100%;width: 100%;">
<vslot style="height: 100%;width: 100%;display: block;">
Public Layout
</vslot>
<head>
<meta name="description" content="Public Layout">
<title>VBase</title>
<style>
.public-container {
display: flex;
height: 100vh;
width: 100vw;
background-color: var(--bg-color-secondary);
align-items: center;
justify-content: center;
}
.content-box {
width: 100%;
max-width: 400px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
</style>
</head>
<body>
<div class="public-container">
<vslot class="content-box">
</vslot>
</div>
</body>
</html>

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="403 Forbidden">
<title>403 Forbidden</title>
<style>
.error-container {
text-align: center;
padding: 50px;
}
.error-code {
font-size: 100px;
font-weight: bold;
color: var(--color-danger);
}
.error-msg {
font-size: 24px;
margin-bottom: 20px;
color: var(--text-color);
}
.btn-home {
padding: 10px 20px;
background-color: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius-md);
transition: background 0.2s;
}
.btn-home:hover {
background-color: var(--color-primary-hover);
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-code">403</div>
<div class="error-msg">{{ $t('common.forbidden') || 'Access Denied' }}</div>
<a href="/" class="btn-home">{{ $t('nav.home') || 'Go Home' }}</a>
</div>
</body>
</html>

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="404 Not Found">
<title>404 Not Found</title>
<style>
.error-container {
text-align: center;
padding: 50px;
}
.error-code {
font-size: 100px;
font-weight: bold;
color: var(--color-primary);
}
.error-msg {
font-size: 24px;
margin-bottom: 20px;
color: var(--text-color);
}
.btn-home {
padding: 10px 20px;
background-color: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius-md);
transition: background 0.2s;
}
.btn-home:hover {
background-color: var(--color-primary-hover);
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-code">404</div>
<div class="error-msg">{{ $t('common.not_found') || 'Page Not Found' }}</div>
<a href="/" class="btn-home">{{ $t('nav.home') || 'Go Home' }}</a>
</div>
</body>
</html>

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Login Page">
<title>{{ $t('auth.login') }}</title>
<style>
.login-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
color: var(--color-primary);
}
.form-group {
margin-bottom: 15px;
}
.form-label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
}
.btn-submit {
width: 100%;
padding: 10px;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.btn-submit:hover {
background-color: var(--color-primary-hover);
}
.links {
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.links a {
color: var(--color-primary);
text-decoration: none;
}
.error-msg {
color: var(--color-danger);
text-align: center;
margin-bottom: 10px;
font-size: 14px;
}
</style>
</head>
<body>
123
<h2 class="login-title">{{ $t('auth.login') }}</h2>
<div v-if="error" class="error-msg">{{ error }}</div>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label class="form-label">{{ $t('auth.username') }}</label>
<input type="text" v:value="username" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">{{ $t('auth.password') }}</label>
<input type="password" v:value="password" class="form-input" required>
</div>
<button type="submit" class="btn-submit">{{ $t('auth.login') }}</button>
</form>
<div class="links">
<a href="/register">{{ $t('auth.register') }}</a>
</div>
</body>
<script setup>
username = "";
password = "";
error = "";
handleLogin = async (e) => {
e.preventDefault();
error = "";
try {
await $env.$vbase.login(username, password);
$router.push('/');
} catch (err) {
error = err.message || "Login failed";
}
};
</script>
</html>

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Register Page">
<title>{{ $t('auth.register') }}</title>
<style>
.register-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
color: var(--color-primary);
}
.form-group {
margin-bottom: 15px;
}
.form-label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
}
.btn-submit {
width: 100%;
padding: 10px;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.btn-submit:hover {
background-color: var(--color-primary-hover);
}
.links {
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.links a {
color: var(--color-primary);
text-decoration: none;
}
.error-msg {
color: var(--color-danger);
text-align: center;
margin-bottom: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<h2 class="register-title">{{ $t('auth.register') }}</h2>
<div v-if="error" class="error-msg">{{ error }}</div>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label class="form-label">{{ $t('auth.username') }}</label>
<input type="text" v:value="username" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">{{ $t('auth.email') }}</label>
<input type="email" v:value="email" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">{{ $t('auth.password') }}</label>
<input type="password" v:value="password" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">{{ $t('common.confirm') }} {{ $t('auth.password') }}</label>
<input type="password" v:value="confirmPassword" class="form-input" required>
</div>
<button type="submit" class="btn-submit">{{ $t('auth.register') }}</button>
</form>
<div class="links">
<a href="/login">{{ $t('auth.login') }}</a>
</div>
</body>
<script setup>
username = "";
email = "";
password = "";
confirmPassword = "";
error = "";
handleRegister = async (e) => {
e.preventDefault();
error = "";
if (password !== confirmPassword) {
error = "Passwords do not match";
return;
}
try {
await $axios.post('/api/auth/register', {
username: username,
email: email,
password: password
});
// Redirect to login on success
$router.push('/login');
$message.success("Registration successful! Please login.");
} catch (err) {
console.error(err);
error = err.message || "Registration failed";
}
};
</script>
</html>

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Dashboard">
<title>{{ $t('nav.dashboard') }}</title>
<style>
.dashboard-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
background: #fff;
padding: 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 10px;
}
.stat-title {
font-size: 14px;
color: var(--text-color-secondary);
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--color-primary);
}
.chart-container {
background: #fff;
padding: 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
height: 400px;
}
</style>
</head>
<body>
<div class="dashboard-container">
<h1>{{ $t('nav.dashboard') }}</h1>
<div class="stats-grid">
<div class="stat-card" v-for="stat in stats">
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-value">{{ stat.value }}</div>
</div>
</div>
<div class="chart-container" id="main-chart"></div>
</div>
</body>
<script setup>
stats = [
{ title: "Total Users", value: "1,234" },
{ title: "Active Orgs", value: "56" },
{ title: "API Calls", value: "89.2k" },
{ title: "Revenue", value: "$12,340" }
];
// Mock data fetch
fetchStats = async () => {
// In real app, call API
// const res = await $axios.get('/api/stats/dashboard');
// stats = res;
};
initChart = () => {
const chartDom = $node.querySelector('#main-chart');
if (!chartDom) return;
const myChart = echarts.init(chartDom);
const option = {
title: {
text: 'Activity Trend'
},
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line',
smooth: true,
itemStyle: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
}
}
]
};
myChart.setOption(option);
// Resize chart on window resize
window.addEventListener('resize', () => {
myChart.resize();
});
};
</script>
<script>
// Run after mount
$data.initChart();
</script>
</html>

@ -1,708 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="登录与注册页面" details="提供用户登录和注册功能的页面,支持用户名/手机号登录及第三方登录">
<title>登录与注册</title>
<style>
body {
font-family: var(--font-family);
height: 100vh;
overflow: hidden;
background-color: var(--bg-color-secondary);
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
}
.circle {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--color-primary), transparent 70%), transparent);
filter: blur(40px);
animation: float 10s infinite ease-in-out;
}
.circle:nth-child(1) {
width: 500px;
height: 500px;
top: -100px;
left: -100px;
animation-delay: 0s;
}
.circle:nth-child(2) {
width: 400px;
height: 400px;
bottom: -50px;
right: -50px;
background: radial-gradient(circle, color-mix(in srgb, var(--color-secondary), transparent 70%), transparent);
animation-delay: -5s;
}
@keyframes float {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(30px, 20px);
}
}
/* 主容器 */
.container {
background-color: var(--color-primary-text);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
position: relative;
overflow: hidden;
width: 850px;
max-width: 95%;
min-height: 600px;
z-index: 1;
}
.form-container {
position: absolute;
top: 0;
height: 100%;
transition: all 0.6s ease-in-out;
background: var(--color-primary-text);
}
.sign-in-container {
left: 0;
width: 50%;
z-index: 2;
}
.sign-up-container {
left: 0;
width: 50%;
opacity: 0;
z-index: 1;
}
.container.right-panel-active .sign-in-container {
transform: translateX(100%);
}
.container.right-panel-active .sign-up-container {
transform: translateX(100%);
opacity: 1;
z-index: 5;
animation: show 0.6s;
}
@keyframes show {
0%,
49.99% {
opacity: 0;
z-index: 1;
}
50%,
100% {
opacity: 1;
z-index: 5;
}
}
.form-container-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 40px;
text-align: center;
}
/* 标题与文本 */
h1 {
font-weight: bold;
margin: 0 0 10px;
color: var(--text-color);
font-size: 28px;
}
.subtitle {
font-size: 14px;
color: var(--text-color-secondary);
margin-bottom: 20px;
}
.social-container {
margin: 15px 0 20px;
display: flex;
gap: 15px;
}
.social-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
transition: all 0.3s;
cursor: pointer;
}
.social-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary), transparent 95%);
}
/* 切换 Tab */
.login-tab {
display: flex;
margin-bottom: 25px;
position: relative;
background: var(--bg-color-secondary);
border-radius: var(--radius-full);
padding: 4px;
width: 100%;
}
.tab-item {
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-full);
color: var(--text-color-secondary);
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.tab-item.active {
color: var(--color-primary);
background: var(--color-primary-text);
box-shadow: var(--shadow-sm);
font-weight: 600;
}
/* 输入框区域 */
.input-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 10px;
}
.phone-row {
display: flex;
gap: 10px;
}
.verify-row {
display: flex;
gap: 10px;
}
/* 链接与错误信息 */
.forgot-password {
color: var(--text-color-tertiary);
font-size: 13px;
text-decoration: none;
margin: 15px 0;
align-self: flex-end;
transition: color 0.3s;
}
.forgot-password:hover {
color: var(--color-primary);
}
.error-message {
color: var(--color-danger);
font-size: 13px;
min-height: 20px;
margin-bottom: 10px;
text-align: left;
width: 100%;
}
/* 侧边遮罩 */
.overlay-container {
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 100%;
overflow: hidden;
transition: transform 0.6s ease-in-out;
z-index: 100;
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: var(--radius-xl);
}
.container.right-panel-active .overlay-container {
transform: translateX(-100%);
border-radius: var(--radius-xl) 0 0 var(--radius-xl);
}
.overlay {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
background-repeat: no-repeat;
background-size: cover;
background-position: 0 0;
color: var(--color-primary-text);
position: relative;
left: -100%;
height: 100%;
width: 200%;
transform: translateX(0);
transition: transform 0.6s ease-in-out;
}
.container.right-panel-active .overlay {
transform: translateX(50%);
}
.overlay-panel {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 40px;
text-align: center;
top: 0;
height: 100%;
width: 50%;
transform: translateX(0);
transition: transform 0.6s ease-in-out;
}
.overlay-panel h1 {
color: var(--color-primary-text);
}
.overlay-panel p {
font-size: 14px;
font-weight: 300;
line-height: 24px;
margin: 20px 0 30px;
color: color-mix(in srgb, var(--color-primary-text), transparent 10%);
}
.overlay-left {
transform: translateX(-20%);
}
.container.right-panel-active .overlay-left {
transform: translateX(0);
}
.overlay-right {
right: 0;
transform: translateX(0);
}
.container.right-panel-active .overlay-right {
transform: translateX(20%);
}
/* 按钮覆盖样式 - REMOVED to avoid conflict with internal styles */
/* v-btn 通用样式微调 - REMOVED to avoid conflict with internal styles */
</style>
</head>
<body layout='public'>
<!-- 背景动画 -->
<div class="bg-decoration">
<div class="circle"></div>
<div class="circle"></div>
</div>
<div class="container" :class="{ 'right-panel-active': isSignUp }" id="container">
<!-- 注册表单 -->
<div class="form-container sign-up-container">
<div class="form-container-inner">
<h1>创建账户</h1>
<div class="subtitle">填写以下信息开始您的旅程</div>
<div class="social-container">
<div class="social-btn" @click="handleSocialLogin('github')"><i class="fa-brands fa-github"></i></div>
<div class="social-btn" @click="handleSocialLogin('weixin')"><i class="fa-brands fa-weixin"></i></div>
<div class="social-btn" @click="handleSocialLogin('google')"><i class="fa-brands fa-google"></i></div>
</div>
<div class="subtitle" style="margin: 0 0 15px; font-size: 12px;">或使用手机/邮箱注册</div>
<div class="input-group">
<v-input v:value="signUpForm.username" placeholder="用户名" :validate="validateUsername"></v-input>
<!-- 手机号输入框带区域选择 -->
<div v-if='$G.cfg.sms' class="phone-row">
<v-input type="select" v:value="signUpForm.region" :opts="{options: regions}"></v-input>
<v-input v:value="signUpForm.phone" placeholder="手机号" :validate="validatePhone"></v-input>
</div>
<div v-if='$G.cfg.sms' class="verify-row">
<v-input v:value="signUpForm.verifyCode" placeholder="验证码" :validate="validateCode"></v-input>
<v-btn variant="outline" :disabled="smsCountdown > 0 || smsLoading" :click="() => sendVerifyCode('signup')"
style="min-width: 100px;">
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</v-btn>
</div>
<v-input type="password" v:value="signUpForm.password" placeholder="密码"
:validate="validatePassword"></v-input>
</div>
<div class="error-message" v-if="signUpError">{{ signUpError }}</div>
<v-btn round block size="lg" :loading="signUpLoading" :click="handleSignUp">立即注册</v-btn>
</div>
</div>
<!-- 登录表单 -->
<div class="form-container sign-in-container">
<div class="form-container-inner">
<h1>欢迎回来</h1>
<div class="subtitle">登录您的账户以继续</div>
<div class="social-container">
<div class="social-btn" @click="handleSocialLogin('github')"><i class="fa-brands fa-github"></i></div>
<div class="social-btn" @click="handleSocialLogin('weixin')"><i class="fa-brands fa-weixin"></i></div>
<div class="social-btn" @click="handleSocialLogin('google')"><i class="fa-brands fa-google"></i></div>
</div>
<div class="subtitle" style="margin: 0 0 15px; font-size: 12px;">或使用您的账户</div>
<!-- 登录方式选择 -->
<div class="login-tab" v-if="$G.cfg.sms">
<div class="tab-item" :class="{ active: loginType === 'username' }" @click="switchLoginType('username')">
账号密码
</div>
<div class="tab-item" :class="{ active: loginType === 'phone' }" @click="switchLoginType('phone')">
手机验证码
</div>
</div>
<!-- 用户名登录 (使用 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">
<div class="phone-row">
<v-input type="select" v:value="signInForm.region" :opts="{options: regions}"></v-input>
<v-input v:value="signInForm.phone" placeholder="手机号" :validate="validatePhone"></v-input>
</div>
<div class="verify-row">
<v-input v:value="signInForm.verifyCode" placeholder="验证码" :validate="validateCode"></v-input>
<v-btn variant="outline" :disabled="smsCountdown > 0 || smsLoading" :click="() => sendVerifyCode('signin')"
style="min-width: 100px;">
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</v-btn>
</div>
<div class="error-message" v-if="signInError">{{ signInError }}</div>
<v-btn round block size="lg" :loading="signInLoading" :click="handleSignIn">登 录</v-btn>
</div>
</div>
</div>
<!-- 覆盖层 -->
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>已有账户?</h1>
<p>请使用您的个人信息登录,保持连接。</p>
<v-btn variant="outline" round class="btn-ghost" :click="switchToSignIn" size="lg"
style="width: 120px;">去登录</v-btn>
</div>
<div class="overlay-panel overlay-right">
<h1>新朋友?</h1>
<p>输入您的个人信息,开始您的旅程。</p>
<v-btn variant="outline" round class="btn-ghost" :click="switchToSignUp" size="lg"
style="width: 120px;">去注册</v-btn>
</div>
</div>
</div>
</div>
<script setup>
// 响应式数据
logout = $router.query.logout;
redirect = $router.query.redirect || '/';
isSignUp = false;
loginType = 'username'; // 'username' 或 'phone'
signUpForm = {username: '', phone: '', verifyCode: '', password: '', region: '+86'};
signInForm = {username: '', password: '', phone: '', verifyCode: '', region: '+86'};
signUpError = '';
signInError = '';
smsCountdown = 0; // 验证码倒计时
smsLoading = false; // 验证码发送加载状态
signUpLoading = false; // 注册按钮加载状态
signInLoading = false; // 登录按钮加载状态
// 验证规则函数
validateUsername = (val) => {
if (!val || val.length < 5) return '5';
return true;
};
validatePassword = (val) => {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[_]).{9,}$/;
if (!regex.test(val)) return '密码必须大于8位且包含大小写字母、下划线和数字';
return true;
};
validatePhone = (val) => {
const region = isSignUp ? signUpForm.region : signInForm.region;
let regex = /^\d{7,15}$/;
if (region === '+86') regex = /^1[3-9]\d{9}$/;
else if (region === '+1') regex = /^\d{10}$/;
if (!regex.test(val)) return '手机号格式不正确';
return true;
};
validateCode = (val) => {
if (!val || val.length < 4) return '';
return true;
};
// 常用国家/地区代码 - 转换为 v-select 格式
regions = [
{value: '+86', label: '+86 中国'},
{value: '+1', label: '+1 美国'},
{value: '+44', label: '+44 英国'},
{value: '+81', label: '+81 日本'},
{value: '+82', label: '+82 韩国'},
{value: '+65', label: '+65 新加坡'},
{value: '+852', label: '+852 香港'},
{value: '+853', label: '+853 澳门'},
{value: '+886', label: '+886 台湾'},
{value: '+91', label: '+91 印度'},
{value: '+33', label: '+33 法国'},
{value: '+49', label: '+49 德国'},
{value: '+7', label: '+7 俄国'},
{value: '+61', label: '+61 澳大利亚'},
{value: '+55', label: '+55 巴西'},
{value: '+39', label: '+39 意大利'},
{value: '+34', label: '+34 西班牙'},
{value: '+31', label: '+31 荷兰'},
{value: '+46', label: '+46 瑞典'},
{value: '+47', label: '+47 挪威'}
];
// 验证手机号格式(根据不同地区调整)
// 已迁移至 validatePhone 函数,保留此函数供 sendVerifyCode 使用
checkPhone = (phone, region) => {
if (region === '+86') {
const regex = /^1[3-9]\d{9}$/;
return regex.test(phone);
} else if (region === '+1') {
const regex = /^\d{10}$/;
return regex.test(phone);
} else {
const regex = /^\d{7,15}$/;
return regex.test(phone);
}
};
// 验证密码是否符合要求
validatePassword = (password) => {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[_]).{9,}$/;
return regex.test(password);
};
// 切换登录方式
switchLoginType = (type) => {
if (signInLoading) return;
loginType = type;
signInError = '';
signInForm.username = '';
signInForm.password = '';
signInForm.phone = '';
signInForm.verifyCode = '';
};
// 切换到注册页面
switchToSignUp = () => {
if (signInLoading || signUpLoading) return;
isSignUp = true;
signUpError = '';
signInError = '';
};
// 切换到登录页面
switchToSignIn = () => {
if (signInLoading || signUpLoading) return;
isSignUp = false;
signUpError = '';
signInError = '';
};
// 发送验证码
sendVerifyCode = async (type) => {
if (smsLoading) return;
const phone = type === 'signup' ? signUpForm.phone : signInForm.phone;
const region = type === 'signup' ? signUpForm.region : signInForm.region;
if (!phone) {
const errorMsg = '请输入手机号';
type === 'signup' ? signUpError = errorMsg : signInError = errorMsg;
return;
}
if (!checkPhone(phone, region)) {
const errorMsg = '请输入正确的手机号格式';
type === 'signup' ? signUpError = errorMsg : signInError = errorMsg;
return;
}
smsLoading = true;
try {
type === 'signup' ? signUpError = '' : signInError = '';
await $axios.post('/api/sms', {phone, region, purpose: type});
$message.success('验证码已发送');
smsCountdown = 60;
const timer = setInterval(() => {
smsCountdown--;
if (smsCountdown <= 0) clearInterval(timer);
}, 1000);
} catch (error) {
const errorMsg = error.message || '发送验证码失败,请重试';
type === 'signup' ? signUpError = errorMsg : signInError = errorMsg;
$message.warning(errorMsg);
} finally {
smsLoading = false;
}
};
// 处理第三方登录
handleSocialLogin = (provider) => {
$message.warning(`未开放 ${provider} 登录`);
};
// 处理注册表单提交
handleSignUp = async () => {
if (signUpLoading) return;
signUpError = '';
if (signUpForm.username.length < 5) {
signUpError = '用户名必须大于5位。';
return;
}
if ($G.cfg.sms) {
if (!validatePhone(signUpForm.phone, signUpForm.region)) {
signUpError = '请输入正确的手机号格式。';
return;
}
if (!signUpForm.verifyCode) {
signUpError = '请输入验证码。';
return;
}
}
if (!validatePassword(signUpForm.password)) {
signUpError = '密码必须大于8位且包含大小写字母、下划线和数字。';
return;
}
signUpLoading = true;
try {
await $axios.post('/api/user', {
username: signUpForm.username,
phone: signUpForm.phone,
region: signUpForm.region,
verify_code: signUpForm.verifyCode,
code: btoa(signUpForm.password),
}, {noretry: true});
$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;
}
};
// 处理登录表单提交
handleSignIn = async () => {
if (signInLoading) return;
signInError = '';
try {
let loginData = {};
if (loginType === 'username') {
if (validateUsername(signInForm.username) !== 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;
if (validateCode(signInForm.verifyCode) !== true) return;
loginData = {phone: signInForm.phone, region: signInForm.region, verify_code: signInForm.verifyCode, type: 'phone'};
}
signInLoading = true;
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);
} finally {
signInLoading = false;
}
};
</script>
</body>
</html>

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="OAuth Apps">
<title>{{ $t('nav.oauth') }}</title>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.app-card {
background: #fff;
padding: 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 10px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-name {
font-size: 18px;
font-weight: bold;
color: var(--color-primary);
}
.app-id {
font-size: 12px;
color: var(--text-color-secondary);
font-family: monospace;
background: var(--bg-color-tertiary);
padding: 4px;
border-radius: var(--radius-sm);
}
.app-redirect {
font-size: 14px;
color: var(--text-color-secondary);
word-break: break-all;
}
.btn-create {
background-color: var(--color-primary);
color: white;
padding: 8px 16px;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
}
.btn-delete {
background-color: var(--color-danger);
color: white;
padding: 4px 8px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
font-size: 12px;
}
</style>
</head>
<body>
<div class="page-header">
<h1>{{ $t('nav.oauth') }}</h1>
<button class="btn-create" @click="createApp">New App</button>
</div>
<div class="app-grid">
<div class="app-card" v-for="app in apps">
<div class="app-header">
<div class="app-name">{{ app.name }}</div>
<button class="btn-delete" @click="deleteApp(app.id)">Delete</button>
</div>
<div class="app-id">ID: {{ app.client_id }}</div>
<div class="app-redirect">Callback: {{ app.redirect_uri }}</div>
</div>
</div>
</body>
<script setup>
apps = [];
loadApps = async () => {
try {
const res = await $axios.get('/api/oauth/clients');
apps = res || [];
} catch (e) {
$message.error(e.message);
}
};
createApp = () => {
$message.prompt("Enter App Name", "New App").then(async (name) => {
if (!name) return;
const uri = await $message.prompt("Enter Redirect URI", "http://localhost:3000/callback");
if (!uri) return;
try {
await $axios.post('/api/oauth/clients', { name: name, redirect_uri: uri });
$message.success("Created");
loadApps();
} catch (e) {
$message.error(e.message);
}
}).catch(() => {});
};
deleteApp = async (id) => {
try {
await $message.confirm("Delete this app?");
await $axios.delete(`/api/oauth/clients/${id}`);
$message.success("Deleted");
loadApps();
} catch (e) {
// Cancelled
}
};
</script>
<script>
$data.loadApps();
</script>
</html>

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Org Detail">
<title>{{ $t('org.detail') }}</title>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section {
background: #fff;
padding: 20px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: 20px;
}
.section-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
color: var(--text-color);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.info-label {
font-size: 14px;
color: var(--text-color-secondary);
}
.info-value {
font-size: 16px;
font-weight: 500;
}
.btn-danger {
background-color: var(--color-danger);
color: white;
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
border: none;
}
.btn-edit {
background-color: var(--color-info);
color: white;
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
border: none;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 10px;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color-tertiary);
font-weight: 600;
}
</style>
</head>
<body>
<div class="page-header">
<h1>{{ org ? org.name : 'Loading...' }}</h1>
<div class="actions">
<button class="btn-edit" @click="editOrg">{{ $t('common.edit') }}</button>
<button class="btn-danger" @click="deleteOrg">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="section">
<div class="section-title">{{ $t('org.info') }}</div>
<div class="info-grid" v-if="org">
<div class="info-item">
<span class="info-label">ID</span>
<span class="info-value">{{ org.id }}</span>
</div>
<div class="info-item">
<span class="info-label">Name</span>
<span class="info-value">{{ org.name }}</span>
</div>
<div class="info-item">
<span class="info-label">Created At</span>
<span class="info-value">{{ new Date(org.created_at).toLocaleDateString() }}</span>
</div>
</div>
</div>
<div class="section">
<div class="section-title">{{ $t('org.members') }}</div>
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="member in members">
<td>{{ member.username }}</td>
<td>{{ member.role }}</td>
<td>
<button class="btn-sm btn-danger" @click="removeMember(member.id)">Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
</body>
<script setup>
orgId = $router.params.id;
org = null;
members = [];
loadData = async () => {
try {
const [orgRes, membersRes] = await Promise.all([
$axios.get(`/api/orgs/${orgId}`),
$axios.get(`/api/orgs/${orgId}/members`)
]);
org = orgRes;
members = membersRes || [];
} catch (e) {
$message.error(e.message);
}
};
editOrg = () => {
$message.info("Edit feature coming soon");
};
deleteOrg = async () => {
try {
await $message.confirm("Are you sure you want to delete this organization?");
await $axios.delete(`/api/orgs/${orgId}`);
$message.success("Deleted");
$router.push('/org');
} catch (e) {
// Cancelled or error
}
};
removeMember = async (userId) => {
// Implement remove logic
$message.info("Remove member feature coming soon");
};
</script>
<script>
$data.loadData();
</script>
</html>

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Org List">
<title>{{ $t('nav.org') }}</title>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.org-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.org-card {
background: #fff;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.org-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.org-name {
font-weight: bold;
font-size: 18px;
color: var(--text-color);
}
.org-meta {
font-size: 14px;
color: var(--text-color-secondary);
}
.btn-create {
padding: 8px 16px;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
padding: 20px;
border-radius: var(--radius-lg);
width: 400px;
max-width: 90%;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="page-header">
<h1>{{ $t('nav.org') }}</h1>
<button class="btn-create" @click="openCreateModal">{{ $t('common.create') }}</button>
</div>
<div class="org-grid">
<div class="org-card" v-for="org in orgs" @click="goToDetail(org.id)">
<div class="org-name">{{ org.name }}</div>
<div class="org-meta">ID: {{ org.id }}</div>
<div class="org-meta">Role: {{ org.role || 'Member' }}</div>
</div>
</div>
<!-- Create Modal -->
<div class="modal-overlay" v-if="showCreateModal">
<div class="modal-content">
<h3>{{ $t('org.create') }}</h3>
<div class="form-group">
<label>{{ $t('org.name') }}</label>
<input type="text" v:value="newOrgName" class="form-input" style="width: 100%; margin-top: 5px;">
</div>
<div class="modal-actions">
<button @click="closeCreateModal" class="btn-cancel">{{ $t('common.cancel') }}</button>
<button @click="createOrg" class="btn-confirm">{{ $t('common.confirm') }}</button>
</div>
</div>
</div>
</body>
<script setup>
orgs = [];
showCreateModal = false;
newOrgName = "";
loadOrgs = async () => {
try {
const res = await $axios.get('/api/orgs');
orgs = res || [];
} catch (e) {
$message.error(e.message);
}
};
openCreateModal = () => {
showCreateModal = true;
newOrgName = "";
};
closeCreateModal = () => {
showCreateModal = false;
};
createOrg = async () => {
if (!newOrgName) return;
try {
await $axios.post('/api/orgs', { name: newOrgName });
$message.success("Created successfully");
closeCreateModal();
loadOrgs();
} catch (e) {
$message.error(e.message);
}
};
goToDetail = (id) => {
$router.push('/org/' + id);
};
</script>
<script>
$data.loadOrgs();
</script>
</html>

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="User Management">
<title>{{ $t('nav.users') }}</title>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
background: #fff;
box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
}
th, td {
text-align: left;
padding: 12px;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color-tertiary);
font-weight: 600;
}
.btn-action {
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
border: none;
margin-right: 5px;
}
.btn-edit {
background-color: var(--color-info);
color: white;
}
.btn-delete {
background-color: var(--color-danger);
color: white;
}
</style>
</head>
<body>
<div class="page-header">
<h1>{{ $t('nav.users') }}</h1>
<button class="btn-action btn-edit" @click="createUser">Create User</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>{{ u.status || 'Active' }}</td>
<td>
<button class="btn-action btn-edit" @click="editUser(u)">Edit</button>
<button class="btn-action btn-delete" @click="deleteUser(u.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</body>
<script setup>
users = [];
loadUsers = async () => {
try {
const res = await $axios.get('/api/users');
users = res || [];
} catch (e) {
$message.error(e.message);
}
};
createUser = () => {
$message.info("Create User Modal coming soon");
};
editUser = (u) => {
$message.info("Edit User " + u.username);
};
deleteUser = async (id) => {
try {
await $message.confirm("Delete user?");
await $axios.delete(`/api/users/${id}`);
$message.success("Deleted");
loadUsers();
} catch (e) {
// Cancelled
}
};
</script>
<script>
$data.loadUsers();
</script>
</html>

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="User Profile">
<title>{{ $t('user.profile') }}</title>
<style>
.profile-container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: #fff;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.avatar-section {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
}
.avatar {
width: 100px;
height: 100px;
border-radius: 50%;
background-color: var(--color-primary-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--color-primary);
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.btn-save {
width: 100%;
padding: 12px;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 16px;
}
.btn-logout {
width: 100%;
padding: 12px;
background-color: var(--color-danger);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 16px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="profile-container">
<h2 style="text-align: center; margin-bottom: 30px;">{{ $t('user.profile') }}</h2>
<div class="avatar-section">
<div class="avatar">
{{ user.username ? user.username.charAt(0).toUpperCase() : 'U' }}
</div>
</div>
<form @submit.prevent="updateProfile">
<div class="form-group">
<label class="form-label">{{ $t('auth.username') }}</label>
<input type="text" v:value="user.username" class="form-input" disabled>
</div>
<div class="form-group">
<label class="form-label">{{ $t('auth.email') }}</label>
<input type="email" v:value="user.email" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Phone</label>
<input type="tel" v:value="user.phone" class="form-input">
</div>
<button type="submit" class="btn-save">{{ $t('common.save') }}</button>
</form>
<button class="btn-logout" @click="handleLogout">{{ $t('auth.logout') }}</button>
</div>
</body>
<script setup>
user = $env.$vbase.user || {};
// Fetch fresh data
loadUser = async () => {
try {
user = await $env.$vbase.fetchUser();
} catch (e) {
console.error(e);
}
};
updateProfile = async () => {
try {
await $axios.patch('/api/auth/me', {
email: user.email,
phone: user.phone
});
$message.success("Profile updated");
loadUser();
} catch (e) {
$message.error(e.message);
}
};
handleLogout = async () => {
await $env.$vbase.logout('/login');
};
</script>
<script>
$data.loadUser();
</script>
</html>

@ -3,16 +3,16 @@
<head>
<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="/vhtml/v.js"></script>
<link rel="stylesheet" href="/assets/common.css">
<link rel="stylesheet" href="/v/common.css">
<title>vbase</title>
<script type="module" key='vhtml' src="/vhtml/vhtml.min.js"></script>
<link rel="stylesheet" href="/assets/global.css">
<link rel="stylesheet" href="/v/global.css">
<link href="/assets/libs/animate/animate.min.css" rel="stylesheet">
<link href="/assets/libs/font-awesome/css/all.min.css" rel="stylesheet">
<script src="/assets/libs/echarts.min.js"></script>
</head>
<body root>
<body>
<vrouter loading style="height: 100%; width: 100%;">
<page-404></page-404>
</vrouter>

@ -1,25 +1,40 @@
/*
* routes.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
const routes = [
{ path: '/', component: '/page/index.html', name: 'home', layout: 'public' },
{ path: '/login', component: '/page/login.html', name: 'login', meta: { auth: false } },
{ path: '/profile', component: '/page/profile.html', layout: 'default', meta: { auth: true } },
{ path: '/app', component: '/page/app.html', name: 'app', layout: 'default', meta: { auth: true } },
{ path: '/settings', component: '/page/settings.html', layout: 'default', meta: { auth: true } },
// Public
{ path: '/login', component: '/page/auth/login.html', layout: 'public', meta: { guest: true } },
{ path: '/register', component: '/page/auth/register.html', layout: 'public', meta: { guest: true } },
// Dashboard (Default Layout)
{
path: '/',
layout: 'default',
meta: { auth: true },
component: '/page/dashboard/index.html'
},
// Org Management
{ path: '/org', component: '/page/sys/org/index.html', layout: 'default', meta: { auth: true } },
{
path: '/app/:id', layout: 'app', meta: { auth: true },
children: [
{ path: '/', component: '/page/app/index.html' },
{ path: '/user', component: '/page/app/user.html' },
{ path: '/auth', component: '/page/app/auth.html' },
{ path: '/settings', component: '/page/app/settings.html' },
]
path: '/org/:id',
component: '/page/sys/org/detail.html',
layout: 'default',
meta: { auth: true }
},
{ path: '*', component: '/page/404.html', name: '404' },
// User System
{ path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } },
{
path: '/users',
component: '/page/sys/user/index.html',
layout: 'default',
meta: { auth: true, roles: ['admin'] }
},
// OAuth Management
{ path: '/oauth/apps', component: '/page/sys/oauth/index.html', layout: 'default', meta: { auth: true } },
// Errors
{ path: '/403', component: '/page/403.html', layout: 'public' },
{ path: '*', component: '/page/404.html', layout: 'public' }
]
export default routes

@ -1,242 +0,0 @@
/*
* auth.js
* Copyright (C) 2025 veypi <i@veypi.com>
*
* Distributed under terms of the MIT license.
*/
class TokenService {
#login_url = '/login'
#refresh_url = '/api/token'
constructor() {
this.tokenKey = 'access';
this.refreshTokenKey = 'refresh';
}
check(domain, id, level) {
let body = this.body();
if (!body || !body.access) {
return false;
}
for (let a of body.access) {
if (a.name === domain && (a.tid === '' || a.tid === id) && a.level >= level) {
return true
}
}
return false
}
setToken(token) {
localStorage.setItem(this.tokenKey, token);
}
getToken() {
return localStorage.getItem(this.tokenKey);
}
toJSON() {
return this.getToken()
}
toString() {
return this.getToken()
}
setRefreshToken(refreshToken) {
localStorage.setItem(this.refreshTokenKey, refreshToken);
}
getRefreshToken() {
return localStorage.getItem(this.refreshTokenKey);
}
clearToken() {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
}
hasToken() {
return !!this.getToken();
}
parseToken(token) {
try {
if (!token || typeof token !== 'string') return null;
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(window.atob(base64));
} catch (error) {
console.info('Token解析失败:', error);
return null;
}
}
__cache = null
body() {
if (!this.__cache) {
this.__cache = this.parseToken(this.getToken());
}
if (!this.__cache) {
this.__cache = this.parseToken(this.getRefreshToken());
}
return this.__cache
}
logout(to, querys) {
this.clearToken();
let url = new URL(this.#login_url, window.location.origin)
let redirect = to || window.location.pathname
url.searchParams.set('redirect', redirect)
for (let key in querys) {
url.searchParams.set(key, querys[key]);
}
location.href = url.toString()
}
async refresh() {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
this.logout()
return;
}
try {
let data = await fetch(this.#refresh_url, {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken })
}).then(res => {
if (res.status !== 200) {
throw new Error(`Token刷新失败状态码: ${res.status}`);
}
return res.text()
})
this.__cache = null; // 清除缓存
this.setToken(data);
} catch (e) {
console.error('Token刷新失败:', e);
this.clearToken()
logout();
}
}
isExpired() {
const decoded = this.body();
if (!decoded) return true;
const currentTime = Date.now() / 1000;
return decoded.exp < currentTime;
}
wrapAxios(instance) {
// 定义一个标志,用于防止在刷新令牌时发送多个刷新请求
let isRefreshing = false;
// 定义一个队列,用于存储在刷新令牌期间失败的请求
let failedQueue = [];
/**
* 处理等待队列中的请求
* 刷新令牌成功后将队列中的请求重新发送刷新失败则拒绝这些请求
* @param {Error|null} error - 如果刷新令牌失败则为错误对象
* @param {string|null} token - 刷新成功后获取的新令牌
*/
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
// 刷新失败,拒绝队列中的所有请求
prom.reject(error);
} else {
// 刷新成功,使用新令牌解决队列中的所有请求
prom.resolve(token);
}
});
// 清空队列
failedQueue = [];
};
// 请求拦截器:在发送请求前添加认证令牌
instance.interceptors.request.use(config => {
const token = this.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, error => {
// 对请求错误做些什么
return Promise.reject(error);
});
let that = this
// 响应拦截器:处理响应数据,特别是针对 401 状态码进行令牌刷新和重试
instance.interceptors.response.use((response) => {
// 任何 2xx 范围内的状态码都会触发此函数
// 这里可以添加其他全局的成功响应处理逻辑
return response;
}, async function(error) {
// 任何超出 2xx 范围的状态码都会触发此函数
const originalRequest = error.config;
// 检查错误响应状态码是否为 401 (未授权)
// 并且确保这不是一个已经重试过的请求 (通过 originalRequest._retry 标记)
if (error.response && error.response.status === 401 && !originalRequest.noretry) {
// 统计该请求的重试次数
originalRequest.__retryCount = originalRequest.__retryCount || 0;
originalRequest.__retryCount++;
// 如果重试次数超过 3 次,则不再重试,直接跳转到登录页
if (originalRequest.__retryCount >= 3) {
// that.logout()
// 拒绝原始请求的 Promise停止后续处理
return Promise.reject(error);
}
// 如果当前正在进行令牌刷新
if (isRefreshing) {
// 将当前失败的请求添加到队列中,等待新令牌
return new Promise(resolve => {
failedQueue.push({ resolve, reject: (err) => { throw err; } });
}).then(token => {
// 刷新成功后,使用新令牌更新请求头
originalRequest.headers.Authorization = `Bearer ${token}`;
// 重新发送原始请求
return instance(originalRequest);
}).catch(err => {
// 如果队列中的请求被拒绝,则抛出错误
return Promise.reject(err);
});
}
// 如果没有正在进行令牌刷新,则设置标志为 true开始刷新
isRefreshing = true;
try {
// 发送请求来刷新令牌
await that.refresh();
const newToken = that.getToken();
// 更新原始请求的 Authorization 头为新的令牌
originalRequest.headers.Authorization = `Bearer ${newToken}`;
// 处理等待队列中的所有请求,用新的令牌重新发送
processQueue(null, newToken);
// 重新发送最初导致 401 错误的请求
return instance(originalRequest);
} catch (refreshError) {
// 如果刷新令牌本身也失败了 (例如refresh token 已过期)
// 清除本地令牌
// 拒绝等待队列中的所有请求
processQueue(refreshError);
// that.clearToken();
that.logout();
// 拒绝原始请求的 Promise
return Promise.reject(refreshError);
} finally {
// 无论成功或失败,最后都要将刷新标志重置为 false
isRefreshing = false;
}
}
// 对于其他类型的错误,或不是需要重试的 401 错误,直接拒绝 Promise
return Promise.reject(error);
});
}
}
export default new TokenService();

@ -0,0 +1,217 @@
class VBase {
constructor(baseURL) {
this.baseURL = baseURL || '';
this.tokenKey = 'vbase_access_token';
this.refreshTokenKey = 'vbase_refresh_token';
this.userKey = 'vbase_user_info';
this.orgKey = 'vbase_current_org';
this._token = localStorage.getItem(this.tokenKey) || '';
this._refreshToken = localStorage.getItem(this.refreshTokenKey) || '';
this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null');
this._currentOrg = JSON.parse(localStorage.getItem(this.orgKey) || 'null');
}
// Getters
get token() { return this._token; }
get refreshToken() { return this._refreshToken; }
get user() { return this._user; }
get currentOrg() { return this._currentOrg; }
// Setters
set token(val) {
this._token = val;
if (val) localStorage.setItem(this.tokenKey, val);
else localStorage.removeItem(this.tokenKey);
}
set refreshToken(val) {
this._refreshToken = val;
if (val) localStorage.setItem(this.refreshTokenKey, val);
else localStorage.removeItem(this.refreshTokenKey);
}
set user(val) {
this._user = val;
if (val) localStorage.setItem(this.userKey, JSON.stringify(val));
else localStorage.removeItem(this.userKey);
}
set currentOrg(val) {
this._currentOrg = val;
if (val) localStorage.setItem(this.orgKey, JSON.stringify(val));
else localStorage.removeItem(this.orgKey);
}
// API Helpers
async request(method, path, data = null, headers = {}) {
const url = `${this.baseURL}${path}`;
const config = {
method,
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
...headers
}
};
if (data) config.body = JSON.stringify(data);
const response = await fetch(url, config);
const resData = await response.json();
if (!response.ok) {
throw resData || new Error(`Request failed: ${response.status}`);
}
if (resData.code && resData.code !== 200) {
throw new Error(resData.message || 'API Error');
}
return resData.data || resData;
}
// Auth Actions
async login(username, password) {
try {
const data = await this.request('POST', '/api/auth/login', { username, password });
if (data.access) {
this.token = data.access;
if (data.refresh) this.refreshToken = data.refresh;
await this.fetchUser();
return true;
}
return false;
} catch (e) {
throw e;
}
}
async logout(redirect) {
try {
// Optional: Call server logout
// await this.request('POST', '/api/auth/logout');
} catch (e) {
console.warn('Logout API failed', e);
} finally {
this.clear();
if (redirect) {
location.href = redirect;
} else {
location.reload();
}
}
}
async refresh() {
if (!this.refreshToken) throw new Error("No refresh token");
try {
const data = await this.request('POST', '/api/auth/refresh', { refresh: this.refreshToken });
if (data.access) {
this.token = data.access;
if (data.refresh) this.refreshToken = data.refresh;
return true;
}
return false;
} catch (e) {
this.logout();
throw e;
}
}
async fetchUser() {
const user = await this.request('GET', '/api/auth/me');
this.user = user;
return user;
}
// Auth Headers
getAuthHeaders() {
const headers = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (this.currentOrg && this.currentOrg.id) {
headers['X-Org-ID'] = this.currentOrg.id;
}
return headers;
}
// Permission Check
hasPermission(permission) {
if (!this.user) return false;
if (this.user.is_admin) return true;
if (!permission) return true;
const userPerms = this.user.permissions || [];
return userPerms.includes(permission);
}
hasRole(role) {
if (!this.user) return false;
if (this.user.is_admin) return true;
const userRoles = this.user.roles || [];
return userRoles.includes(role);
}
// State Management
clear() {
this.token = '';
this.refreshToken = '';
this.user = null;
this.currentOrg = null;
}
isExpired(token) {
if (!token) token = this.token;
if (!token) return true;
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
const now = Math.floor(Date.now() / 1000);
return payload.exp && payload.exp < now;
} catch (e) {
return true;
}
}
wrapAxios(axiosInstance) {
// Request Interceptor
axiosInstance.interceptors.request.use(config => {
const headers = this.getAuthHeaders();
for (const key in headers) {
config.headers[key] = headers[key];
}
return config;
}, error => Promise.reject(error));
// Response Interceptor
axiosInstance.interceptors.response.use(response => {
const res = response.data;
if (res && res.code === 200) {
return res.data;
}
if (res && res.code && res.code !== 200) {
return Promise.reject(new Error(res.message || 'Error'));
}
return res || response;
}, async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await this.refresh();
const headers = this.getAuthHeaders();
originalRequest.headers['Authorization'] = headers['Authorization'];
return axiosInstance(originalRequest);
} catch (e) {
this.logout(window.location.pathname);
return Promise.reject(e);
}
}
return Promise.reject(error?.response?.data || error);
});
}
}
export default VBase;
Loading…
Cancel
Save