init: laimiao-web-new

This commit is contained in:
light committed 2026-11-07 10:43:06 +08:00
commit c226cf7054
122 files changed
+18458

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# ---------------------------------------------------------------
# 接口统一指向本地 team-api(localhost:8000)
# 本地: http://localhost:8000
# 生产: https://laimiaoai.com
# /api 在 vite dev/preview 下由 vite.config.ts 代理到该地址;
# 登录页(login/)直接按绝对地址请求,依赖 team-api 的 CORS 配置。
# ---------------------------------------------------------------
VITE_TEAM_API_URL=http://localhost:8000
# 微信公众号(开放平台)扫码登录
VITE_WECHAT_APPID=wx30464a698b929bf4
# 技能 Agent 运行后端(laimiao-agent-v2)
VITE_SKILL_AGENT_URL=http://localhost:8100
# 技能 Agent 运行后端(会话列表 / 生成,laimiao-agent-v2)
VITE_AGENT_API_URL=http://localhost:8100
+5
View File
@@ -0,0 +1,5 @@
# 生产环境(site.laimiaoai.com):同域访问 team-api 与 agent
VITE_TEAM_API_URL=/
VITE_SKILL_AGENT_URL=/skill-agent
VITE_AGENT_API_URL=/skill-agent
VITE_WECHAT_APPID=wx30464a698b929bf4
+11
View File
@@ -0,0 +1,11 @@
# 测试环境:https://laimiao-web-test.kapokcloud.com
# team-api / agent 均由测试服务器同源反向代理,避免跨域
VITE_TEAM_API_URL=https://laimiao-web-test.kapokcloud.com
VITE_SKILL_AGENT_URL=/skill-agent
VITE_AGENT_API_URL=/skill-agent
VITE_WECHAT_APPID=wx30464a698b929bf4
VITE_WECHAT_LOGIN_ORIGIN=https://laimiao-web-test.kapokcloud.com
# WeChat 授权回调域仅登记了 laimiaoai.com,先回调到该域名再由 Caddy 302 回测试域名
VITE_WECHAT_CALLBACK_URL=https://laimiaoai.com/laimiao-web-test-callback
VITE_LAIMIAO_LOGIN_URL=/login/
VITE_IMAGE_PROXY=false
+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?
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#EEF0E4" />
<meta name="format-detection" content="telephone=no,email=no,address=no" />
<meta name="application-name" content="Mindora" />
<title>Mindora — Smarter Mental Health, Powered by AI</title>
<meta
name="description"
content="Mindora brings instant mental health insights, personalized advice, and virtual care, anytime, anywhere."
/>
<meta
name="keywords"
content="Mindora, mental health, AI health insights, sleep, HRV, biomarkers, virtual care"
/>
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<link rel="canonical" href="https://mindora.app/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Mindora" />
<meta property="og:locale" content="en_US" />
<meta property="og:title" content="Mindora — Smarter Mental Health, Powered by AI" />
<meta
property="og:description"
content="Instant mental health insights, personalized advice, and virtual care, anytime, anywhere."
/>
<meta property="og:url" content="https://mindora.app/" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Mindora — Smarter Mental Health, Powered by AI" />
<meta
name="twitter:description"
content="Instant mental health insights, personalized advice, and virtual care, anytime, anywhere."
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="mask-icon" href="/favicon.svg" color="#2C3A22" />
<link href="https://fonts.googleapis.cn" rel="preconnect" />
<link href="https://fonts.gstatic.cn" rel="preconnect" crossorigin />
<link
href="https://fonts.googleapis.cn/css2?family=Brygada+1918:wght@400;500;600&amp;family=Poppins:wght@400;500;600&amp;family=Urbanist:wght@400;500;600&amp;display=swap"
rel="stylesheet"
media="print"
onload="this.media='all'"
/>
<noscript>
<link
href="https://fonts.googleapis.cn/css2?family=Brygada+1918:wght@400;500;600&amp;family=Poppins:wght@400;500;600&amp;family=Urbanist:wght@400;500;600&amp;display=swap"
rel="stylesheet"
/>
</noscript>
</head>
<body>
<div id="app"></div>
<script>
(function () {
var app = document.getElementById('app')
if (!app) return
var splash = document.createElement('div')
splash.id = 'app-boot'
splash.setAttribute(
'style',
'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
'gap:14px;background:#EEF0E4;color:#28311F;font:500 15px/1.6 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;z-index:99999;',
)
splash.innerHTML =
'<span style="width:22px;height:22px;border-radius:50%;' +
'border:2px solid rgba(40,49,31,.2);border-top-color:#2C3A22;animation:appBootSpin .8s linear infinite;display:inline-block"></span>' +
'<span>Mindora</span>'
var spin = document.createElement('style')
spin.textContent = '@keyframes appBootSpin{to{transform:rotate(360deg)}}'
document.head.appendChild(spin)
document.body.appendChild(splash)
})()
</script>
<noscript>
<div
style="
max-width: 720px;
margin: 40px auto;
padding: 0 20px;
font-family: Poppins, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
line-height: 1.7;
color: #28311f;
background: #eef0e4;
"
>
<h1>Mindora — Smarter Mental Health, Powered by AI</h1>
<p>Get instant mental health insights, personalized advice, and virtual care, anytime, anywhere.</p>
<p>This is an interactive experience. Please enable JavaScript to continue.</p>
</div>
</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+5184
View File
File diff suppressed because it is too large. Load diff
+1372
View File
File diff suppressed because it is too large. Load diff
Symlink
+1
View File
@@ -0,0 +1 @@
public/industries
+459
View File
@@ -0,0 +1,459 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#F5F8EF" />
<meta name="format-detection" content="telephone=no,email=no,address=no" />
<title>邀请好友|来秒AI</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%2308482D'/%3E%3Cpath d='M18.6 3 7.5 18h6.7l-1.7 11L24.5 13h-6.6l.7-10Z' fill='%23DAE7C7'/%3E%3C/svg%3E"
/>
<link rel="preconnect" href="https://fonts.googleapis.cn" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.cn/css2?family=Inter:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700;900&display=swap"
rel="stylesheet"
/>
<script>
window.__TEAM_API_BASE__ = "%VITE_TEAM_API_URL%";
</script>
<style>
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: #f5f8ef;
color: #101410;
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC',
'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
}
.page {
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
padding: 32px 20px 40px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
color: #101410;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
text-decoration: none;
}
.brand svg {
width: 24px;
height: 24px;
display: block;
}
.card {
width: 100%;
max-width: 440px;
padding: 36px 28px 28px;
border-radius: 28px;
background: #fff;
border: 1px solid rgba(8, 72, 45, 0.08);
box-shadow: 0 18px 48px -32px rgba(8, 72, 45, 0.45);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
#content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.avatar {
width: 76px;
height: 76px;
border-radius: 999px;
overflow: hidden;
background: #eef2e6;
display: flex;
align-items: center;
justify-content: center;
color: #08482d;
font-size: 28px;
font-weight: 700;
margin: 0 auto 18px;
flex: none;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.name {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.desc {
margin: 8px 0 22px;
color: #6b7268;
font-size: 14px;
line-height: 1.7;
}
.reward {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 14px;
border-radius: 14px;
background: #fffbf6;
border: 1px solid #ffedd5;
color: #c44714;
font-size: 13px;
margin-bottom: 24px;
}
.reward strong {
font-weight: 700;
}
.actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.btn {
width: 100%;
height: 48px;
border-radius: 14px;
border: 1px solid rgba(8, 72, 45, 0.16);
background: #fff;
color: #08482d;
font-family: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.btn:hover {
border-color: rgba(8, 72, 45, 0.4);
transform: translateY(-1px);
}
.btn--primary {
background: #08482d;
border-color: #08482d;
color: #fff;
}
.btn--primary:hover {
background: #063a24;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn:focus-visible {
outline: 2px solid #08482d;
outline-offset: 2px;
}
.state {
padding: 18px 6px 4px;
}
.state h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
}
.state p {
margin: 0;
color: #6b7268;
font-size: 13px;
line-height: 1.7;
}
.spinner {
width: 34px;
height: 34px;
margin: 24px auto;
border-radius: 999px;
border: 3px solid #dae7c7;
border-top-color: #08482d;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
left: 50%;
bottom: 36px;
transform: translateX(-50%) translateY(10px);
padding: 10px 18px;
border-radius: 999px;
background: rgba(16, 20, 16, 0.88);
color: #fff;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main class="page">
<a class="brand" href="/">
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M19 2 5 19h8l-2 11L27 12h-9l1-10Z" fill="#08482D" />
<path d="M19 2 11 12" fill="none" stroke="#DAE7C7" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span>来秒AI</span>
</a>
<section class="card">
<div id="loading" class="spinner"></div>
<div id="invalid" class="state" hidden>
<h2>邀请链接无效</h2>
<p id="invalidText">邀请码不存在或已失效</p>
<div class="actions" style="margin-top: 20px">
<a class="btn btn--primary" href="/" style="display: flex; align-items: center; justify-content: center; text-decoration: none">返回首页</a>
</div>
</div>
<div id="content" hidden>
<div class="avatar" id="avatar"></div>
<h1 class="name" id="nickname">好友</h1>
<p class="desc" id="desc">邀请你一起用 来秒AI 把灵感变成作品</p>
<div class="reward">
<span>加入即可获得</span>
<strong>200</strong>
<span>算力积分奖励</span>
</div>
<div class="actions">
<button class="btn btn--primary" id="acceptBtn" type="button" hidden>开始使用来秒AI</button>
<button class="btn btn--primary" id="loginBtn" type="button" hidden>微信一键登录</button>
<a class="btn" href="/" style="display: flex; align-items: center; justify-content: center; text-decoration: none">了解来秒AI</a>
</div>
</div>
</section>
</main>
<div class="toast" id="toast"></div>
<script>
(function () {
"use strict";
const API = (function () {
const base = String(window.__TEAM_API_BASE__ || "");
return base && base.charAt(0) !== "%" ? base.replace(/\/+$/, "") : "";
})();
const PENDING_KEY = "invite_pending_code";
const loadingEl = document.getElementById("loading");
const invalidEl = document.getElementById("invalid");
const invalidTextEl = document.getElementById("invalidText");
const contentEl = document.getElementById("content");
const avatarEl = document.getElementById("avatar");
const nicknameEl = document.getElementById("nickname");
const descEl = document.getElementById("desc");
const acceptBtn = document.getElementById("acceptBtn");
const loginBtn = document.getElementById("loginBtn");
const toastEl = document.getElementById("toast");
let toastTimer = 0;
function showToast(message) {
toastEl.textContent = message;
toastEl.classList.add("is-show");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2600);
}
function readToken() {
return localStorage.getItem("access_token") || "";
}
function inviteCode() {
const fromQuery = new URLSearchParams(window.location.search).get("code");
if (fromQuery) return fromQuery.trim();
const match = window.location.pathname.match(/\/invite\/([A-Za-z0-9]+)/);
return match ? match[1] : "";
}
function currentInviteUrl(code) {
return window.location.origin + "/invite/?code=" + encodeURIComponent(code);
}
async function parseJson(response) {
const text = await response.text();
try {
return JSON.parse(text.replace(/([\[:,]\s*)(\d{16,})(\s*[,\]}])/g, '$1"$2"$3'));
} catch (error) {
return {};
}
}
function showInvalid(message) {
loadingEl.hidden = true;
contentEl.hidden = true;
invalidEl.hidden = false;
if (message) invalidTextEl.textContent = message;
}
function renderUser(info) {
const nickname = info.nickname || "好友";
nicknameEl.textContent = nickname;
descEl.textContent = "邀请你一起用 来秒AI 把灵感快速变成可发布的图片";
avatarEl.textContent = "";
if (info.avatar) {
const img = document.createElement("img");
img.src = info.avatar;
img.alt = nickname;
img.referrerPolicy = "no-referrer";
avatarEl.appendChild(img);
} else {
avatarEl.textContent = nickname.slice(0, 1);
}
}
async function acceptInvite(code) {
const token = readToken();
if (!token) return false;
acceptBtn.disabled = true;
try {
const response = await fetch(API + "/api/user/invitation/invite", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: token },
body: JSON.stringify({ code: code }),
});
const data = await parseJson(response);
if (data.ret === 101010) {
localStorage.removeItem("access_token");
return false;
}
if (data.ret !== undefined && data.ret !== 0) {
const message = String(data.msg || "邀请失败,请重试");
showToast(message.includes("重复绑定") ? "你已绑定过邀请人,直接开始创作吧" : message);
} else {
showToast(data.msg || "邀请成功,奖励已发放");
}
sessionStorage.removeItem(PENDING_KEY);
window.location.href = "/generate";
return true;
} catch (error) {
showToast("邀请失败,请重试");
return false;
} finally {
acceptBtn.disabled = false;
}
}
function goLogin(code) {
sessionStorage.setItem(PENDING_KEY, code);
const target = currentInviteUrl(code);
window.location.href = "/login/?redirect=" + encodeURIComponent(target);
}
async function init() {
const code = inviteCode();
if (!code) {
showInvalid("缺少邀请码,请确认链接是否完整");
return;
}
try {
const response = await fetch(API + "/api/user/invite/role?code=" + encodeURIComponent(code));
const data = await parseJson(response);
if (data.ret !== undefined && data.ret !== 0) {
showInvalid(data.msg || "邀请码不存在或已失效");
return;
}
if (data.role === "team") {
showInvalid("该邀请链接不是好友邀请");
return;
}
loadingEl.hidden = true;
contentEl.hidden = false;
renderUser({ nickname: data.nickname, avatar: data.avatar });
const loggedIn = !!readToken();
if (loggedIn) {
acceptBtn.hidden = false;
acceptBtn.addEventListener("click", function () {
void acceptInvite(code);
});
if (sessionStorage.getItem(PENDING_KEY) === code) {
void acceptInvite(code);
}
} else {
loginBtn.hidden = false;
loginBtn.addEventListener("click", function () {
goLogin(code);
});
}
} catch (error) {
showInvalid("无法获取邀请信息,请稍后重试");
}
}
void init();
})();
</script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#F5F8EF" />
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
<meta name="format-detection" content="telephone=no,email=no,address=no" />
<title>资产库|来秒AI</title>
<meta name="description" content="来秒AI 资产库:查看你的作品与素材文件。" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%2308482D'/%3E%3Cpath d='M18.6 3 7.5 18h6.7l-1.7 11L24.5 13h-6.6l.7-10Z' fill='%23DAE7C7'/%3E%3C/svg%3E"
/>
<link rel="preconnect" href="https://fonts.googleapis.cn" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.cn/css2?family=Inter:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700;900&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.cn/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet"
/>
</head>
<body class="m-0 bg-white text-[#1b1b1b]">
<div id="app"></div>
<script type="module" src="/src/library/main.ts"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="format-detection" content="telephone=no,email=no,address=no" />
<title>微信扫码登录|来秒AI</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- 独立登录页:只展示微信二维码,不加载主应用 bundle -->
<style>
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: #fff;
color: #141414;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei',
'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.wx-login {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 24px;
text-align: center;
}
.wx-login__title {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.5px;
}
/* 尺寸与微信 iframe 内的 .qrcode(LOGIN_QR_CSS)对齐 */
.wx-login__qr {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 14px;
}
.wx-login__qr iframe {
display: block;
border: 0;
width: 100%;
height: 100%;
}
</style>
<!-- defer:HTML 解析完立即执行,且早于下方 module 脚本,登录逻辑无需等待加载判断 -->
<script defer src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>
</head>
<body>
<main class="wx-login">
<h1 class="wx-login__title">微信扫码登录</h1>
<div class="wx-login__qr" id="qr"></div>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
declare global {
interface Window {
WxLogin?: (options: Record<string, unknown>) => void
}
}
const TEAM_API = (import.meta.env.VITE_TEAM_API_URL || 'http://localhost:8000').replace(/\/+$/, '')
const WECHAT_APPID = import.meta.env.VITE_WECHAT_APPID || ''
const WECHAT_ORIGIN = (import.meta.env.VITE_WECHAT_LOGIN_ORIGIN || window.location.origin).replace(/\/+$/, '')
// 微信授权回调地址:默认落在当前域名的登录页;测试环境可用 VITE_WECHAT_CALLBACK_URL
// 指向已登记授权回调域的域名(如 laimiaoai.com)再中转回测试域名。
const WECHAT_CALLBACK_URL = import.meta.env.VITE_WECHAT_CALLBACK_URL || `${WECHAT_ORIGIN}/login/`
// 隐藏微信 iframe 自带的标题栏/状态栏,只保留二维码(与 tripcn.ai-web 登录页一致)
const LOGIN_QR_CSS =
'data:text/css;base64,I3FyQ29kZUlmcmFtZSBpZnJhbWUgewogIHdpZHRoOiAxMDAlOwogIGhlaWdodDogMTAwJTsKICBvdmVyZmxvdzogaGlkZGVuOwp9Ci5pbXBvd2VyQm94IHsKICBkaXNwbGF5OiBmbGV4OwogIGp1c3RpZnktY29udGVudDogY2VudGVyOwogIGFsaWduLWl0ZW1zOiBjZW50ZXI7CiAgd2lkdGg6IDEwMCU7CiAgaGVpZ2h0OiAxMDAlOwp9Ci5pbXBvd2VyQm94IC5xcmNvZGUgewogIHdpZHRoOiAyMDBweDsKICBoZWlnaHQ6IDIwMHB4OwogIHBhZGRpbmc6IDBweDsKICBtYXJnaW46IGF1dG87CiAgYm9yZGVyOiBub25lOwp9Ci5pbXBvd2VyQm94IC50aXRsZSB7CiAgZGlzcGxheTogbm9uZTsKfQouaW1wb3dlckJveCAuaW5mbyB7CiAgZGlzcGxheTogbm9uZTsKfQouc3RhdHVzX2ljb24gewogIGRpc3BsYXk6IG5vbmU7Cn0KLmltcG93ZXJCb3ggLnN0YXR1cyB7CiAgZGlzcGxheTogbm9uZTsKfQ=='
interface WeixinLoginResponse {
uid: string
nickname: string
avatar: string
mobile: string
introduce: string
access_token: string
open_token: string
ret?: number
msg?: string
}
interface TeamBasicInfo {
team_id: string | number
team_name: string
enable_invite: number
role: string
score: number
}
const TEAM_STORAGE_KEY = 'team_id'
const TEAM_SCORE_KEY = 'team_score'
const qrEl = document.getElementById('qr') as HTMLElement
// 接口会返回未加引号的长整型 id,JSON.parse 会精度丢失甚至报错,先补引号
function parseApiJson<T>(text: string): T {
return JSON.parse(text.replace(/(\[[,:]\s*)(\d{16,})(\s*[\],}])/g, '$1"$2"$3')) as T
}
function showQrMessage(message: string): void {
if (qrEl) qrEl.textContent = message
}
function renderQrCode(): void {
if (!WECHAT_APPID) {
showQrMessage('缺少微信 AppID 配置')
return
}
if (typeof window.WxLogin !== 'function') {
showQrMessage('二维码加载失败,请刷新重试')
return
}
qrEl.textContent = ''
window.WxLogin({
self_redirect: false,
id: 'qr',
appid: WECHAT_APPID,
scope: 'snsapi_login',
redirect_uri: encodeURIComponent(WECHAT_CALLBACK_URL),
state: 'laimiao',
href: LOGIN_QR_CSS,
})
}
// 只接受站内绝对路径,避免 ?redirect= 被当作开放重定向
function redirectTarget(): string {
const redirect = new URLSearchParams(window.location.search).get('redirect')
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) return redirect
return '/generate'
}
async function cacheTeam(token: string): Promise<void> {
try {
const res = await fetch(`${TEAM_API}/api/team/team_list`, {
headers: { Authorization: token },
})
if (!res.ok) return
const data = parseApiJson<{ ret?: number; msg?: string; data_list?: TeamBasicInfo[] }>(
await res.text(),
)
if (data.ret !== undefined && data.ret !== 0) return
const teams = data.data_list || []
const personal =
teams.find((team) => team.enable_invite === 0 && team.team_name === '我的') ||
teams.find((team) => team.enable_invite === 0) ||
teams[0]
if (!personal) return
localStorage.setItem(TEAM_STORAGE_KEY, String(personal.team_id))
localStorage.setItem(TEAM_SCORE_KEY, String(personal.score ?? 0))
} catch {
// 团队信息预热失败不阻断登录
}
}
async function completeWeixinLogin(code: string): Promise<void> {
showQrMessage('正在登录…')
try {
const res = await fetch(`${TEAM_API}/api/user/weixin/login?code=${encodeURIComponent(code)}`, {
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = parseApiJson<WeixinLoginResponse>(await res.text())
if (data.ret !== undefined && data.ret !== 0) throw new Error(data.msg || '登录失败')
localStorage.setItem('access_token', data.access_token)
if (data.open_token) localStorage.setItem('open_token', data.open_token)
localStorage.setItem(
'user_info',
JSON.stringify({
uid: data.uid,
nickname: data.nickname || '未知用户',
avatar: data.avatar || '',
mobile: data.mobile || '',
introduce: data.introduce || '',
}),
)
await cacheTeam(data.access_token)
window.location.href = redirectTarget()
} catch (error) {
console.error('登录失败:', error)
showQrMessage('登录失败,请刷新重试')
}
}
const code = new URLSearchParams(window.location.search).get('code')
if (code) {
void completeWeixinLogin(code)
} else {
renderQrCode()
}
+2722
View File
File diff suppressed because it is too large. Load diff
+2620
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "laimiao-web-new",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build-only": "vite build",
"type-check": "vue-tsc -b",
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.4.17",
"marked": "^18.1.2",
"qrcode": "^1.5.4",
"vue": "^3.5.44",
"vue-router": "^5.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.3",
"typescript": "~5.9.3",
"vite": "^8.3.5",
"vue-tsc": "^3.3.11"
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="15" fill="#EEF0E4"/>
<g fill="none" stroke="#2C3A22" stroke-width="2.8" stroke-linecap="round">
<circle cx="32" cy="32" r="8.5" fill="#2C3A22" stroke="none"/>
<path d="M32 10v6.5M32 47.5V54M10 32h6.5M47.5 32H54"/>
<path d="m16.5 16.5 4.6 4.6M42.9 42.9l4.6 4.6M47.5 16.5l-4.6 4.6M21.1 42.9l-4.6 4.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import RechargeDialog from '../components/RechargeDialog.vue'
import ScoreDetailDialog from '../components/ScoreDetailDialog.vue'
import { getMyTeam } from '../utils/teamApi'
const rechargeOpen = ref(false)
const scoreDetailOpen = ref(false)
const teamId = ref(localStorage.getItem('team_id') || '')
const teamScore = ref(Number(localStorage.getItem('team_score') || 0))
async function syncTeam(): Promise<void> {
const team = await getMyTeam()
if (!team) return
teamId.value = String(team.team_id)
teamScore.value = team.score ?? 0
window.dispatchEvent(new CustomEvent('laimiao:team-updated', { detail: { score: teamScore.value } }))
}
function openRecharge(): void {
void syncTeam()
rechargeOpen.value = true
}
function openScoreDetail(): void {
void syncTeam()
scoreDetailOpen.value = true
}
function handlePaymentSuccess(): void {
void syncTeam()
}
onMounted(() => {
window.addEventListener('laimiao:open-recharge', openRecharge)
window.addEventListener('laimiao:open-score-detail', openScoreDetail)
})
onUnmounted(() => {
window.removeEventListener('laimiao:open-recharge', openRecharge)
window.removeEventListener('laimiao:open-score-detail', openScoreDetail)
})
</script>
<template>
<RechargeDialog
v-model:isOpen="rechargeOpen"
:team-id="teamId"
vip-type="laimiao"
@payment-success="handlePaymentSuccess"
/>
<ScoreDetailDialog v-model:isOpen="scoreDetailOpen" :team-id="teamId" :team-score="teamScore" />
</template>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import AccountWidgets from './AccountWidgets.vue'
import '../ui/style.css'
const host = document.createElement('div')
host.id = 'laimiao-account-widgets'
document.body.appendChild(host)
createApp(AccountWidgets).mount(host)
+503
View File
@@ -0,0 +1,503 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { listSessionsApi, type SessionResponse } from '../utils/graphAgent'
import { getToken } from '../utils/auth'
import { getTeamId } from '../utils/teamApi'
import { proxiedUrl, withJpgFormat } from '../utils/proxiedUrl'
import { sessionList, sessionsLoaded, setSessionList, upsertSession as upsertSharedSession } from '../utils/sessionStore'
const props = withDefaults(defineProps<{ sessions?: SessionResponse[]; activeSessionId?: string }>(), {
sessions: () => [],
activeSessionId: '',
})
const emit = defineEmits<{
(e: 'new-chat'): void
}>()
interface UserInfo {
nickname?: string
avatar?: string
}
function goHome() {
window.location.href = '/generate'
}
function goMe(tab?: string) {
window.location.href = tab ? `/me?tab=${tab}` : '/me'
}
const userInfo = ref<UserInfo>({})
function loadUserInfo() {
try {
const raw = localStorage.getItem('user_info')
if (raw) {
userInfo.value = JSON.parse(raw)
}
} catch {
userInfo.value = {}
}
}
async function loadSessions() {
if (!getToken()) return
try {
const teamId = await getTeamId()
if (!teamId || teamId === '0') return
const data = await listSessionsApi(teamId, 1)
const server = data.sessions || []
const serverIds = new Set(server.map(s => s.session_id))
// 保留本地乐观插入、服务端列表尚未返回的会话,避免新建会话一闪而过
const optimistic = sessionList.value.filter(s => !serverIds.has(s.session_id))
setSessionList([...optimistic, ...server])
sessionsLoaded.value = true
} catch {
// 请求失败时保留已有列表(含乐观插入的会话)
}
}
onMounted(() => {
loadUserInfo()
// 已加载过则复用共享缓存,切换视图(如资产库)挂载时不再重复请求
if (!sessionsLoaded.value) loadSessions()
})
const sidebarOpen = ref(false)
const isCollapsed = ref(false)
const activeMenu = ref<'new-chat' | `recent-${number}`>('new-chat')
interface RecentItem {
id: number
sessionId: string
title: string
ts: number
}
const recentItems = computed<RecentItem[]>(() =>
(props.sessions.length > 0 ? props.sessions : sessionList.value).map((s, i) => ({
id: i,
sessionId: s.session_id,
title: s.title || `对话 ${i + 1}`,
ts: s.update_time || s.add_time || Math.floor(Date.now() / 1000),
})),
)
// 日期分组:今天 / 7天内 / 30天内 / 更早
interface RecentGroup {
key: string
label: string
items: RecentItem[]
}
const FIXED_BUCKETS: { max: number; key: string; label: string }[] = [
{ max: 0, key: 'today', label: '今天' },
{ max: 7, key: '7d', label: '7天内' },
{ max: 30, key: '30d', label: '30天内' },
{ max: Infinity, key: 'earlier', label: '更早' },
]
function bucketOf(ts: number): { key: string; label: string } {
const start = new Date(ts * 1000)
start.setHours(0, 0, 0, 0)
const startToday = new Date()
startToday.setHours(0, 0, 0, 0)
const diffDays = Math.floor((startToday.getTime() - start.getTime()) / 86400000)
for (const bucket of FIXED_BUCKETS) {
if (diffDays <= bucket.max) return bucket
}
return FIXED_BUCKETS[3]
}
const recentGroups = computed<RecentGroup[]>(() => {
const groups = new Map<string, RecentGroup>()
for (const item of recentItems.value) {
const bucket = bucketOf(item.ts)
let group = groups.get(bucket.key)
if (!group) {
group = { key: bucket.key, label: bucket.label, items: [] }
groups.set(bucket.key, group)
}
group.items.push(item)
}
for (const group of groups.values()) group.items.sort((a, b) => b.ts - a.ts)
// 按固定顺序输出(今天 → 7天内 → 30天内 → 更早)
return FIXED_BUCKETS.map(b => groups.get(b.key)).filter((g): g is RecentGroup => !!g)
})
const collapsedGroups = ref<Set<string>>(new Set())
function syncGroupCollapse() {
collapsedGroups.value = new Set(
recentGroups.value.filter(g => g.key !== 'today').map(g => g.key),
)
}
onMounted(syncGroupCollapse)
watch(recentGroups, syncGroupCollapse)
function isGroupCollapsed(key: string): boolean {
return collapsedGroups.value.has(key)
}
function toggleGroup(key: string): void {
if (collapsedGroups.value.has(key)) {
collapsedGroups.value.delete(key)
} else {
collapsedGroups.value.add(key)
}
}
function resetGroupCollapse(): void {
syncGroupCollapse()
}
function expandSidebar(): void {
if (!isCollapsed.value) return
isCollapsed.value = false
resetGroupCollapse()
}
async function handleNewChat() {
if (isCollapsed.value) {
expandSidebar()
return
}
activeMenu.value = 'new-chat'
if (sidebarOpen.value) sidebarOpen.value = false
emit('new-chat')
}
function handleRecentItemClick(item: RecentItem) {
if (isCollapsed.value) {
expandSidebar()
return
}
activeMenu.value = `recent-${item.id}`
if (item.sessionId) {
localStorage.setItem('current_session_id', item.sessionId)
window.location.href = `/generate?session=${encodeURIComponent(item.sessionId)}`
}
}
// 当外部传入的当前会话 ID 变化时,同步选中态(支持路由/历史/生成后自动高亮)
function syncActiveMenu() {
const sid = props.activeSessionId
if (!sid) {
activeMenu.value = 'new-chat'
return
}
const match = recentItems.value.find((item) => item.sessionId === sid)
activeMenu.value = match ? `recent-${match.id}` : 'new-chat'
}
watch(() => props.activeSessionId, syncActiveMenu, { immediate: true })
// 会话列表异步加载/刷新后,若当前会话已出现在列表中则自动选中
watch(recentItems, syncActiveMenu)
function handleGroupToggle(label: string) {
if (isCollapsed.value) {
expandSidebar()
return
}
toggleGroup(label)
}
function toggleCollapse() {
if (isCollapsed.value) {
isCollapsed.value = false
resetGroupCollapse()
} else {
isCollapsed.value = true
}
}
// 乐观插入新会话(不依赖列表接口返回,保证实时显示)
function upsertSession(session: SessionResponse) {
upsertSharedSession(session)
}
defineExpose({ loadSessions, upsertSession })
</script>
<template>
<!-- Desktop Sidebar -->
<aside
class="hidden sm:flex bg-[#f9f9f9] border-r border-[#eeeeee] flex-col shrink-0 transition-all duration-300 ease-in-out overflow-hidden"
:class="isCollapsed ? 'w-20' : 'w-64'"
>
<div class="p-4 flex items-center justify-between mb-4">
<div
@click="goHome()"
class="flex items-center gap-2.5 cursor-pointer hover:scale-105 transform duration-200 ml-2 overflow-hidden whitespace-nowrap transition-all duration-200"
:class="isCollapsed ? 'opacity-0 max-w-0 ml-0' : 'opacity-100 max-w-[180px]'"
>
<svg class="w-6 h-6 shrink-0" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M19 2 5 19h8l-2 11L27 12h-9l1-10Z" fill="#08482D" />
<path d="M19 2 11 12" fill="none" stroke="#DAE7C7" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span class="font-bold text-lg tracking-tight text-gray-900">来秒AI</span>
</div>
<button
@click="toggleCollapse"
class="flex items-center justify-center mt-1 group w-7 h-7 rounded-lg hover:bg-[#F0F0F0] transition-colors duration-200 cursor-pointer shrink-0"
:class="{ 'mx-auto': isCollapsed }"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" class="text-[#333333] w-5 h-5">
<rect width="15.167" height="11.833" x="2.417" y="4.083" stroke="currentColor" stroke-width="1.5" rx="0.917"></rect>
<path stroke="currentColor" stroke-width="1.5" d="M6.667 4.167v11.666"></path>
</svg>
</button>
</div>
<nav class="flex-1 px-4 overflow-y-auto" :class="{ 'space-y-8': !isCollapsed, 'space-y-2': isCollapsed }">
<div>
<h3
class="px-4 text-[10px] font-bold uppercase tracking-widest mb-3 whitespace-nowrap"
:class="isCollapsed ? 'text-[#f9f9f9]' : 'text-gray-400'"
>
开启创作
</h3>
<div class="space-y-1">
<button
type="button"
class="flex items-center gap-2.5 px-4 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer hover:scale-105 transform duration-200 w-full"
:class="[
activeMenu === 'new-chat'
? 'bg-[#f5f5f5] text-black font-semibold'
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-900',
{ 'justify-center px-0': isCollapsed }
]"
@click="handleNewChat"
>
<span class="material-symbols-outlined text-[20px] shrink-0">edit_square</span>
<span
class="overflow-hidden whitespace-nowrap transition-opacity duration-200"
:class="isCollapsed ? 'opacity-0' : 'opacity-100'"
>新对话</span>
</button>
</div>
</div>
<div>
<h3
class="px-4 text-[10px] font-bold uppercase tracking-widest mb-3 whitespace-nowrap"
:class="isCollapsed ? 'text-[#f9f9f9]' : 'text-gray-400'"
>
最近
</h3>
<!-- 收起态:只显示一个图标,点击展开侧边栏 -->
<button
v-show="isCollapsed"
type="button"
class="group flex items-center justify-center w-full py-2.5 rounded-lg transition-all cursor-pointer hover:bg-[#f5f5f5] hover:scale-105 transform duration-200"
:class="isCollapsed ? 'opacity-100' : 'opacity-0'"
@click="expandSidebar"
>
<span class="material-symbols-outlined text-[20px] shrink-0 text-gray-500 group-hover:text-black transition-colors">sms</span>
</button>
<!-- 展开态:按日期分组 -->
<div
v-show="!isCollapsed"
class="space-y-2 transition-opacity duration-200"
:class="isCollapsed ? 'opacity-0' : 'opacity-100'"
>
<div v-for="group in recentGroups" :key="group.key">
<button
type="button"
class="flex items-center justify-between w-full px-4 py-1.5 rounded-lg text-[11px] font-semibold text-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-colors cursor-pointer"
@click="handleGroupToggle(group.key)"
>
<span>{{ group.label }}</span>
<span class="material-symbols-outlined text-sm transition-transform duration-200" :class="isGroupCollapsed(group.key) ? '' : 'rotate-180'">expand_more</span>
</button>
<div v-if="!isGroupCollapsed(group.key)" class="space-y-1 mt-1">
<button
v-for="item in group.items"
:key="item.id"
type="button"
class="flex items-center gap-2.5 px-4 py-2.5 rounded-lg text-[13px] font-medium transition-all cursor-pointer hover:scale-105 transform duration-200 w-full"
:class="
activeMenu === `recent-${item.id}` ? 'bg-[#f5f5f5] text-black font-semibold' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900'
"
@click="handleRecentItemClick(item)"
>
<span
class="material-symbols-outlined text-[14px] shrink-0"
:class="activeMenu === `recent-${item.id}` ? 'text-black' : 'text-gray-400'"
>
sms
</span>
<span class="truncate">{{ item.title }}</span>
</button>
</div>
</div>
</div>
</div>
</nav>
<div class="p-4 border-t border-[#eeeeee]">
<div class="flex items-center" :class="{ 'justify-center': isCollapsed }">
<div
class="flex items-center gap-3 px-2 py-2 cursor-pointer rounded-lg transition-all flex-1 min-w-0 hover:bg-gray-50"
@click="goMe()"
>
<div class="w-8 h-8 rounded-full bg-gray-100 overflow-hidden ring-1 ring-gray-100 flex items-center justify-center shrink-0">
<img
v-if="userInfo.avatar"
:src="withJpgFormat(proxiedUrl(userInfo.avatar))"
alt="avatar"
class="w-full h-full object-cover"
/>
<span v-else class="material-symbols-outlined text-gray-400">person</span>
</div>
<div
class="flex-1 min-w-0 flex items-center gap-2 overflow-hidden whitespace-nowrap transition-opacity duration-200"
:class="isCollapsed ? 'opacity-0 max-w-0' : 'opacity-100'"
>
<p class="text-xs font-semibold text-gray-900 truncate min-w-0 flex-1">{{ userInfo.nickname || '旅行创作者' }}</p>
</div>
</div>
</div>
</div>
</aside>
<!-- Mobile Top Nav -->
<nav class="sm:hidden fixed top-0 left-0 right-0 z-40 h-14 bg-white border-b border-[#eeeeee] flex items-center justify-between px-4">
<div class="flex items-center gap-3">
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
@click="sidebarOpen = true"
>
<span class="material-symbols-outlined text-gray-700 text-[22px]">menu</span>
</button>
<div class="flex items-center gap-2 cursor-pointer" @click="goHome()">
<svg class="w-6 h-6 shrink-0" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M19 2 5 19h8l-2 11L27 12h-9l1-10Z" fill="#08482D" />
<path d="M19 2 11 12" fill="none" stroke="#DAE7C7" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span class="font-bold text-base text-gray-900">来秒AI</span>
</div>
</div>
</nav>
<!-- Mobile Sidebar -->
<Teleport to="body">
<Transition name="mobile-sidebar">
<div v-if="sidebarOpen" class="fixed inset-0 z-50 sm:hidden">
<div
class="absolute inset-0 bg-black/40 backdrop-blur-sm"
@click="sidebarOpen = false"
></div>
<aside class="absolute left-0 top-0 h-full bg-[#f9f9f9] border-r border-[#eeeeee] flex flex-col shrink-0 w-64 shadow-2xl">
<div class="p-6 flex items-center justify-between mb-4 gap-2.5">
<div class="flex items-center gap-2.5">
<svg class="w-6 h-6 shrink-0" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M16 2.5 29.5 16 16 29.5 2.5 16 16 2.5Z" stroke="#141414" stroke-width="1.7" stroke-linejoin="round"/>
<path d="M16 9.5 22.5 16 16 22.5 9.5 16 16 9.5Z" fill="#141414"/>
</svg>
<span class="font-bold text-lg tracking-tight text-gray-900">来秒AI</span>
</div>
<button @click="sidebarOpen = false" class="text-gray-500 hover:text-gray-900 cursor-pointer hover:scale-110 transform duration-200">
<span class="material-symbols-outlined text-xl">close</span>
</button>
</div>
<nav class="flex-1 px-4 space-y-8 overflow-y-auto">
<div>
<h3 class="px-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">开启创作</h3>
<div class="space-y-1">
<button
type="button"
class="flex items-center gap-2.5 px-4 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer hover:scale-105 transform duration-200 w-full"
:class="activeMenu === 'new-chat' ? 'bg-[#f5f5f5] text-black font-semibold' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900'"
@click="handleNewChat"
>
<span class="material-symbols-outlined text-[20px] shrink-0">edit_square</span>
新对话
</button>
</div>
</div>
<div>
<h3 class="px-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">最近</h3>
<div class="space-y-2">
<div v-for="group in recentGroups" :key="group.key">
<button
type="button"
class="flex items-center justify-between w-full px-4 py-1.5 rounded-lg text-[11px] font-semibold text-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-colors cursor-pointer"
@click="toggleGroup(group.key)"
>
<span>{{ group.label }}</span>
<span class="material-symbols-outlined text-sm transition-transform duration-200" :class="isGroupCollapsed(group.key) ? '' : 'rotate-180'">expand_more</span>
</button>
<div v-if="!isGroupCollapsed(group.key)" class="space-y-1 mt-1">
<button
v-for="item in group.items"
:key="item.id"
type="button"
class="flex items-center gap-2.5 px-4 py-2.5 rounded-lg text-[13px] font-medium transition-all cursor-pointer hover:scale-105 transform duration-200 w-full"
:class="activeMenu === `recent-${item.id}` ? 'bg-[#f5f5f5] text-black font-semibold' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900'"
@click="handleRecentItemClick(item)"
>
<span
class="material-symbols-outlined text-[14px] shrink-0"
:class="activeMenu === `recent-${item.id}` ? 'text-black' : 'text-gray-400'"
>
sms
</span>
<span class="truncate">{{ item.title }}</span>
</button>
</div>
</div>
</div>
</div>
</nav>
<div class="p-4 border-t border-[#eeeeee]">
<div
class="flex items-center gap-3 px-2 py-2 cursor-pointer rounded-lg transition-all hover:bg-gray-50"
@click="sidebarOpen = false; goMe()"
>
<div class="w-8 h-8 rounded-full bg-gray-100 overflow-hidden ring-1 ring-gray-100 flex items-center justify-center shrink-0">
<img
v-if="userInfo.avatar"
:src="withJpgFormat(proxiedUrl(userInfo.avatar))"
alt="avatar"
class="w-full h-full object-cover"
/>
<span v-else class="material-symbols-outlined text-gray-400">person</span>
</div>
<div class="flex-1 min-w-0 flex items-center gap-2">
<p class="text-xs font-semibold text-gray-900 truncate min-w-0 flex-1">{{ userInfo.nickname || '旅行创作者' }}</p>
</div>
</div>
</div>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.mobile-sidebar-enter-active {
transition: all 0.3s ease;
}
.mobile-sidebar-leave-active {
transition: all 0.3s ease;
}
.mobile-sidebar-enter-from,
.mobile-sidebar-leave-to {
opacity: 0;
}
.mobile-sidebar-enter-from aside,
.mobile-sidebar-leave-to aside {
transform: translateX(-100%);
}
.mobile-sidebar-enter-to aside,
.mobile-sidebar-leave-from aside {
transform: translateX(0);
}
</style>
+253
View File
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { withJpgFormat } from '../utils/proxiedUrl'
const props = withDefaults(
defineProps<{
/** 图片列表(单张图片传单元素数组;空数组表示关闭) */
images: string[]
/** 打开时初始选中的图片下标 */
initialIndex?: number
/** 图片下方的说明文字(可选) */
caption?: string
/** 无说明文字时 img alt 的前缀 */
altPrefix?: string
/** 遮罩层层级 */
zIndex?: number
}>(),
{
initialIndex: 0,
caption: '',
altPrefix: '图片',
zIndex: 50,
},
)
const emit = defineEmits<{
(e: 'close'): void
}>()
const LIGHTBOX_ZOOM_MAX = 6
const index = ref(0)
const scale = ref(1)
const pos = ref({ x: 0, y: 0 })
const dragging = ref(false)
let dragStart = { x: 0, y: 0 }
const isOpen = computed(() => props.images.length > 0)
// 打开时定位到初始图片并锁定页面滚动,关闭时恢复
watch(
() => props.images.length,
(len) => {
if (len > 0) {
index.value = Math.min(Math.max(props.initialIndex, 0), len - 1)
scale.value = 1
pos.value = { x: 0, y: 0 }
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
},
)
onUnmounted(() => {
document.body.style.overflow = ''
})
function resetZoom() {
scale.value = 1
pos.value = { x: 0, y: 0 }
}
function zoomBy(factor: number) {
const next = Math.min(LIGHTBOX_ZOOM_MAX, Math.max(1, scale.value * factor))
scale.value = next
if (next === 1) pos.value = { x: 0, y: 0 }
}
function toggleZoom() {
if (scale.value > 1) {
resetZoom()
} else {
scale.value = 2.5
}
}
function onWheel(e: WheelEvent) {
zoomBy(e.deltaY < 0 ? 1.2 : 1 / 1.2)
}
function onPointerDown(e: PointerEvent) {
if (scale.value <= 1) return
dragging.value = true
dragStart = {
x: e.clientX - pos.value.x,
y: e.clientY - pos.value.y,
}
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value) return
pos.value = {
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
}
}
function onPointerUp() {
dragging.value = false
}
function navigate(direction: 'prev' | 'next') {
if (props.images.length <= 1) return
const count = props.images.length
index.value =
direction === 'next'
? (index.value + 1) % count
: (index.value - 1 + count) % count
resetZoom()
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen.value) return
if (e.key === 'Escape') {
emit('close')
} else if (e.key === 'ArrowLeft') {
navigate('prev')
} else if (e.key === 'ArrowRight') {
navigate('next')
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Teleport to="body">
<Transition name="lightbox">
<div
v-if="isOpen"
class="fixed inset-0 bg-black/90 flex items-center justify-center p-4 overflow-hidden"
:style="{ zIndex }"
@click="emit('close')"
@wheel.prevent="onWheel"
>
<!-- Zoom Controls & Close -->
<div class="absolute top-4 right-4 z-10 flex items-center gap-1.5" @click.stop>
<button
type="button"
aria-label="缩小"
class="w-10 h-10 flex items-center justify-center rounded-full text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
:class="scale > 1 ? 'bg-white/10 hover:bg-white/20' : 'bg-white/5'"
:disabled="scale <= 1"
@click="zoomBy(1 / 1.25)"
>
<span class="material-symbols-outlined text-2xl">zoom_out</span>
</button>
<button
type="button"
aria-label="重置缩放"
class="h-10 px-3 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white text-sm font-medium transition-colors cursor-pointer"
@click="resetZoom"
>
{{ Math.round(scale * 100) }}%
</button>
<button
type="button"
aria-label="放大"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="scale >= LIGHTBOX_ZOOM_MAX"
@click="zoomBy(1.25)"
>
<span class="material-symbols-outlined text-2xl">zoom_in</span>
</button>
<button
type="button"
aria-label="关闭"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors cursor-pointer"
@click="emit('close')"
>
<span class="material-symbols-outlined text-2xl">close</span>
</button>
</div>
<!-- Previous Button -->
<button
v-if="images.length > 1"
type="button"
aria-label="上一张"
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors cursor-pointer z-10 hidden md:flex"
@click.stop="navigate('prev')"
>
<span class="material-symbols-outlined text-3xl">chevron_left</span>
</button>
<!-- Next Button -->
<button
v-if="images.length > 1"
type="button"
aria-label="下一张"
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors cursor-pointer z-10 hidden md:flex"
@click.stop="navigate('next')"
>
<span class="material-symbols-outlined text-3xl">chevron_right</span>
</button>
<!-- Image Container -->
<div
class="max-w-[92vw] max-h-[85vh] flex flex-col items-center justify-center"
@click.stop
>
<img
:src="withJpgFormat(images[index])"
class="max-w-full object-contain rounded-lg select-none"
:class="[
caption ? 'max-h-[78vh]' : 'max-h-[85vh]',
scale > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
]"
:style="{
transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale})`,
transition: dragging ? 'none' : 'transform 0.15s ease-out',
}"
:alt="caption || `${altPrefix} ${index + 1}`"
draggable="false"
@dblclick.prevent="toggleZoom"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<p v-if="caption" class="text-white text-lg font-bold mt-4">{{ caption }}</p>
</div>
<!-- Image Counter -->
<div
v-if="images.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-white/10 text-white text-sm font-medium"
>
{{ index + 1 }} / {{ images.length }}
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lightbox-enter-active,
.lightbox-leave-active {
transition: opacity 0.3s ease;
}
.lightbox-enter-from,
.lightbox-leave-to {
opacity: 0;
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, watch, onUnmounted, nextTick } from 'vue'
import { weixinQrpayApi, alipayPageApi, weixinPayQueryApi, alipayPayQueryApi } from '../utils/payApi'
import QRCode from 'qrcode'
interface Props {
isOpen: boolean
orderId: string
amount: number
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const paymentMethod = ref<'weixin' | 'alipay'>('weixin')
const alipayUrl = ref('')
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const isLoading = ref(false)
const showLoadingOverlay = ref(false)
let payPollTimer: number | null = null
function close() {
emit('update:isOpen', false)
resetPayment()
}
function resetPayment() {
alipayUrl.value = ''
paymentMethod.value = 'weixin'
if (payPollTimer !== null) {
clearInterval(payPollTimer)
payPollTimer = null
}
}
function selectPaymentMethod(method: 'weixin' | 'alipay') {
if (paymentMethod.value === method) return
paymentMethod.value = method
loadPaymentInfo()
}
function startPollingPayStatus() {
if (payPollTimer !== null) {
clearInterval(payPollTimer)
}
payPollTimer = window.setInterval(async () => {
if (!props.orderId) return
try {
const res =
paymentMethod.value === 'weixin'
? await weixinPayQueryApi(props.orderId)
: await alipayPayQueryApi(props.orderId)
if (res.ispay) {
if (payPollTimer !== null) {
clearInterval(payPollTimer)
payPollTimer = null
}
emit('success')
emit('update:isOpen', false)
resetPayment()
}
} catch (error) {
console.error('查询支付状态失败:', error)
}
}, 2000)
}
async function loadPaymentInfo() {
if (!props.orderId) return
try {
isLoading.value = true
showLoadingOverlay.value = true
if (paymentMethod.value === 'weixin') {
const res = await weixinQrpayApi(props.orderId)
isLoading.value = false
await nextTick()
await nextTick()
await renderQrCode(res.code_url)
} else {
const res = await alipayPageApi(props.orderId)
// 支付宝返回 order_string,构建为完整的支付 URL 并通过 iframe 加载
alipayUrl.value = `https://openapi.alipay.com/gateway.do?${res.order_string}`
isLoading.value = false
startPollingPayStatus()
}
} catch (error) {
console.error('获取支付信息失败:', error)
isLoading.value = false
showLoadingOverlay.value = false
}
}
async function renderQrCode(url: string) {
let retryCount = 0
const maxRetries = 30
while (!qrCanvas.value && retryCount < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 50))
retryCount++
}
if (!qrCanvas.value) {
console.error('生成二维码失败: canvas 未准备好')
showLoadingOverlay.value = false
return
}
try {
await QRCode.toCanvas(qrCanvas.value, url, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
})
showLoadingOverlay.value = false
startPollingPayStatus()
} catch (error) {
console.error('生成二维码失败:', error)
showLoadingOverlay.value = false
}
}
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
loadPaymentInfo()
} else {
resetPayment()
}
}
)
watch(
paymentMethod,
() => {
if (props.isOpen) {
loadPaymentInfo()
}
}
)
onUnmounted(() => {
if (payPollTimer !== null) {
clearInterval(payPollTimer)
}
})
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm z-[210] flex items-center justify-center p-6" @click.self="close">
<div class="w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col">
<div class="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900">确认支付</h2>
</div>
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors text-gray-400" @click="close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-8">
<!-- 支付金额 -->
<div class="text-center mb-8">
<p class="text-sm text-gray-500 mb-2">应付金额</p>
<p class="text-4xl font-bold text-gray-900">¥{{ amount.toFixed(2) }}</p>
</div>
<!-- 支付方式选择 -->
<div class="mb-6">
<p class="text-sm font-medium text-gray-700 mb-3">选择支付方式</p>
<div class="flex gap-3">
<button
class="flex-1 py-3 rounded-lg border-2 transition-all flex items-center justify-center gap-2"
:class="paymentMethod === 'weixin' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300'"
@click="selectPaymentMethod('weixin')"
>
<span class="material-symbols-outlined text-green-500">chat</span>
<span class="text-sm font-medium" :class="paymentMethod === 'weixin' ? 'text-green-700' : 'text-gray-700'">微信支付</span>
</button>
<button
class="flex-1 py-3 rounded-lg border-2 transition-all flex items-center justify-center gap-2"
:class="paymentMethod === 'alipay' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'"
@click="selectPaymentMethod('alipay')"
>
<span class="material-symbols-outlined text-blue-500">account_balance_wallet</span>
<span class="text-sm font-medium" :class="paymentMethod === 'alipay' ? 'text-blue-700' : 'text-gray-700'">支付宝</span>
</button>
</div>
</div>
<!-- 支付内容区域 -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex flex-col items-center gap-3 text-gray-400">
<span class="material-symbols-outlined animate-spin text-3xl">refresh</span>
<span>加载中...</span>
</div>
</div>
<div v-show="!isLoading && paymentMethod === 'weixin'" class="flex flex-col items-center">
<div class="relative mb-4 w-48 h-48 rounded-lg border border-gray-200 flex items-center justify-center">
<canvas ref="qrCanvas" class="w-full h-full"></canvas>
<div v-if="showLoadingOverlay" class="absolute inset-0 bg-white/90 rounded-lg flex items-center justify-center">
<span class="material-symbols-outlined animate-spin text-gray-400">refresh</span>
</div>
</div>
<p class="text-xs text-gray-500 mb-2">请使用微信扫码支付</p>
<p class="text-xs text-gray-400">支付成功后页面将自动关闭</p>
</div>
<div v-show="!isLoading && paymentMethod === 'alipay' && alipayUrl" class="flex flex-col items-center">
<div class="relative mb-4 w-48 h-48 rounded-lg border border-gray-200 bg-white overflow-hidden">
<iframe
:src="alipayUrl"
class="absolute"
style="width: 200px; height: 200px; left: 20px; top: 20px; transform: scale(1.14); transform-origin: top left;"
frameborder="0"
scrolling="no"
></iframe>
</div>
<p class="text-xs text-gray-500 mb-2">请使用支付宝扫码支付</p>
<p class="text-xs text-gray-400">支付成功后页面将自动关闭</p>
</div>
</div>
</div>
</div>
</template>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { createOrderApi } from '../utils/payApi'
import PaymentDialog from './PaymentDialog.vue'
interface Props {
isOpen: boolean
teamId: string
vipType?: string
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
(e: 'payment-success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const vipType = computed(() => props.vipType || 'team')
// 固定充值方案(不请求 goods_list 接口)
// 注意:vip_level 需与后端 goods_vip 表数据对应,如需调整只改这里
const RECHARGE_PLANS = [
{ amount: 100, vipLevel: 1 },
{ amount: 500, vipLevel: 3 },
{ amount: 1000, vipLevel: 4 }
]
// 积分兑换比例:1元 = 10积分
const POINTS_PER_YUAN = 10
const selectedPlan = ref<number>(0)
const totalPrice = computed(() => RECHARGE_PLANS[selectedPlan.value]?.amount ?? 0)
const showPaymentDialog = ref(false)
const orderId = ref('')
const isCreatingOrder = ref(false)
function close() {
emit('update:isOpen', false)
}
function selectPlan(index: number) {
selectedPlan.value = index
}
async function handlePurchase() {
const amount = totalPrice.value
if (amount <= 0) return
try {
isCreatingOrder.value = true
const plan = RECHARGE_PLANS[selectedPlan.value]
const res = await createOrderApi({
vip_level: plan.vipLevel,
price: amount,
score: amount * POINTS_PER_YUAN,
team_id: props.teamId,
month: 0,
type: 0
})
orderId.value = res.order_id
showPaymentDialog.value = true
} catch (error) {
console.error('创建订单失败:', error)
alert('创建订单失败,请重试')
} finally {
isCreatingOrder.value = false
}
}
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
selectedPlan.value = 0
}
}
)
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm z-[200] flex items-center justify-center p-6" @click.self="close">
<div class="w-full max-w-[1000px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
<div>
<div class="flex items-baseline gap-3 flex-wrap">
<h2 class="text-xl font-bold text-gray-900">{{ vipType === 'team' ? '升级团队方案' : '充值' }}</h2>
<p v-if="vipType !== 'team'" class="text-xs text-gray-400">惊喜5折特惠:每生成一张图片仅需50积分(<span class="line-through">原价:100积分/张</span>)</p>
<p v-else class="text-xs text-gray-400">根据您的团队规模选择最合适的算力包</p>
</div>
</div>
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors text-gray-400" @click="close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="flex-1 overflow-y-auto p-8 lg:p-12">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 pb-2">
<!-- 固定金额方案 -->
<div
v-for="(plan, index) in RECHARGE_PLANS"
:key="plan.amount"
class="relative group p-6 rounded-2xl border-2 border-gray-100 transition-all duration-300 flex flex-col pt-8"
:class="[
selectedPlan === index
? 'border-[#FF8C00] bg-white shadow-xl'
: 'border-gray-100 bg-white hover:shadow-lg'
]"
@click="selectPlan(index)"
>
<div class="mb-6">
<div class="h-11 flex items-center">
<span class="text-3xl font-bold price-transition">¥{{ plan.amount.toLocaleString() }}</span>
</div>
<div class="mt-2 text-xs text-gray-500">
到账 {{ (plan.amount * POINTS_PER_YUAN).toLocaleString() }} 积分
</div>
</div>
<div class="mb-8">
<button
v-if="selectedPlan === index"
class="w-full h-[38px] rounded-lg text-xs font-bold bg-gradient-to-r from-[#FF8C00] to-[#FFD700] text-white hover:opacity-90 transition-all shadow-lg"
@click.stop="handlePurchase"
>
立即充值
</button>
<button
v-else
class="w-full h-[38px] rounded-lg text-xs font-bold border border-black text-black hover:bg-gray-50 transition-colors"
@click.stop="selectPlan(index)"
>
选择此方案
</button>
</div>
</div>
</div>
</div>
<div class="px-8 py-6 bg-gray-50 border-t border-gray-100 flex items-center justify-end">
<div class="flex items-center gap-4">
<span class="text-xs text-gray-500">应付金额:</span>
<span class="text-2xl font-bold text-gray-900 price-transition">¥{{ totalPrice.toLocaleString() }}</span>
</div>
</div>
</div>
<!-- 支付弹窗 -->
<PaymentDialog
v-model:isOpen="showPaymentDialog"
:order-id="orderId"
:amount="totalPrice"
@success="emit('payment-success')"
/>
</div>
</template>
<style scoped>
.price-transition {
transition: all 0.3s ease;
}
</style>
+259
View File
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { getScoreDetailApi, type ScoreDetailItem } from '../utils/teamApi'
import { proxiedUrl, withJpgFormat } from '../utils/proxiedUrl'
const FALLBACK_AVATAR = '/favicon.svg'
interface ScoreRecord {
id: string
userName: string
avatar?: string
change: number
description: string
timestamp: number
}
interface Props {
isOpen: boolean
teamScore: number
teamId: string
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const records = ref<ScoreRecord[]>([])
const page = ref(1)
const pageSize = ref(20)
const isLoading = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const sentinel = ref<HTMLElement | null>(null)
const showNoMore = ref(false)
const isLastPage = ref(false)
const hasMore = computed(() => !isLastPage.value)
function close() {
emit('update:isOpen', false)
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function mapRecords(dataList: ScoreDetailItem[]): ScoreRecord[] {
return dataList.map((item) => ({
id: item.id,
userName: item.nickname?.trim() || '旅图',
avatar: item.avatar ? withJpgFormat(proxiedUrl(item.avatar)) : '',
change: item.score,
description: item.score_text,
timestamp: item.add_time
}))
}
async function loadData() {
if (!props.teamId) return
try {
isLoading.value = true
const res = await getScoreDetailApi(props.teamId, page.value, pageSize.value)
const dataList = res.data_list || []
records.value = mapRecords(dataList)
isLastPage.value = dataList.length < pageSize.value
await nextTick()
checkScrollPosition()
} catch (error) {
console.error('获取积分详情失败:', error)
} finally {
isLoading.value = false
}
}
async function loadMore() {
if (isLoading.value || !hasMore.value || !props.teamId) return
try {
page.value++
isLoading.value = true
const res = await getScoreDetailApi(props.teamId, page.value, pageSize.value)
const dataList = res.data_list || []
records.value = [...records.value, ...mapRecords(dataList)]
isLastPage.value = dataList.length < pageSize.value
await nextTick()
checkScrollPosition()
} catch (error) {
console.error('加载更多积分记录失败:', error)
page.value--
} finally {
isLoading.value = false
}
}
function handleScroll() {
checkScrollPosition()
}
function checkScrollPosition() {
if (!sentinel.value || !scrollContainer.value || isLoading.value) {
return
}
const rect = sentinel.value.getBoundingClientRect()
const containerRect = scrollContainer.value.getBoundingClientRect()
const distanceToBottom = rect.top - containerRect.top - containerRect.height
if (!hasMore.value && records.value.length > 0) {
showNoMore.value = distanceToBottom <= 100
} else {
showNoMore.value = false
}
if (distanceToBottom <= 100 && hasMore.value) {
loadMore()
}
}
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
page.value = 1
showNoMore.value = false
loadData()
nextTick(() => {
if (scrollContainer.value) {
scrollContainer.value.addEventListener('scroll', handleScroll)
}
})
} else {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
showNoMore.value = false
}
})
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 bg-gray-900/40 backdrop-blur-[2px] z-[60] flex items-center justify-center p-4" @click.self="close">
<div class="bg-white w-full max-w-5xl max-h-[85vh] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<div class="px-4 sm:px-10 py-6 sm:py-8 border-b border-gray-100 flex items-center justify-between">
<div>
<h2 class="text-xl sm:text-2xl font-bold text-gray-900">积分明细</h2>
<div class="flex items-center gap-1.5 mt-1 text-sm sm:text-base">
<span class="text-gray-400">当前剩余:</span>
<span class="font-bold text-gray-900">{{ teamScore.toLocaleString() }}</span>
<span class="text-gray-400">算力积分</span>
</div>
</div>
<button class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors" @click="close">
<span class="material-symbols-outlined text-gray-400">close</span>
</button>
</div>
<div class="flex-1 px-4 sm:px-10 pb-4 sm:pb-10 flex flex-col min-h-[300px]">
<div ref="scrollContainer" class="overflow-y-auto flex-1">
<table class="w-full border-collapse">
<thead class="sticky top-0 bg-white z-10">
<tr>
<th class="table-th text-[10px] sm:text-[11px]">用户</th>
<th class="table-th text-[10px] sm:text-[11px]">积分变动</th>
<th class="table-th text-[10px] sm:text-[11px] hidden sm:table-cell">积分说明</th>
<th class="table-th text-[10px] sm:text-[11px]">时间</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="record in records" :key="record.id" class="group">
<td class="table-td">
<div class="flex items-center gap-2 sm:gap-3">
<img
:alt="record.userName"
class="w-7 h-7 sm:w-8 sm:h-8 rounded-full ring-1 ring-gray-100 object-cover bg-gray-50 shrink-0"
:src="record.avatar || FALLBACK_AVATAR"
/>
<span class="font-medium text-gray-900 text-sm sm:text-base">{{ record.userName }}</span>
</div>
</td>
<td class="table-td font-semibold text-sm sm:text-base" :class="record.change > 0 ? 'text-green-600' : 'text-red-600'">
{{ record.change > 0 ? '+' : '' }}{{ record.change.toLocaleString() }}
</td>
<td class="table-td text-sm hidden sm:table-cell">{{ record.description }}</td>
<td class="table-td text-gray-400 text-xs sm:text-sm">{{ formatTime(record.timestamp) }}</td>
</tr>
<tr v-if="records.length === 0 && !isLoading">
<td class="table-td text-center text-gray-400 py-8" colspan="4">暂无积分记录</td>
</tr>
</tbody>
</table>
<div ref="sentinel" class="py-4">
<div v-if="isLoading && hasMore" class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="material-symbols-outlined animate-spin text-sm">refresh</span>
<span>加载中...</span>
</div>
<div v-else-if="showNoMore" class="text-center text-gray-400 text-sm">
没有更多记录了
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.table-th {
padding-top: 1rem;
padding-bottom: 1rem;
font-size: 0.875rem;
font-weight: 600;
color: rgb(156 163 175);
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: left;
border-bottom: 1px solid rgb(243 244 246);
background-color: rgb(255 255 255);
}
@media (min-width: 640px) {
.table-th {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
}
.table-td {
padding-top: 1rem;
padding-bottom: 1rem;
font-size: 1rem;
color: rgb(75 85 99);
border-bottom: 1px solid rgb(249 250 251);
}
@media (min-width: 640px) {
.table-td {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 -translate-y-5"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-5"
>
<div
v-if="show"
class="fixed top-8 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-3 bg-[#F5F5F5] border border-gray-200 px-5 py-3 rounded-full shadow-lg"
>
<span class="material-symbols-outlined text-[20px] font-bold" :class="type === 'success' ? 'text-gray-900' : 'text-red-500'">
{{ type === 'success' ? 'check' : 'error' }}
</span>
<span class="text-sm font-medium text-gray-900">{{ message }}</span>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
interface Props {
show: boolean
message: string
type?: 'success' | 'error'
}
withDefaults(defineProps<Props>(), {
type: 'success'
})
</script>
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more