init: laimiao-web-new
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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&family=Poppins:wght@400;500;600&family=Urbanist:wght@400;500;600&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&family=Poppins:wght@400;500;600&family=Urbanist:wght@400;500;600&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>
|
||||
@@ -0,0 +1 @@
|
||||
public/industries
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 197 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||