Import local app source

This commit is contained in:
Codex
2026-04-23 10:43:49 +00:00
commit 283972860c
65 changed files with 7942 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.0.1"
+329
View File
@@ -0,0 +1,329 @@
# sunderer_app/api/custom_user_signup.py
import frappe
from frappe import _ # 虽然这里不直接用 _(),但保持导入
from frappe.utils import validate_email_address, random_string, get_url, generate_hash
import json # 导入 json 模块
@frappe.whitelist(allow_guest=True)
def custom_sign_up(email, full_name, password=None, send_email=True, **kwargs):
"""
自定义用户注册 API,模仿 Frappe 的 sign_up 但发送自定义邮件链接
Args:
email (str): 用户邮箱
full_name (str): 用户全名
password (str, optional): 用户密码 (如果需要立即设置)
send_email (bool, optional): 是否发送激活邮件. Defaults to True.
**kwargs: 其他可能传递给 User DocType 的字段
Returns:
dict: 包含操作结果的字典
"""
try:
# --- 1. 验证输入 ---
email = validate_email_address(email, throw=True) # 验证邮箱格式
if frappe.db.exists("User", {"email": email}):
# 不再使用 frappe.throw,而是直接返回错误信息
return {"status": "error", "message": _("该邮箱地址已被注册。")}
if not full_name:
return {"status": "error", "message": _("请输入全名。")}
# --- 2. 创建 User 文档 ---
user = frappe.get_doc({
"doctype": "User",
"email": email,
"first_name": full_name.split(" ")[0], # 简单拆分姓
"last_name": " ".join(full_name.split(" ")[1:]) if len(full_name.split(" ")) > 1 else "", # 其余部分为名
"enabled": 1 if password else 0, # 如果提供了密码,可以立即启用;否则等待激活
"new_password": password, # 如果提供了密码,设置它
"send_welcome_email": 0, # 我们自己发送邮件
"user_type": "Website User", # 通常注册的是网站用户
# **kwargs: 添加其他通过 kwargs 传递的字段
**kwargs
})
# --- 3. 插入用户文档 ---
user.insert(ignore_permissions=True)
# --- 4. 生成激活密钥 ---
# 如果没有提供密码,或者总是需要激活邮件,则生成密钥
if not password:
# Use frappe.generate_hash instead of get_password_reset_key
reset_key = frappe.generate_hash(length=100) # Generate a secure key
else:
reset_key = None # 如果密码已设置,可能不需要密钥,或用于其他目的
# --- 5. 保存用户文档 (如果需要更新密钥) ---
if reset_key:
user.reload() # 重新加载文档以确保它是最新的
user.reset_password_key = reset_key
user.save(ignore_permissions=True)
# --- 6. 发送自定义激活邮件 ---
if send_email and reset_key:
send_custom_activation_email(user.email, reset_key)
# --- 7. 返回结果 ---
if password:
return {"status": "success", "message": _("用户注册成功,您现在可以登录。")}
else:
return {"status": "success", "message": _("激活邮件已发送,请查收邮箱。")}
except frappe.DuplicateEntryError:
# 这个异常可能在 user.insert() 时由数据库唯一性约束触发
return {"status": "error", "message": _("该邮箱地址已被注册。")}
except Exception as e:
# 捕获所有其他意外错误
frappe.log_error(f"注册过程中发生未知错误,邮箱: {email}, 错误: {str(e)}")
return {"status": "error", "message": _("注册过程中发生未知错误,请稍后再试。")}
@frappe.whitelist(allow_guest=True)
def send_custom_activation_email(user_email, reset_key):
"""
发送包含自定义激活链接的邮件
"""
# 构造自定义激活 URL
activation_url = f"{get_url()}/activate-account?key={reset_key}" # Use get_url helper
# 使用中文主题和内容
subject = _("决裂者终端 - 激活您的账户") # 或者 _("决裂者终端 - 设置密码")
message = f"""
<p>您好!</p>
<p>请点击下方链接设置密码并激活您的账户:</p>
<p><a href="{activation_url}">{activation_url}</a></p>
<p>如果您未发起此请求,请忽略此邮件。</p>
"""
frappe.sendmail(
recipients=[user_email],
subject=subject,
message=message,
delayed=False # Send immediately
)
@frappe.whitelist(allow_guest=True)
def trigger_account_setup_email(email):
try:
# --- 1. 验证邮箱格式 (可选,但推荐) ---
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
return {"exc": "请输入有效的邮箱地址!"}
# --- 2. 检查邮箱是否已存在于系统中 ---
if frappe.db.exists("User", {"email": email}):
return {"exc": "该邮箱地址已被注册。"}
# --- 3. 生成唯一的、有时效性的设置密钥 ---
setup_key = frappe.generate_hash(length=100) # 生成一个足够长的随机密钥
# --- 4. 创建初始的 User 记录 (禁用) ---
# 由于没有全名,我们暂时使用邮箱前缀作为名字,或者可以要求前端提供
username_parts = email.split('@')
first_name = username_parts[0] # 使用邮箱前缀作为名字
user_doc = frappe.get_doc({
"doctype": "User",
"email": email,
"first_name": first_name,
"last_name": "", # 可以留空或根据需要填充
"enabled": 0, # 初始状态为禁用
"send_welcome_email": 0, # 我们自己发送邮件
"user_type": "Website User",
# 可以根据需要添加其他默认字段
})
user_doc.insert(ignore_permissions=True) # 插入用户,此时是禁用的
print(f"Created initial disabled User: {user_doc.name}") # 调试日志
# --- 5. 创建 Signup Key DocType 记录 ---
signup_key_doc = frappe.new_doc("Signup Key")
signup_key_doc.key = setup_key # 使用生成的 setup_key 作为 key 字段
signup_key_doc.email = email # 存储关联的邮箱
signup_key_doc.used = 0 # 标记为未使用
# 如果 Signup Key DocType 有 expires_on 字段,可以设置过期时间
# signup_key_doc.expires_on = add_to_date(now_datetime(), hours=1) # 1小时后过期
signup_key_doc.insert(ignore_permissions=True)
print(f"Created Signup Key: {signup_key_doc.name} for email: {email}") # 调试日志
# --- 6. 构造 IncreaseAcc 链接 ---
site_url = get_url()
setup_url = f"{site_url}/IncreaseAcc?key={setup_key}"
# --- 7. 发送包含链接的邮件 ---
send_setup_link_email(email, setup_url)
# --- 8. 返回成功信息 ---
return {"message": "注册链接已发送至您的邮箱,请查收。"}
except frappe.DuplicateEntryError as e:
# 如果 User 或 Signup Key 插入时违反唯一性约束
frappe.log_error(f"Trigger account setup failed due to duplicate entry, email: {email}, error: {str(e)}")
return {"exc": "用户或密钥已存在,请稍后再试。"}
except Exception as e:
# 捕获所有其他意外错误
frappe.log_error(f"触发账户设置邮件失败,邮箱: {email}, 错误: {str(e)}")
import traceback
frappe.log_error(f"触发账户设置邮件错误堆栈:\n{traceback.format_exc()}")
# 如果 User 创建失败,尝试回滚 Signup Key (可选)
# frappe.delete_doc_if_exists("Signup Key", setup_key_name, ignore_permissions=True)
return {"exc": f"发送失败: {str(e)}"}
@frappe.whitelist(allow_guest=True)
def send_setup_link_email(user_email, setup_url):
"""
发送包含账户设置链接的邮件
"""
# 使用中文主题和内容
subject = "决裂者终端 - 完成账户注册"
message = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #00AFFF;">决裂者终端</h2>
<p>您好!</p>
<p>请点击下方链接完成您的账户注册:</p>
<p><a href="{setup_url}" style="display: inline-block; padding: 15px 30px; background-color: #00AFFF; color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">完成注册</a></p>
<p><em>此链接将在 1 小时内有效。</em></p>
<p>如果您未发起此请求,请忽略此邮件。</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ccc;">
<p style="color: #666; font-size: 12px;">© 2026 Sunderer Games. All rights reserved.</p>
</div>
"""
frappe.sendmail(
recipients=[user_email],
subject=subject,
message=message,
delayed=False # 立即发送
)
@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True)
def complete_account(key, password):
print(f"[DEBUG] 开始执行 complete_account,传入的 key: {key[:10]}...")
try:
signup_key_doc_list = frappe.get_all("Signup Key", fields=["name", "email"], filters={"key": key, "used": 0})
print(f"[DEBUG] 查询到的 signup_key_doc_list: {signup_key_doc_list}")
if not signup_key_doc_list:
print("[DEBUG] 未找到有效的未使用密钥,抛出错误。")
frappe.throw("无效或已过期的密钥。", frappe.ValidationError)
return
signup_key_name = signup_key_doc_list[0].name
email = signup_key_doc_list[0].email
print(f"[DEBUG] 获取到的邮箱: {email}")
# --- 获取用户文档 ---
try:
user_doc = frappe.get_doc("User", email)
print(f"[DEBUG] 找到用户文档: {user_doc.name}, 启用状态: {user_doc.enabled}")
except frappe.DoesNotExistError:
print(f"[ERROR] 用户 {email} 不存在。")
frappe.throw(f"用户 {email} 不存在。")
return
# --- 新增:手动进行密码强度校验 ---
# 为密码强度检查准备用户信息
user_info = {
"email": user_doc.email or "",
"first_name": user_doc.first_name or "",
"last_name": user_doc.last_name or "",
"username": user_doc.name or "" # 如果 User 文档有 username 字段,也可以包含
}
# 调用 Frappe 的密码强度检查函数
# 注意:这个函数名可能因版本而异,但通常在 frappe.utils.password_strength 中
# 我们会尝试不同的函数名
try:
from frappe.utils.password_strength import get_password_strength
strength_result = get_password_strength(password, user_info)
except ImportError:
# 如果无法导入 get_password_strength,尝试其他方式
# 这里我们假设它在 frappe.utils 中,或者使用一个简单的自定义检查
strength_result = {"score": 0, "feedback": {"warning": "", "suggestions": ["密码至少需要8位字符,包含大小写字母和数字"]}}
# 或者,您可以在这里实现更复杂的自定义强度检查逻辑
score = strength_result.get('score', 0)
feedback = strength_result.get('feedback', {})
# 定义最低分数要求(Frappe 默认是2)
min_score = 2
if score < min_score:
suggestions = feedback.get('suggestions', [])
warning = feedback.get('warning', "")
error_message_parts = ["密码强度不足,请设置一个更强的密码。"]
if warning:
error_message_parts.append(f"警告: {warning}")
if suggestions:
error_message_parts.append(f"建议: {', '.join(suggestions)}")
# 使用 frappe.throw 返回结构化的错误信息,便于前端解析
frappe.throw({
"message": " ".join(error_message_parts),
"type": "password_strength",
"details": {
"score": score,
"min_score": min_score,
"suggestions": suggestions,
"warning": warning
}
})
# --- 新增结束:手动进行密码强度校验 ---
# --- 关键部分:更新密码和角色 ---
try:
user_doc.new_password = password
user_doc.enabled = 1 # 确保用户处于启用状态
# --- 添加或修改角色 ---
default_roles = ["Sunderer User"] # 根据你的需求调整
for role_name in default_roles:
if frappe.db.exists("Role", role_name):
if not any(d.role == role_name for d in user_doc.roles):
user_doc.append("roles", {"role": role_name})
print(f"[DEBUG] 已将角色 '{role_name}' 添加到用户 {email}")
else:
print(f"[WARNING] 角色 '{role_name}' 不存在。跳过。")
# --- 角色添加结束 ---
# 在手动检查通过后,再执行 save(),此时应该不会再因为密码强度问题失败
user_doc.save(ignore_permissions=True)
print(f"[DEBUG] 成功更新用户 {email} 的密码、角色和启用状态。")
except Exception as pwd_error:
frappe.log_error(f"在完成账户 {email} 时更新用户详细信息(密码/角色)失败: {str(pwd_error)}")
print(f"[ERROR] 更新用户 {email} 详细信息失败: {str(pwd_error)}")
frappe.throw(f"设置用户账户失败: {str(pwd_error)}")
return
# --- 仅当用户信息更新成功后,才将密钥标记为已使用 ---
try:
frappe.db.set_value("Signup Key", signup_key_name, "used", 1)
print(f"[DEBUG] 已将 Signup Key {signup_key_name} 标记为已使用。")
except Exception as db_error:
frappe.log_error(f"用户更新后,将 Signup Key {signup_key_name} 标记为已使用失败: {str(db_error)}")
print(f"[ERROR] 将 Signup Key 标记为已使用失败: {str(db_error)}")
frappe.msgprint("密码已设置,但请就确认链接状态联系技术支持。")
frappe.db.commit()
print(f"[DEBUG] 已提交 key: {key[:10]}... 的事务。")
return {"message": "账户已激活,密码设置成功!"}
except frappe.AuthenticationError as e:
frappe.log_error(f"complete_account 中发生身份验证错误: {str(e)}")
print(f"[ERROR] 身份验证错误: {str(e)}")
frappe.throw(f"身份验证错误: {str(e)}")
except Exception as e:
frappe.log_error(f"激活账户时发生错误,key {key[:10]}...: {str(e)}")
print(f"[ERROR] 激活账户时发生错误,key {key[:10]}...: {str(e)}")
frappe.throw("激活您的账户时发生错误。请稍后重试或联系技术支持。")
+106
View File
@@ -0,0 +1,106 @@
# sunderer_app/sunderer_app/api.py
import frappe
from frappe.utils import random_string, now_datetime, add_to_date
import secrets
import string
@frappe.whitelist(allow_guest=True)
def generate_verification_code(length=6):
"""生成指定长度的数字验证码"""
code = ''.join([str(secrets.randbelow(10)) for _ in range(length)])
return code
@frappe.whitelist(allow_guest=True)
def send_verification_email(email, verification_code):
"""发送验证码邮件"""
try:
# 邮件标题
subject = "决裂者终端 - 验证码"
# 邮件内容
message = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #00AFFF;">决裂者终端</h2>
<p>您好!</p>
<p>您的验证码是:</p>
<div style="background: linear-gradient(135deg, #00AFFF, #0077CC);
padding: 20px;
border-radius: 10px;
text-align: center;
color: white;
font-size: 24px;
font-weight: bold;
letter-spacing: 5px;
margin: 20px 0;">
{verification_code}
</div>
<p>此验证码将在 10 分钟内有效。</p>
<p>如果您没有请求此验证码,请忽略此邮件。</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ccc;">
<p style="color: #666; font-size: 12px;">© 2026 Sunderer Games. All rights reserved.</p>
</div>
"""
# 发送邮件
frappe.sendmail(
recipients=[email],
subject=subject,
message=message,
delayed=False # 立即发送
)
return {"success": True, "message": "验证码已发送到您的邮箱"}
except Exception as e:
frappe.log_error(f"发送验证码邮件失败: {str(e)}")
return {"success": False, "message": str(e)}
@frappe.whitelist(allow_guest=True)
def send_signup_code(email):
"""发送注册验证码的主函数"""
try:
# 验证邮箱格式
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
return {"exc": "请输入有效的邮箱地址!"}
# 生成验证码
verification_code = generate_verification_code(6)
# 将验证码存储到缓存中(有效期10分钟)
cache_key = f"signup_code_{email}"
frappe.cache().set_value(cache_key, verification_code, expires_in_sec=600) # 10分钟
# 发送邮件
result = send_verification_email(email, verification_code)
if result["success"]:
return {"message": "验证码已发送到您的邮箱"}
else:
return {"exc": f"发送失败: {result['message']}"}
except Exception as e:
frappe.log_error(f"发送注册验证码失败: {str(e)}")
return {"exc": f"发送失败: {str(e)}"}
@frappe.whitelist(allow_guest=True)
def verify_signup_code(email, code):
"""验证注册验证码"""
try:
cache_key = f"signup_code_{email}"
stored_code = frappe.cache().get_value(cache_key)
if not stored_code:
return {"exc": "验证码已过期,请重新获取"}
if str(stored_code) != str(code):
return {"exc": "验证码错误"}
# 验证成功,清除缓存
frappe.cache().delete_value(cache_key)
return {"success": True}
except Exception as e:
frappe.log_error(f"验证注册验证码失败: {str(e)}")
return {"exc": f"验证失败: {str(e)}"}
View File
+19
View File
@@ -0,0 +1,19 @@
# sunderer_app/sunderer_app/custom_user.py
import frappe
from frappe.core.doctype.user.user import User as CoreUser
class CustomUser(CoreUser):
def get_redirect_url(self):
"""自定义用户创建后的重定向URL"""
# 检查是否是新用户注册
if self.flags.in_insert and self.new_password:
# 新用户设置密码后,跳转到自定义页面
return "/custom-update-password"
# 默认行为
return super().get_redirect_url()
def after_user_creation(doc, method):
"""用户创建后触发的钩子"""
# 这里可以添加额外的用户初始化逻辑
pass
+12
View File
@@ -0,0 +1,12 @@
# delete_signup_key.py
import frappe
def delete_signup_key_doctype():
frappe.delete_doc("DocType", "Signup Key", force=True)
print("Signup Key DocType deleted.")
if __name__ == "__main__":
frappe.connect()
delete_signup_key_doctype()
frappe.db.commit()
frappe.destroy()
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon3.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>决裂者终端用户中心</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "my-vue-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --config vite.config.js",
"build": "vite build --config vite.config.js",
"preview": "vite preview --config vite.config.js"
},
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"naive-ui": "^2.43.2",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^5.0.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+21
View File
@@ -0,0 +1,21 @@
<!-- src/App.vue -->
<script setup>
import { RouterView } from 'vue-router'
</script>
<template>
<div id="app">
<RouterView />
</div>
</template>
<style>
/* 确保全屏撑开 */
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
background: #000;
overflow: hidden;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import LoginView from './views/LoginView.vue'
import { ref } from 'vue'
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
loading.value = true
error.value = ''
try {
const res = await fetch('/api/method/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ usr: email.value, pwd: password.value })
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Invalid credentials')
}
// 登录成功!跳转到 Frappe 应用
window.location.href = 'http://172.25.162.172:8000/app' // 或你的目标页面
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
</script>
<template>
<div style="display: flex; justify-content: center; align-items: center; height: 100vh; background: #0f0f1b; color: white;">
<div style="background: #1a1a2e; padding: 2rem; border-radius: 10px; width: 300px; box-shadow: 0 4px 20px rgba(0,0,0,0.5);">
<h2 style="text-align: center; margin-bottom: 1.5rem;">Sunderer Login</h2>
<form @submit.prevent="handleLogin">
<input
v-model="email"
type="text"
placeholder="Email or Username"
style="width: 100%; padding: 0.5rem; margin: 0.5rem 0; border: 1px solid #444; background: #0f0f1b; color: white; border-radius: 4px;"
required
/>
<input
v-model="password"
type="password"
placeholder="Password"
style="width: 100%; padding: 0.5rem; margin: 0.5rem 0; border: 1px solid #444; background: #0f0f1b; color: white; border-radius: 4px;"
required
/>
<div v-if="error" style="color: #ff6b6b; margin-top: 0.5rem; font-size: 0.9rem;">
{{ error }}
</div>
<button
type="submit"
:disabled="loading"
style="width: 100%; padding: 0.5rem; background: #4e54c8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;"
>
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
// src/increaseacc.js
import { createApp } from 'vue'
import IncreaseAccView from './views/IncreaseAccView.vue'
const app = createApp(IncreaseAccView)
// 挂载到 DOM
app.mount('#app')
// 导出 app 实例,以便 UMD 格式可以访问
// 如果指定了 name: 'IncreaseAccApp',这个 app 实例将可通过 window.IncreaseAccApp 访问
export default app
@@ -0,0 +1,141 @@
<!-- src/layouts/AppLayout.vue -->
<template>
<n-layout position="absolute">
<!-- 顶部栏 -->
<n-layout-header class="header" bordered>
<div class="header-content">
<h1>My Admin Panel</h1>
<div class="header-actions">
<n-dropdown trigger="click" :options="profileOptions" @select="handleProfileSelect">
<n-button text>
<n-avatar round size="small" :src="userAvatar" />
{{ userName }}
</n-button>
</n-dropdown>
</div>
</div>
</n-layout-header>
<n-layout has-sider position="absolute" style="top: 64px;">
<!-- 侧边栏 -->
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
:native-scrollbar="false"
show-trigger
:collapsed="sCollapsed"
@collapse="sCollapsed = !sCollapsed"
@expand="sCollapsed = !sCollapsed"
>
<n-menu
v-model:value="activeKey"
:collapsed="sCollapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:indent="18"
/>
</n-layout-sider>
<!-- 主内容区 -->
<n-layout content-style="padding: 24px;">
<router-view /> <!-- 这里是后台各页面的渲染位置 -->
</n-layout>
</n-layout>
</n-layout>
</template>
<script setup>
import { ref, h } from 'vue'
import { useRouter } from 'vue-router'
import { NIcon, NText } from 'naive-ui'
import { BookOutline as BookIcon, PersonOutline as PersonIcon, LogOutOutline as LogoutIcon } from '@vicons/ionicons5'
const router = useRouter()
const sCollapsed = ref(false)
const activeKey = ref('dashboard')
// 示例用户信息
const userName = ref('Admin User')
const userAvatar = ref('https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg')
function renderIcon(icon) {
return () => h(NIcon, null, { default: () => h(icon) })
}
function renderMenuLabel(option) {
if (sCollapsed.value) {
return h(NIcon, null, { default: () => h(option.icon) });
} else {
return h('span', null, option.label);
}
}
// 侧边栏菜单
const menuOptions = [
{
label: 'Dashboard',
key: 'dashboard',
icon: renderIcon(BookIcon),
props: { onClick: () => router.push('/app/dashboard') }
},
{
label: 'Users',
key: 'users',
icon: renderIcon(PersonIcon),
props: { onClick: () => router.push('/app/users') }
},
// 添加更多菜单项...
]
// 顶栏用户菜单
const profileOptions = [
{ label: 'Profile', key: 'profile' },
{ label: 'Settings', key: 'settings' },
{ type: 'divider', key: 'd1' },
{ label: 'Logout', key: 'logout', icon: renderIcon(LogoutIcon) }
]
// 顶栏菜单选择处理
const handleProfileSelect = (key) => {
switch (key) {
case 'profile':
console.log('Go to Profile');
break;
case 'settings':
console.log('Go to Settings');
break;
case 'logout':
// 执行登出逻辑,例如清除 token
localStorage.removeItem('authToken'); // 示例
router.push('/login'); // 跳转回登录页
break;
default:
break;
}
}
</script>
<style scoped>
.header {
height: 64px;
display: flex;
align-items: center;
padding: 0 20px;
background-color: #fff;
}
.header-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions .n-button .n-avatar {
margin-right: 8px;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // 确保这个导入是正确的
const app = createApp(App)
app.use(router) // 使用路由器插件
app.mount('#app')
+80
View File
@@ -0,0 +1,80 @@
// C:\vueprj\my-vue-app\src\router.js
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from './views/LoginView.vue'
import RegisterView from './views/RegisterView.vue'
import CustomPasswordSetup from './views/CustomPasswordSetup.vue'
// --- 新增:导入后台布局和页面 ---
import AppLayout from './layouts/Applayout.vue' // 导入你创建的后台布局
const DashboardView = () => import('./views/sundererapp/DashboardView.vue') // 动态导入后台页面
const UsersView = () => import('./views/sundererapp/UsersView.vue') // 动态导入后台页面
// --- 新增结束 ---
const routes = [
{
path: '/',
name: 'Home',
component: LoginView
},
{
path: '/login',
name: 'Login',
component: LoginView
},
{
path: '/register',
name: 'Register',
component: RegisterView
},
{
path: '/update-password',
name: 'CustomPasswordSetup',
component: CustomPasswordSetup
},
{
path: '/custom-update-password',
name: 'CustomPasswordSetupAlt',
component: CustomPasswordSetup
},
// --- 新增:后台路由组 ---
{
path: '/sundererapp', // 后台页面的基路径
component: AppLayout, // 使用 AppLayout 作为父组件
meta: { requiresAuth: true }, // 可选:标记此路由组需要认证
children: [ // 定义后台页面的子路由
{
path: '', // /sundererapp 访问时,默认重定向到 dashboard
redirect: '/sundererapp/dashboard'
},
{
path: 'dashboard', // 最终路径为 /sundererapp/dashboard
name: 'AppDashboard',
component: DashboardView // 渲染 DashboardView 组件
},
{
path: 'users', // 最终路径为 /sundererapp/users
name: 'AppUsers',
component: UsersView // 渲染 UsersView 组件
},
// 你可以在这里添加更多后台页面,例如:
// {
// path: 'settings',
// name: 'AppSettings',
// component: () => import('./views/sundererapp/SettingsView.vue')
// }
]
},
// --- 新增结束 ---
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: LoginView
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
+79
View File
@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+21
View File
@@ -0,0 +1,21 @@
// src/main.js (或 src/sundererapp.js)
import { createApp } from 'vue'
import App from './App.vue' // 你可能有一个顶层的 App.vue,或者可以直接创建一个简单的根组件
import router from './router.js' // 导入你配置好的路由
// 如果你的 App.vue 是一个空壳,只是为了挂载 router-view
// 你也可以不导入 App.vue,直接创建一个根组件
// const RootComponent = {
// template: '<router-view />'
// }
// const app = createApp(RootComponent);
const app = createApp(App); // 创建 Vue 应用实例
app.use(router) // 安装 Vue Router 插件
app.mount('#app') // 挂载到 DOM
// 导出 app 实例,以便 UMD 格式可以访问
// 如果 vite.config.js 中指定了 name: 'SundererApp',这个 app 实例将可通过 window.SundererApp 访问
export default app
@@ -0,0 +1,166 @@
<template>
<div class="password-setup-container">
<div class="setup-card">
<h2>设置密码</h2>
<form @submit.prevent="setupPassword">
<div class="input-group">
<label>新密码</label>
<input
v-model="newPassword"
type="password"
placeholder="请输入新密码"
class="input-field"
required
/>
</div>
<div class="input-group">
<label>确认密码</label>
<input
v-model="confirmPassword"
type="password"
placeholder="请再次输入密码"
class="input-field"
required
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<button
type="submit"
:disabled="loading"
class="btn-primary"
>
{{ loading ? '设置中...' : '设置密码' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref('')
const loading = ref(false)
const setupPassword = async () => {
if (newPassword.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return
}
if (newPassword.value.length < 6) {
error.value = '密码长度至少为6位'
return
}
loading.value = true
error.value = ''
try {
const actualKey = route.query.key
if (!actualKey) {
error.value = '无效的密码重置链接'
return
}
const response = await fetch('http://172.25.162.172:8000/api/method/frappe.core.doctype.user.user.update_password', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: actualKey,
new_password: newPassword.value
})
})
const result = await response.json()
if (result.exc) {
error.value = result.exc || '设置密码失败'
} else {
alert('密码设置成功!')
router.push('/login')
}
} catch (err) {
error.value = '网络错误,请稍后再试'
console.error(err)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.password-setup-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #00AFFF, #0077CC);
color: white;
}
.setup-card {
width: 400px;
padding: 30px;
background: rgba(0, 0, 0, 0.295);
backdrop-filter: blur(12px);
border-radius: 20px;
box-shadow: 0 0 30px rgba(0, 175, 255, 0.4);
border: 1px solid rgba(0, 175, 255, 0.3);
text-align: center;
}
.input-group {
margin-bottom: 15px;
text-align: left;
}
.input-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.input-field {
width: 94%;
padding: 12px;
border: none;
border-radius: 10px;
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 0 0 1px rgba(0, 175, 255, 0.3);
}
.btn-primary {
width: 100%;
padding: 12px;
background: linear-gradient(90deg, #4CAF50, #45a049);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
margin-top: 20px;
font-size: 1rem;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.error {
color: #ff6b6b;
margin: 10px 0;
font-size: 14px;
}
</style>
@@ -0,0 +1,283 @@
<template>
<div class="container">
<h2>设置您的密码</h2>
<form @submit.prevent="setupPassword" class="register-view">
<div class="form-group">
<input type="password" id="password" v-model="password" required placeholder="请输入新密码" class="input-field"/>
</div>
<div class="form-group">
<input type="password" id="confirmPassword" v-model="confirmPassword" required placeholder="请确认新密码" class="input-field"/>
</div>
<button type="submit" :disabled="loading" class="btn-primary">{{ loading ? '正在设置...' : '设置密码' }}</button>
<!-- 用于显示普通错误信息 -->
<div v-if="message" :class="{ 'success-message': success, 'error-message': !success }">{{ message }}</div>
<!-- 专门用于显示密码强度错误信息 -->
<div v-if="passwordStrengthMessage" class="password-strength-error">
{{ passwordStrengthMessage }}
</div>
</form>
<div class="back-to-login">
<button @click="goBackToLogin" class="btn-secondary"> 返回登录</button>
</div>
</div>
</template>
<script>
export default {
name: 'IncreaseAcc',
data() {
return {
password: '',
confirmPassword: '',
message: '', // 用于存储一般性错误或成功信息
passwordStrengthMessage: '', // 专门用于存储密码强度错误信息
success: false,
loading: false,
key: null // 存储从 URL 获取的 key
}
},
mounted() {
const urlParams = new URLSearchParams(window.location.search);
this.key = urlParams.get('key');
console.log("从 URL 获取的 key:", this.key);
if (!this.key) {
this.message = '未找到密钥。';
this.success = false;
}
},
methods: {
async setupPassword() {
// 清空之前的消息
this.message = '';
this.passwordStrengthMessage = '';
this.success = false;
if (this.password !== this.confirmPassword) {
this.message = '两次输入的密码不匹配。';
this.success = false;
return;
}
if (!this.key) {
this.message = '缺少密钥参数。';
this.success = false;
return;
}
this.loading = true;
try {
const response = await fetch('/api/method/sunderer_app.api.custom_user_signup.complete_account', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: this.key, password: this.password})
});
const data = await response.json();
console.log("API 响应数据:", data);
if (response.ok) {
// 成功情况
this.message = "账户已激活,密码设置成功!";
this.success = true;
setTimeout(() => {
window.location.href = 'http://172.25.162.172:8000/';
}, 5000); // 5秒后跳转
} else {
// --- 修改错误处理逻辑 ---
let errorMessage = '操作失败,请稍后再试。';
let isPasswordStrengthError = false; // 标记是否是密码强度错误
if (data && data.exc) {
// 尝试解析 frappe.throw 返回的 exc 信息
try {
// Frappe 可能会将多个错误信息包装在一个字符串中,或者直接返回一个对象
let parsedExc = data.exc;
if (typeof data.exc === 'string') {
// 尝试解析为 JSON 对象(如果后端返回了 JSON 结构的 exc)
parsedExc = JSON.parse(data.exc);
}
// 检查 parsedExc 是否是我们之前在后端构造的密码强度错误对象
if (parsedExc && parsedExc.type === 'password_strength') {
// 是密码强度错误
isPasswordStrengthError = true;
// 构建更友好的提示信息
const details = parsedExc.details;
const warning = details.warning || "";
const suggestions = details.suggestions || [];
const parts = [];
if (warning) parts.push(warning);
if (suggestions.length > 0) parts.push(`建议: ${suggestions.join(', ')}`);
this.passwordStrengthMessage = parts.join(' ');
} else {
// 不是密码强度错误,处理其他 exc
errorMessage = parsedExc._server_messages || parsedExc.message || data._server_messages || '未知错误';
this.message = errorMessage;
}
} catch (parseError) {
// 如果 exc 不是 JSON 格式,或者解析失败,则直接显示 exc 内容
console.error("解析后端错误信息失败:", parseError);
// 检查 exc 字符串是否包含密码强度相关的关键词
if (typeof data.exc === 'string' &&
(data.exc.includes("密码强度") || data.exc.includes("password") || data.exc.includes("strength") || data.exc.includes("重复"))) {
isPasswordStrengthError = true;
// 尝试从原始字符串中提取一些信息,或者给出通用提示
// 这里可以根据实际返回的字符串进行更精细的解析
this.passwordStrengthMessage = "密码强度不足,请设置一个更强的密码。";
} else {
this.message = data.exc || errorMessage;
}
}
} else if (data && data.message) {
// 检查 data.message 是否是密码强度错误对象
if (typeof data.message === 'object' && data.message.type === 'password_strength') {
isPasswordStrengthError = true;
const details = data.message.details;
const warning = details.warning || "";
const suggestions = details.suggestions || [];
const parts = [];
if (warning) parts.push(warning);
if (suggestions.length > 0) parts.push(`建议: ${suggestions.join(', ')}`);
this.passwordStrengthMessage = parts.join(' ');
} else {
// 不是密码强度错误,处理其他 message
errorMessage = typeof data.message === 'object' ? JSON.stringify(data.message) : data.message;
this.message = errorMessage;
}
} else {
// 如果 data 既没有 exc 也没有 message,可能是网络错误或其他问题
this.message = '服务器返回了错误,但没有详细信息。';
}
// 如果不是密码强度错误,则设置 general message
if (!isPasswordStrengthError && !this.passwordStrengthMessage) {
this.message = errorMessage; // 这行其实可能已经在上面的条件分支中设置了
}
// --- 修改结束 ---
}
} catch (error) {
console.error("网络错误或请求失败:", error);
this.message = '网络错误或请求失败。';
this.success = false;
} finally {
this.loading = false;
}
},
goBackToLogin() {
window.location.href = '/login'; // 跳转到登录页
}
}
}
</script>
<style scoped>
/* ... (保持原有的 CSS 不变) ... */
.register-view {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #00AFFF, #0077CC);
color: white;
}
.container {
width: 400px;
padding: 30px;
background: rgba(0, 0, 0, 0.295);
backdrop-filter: blur(12px);
border-radius: 20px;
box-shadow: 0 0 30px rgba(0, 175, 255, 0.4);
border: 1px solid rgba(0, 175, 255, 0.3);
text-align: center;
}
h2 {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.input-field {
width: 100%;
padding: 20px;
margin: 10px 0;
border: none;
border-radius: 10px;
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 0 0 1px rgba(0, 175, 255, 0.3);
}
.btn-primary {
width: 100%;
padding: 12px;
background: linear-gradient(90deg, #4CAF50, #45a049);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
margin: 10px 0;
font-size: 1rem;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-primary:disabled {
background: #666;
cursor: not-allowed;
}
.success-message {
color: green;
margin-top: 15px;
text-align: center;
}
.error-message {
color: red;
margin-top: 15px;
text-align: center;
}
/* 新增:专门用于显示密码强度错误的样式 */
.password-strength-error {
color: orange; /* 使用橙色区分于红色的一般错误 */
margin-top: 15px;
text-align: center;
font-size: 0.9em; /* 字体稍小一点 */
font-style: italic; /* 斜体强调 */
}
.back-to-login {
margin-top: 20px;
}
.btn-secondary {
width: 100%;
padding: 10px;
background: linear-gradient(90deg, #6bffce, #c74dff);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
margin-top: 20px;
font-size: 1rem;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
</style>
@@ -0,0 +1,258 @@
<template>
<div class="login-container">
<!-- 背景几何图案 -->
<div class="bg-pattern"></div>
<!-- 登录卡 -->
<div class="login-card">
<div class="logo">
<span class="brand">决裂者终端</span>
</div>
<form @submit.prevent="handleLogin">
<input
v-model="email"
type="text"
placeholder="请输入账号"
required
class="input-field"
/>
<input
v-model="password"
type="password"
placeholder="请输入密码"
required
class="input-field"
/>
<div v-if="error" class="error">{{ error }}</div>
<div class="auth-buttons">
<button type="submit" :disabled="loginLoading" class="login-btn">
{{ loginLoading ? '正在登录. . .' : '登录' }}
</button>
<button type="button" @click="goToRegister" :disabled="registerLoading" class="register-btn">
{{ registerLoading ? '注册中...' : '注册' }}
</button>
</div>
</form>
<p class="footer">© 2026 Sunderer games. </p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router' // ✅ 确保导入
const router = useRouter()
const email = ref('')
const password = ref('')
const error = ref('')
const loginLoading = ref(false)
const registerLoading = ref(false)
async function handleLogin() {
loginLoading.value = true
error.value = ''
try {
const res = await fetch('/api/method/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ usr: email.value, pwd: password.value })
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || '账号或密码错误')
}
// 跳转到 Frappe 后台
window.location.href = 'http://172.25.162.172:8000/app'
} catch (err) {
error.value = err.message
} finally {
loginLoading.value = false
}
}
async function handleRegister() {
registerLoading.value = true
error.value = ''
try {
const res = await fetch('/api/method/frappe.core.doctype.user.user.sign_up', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.value,
full_name: email.value.split('@')[0], // 简单处理,可用其他字段
redirect_to: '/app'
})
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || '注册失败')
}
// 注册成功后,Frappe 会发送验证邮件
alert('注册成功!请查收邮箱验证链接。')
// 或跳转到提示页
} catch (err) {
error.value = err.message
} finally {
registerLoading.value = false
}
}
function goToRegister() {
router.push('/register')
}
</script>
<style scoped>
.login-container {
position: relative;
width: 100vw;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
background: linear-gradient(135deg, #00AFFF, #0077CC);
}
/* 登录卡片保持原样 */
.login-card {
background: rgba(0, 0, 0, 0.295);
backdrop-filter: blur(12px);
border-radius: 20px;
padding: 2.5rem;
width: 360px;
box-shadow: 0 0 30px rgba(0, 175, 255, 0.4);
border: 1px solid rgba(0, 175, 255, 0.3);
position: relative;
z-index: 1;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center; /* 水平居中内容 */
align-items: center; /* 垂直居中内容 */
min-height: 300px; /* 防止高度塌陷 */
}
/* 其他样式(logo、输入框、按钮等)保持不变... */
.logo {
font-family: 'Arial', sans-serif;
font-weight: bold;
margin-bottom: 1.5rem;
letter-spacing: 1px;
color: white;
text-shadow: 0 0 10px rgba(0, 175, 255, 0.8), 0 0 20px rgba(0, 175, 255, 0.6);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.brand {
color: white;
font-size: 2.2rem;
text-shadow: 0 0 10px rgba(0, 175, 255, 0.8), 0 0 20px rgba(0, 175, 255, 0.6);
}
.icon {
color: #00FFFF;
font-size: 1.8rem;
margin: 0 0.5rem;
text-shadow: 0 0 15px rgba(0, 255, 255, 0.7);
}
.input-field {
width: 80%;
padding: 0.8rem 1rem;
margin-bottom: 1rem;
border: none;
border-radius: 10px;
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 0 0 1px rgba(0, 175, 255, 0.3);
}
.input-field:focus {
box-shadow: 0 0 0 2px rgba(0, 175, 255, 0.7), 0 0 10px rgba(0, 175, 255, 0.4);
}
.error {
color: #ff6b6b;
font-size: 0.85rem;
margin: -0.5rem 0 1rem;
}
.login-btn {
width: 70%;
padding: 0.8rem;
background: linear-gradient(90deg, #00AFFF, #0077CC);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 0 15px rgba(0, 175, 255, 0.5);
}
.register-btn {
width: 70%;
padding: 0.8rem;
background: linear-gradient(90deg, #6bffce, #c74dff);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 0 15px rgba(255, 107, 107, 0.5);
}
.login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(0, 175, 255, 0.7);
}
.login-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.auth-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.footer {
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
text-align: center;
}
.register-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(255, 107, 107, 0.7);
}
.register-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,238 @@
<template>
<div class="register-view">
<div class="container">
<h2>用户注册</h2>
<div v-if="step === 'email'" class="step-email">
<input v-model="email" type="email" placeholder="请输入邮箱地址" class="input-field" />
<button @click="sendCode" :disabled="sending" class="btn-primary">
{{ sending ? '发送中...' : '发送验证码' }}
</button>
<p v-if="error" class="error-msg">{{ error }}</p>
</div>
<div v-else-if="step === 'verify'" class="step-verify">
<input v-model="code" type="text" placeholder="请输入6位验证码" maxlength="6" class="input-field" />
<button @click="verifyAndSignup" class="btn-primary">
完成注册
</button>
<p v-if="error" class="error-msg">{{ error }}</p>
</div>
<!-- 添加返回登录按钮 -->
<div class="back-to-login">
<button @click="goBackToLogin" class="btn-secondary">
返回登录
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router' // 引入路由
const router = useRouter() // 获取路由实例
const email = ref('')
const code = ref('')
const step = ref('email')
const error = ref('')
const sending = ref(false)
const validateEmail = (email) => {
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return regex.test(email)
}
const sendCode = async () => {
if (!validateEmail(email.value)) {
error.value = '请输入有效的邮箱地址!'
return
}
sending.value = true
error.value = ''
try {
const response = await fetch('/api/method/sunderer_app.api.verification.send_signup_code', { // 使用相对路径
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.value
})
})
const result = await response.json()
if (result.exc) {
error.value = result.exc
} else {
step.value = 'verify'
console.log('验证码发送成功')
}
} catch (err) {
error.value = '网络错误,请稍后再试'
console.error(err)
} finally {
sending.value = false
}
}
const verifyAndSignup = async () => {
console.log("DEBUG: verifyAndSignup function called");
console.log('点击完成注册')
console.log('email:', email.value)
console.log('code:', code.value)
if (code.value.length !== 6) {
error.value = '请输入6位验证码!'
console.log("DEBUG: Code length check failed");
return
}
console.log("DEBUG: Code length check passed");
try {
// 1. 验证验证码
const verifyResponse = await fetch('/api/method/sunderer_app.api.verification.verify_signup_code', { // 使用相对路径
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.value,
code: code.value
})
})
const verifyResult = await verifyResponse.json()
console.log("DEBUG: Verify API Response:", verifyResult);
if (verifyResult.exc) {
error.value = verifyResult.exc
console.log("DEBUG: Verify API returned error:", verifyResult.exc);
return
}
console.log("DEBUG: Verify API succeeded, proceeding to trigger email...");
// 2. 验证成功后,调用新API发送包含 IncreaseAcc 链接的邮件
// 注意:你需要在后端创建一个新API来处理这个逻辑
const triggerEmailResponse = await fetch('/api/method/sunderer_app.api.custom_user_signup.trigger_account_setup_email', { // 新API路径
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.value
})
});
const triggerEmailResult = await triggerEmailResponse.json();
console.log("DEBUG: Trigger Email API Response:", triggerEmailResult);
if (triggerEmailResult.exc) {
error.value = triggerEmailResult.exc;
console.log("DEBUG: Trigger Email API returned error:", triggerEmailResult.exc);
return;
}
// 3. 提示用户并结束当前流程
alert('验证成功!请查收邮箱中的注册链接,点击链接完成账户设置。');
console.log('Verification successful, email sent with link.');
// 可以选择清空输入框或跳转到等待页面,这里简单提示后清空
email.value = '';
code.value = '';
step.value = 'email'; // 回到初始状态,或者可以设计一个等待页面
error.value = ''; // 清除可能存在的旧错误
} catch (err) {
error.value = '网络异常,请稍后再试';
console.error('注册异常:', err);
}
}
const goBackToLogin = () => {
router.push('/login')
}
</script>
<style scoped>
/* ... CSS 样式保持不变 ... */
.register-view {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #00AFFF, #0077CC);
color: white;
}
.container {
width: 400px;
padding: 30px;
background: rgba(0, 0, 0, 0.295);
backdrop-filter: blur(12px);
border-radius: 20px;
box-shadow: 0 0 30px rgba(0, 175, 255, 0.4);
border: 1px solid rgba(0, 175, 255, 0.3);
text-align: center;
}
.input-field {
width: 90%;
padding: 20px;
margin: 10px 0;
border: none;
border-radius: 10px;
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 0 0 1px rgba(0, 175, 255, 0.3);
}
.btn-primary {
width: 100%;
padding: 12px;
background: linear-gradient(90deg, #4CAF50, #45a049);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
margin: 10px 0;
font-size: 1rem;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-primary:disabled {
background: #666;
cursor: not-allowed;
}
.btn-secondary {
width: 100%;
padding: 10px;
background: linear-gradient(90deg, #6bffce, #c74dff);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
margin-top: 20px;
font-size: 1rem;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.error-msg {
color: #ff6b6b;
margin: 10px 0;
font-size: 14px;
}
.back-to-login {
margin-top: 20px;
}
</style>
@@ -0,0 +1,14 @@
<!-- src/views/app/DashboardView.vue -->
<template>
<div>
<n-h2>Dashboard</n-h2>
<p>Welcome to your admin dashboard!</p>
<n-card title="Quick Stats" style="margin-top: 20px;">
<n-statistic label="Total Users" value="12345" />
</n-card>
</div>
</template>
<script setup>
// Dashboard 相关逻辑
</script>
@@ -0,0 +1,26 @@
<!-- src/views/app/UsersView.vue -->
<template>
<div>
<n-h2>Users Management</n-h2>
<p>This is the users management page.</p>
<n-data-table :columns="columns" :data="data" />
</div>
</template>
<script setup>
import { h } from 'vue'
import { NTag, NButton } from 'naive-ui'
// 示例数据
const columns = [
{ title: 'Name', key: 'name' },
{ title: 'Age', key: 'age' },
{ title: 'Tags', key: 'tags', render: (row) => row.tags.map(tag => h(NTag, { type: 'info' }, { default: () => tag })) },
{ title: 'Actions', key: 'actions', render: () => h(NButton, { size: 'small', type: 'primary' }, { default: () => 'Edit' }) }
]
const data = [
{ name: 'John Doe', age: 32, tags: ['admin', 'dev'] },
{ name: 'Jane Smith', age: 28, tags: ['user'] }
]
</script>
+38
View File
@@ -0,0 +1,38 @@
// vite.dev.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/assets/sunderer_app/dist/', // 开发环境使用根路径
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false
}
}
},
build: {
outDir: '../public/dist',
emptyOutDir: true,
assetsDir: '',
manifest: true,
rollupOptions: {
output: {
entryFileNames: 'js/[name].[hash].js',
chunkFileNames: 'js/[name].[hash].js',
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'css/[name].[hash].[ext]';
}
return 'assets/[name].[hash].[ext]';
}
}
}
}
})
+56
View File
@@ -0,0 +1,56 @@
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
// 导入 Naive UI 的解析器,告诉插件哪些组件属于 Naive UI
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: [
'vue',
'vue-router',
{
// 配置自动导入 Naive UI 的特定 API
'naive-ui': [
'useMessage', // 用于显示消息提示
'useDialog', // 用于显示对话框
'useNotification', // 用于显示通知
'useLoadingBar' // 用于显示顶部加载条
// 你可以根据需要添加更多,例如 'NButton', 'NCard' 等,
// 但通常推荐让 Components 插件处理组件导入,这里只放 API。
]
}
]
}),
Components({
resolvers: [NaiveUiResolver()]
})
],
build: {
outDir: '../public/SundererPage',
emptyOutDir: false,
rollupOptions: {
input: {
sundererapp: 'src/sundererapp.js' // 入口文件
},
output: {
format: 'umd', // 设置输出格式为 UMD
name: 'SundererApp', // 指定全局变量名,构建后应用会挂载到 window.IncreaseAccApp
// 可以保留 hash 以利用缓存,但 UMD 通常用于简单场景,可选
entryFileNames: 'js/sundererapp.[hash].js', // 例如: increaseacc.js (或 [name].[hash].js)
chunkFileNames: 'js/sundererapp.[hash].js',
assetFileNames: 'js/sundererapp.[hash].[ext]', // 例如: increaseacc.css (或 [name].[hash].[ext])
// UMD 格式可能需要外部依赖,如果 Vue 是全局引入的,需要声明
// globals: {
// vue: 'Vue' // 假设 Vue 已通过 script 标签全局引入
// }
}
}
}
})
+271
View File
@@ -0,0 +1,271 @@
app_name = "sunderer_app"
app_title = "Sunderer"
app_publisher = "test"
app_description = "test"
app_email = "898879719@qq.com"
app_license = "agpl-3.0"
# sunderer_app/hooks.py
website_context = {
"favicon": "/assets/sunderer_app/icon3.svg",
"splash_image": "/assets/sunderer_app/images/icon3.svg"
}
website_route_rules = [
{"from_route": "/sundererapp/<path:app_path>", "to_route": "sundererapp/index"},
]
# 禁用 Frappe 默认的登录重定向(非常重要!)
disable_website_login_redirect = True
# Apps
# ------------------
# required_apps = []
# Each item in the list will be shown as an app in the apps page
# add_to_apps_screen = [
# {
# "name": "sunderer_app",
# "logo": "/assets/sunderer_app/logo.png",
# "title": "Sunderer",
# "route": "/sunderer_app",
# "has_permission": "sunderer_app.api.permission.has_app_permission"
# }
# ]
# Includes in <head>
# ------------------
# include js, css files in header of desk.html
# app_include_css = "/assets/sunderer_app/css/sunderer_app.css"
# app_include_js = "/assets/sunderer_app/js/sunderer_app.js"
# include js, css files in header of web template
# web_include_css = "/assets/sunderer_app/css/sunderer_app.css"
# web_include_js = "/assets/sunderer_app/js/sunderer_app.js"
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "sunderer_app/public/scss/website"
# include js, css files in header of web form
# webform_include_js = {"doctype": "public/js/doctype.js"}
# webform_include_css = {"doctype": "public/css/doctype.css"}
# include js in page
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
# doctype_js = {"doctype" : "public/js/doctype.js"}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
# Svg Icons
# ------------------
# include app icons in desk
# app_include_icons = "sunderer_app/public/icons.svg"
# Home Pages
# ----------
# application home page (will override Website Settings)
# home_page = "login"
# website user home page (by Role)
# role_home_page = {
# "Role": "home_page"
# }
# Generators
# ----------
# automatically create page for each record of this doctype
# website_generators = ["Web Page"]
# Jinja
# ----------
# add methods and filters to jinja environment
# jinja = {
# "methods": "sunderer_app.utils.jinja_methods",
# "filters": "sunderer_app.utils.jinja_filters"
# }
# Installation
# ------------
# before_install = "sunderer_app.install.before_install"
# after_install = "sunderer_app.install.after_install"
# Uninstallation
# ------------
# before_uninstall = "sunderer_app.uninstall.before_uninstall"
# after_uninstall = "sunderer_app.uninstall.after_uninstall"
# Integration Setup
# ------------------
# To set up dependencies/integrations with other apps
# Name of the app being installed is passed as an argument
# before_app_install = "sunderer_app.utils.before_app_install"
# after_app_install = "sunderer_app.utils.after_app_install"
# Integration Cleanup
# -------------------
# To clean up dependencies/integrations with other apps
# Name of the app being uninstalled is passed as an argument
# before_app_uninstall = "sunderer_app.utils.before_app_uninstall"
# after_app_uninstall = "sunderer_app.utils.after_app_uninstall"
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config
# notification_config = "sunderer_app.notifications.get_notification_config"
# Permissions
# -----------
# Permissions evaluated in scripted ways
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
# DocType Class
# ---------------
# Override standard doctype classes
# override_doctype_class = {
# "ToDo": "custom_app.overrides.CustomToDo"
# }
# Document Events
# ---------------
# Hook on document methods and events
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks
# ---------------
# scheduler_events = {
# "all": [
# "sunderer_app.tasks.all"
# ],
# "daily": [
# "sunderer_app.tasks.daily"
# ],
# "hourly": [
# "sunderer_app.tasks.hourly"
# ],
# "weekly": [
# "sunderer_app.tasks.weekly"
# ],
# "monthly": [
# "sunderer_app.tasks.monthly"
# ],
# }
# Testing
# -------
# before_tests = "sunderer_app.install.before_tests"
# Overriding Methods
# ------------------------------
#
# override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "sunderer_app.event.get_events"
# }
#
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
# override_doctype_dashboards = {
# "Task": "sunderer_app.task.get_dashboard_data"
# }
# exempt linked doctypes from being automatically cancelled
#
# auto_cancel_exempted_doctypes = ["Auto Repeat"]
# Ignore links to specified DocTypes when deleting documents
# -----------------------------------------------------------
# ignore_links_on_delete = ["Communication", "ToDo"]
# Request Events
# ----------------
# before_request = ["sunderer_app.utils.before_request"]
# after_request = ["sunderer_app.utils.after_request"]
# Job Events
# ----------
# before_job = ["sunderer_app.utils.before_job"]
# after_job = ["sunderer_app.utils.after_job"]
# User Data Protection
# --------------------
# user_data_fields = [
# {
# "doctype": "{doctype_1}",
# "filter_by": "{filter_by}",
# "redact_fields": ["{field_1}", "{field_2}"],
# "partial": 1,
# },
# {
# "doctype": "{doctype_2}",
# "filter_by": "{filter_by}",
# "partial": 1,
# },
# {
# "doctype": "{doctype_3}",
# "strict": False,
# },
# {
# "doctype": "{doctype_4}"
# }
# ]
# Authentication and authorization
# --------------------------------
# auth_hooks = [
# "sunderer_app.auth.validate"
# ]
# Automatically update python controller files with type annotations for this app.
# export_python_type_annotations = True
# default_log_clearing_doctypes = {
# "Logging DocType Name": 30 # days to retain logs
# }
# Translation
# ------------
# List of apps whose translatable strings should be excluded from this app's translations.
# ignore_translatable_strings_from = []
+1
View File
@@ -0,0 +1 @@
Sunderer
+6
View File
@@ -0,0 +1,6 @@
[pre_model_sync]
# Patches added in this section will be executed before doctypes are migrated
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"index.html": {
"file": "js/index.c1R21Jxr.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"css": [
"css/index.CJO3dQ9s.css"
]
}
}
+1
View File
@@ -0,0 +1 @@
html,body,#app{height:100%;margin:0;padding:0;background:#000;overflow:hidden}.login-container[data-v-6eda41c4]{position:relative;width:100vw;min-height:100vh;display:flex;justify-content:center;align-items:center;overflow:hidden;background:linear-gradient(135deg,#00afff,#07c)}.login-card[data-v-6eda41c4]{background:#0000004b;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-radius:20px;padding:2.5rem;width:360px;box-shadow:0 0 30px #00afff66;border:1px solid rgba(0,175,255,.3);position:relative;z-index:1;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:300px}.logo[data-v-6eda41c4]{font-family:Arial,sans-serif;font-weight:700;margin-bottom:1.5rem;letter-spacing:1px;color:#fff;text-shadow:0 0 10px rgba(0,175,255,.8),0 0 20px rgba(0,175,255,.6);display:inline-flex;align-items:center;gap:.5rem}.brand[data-v-6eda41c4]{color:#fff;font-size:2.2rem;text-shadow:0 0 10px rgba(0,175,255,.8),0 0 20px rgba(0,175,255,.6)}.icon[data-v-6eda41c4]{color:#0ff;font-size:1.8rem;margin:0 .5rem;text-shadow:0 0 15px rgba(0,255,255,.7)}.input-field[data-v-6eda41c4]{width:80%;padding:.8rem 1rem;margin-bottom:1rem;border:none;border-radius:10px;background:#fff;color:#000;font-size:1rem;outline:none;transition:all .3s ease;box-shadow:0 0 0 1px #00afff4d}.input-field[data-v-6eda41c4]:focus{box-shadow:0 0 0 2px #00afffb3,0 0 10px #00afff66}.error[data-v-6eda41c4]{color:#ff6b6b;font-size:.85rem;margin:-.5rem 0 1rem}.login-btn[data-v-6eda41c4]{width:70%;padding:.8rem;background:linear-gradient(90deg,#00afff,#07c);color:#fff;border:none;border-radius:10px;font-size:1rem;font-weight:600;cursor:pointer;transition:transform .2s,box-shadow .2s;box-shadow:0 0 15px #00afff80}.register-btn[data-v-6eda41c4]{width:70%;padding:.8rem;background:linear-gradient(90deg,#6bffce,#c74dff);color:#fff;border:none;border-radius:10px;font-size:1rem;font-weight:600;cursor:pointer;transition:transform .2s,box-shadow .2s;box-shadow:0 0 15px #ff6b6b80}.login-btn[data-v-6eda41c4]:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 0 20px #00afffb3}.login-btn[data-v-6eda41c4]:disabled{opacity:.7;cursor:not-allowed}.auth-buttons[data-v-6eda41c4]{display:flex;gap:1rem;justify-content:center;margin-top:1.5rem}.footer[data-v-6eda41c4]{margin-top:1.5rem;color:#fff9;font-size:.8rem;text-align:center}.register-btn[data-v-6eda41c4]:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 0 20px #ff6b6bb3}.register-btn[data-v-6eda41c4]:disabled{opacity:.7;cursor:not-allowed}.register-view[data-v-14690773]{display:flex;justify-content:center;align-items:center;min-height:100vh;background:linear-gradient(135deg,#00afff,#07c);color:#fff}.container[data-v-14690773]{width:400px;padding:30px;background:#0000004b;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-radius:20px;box-shadow:0 0 30px #00afff66;border:1px solid rgba(0,175,255,.3);text-align:center}.input-field[data-v-14690773]{width:90%;padding:20px;margin:10px 0;border:none;border-radius:10px;background:#fff;color:#000;font-size:1rem;outline:none;transition:all .3s ease;box-shadow:0 0 0 1px #00afff4d}.btn-primary[data-v-14690773]{width:100%;padding:12px;background:linear-gradient(90deg,#4caf50,#45a049);color:#fff;border:none;border-radius:10px;cursor:pointer;margin:10px 0;font-size:1rem;font-weight:600;transition:transform .2s,box-shadow .2s}.btn-primary[data-v-14690773]:disabled{background:#666;cursor:not-allowed}.btn-secondary[data-v-14690773]{width:100%;padding:10px;background:linear-gradient(90deg,#6bffce,#c74dff);color:#fff;border:none;border-radius:10px;cursor:pointer;margin-top:20px;font-size:1rem;font-weight:600;transition:transform .2s,box-shadow .2s}.error-msg[data-v-14690773]{color:#ff6b6b;margin:10px 0;font-size:14px}.back-to-login[data-v-14690773]{margin-top:20px}.password-setup-container[data-v-378de9a4]{display:flex;justify-content:center;align-items:center;min-height:100vh;background:linear-gradient(135deg,#00afff,#07c);color:#fff}.setup-card[data-v-378de9a4]{width:400px;padding:30px;background:#0000004b;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-radius:20px;box-shadow:0 0 30px #00afff66;border:1px solid rgba(0,175,255,.3);text-align:center}.input-group[data-v-378de9a4]{margin-bottom:15px;text-align:left}.input-group label[data-v-378de9a4]{display:block;margin-bottom:5px;font-weight:700}.input-field[data-v-378de9a4]{width:94%;padding:12px;border:none;border-radius:10px;background:#fff;color:#000;font-size:1rem;outline:none;transition:all .3s ease;box-shadow:0 0 0 1px #00afff4d}.btn-primary[data-v-378de9a4]{width:100%;padding:12px;background:linear-gradient(90deg,#4caf50,#45a049);color:#fff;border:none;border-radius:10px;cursor:pointer;margin-top:20px;font-size:1rem;font-weight:600;transition:transform .2s,box-shadow .2s}.error[data-v-378de9a4]{color:#ff6b6b;margin:10px 0;font-size:14px}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/sunderer_app/dist/icon3.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>决裂者终端用户中心</title>
<script type="module" crossorigin src="/assets/sunderer_app/dist/js/index.c1R21Jxr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/sunderer_app/dist/css/index.CJO3dQ9s.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+90
View File
@@ -0,0 +1,90 @@
# sunderer_app/sunderer_app/setup_doctype.py
import frappe
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
def create_signup_key_doctype():
if frappe.db.exists("DocType", "Signup Key"):
print("DocType 'Signup Key' already exists.")
return
print("Creating DocType 'Signup Key'...")
try:
# 创建 DocType 文档对象
signup_key_doctype = frappe.new_doc("DocType")
signup_key_doctype.name = "Signup Key"
signup_key_doctype.module = "Sunderer" # 替换为你应用的模块名
signup_key_doctype.is_submittable = 0
signup_key_doctype.is_tree = 0
signup_key_doctype.istable = 0
# signup_key_doctype.autoname = "field:key" # <--- 移除这一行!
# signup_key_doctype.autoname = "hash" # <--- 或者选择这一行 (如果你想用哈希作为 name)
signup_key_doctype.allow_rename = 0
# 插入 DocType (此时字段还不存在)
signup_key_doctype.insert(ignore_permissions=True)
# 创建字段
# 1. Key 字段
key_field = frappe.new_doc("DocField")
key_field.parent = "Signup Key"
key_field.parenttype = "DocType"
key_field.parentfield = "fields"
key_field.fieldname = "key"
key_field.label = "Key"
key_field.fieldtype = "Data"
key_field.reqd = 1 # 必填
key_field.unique = 1 # 唯一索引,很重要!
key_field.insert(ignore_permissions=True)
# 2. Email 字段
email_field = frappe.new_doc("DocField")
email_field.parent = "Signup Key"
email_field.parenttype = "DocType"
email_field.parentfield = "fields"
email_field.fieldname = "email"
email_field.label = "Email"
email_field.fieldtype = "Data"
email_field.options = "Email"
email_field.reqd = 1 # 必填
email_field.insert(ignore_permissions=True)
# 3. Used 字段
used_field = frappe.new_doc("DocField")
used_field.parent = "Signup Key"
used_field.parenttype = "DocType"
used_field.parentfield = "fields"
used_field.fieldname = "used"
used_field.label = "Used"
used_field.fieldtype = "Check"
used_field.default = "0"
used_field.insert(ignore_permissions=True)
# 4. Expires On 字段 (可选)
expires_on_field = frappe.new_doc("DocField")
expires_on_field.parent = "Signup Key"
expires_on_field.parenttype = "DocType"
expires_on_field.parentfield = "fields"
expires_on_field.fieldname = "expires_on"
expires_on_field.label = "Expires On"
expires_on_field.fieldtype = "Datetime"
expires_on_field.insert(ignore_permissions=True)
# 可选:设置列表视图默认排序
make_property_setter("Signup Key", "list_view_fields", "value", "['key', 'email', 'used', 'expires_on']", "Text", for_doctype=True)
# 保存 DocType 以应用所有更改
signup_key_doctype.save(ignore_permissions=True)
print("Successfully created DocType 'Signup Key' with fields: key, email, used, expires_on.")
except Exception as e:
frappe.log_error(title="Failed to Create Signup Key DocType", message=frappe.get_traceback())
print(f"Error creating DocType 'Signup Key': {str(e)}")
if __name__ == "__main__":
frappe.connect()
create_signup_key_doctype()
frappe.db.commit()
frappe.destroy()
View File
View File
@@ -0,0 +1,21 @@
<!-- www/sundererapp.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>决裂者终端-用户中心</title>
<!-- 如果你的 Vue 应用构建后生成了独立的 CSS 文件,取消下面这行的注释并调整文件名 -->
<!-- <link rel="stylesheet" href="/sundererapp.[hash].css"> -->
</head>
<body>
<div id="app"></div>
<!-- 引入你的 Vue 应用构建后的 JS 文件 -->
<!-- 路径必须与 vite.config.js 中 outDir 和 rollupOptions.output 的配置结果匹配 -->
<!-- 假设 vite.config.js 中 outDir: '../../public/' 且 input key 为 'sundererapp' -->
<!-- 构建后文件会出现在 sunderer_app/public/ 目录下,例如: sundererapp.a1b2c3d4.js -->
<!-- 所以这里的 src 应该是 /sundererapp.[hash].js -->
<!-- 注意:你需要将 [hash] 替换为实际构建后生成的哈希值 -->
<script src="/assets/sunderer_app/SundererPage/js/sundererapp.BRnFqPg_.js"></script>
</body>
</html>
@@ -0,0 +1,11 @@
# templates/pages/sundererapp/sundererapp.py
import frappe
from frappe import _
def get_context(context):
"""
SPA 路由处理逻辑。
由于 hooks.py 将路由映射到这里,且模板文件名为 sundererapp.html
Frappe 会自动查找 sundererapp/sundererapp.html 作为模板。
"""
pass # 保持简单,让 Frappe 自动渲染模板
+6
View File
@@ -0,0 +1,6 @@
# sunderer_app/utils.py
import frappe
def update_website_context(context):
context["brand_html"] = "Sunderer"
# 其他上下文覆盖
+20
View File
@@ -0,0 +1,20 @@
<!-- sunderer_app/sunderer_app/www/IncreaseAcc/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>决裂者终端 - 完成账户注册</title>
<!-- 引用构建后生成的 CSS 文件 (如果存在) -->
<!-- <link rel="stylesheet" href="/assets/sunderer_app/css/increaseacc.css"> -->
<!-- 如果没有独立 CSS 文件,则保持注释 -->
</head>
<body>
<div id="app"></div>
<!-- 引用构建后生成的 UMD 格式的 JS 文件 -->
<!-- 路径指向 public/js 目录 -->
<script src="/assets/sunderer_app/IncreaseAcc/increaseacc.js"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/sunderer_app/dist/icon3.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>决裂者终端用户中心</title>
<script type="module" crossorigin src="/assets/sunderer_app/dist/js/index.c1R21Jxr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/sunderer_app/dist/css/index.CJO3dQ9s.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!-- www/sundererapp.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>决裂者终端-用户中心</title>
<!-- 如果你的 Vue 应用构建后生成了独立的 CSS 文件,取消下面这行的注释并调整文件名 -->
<!-- <link rel="stylesheet" href="/sundererapp.[hash].css"> -->
</head>
<body>
<div id="app"></div>
<!-- 引入你的 Vue 应用构建后的 JS 文件 -->
<!-- 路径必须与 vite.config.js 中 outDir 和 rollupOptions.output 的配置结果匹配 -->
<!-- 假设 vite.config.js 中 outDir: '../../public/' 且 input key 为 'sundererapp' -->
<!-- 构建后文件会出现在 sunderer_app/public/ 目录下,例如: sundererapp.a1b2c3d4.js -->
<!-- 所以这里的 src 应该是 /sundererapp.[hash].js -->
<!-- 注意:你需要将 [hash] 替换为实际构建后生成的哈希值 -->
<script src="/assets/sunderer_app/SundererPage/js/sundererapp.BRnFqPg_.js"></script>
</body>
</html>