chore: 清理垃圾文件
This commit is contained in:
parent
025f56e163
commit
8dbb841489
|
|
@ -1,151 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import { isLldapAdmin } from '@/lib/ldap'
|
|
||||||
import { sendSetupLinkEmail } from '@/lib/email'
|
|
||||||
import { signSetupToken } from '@/lib/setup-token'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
|
||||||
|
|
||||||
function generatePassword(): string {
|
|
||||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
|
||||||
const lower = 'abcdefghjkmnpqrstuvwxyz'
|
|
||||||
const digits = '23456789'
|
|
||||||
const special = '!@#$%&*'
|
|
||||||
const all = upper + lower + digits + special
|
|
||||||
const crypto = globalThis.crypto
|
|
||||||
const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length]
|
|
||||||
// 确保每种类型至少一个,其余随机填充到 12 位
|
|
||||||
let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special)
|
|
||||||
for (let i = 4; i < 12; i++) pwd += pick(all)
|
|
||||||
// 打乱顺序
|
|
||||||
return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${siteUrl}/api/internal/roles`, {
|
|
||||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
return (data.roles || []).map((r: { name: string }) => r.name)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${siteUrl}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
})
|
|
||||||
return res.ok
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接更新站点 SQLite 数据库中的用户角色
|
|
||||||
function setRoleSQL(dbPath: string, username: string, role: string): string {
|
|
||||||
return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
|
||||||
const session = verifySharedJwt(token)
|
|
||||||
if (!session || !(await isLldapAdmin(session.username))) {
|
|
||||||
return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, displayName, assetsRole, issueRole, email } = await request.json()
|
|
||||||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
|
||||||
if (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 })
|
|
||||||
|
|
||||||
const password = generatePassword()
|
|
||||||
|
|
||||||
// 从各站点实时获取可用角色列表
|
|
||||||
const [assetsRoles, issueRoles] = await Promise.all([
|
|
||||||
fetchRoles('http://assets-ai:3000'),
|
|
||||||
fetchRoles('http://issue-ai:3000'),
|
|
||||||
])
|
|
||||||
|
|
||||||
const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer'
|
|
||||||
const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer'
|
|
||||||
|
|
||||||
const safeName = (displayName || username).replace(/'/g, "'\\''")
|
|
||||||
const safeUser = username.replace(/'/g, "'\\''")
|
|
||||||
const lldapEmail = email || ''
|
|
||||||
const d = new Date()
|
|
||||||
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
|
|
||||||
const userUuid = crypto.randomUUID()
|
|
||||||
|
|
||||||
// 1. LLDAP SQLite 插入用户
|
|
||||||
const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');`
|
|
||||||
await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 })
|
|
||||||
|
|
||||||
// 2. 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置)
|
|
||||||
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
|
|
||||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
|
||||||
|
|
||||||
// 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题
|
|
||||||
const b64Pass = Buffer.from(password).toString('base64')
|
|
||||||
await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 })
|
|
||||||
const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'`
|
|
||||||
await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 })
|
|
||||||
|
|
||||||
// 3. 自动登录各站点触发用户同步
|
|
||||||
const [assetsOk, issueOk] = await Promise.all([
|
|
||||||
syncToSite('http://assets-ai:3000', username, password),
|
|
||||||
syncToSite('http://issue-ai:3000', username, password),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值)
|
|
||||||
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
|
|
||||||
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
|
|
||||||
const roleResults = { assets: false, issue: false }
|
|
||||||
if (assetsOk) {
|
|
||||||
try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {}
|
|
||||||
}
|
|
||||||
if (issueOk) {
|
|
||||||
try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码)
|
|
||||||
let emailSent = false
|
|
||||||
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
try {
|
|
||||||
const setupToken = signSetupToken(username)
|
|
||||||
const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}`
|
|
||||||
await sendSetupLinkEmail(email, username, setupUrl, displayName || username)
|
|
||||||
emailSent = true
|
|
||||||
} catch (e) {
|
|
||||||
console.error('发送邮件失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
password: emailSent ? undefined : password,
|
|
||||||
synced: { assets: assetsOk, issue: issueOk },
|
|
||||||
roles: { assets: ar, issue: ir, applied: roleResults },
|
|
||||||
emailSent,
|
|
||||||
message: emailSent
|
|
||||||
? `用户已创建,密码设置链接已发送至 ${email}`
|
|
||||||
: '用户已创建并同步至所有站点',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '创建失败'
|
|
||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import { isLldapAdmin } from '@/lib/ldap'
|
|
||||||
|
|
||||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
|
||||||
|
|
||||||
async function fetchRoles(url: string): Promise<{ name: string; display_name: string }[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}/api/internal/roles`, {
|
|
||||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
return data.roles || []
|
|
||||||
} catch { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
|
||||||
const session = verifySharedJwt(token)
|
|
||||||
if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
const [assetsRoles, issueRoles] = await Promise.all([
|
|
||||||
fetchRoles('http://assets-ai:3000'),
|
|
||||||
fetchRoles('http://issue-ai:3000'),
|
|
||||||
])
|
|
||||||
|
|
||||||
return NextResponse.json({ assets: assetsRoles, issue: issueRoles })
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import { isLldapAdmin } from '@/lib/ldap'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
const ASSETS_DB = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
|
|
||||||
const ISSUE_DB = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
|
||||||
const session = verifySharedJwt(token)
|
|
||||||
if (!session || !(await isLldapAdmin(session.username))) {
|
|
||||||
return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`,
|
|
||||||
{ timeout: 5000 }
|
|
||||||
)
|
|
||||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
|
||||||
let synced = 0
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const [user, mail] = line.split('|')
|
|
||||||
const su = user.replace(/'/g, "''")
|
|
||||||
const sm = (mail || '').replace(/'/g, "''")
|
|
||||||
for (const db of [ASSETS_DB, ISSUE_DB]) {
|
|
||||||
try {
|
|
||||||
await execAsync(
|
|
||||||
`sqlite3 "${db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`,
|
|
||||||
{ timeout: 3000 }
|
|
||||||
)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
synced++
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, synced })
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import { isLldapAdmin } from '@/lib/ldap'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
|
||||||
|
|
||||||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${siteUrl}/api/internal/roles`, {
|
|
||||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
return (data.roles || []).map((r: { name: string }) => r.name)
|
|
||||||
} catch { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryDb(dbPath: string, sql: string): Promise<string> {
|
|
||||||
return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> {
|
|
||||||
const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;')
|
|
||||||
return out.trim().split('\n').filter(Boolean).map(line => {
|
|
||||||
const [username, display_name, role] = line.split('|')
|
|
||||||
return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAdmin() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return false
|
|
||||||
const session = verifySharedJwt(token)
|
|
||||||
return session ? isLldapAdmin(session.username) : false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET — 列出各站点用户及其角色
|
|
||||||
export async function GET() {
|
|
||||||
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [assetsRoles, issueRoles] = await Promise.all([
|
|
||||||
fetchRoles('http://assets-ai:3000'),
|
|
||||||
fetchRoles('http://issue-ai:3000'),
|
|
||||||
])
|
|
||||||
|
|
||||||
const [assetsUsers, issueUsers] = await Promise.all([
|
|
||||||
getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles),
|
|
||||||
getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 从 LLDAP 获取所有用户邮箱
|
|
||||||
let emails: Record<string, string> = {}
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`,
|
|
||||||
{ timeout: 3000 }
|
|
||||||
)
|
|
||||||
stdout.trim().split('\n').filter(Boolean).forEach(line => {
|
|
||||||
const [uid, e] = line.split('|')
|
|
||||||
emails[uid] = e || ''
|
|
||||||
})
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
assetsRoles,
|
|
||||||
issueRoles,
|
|
||||||
users: { assets: assetsUsers, issue: issueUsers },
|
|
||||||
emails,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT — 更新用户角色
|
|
||||||
export async function PUT(request: Request) {
|
|
||||||
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { username, site, role } = await request.json()
|
|
||||||
if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
|
|
||||||
if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 })
|
|
||||||
|
|
||||||
const dbPath = site === 'assets'
|
|
||||||
? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db')
|
|
||||||
: (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db')
|
|
||||||
|
|
||||||
// 验证角色合法性
|
|
||||||
const roles = await fetchRoles(`http://${site}-ai:3000`)
|
|
||||||
if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 })
|
|
||||||
|
|
||||||
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 })
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: '更新失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import { isLldapAdmin } from '@/lib/ldap'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
function checkAdmin() {
|
|
||||||
return async () => {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return false
|
|
||||||
const session = verifySharedJwt(token)
|
|
||||||
return session ? isLldapAdmin(session.username) : false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET — 列出 LLDAP 中所有用户
|
|
||||||
export async function GET() {
|
|
||||||
const isAdmin = await checkAdmin()()
|
|
||||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`,
|
|
||||||
{ timeout: 5000 }
|
|
||||||
)
|
|
||||||
const users = stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
||||||
const [user_id, email, display_name, creation_date] = line.split('|')
|
|
||||||
return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date }
|
|
||||||
})
|
|
||||||
return NextResponse.json({ users })
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE — 删除用户(LLDAP + 各站点)
|
|
||||||
export async function DELETE(request: Request) {
|
|
||||||
const isAdmin = await checkAdmin()()
|
|
||||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { username } = await request.json()
|
|
||||||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
|
||||||
if (username === 'admin' || username === 'localadmin') {
|
|
||||||
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeUser = username.replace(/'/g, "''")
|
|
||||||
|
|
||||||
// 删除 LLDAP 用户
|
|
||||||
const lldapSQL = `DELETE FROM users WHERE user_id='${safeUser}';`
|
|
||||||
await execAsync(
|
|
||||||
`docker exec lldap /bin/sh -c "cat > /tmp/del.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/del.sql"`,
|
|
||||||
{ timeout: 5000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 删除各站点本地用户
|
|
||||||
const results: Record<string, boolean> = {}
|
|
||||||
for (const [site, dbPath] of Object.entries({
|
|
||||||
assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db',
|
|
||||||
issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db',
|
|
||||||
})) {
|
|
||||||
try {
|
|
||||||
await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 })
|
|
||||||
results[site] = true
|
|
||||||
} catch { results[site] = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, deleted: results })
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: '删除失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH — 修改用户邮箱(admin 权限)
|
|
||||||
export async function PATCH(request: Request) {
|
|
||||||
const isAdmin = await checkAdmin()()
|
|
||||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { username, email } = await request.json()
|
|
||||||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
|
||||||
if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeUser = username.replace(/'/g, "''")
|
|
||||||
const safeEmail = (email || '').replace(/'/g, "''")
|
|
||||||
const d = new Date()
|
|
||||||
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
|
|
||||||
|
|
||||||
const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';`
|
|
||||||
await execAsync(
|
|
||||||
`docker exec lldap /bin/sh -c "cat > /tmp/uea.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/uea.sql"`,
|
|
||||||
{ timeout: 5000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 同步更新 assets / issue 本地用户表
|
|
||||||
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
|
|
||||||
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
|
|
||||||
for (const dbPath of [assetsDb, issueDb]) {
|
|
||||||
try {
|
|
||||||
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 })
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, username, email: email || '' })
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: '修改失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
|
||||||
|
|
||||||
const session = verifySharedJwt(token)
|
|
||||||
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
|
||||||
|
|
||||||
const { currentPassword, newPassword } = await request.json()
|
|
||||||
if (!currentPassword || !newPassword) {
|
|
||||||
return NextResponse.json({ error: '请输入当前密码和新密码' }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (newPassword.length < 8) {
|
|
||||||
return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 })
|
|
||||||
}
|
|
||||||
// 密码复杂度:大写/小写/数字/特殊字符 4选3
|
|
||||||
const hasUpper = /[A-Z]/.test(newPassword)
|
|
||||||
const hasLower = /[a-z]/.test(newPassword)
|
|
||||||
const hasDigit = /[0-9]/.test(newPassword)
|
|
||||||
const hasSpecial = /[^A-Za-z0-9]/.test(newPassword)
|
|
||||||
const complexityScore = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
|
||||||
if (complexityScore < 3) {
|
|
||||||
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置)
|
|
||||||
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
|
|
||||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
|
||||||
|
|
||||||
const safeUser = session.username.replace(/'/g, "'\\''")
|
|
||||||
const safePass = newPassword.replace(/'/g, "'\\''")
|
|
||||||
const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'`
|
|
||||||
|
|
||||||
const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 })
|
|
||||||
if (stderr && !stderr.includes('Successfully')) {
|
|
||||||
return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : '修改失败'
|
|
||||||
if (msg.includes('command not found') || msg.includes('No such container')) {
|
|
||||||
return NextResponse.json({ error: '密码服务不可用' }, { status: 503 })
|
|
||||||
}
|
|
||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
|
||||||
import { ldapAuth } from '@/lib/ldap'
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const { username, password } = await request.json()
|
|
||||||
if (!username || !password) {
|
|
||||||
return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ldapAuth(username, password)
|
|
||||||
if (!result.success) {
|
|
||||||
if (result.unreachable) {
|
|
||||||
return NextResponse.json({ error: '认证服务暂时不可用,请稍后再试' }, { status: 503 })
|
|
||||||
}
|
|
||||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = signSharedJwt({ username: result.username!, displayName: result.displayName! })
|
|
||||||
const cfg = sharedCookieConfig()
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
cookieStore.set(cfg.name, token, cfg)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
user: { username: result.username, displayName: result.displayName },
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: '登录失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
|
||||||
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'https://oa.tlyq.ai'))
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import { isLldapAdmin } from '@/lib/ldap'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
async function getLldapEmail(username: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const safeUser = username.replace(/'/g, "''")
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`docker exec lldap /bin/sh -c "echo 'SELECT email FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`,
|
|
||||||
{ timeout: 3000 }
|
|
||||||
)
|
|
||||||
return stdout.trim() || ''
|
|
||||||
} catch { return '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
|
||||||
|
|
||||||
const payload = verifySharedJwt(token)
|
|
||||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
|
||||||
|
|
||||||
const [admin, email] = await Promise.all([
|
|
||||||
isLldapAdmin(payload.username),
|
|
||||||
getLldapEmail(payload.username),
|
|
||||||
])
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
user: { username: payload.username, displayName: payload.displayName, email, isAdmin: admin },
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('tlyq_session')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
|
||||||
|
|
||||||
const payload = verifySharedJwt(token)
|
|
||||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
|
||||||
|
|
||||||
const { email } = await request.json()
|
|
||||||
if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeUser = payload.username.replace(/'/g, "''")
|
|
||||||
const safeEmail = (email || '').replace(/'/g, "''")
|
|
||||||
const d = new Date()
|
|
||||||
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
|
|
||||||
|
|
||||||
const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';`
|
|
||||||
await execAsync(
|
|
||||||
`docker exec lldap /bin/sh -c "cat > /tmp/ue.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/ue.sql"`,
|
|
||||||
{ timeout: 5000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 同步更新 assets / issue 本地用户表
|
|
||||||
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
|
|
||||||
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
|
|
||||||
for (const dbPath of [assetsDb, issueDb]) {
|
|
||||||
try {
|
|
||||||
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 })
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, email: email || '' })
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '修改失败'
|
|
||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { verifySetupToken } from '@/lib/setup-token'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const { token, password } = await request.json()
|
|
||||||
if (!token || !password) {
|
|
||||||
return NextResponse.json({ error: '参数不完整' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = verifySetupToken(token)
|
|
||||||
if (!payload) {
|
|
||||||
return NextResponse.json({ error: '链接已过期或无效,请联系管理员重新创建账号' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
return NextResponse.json({ error: '密码至少 8 位' }, { status: 400 })
|
|
||||||
}
|
|
||||||
const hasUpper = /[A-Z]/.test(password)
|
|
||||||
const hasLower = /[a-z]/.test(password)
|
|
||||||
const hasDigit = /[0-9]/.test(password)
|
|
||||||
const hasSpecial = /[^A-Za-z0-9]/.test(password)
|
|
||||||
const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
|
||||||
if (score < 3) {
|
|
||||||
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout: adminPassOut } = await execAsync(
|
|
||||||
'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }
|
|
||||||
)
|
|
||||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
|
||||||
|
|
||||||
const safeUser = payload.username.replace(/'/g, "'\\''")
|
|
||||||
const safePass = password.replace(/'/g, "'\\''")
|
|
||||||
const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'`
|
|
||||||
|
|
||||||
const { stderr } = await execAsync(cmd, { timeout: 10000 })
|
|
||||||
if (stderr && !stderr.includes('Successfully')) {
|
|
||||||
return NextResponse.json({ error: stderr.trim() || '设置失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : '设置失败'
|
|
||||||
if (msg.includes('command not found') || msg.includes('No such container')) {
|
|
||||||
return NextResponse.json({ error: '密码服务不可用' }, { status: 503 })
|
|
||||||
}
|
|
||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { verifySharedJwt } from '@/lib/jwt'
|
|
||||||
import Header from '@/components/Header'
|
|
||||||
|
|
||||||
function siteUrl(url: string, domain: string): string {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return `https://${domain}`
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const CORE_SITES = [
|
|
||||||
{ name: '资产管理', url: 'https://assets.tlyq.ai', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
|
|
||||||
{ name: '工单跟踪', url: 'https://issue.tlyq.ai', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const OTHER_SITES = [
|
|
||||||
{ name: '官网', url: 'https://www.tlyq.ai', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
|
||||||
{ name: '云平台', url: 'https://cloud.tlyq.ai', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
|
|
||||||
{ name: 'Token 工厂', url: 'https://token.tlyq.ai', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
|
|
||||||
{ name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const COLORS: Record<string, { light: string; tag: string }> = {
|
|
||||||
'#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' },
|
|
||||||
'#7c3aed': { light: 'rgba(124,58,237,0.08)', tag: '#7c3aed' },
|
|
||||||
'#059669': { light: 'rgba(5,150,105,0.08)', tag: '#059669' },
|
|
||||||
'#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' },
|
|
||||||
'#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' },
|
|
||||||
'#db2777': { light: 'rgba(219,39,119,0.08)', tag: '#db2777' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const sessionCookie = cookieStore.get('session')?.value
|
|
||||||
let username = ''
|
|
||||||
if (sessionCookie) {
|
|
||||||
try { username = JSON.parse(sessionCookie).username || '' } catch { }
|
|
||||||
}
|
|
||||||
if (!username) redirect('/login')
|
|
||||||
|
|
||||||
const tlyqToken = cookieStore.get('tlyq_session')?.value
|
|
||||||
let displayName = username
|
|
||||||
if (tlyqToken) {
|
|
||||||
const shared = verifySharedJwt(tlyqToken)
|
|
||||||
if (shared && shared.displayName && shared.displayName !== shared.username) {
|
|
||||||
displayName = shared.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<div style={{ maxWidth: 1160, margin: '0 auto', padding: '32px 28px 60px' }}>
|
|
||||||
<div style={{ marginBottom: 28 }}>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 }}>tlyq.ai / OA PORTAL</div>
|
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)', margin: 0 }}>欢迎回来,{displayName}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px', paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>核心系统</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
|
||||||
{CORE_SITES.map(site => {
|
|
||||||
const c = COLORS[site.dot]
|
|
||||||
return (
|
|
||||||
<a key={site.name} href={siteUrl(site.url, site.domain)} target="_blank" rel="noopener noreferrer" className="sc" style={{
|
|
||||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--border)',
|
|
||||||
borderRadius: 12, padding: 22, textDecoration: 'none', minHeight: 140,
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)', transition: 'all 0.2s',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
||||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: site.dot, flexShrink: 0 }}></div>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', flex: 1 }}>{site.name}</div>
|
|
||||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: c.light, color: c.tag, fontWeight: 500, letterSpacing: '0.03em' }}>{site.tag}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, flex: 1 }}>{site.desc}</div>
|
|
||||||
<div className="ch" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 'auto', paddingTop: 8, opacity: 0, transform: 'translateY(4px)', transition: 'all 0.25s ease' }}>{site.domain} →</div>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '36px 0 12px', paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>其他站点</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
|
||||||
{OTHER_SITES.map(site => {
|
|
||||||
const c = COLORS[site.dot]
|
|
||||||
return (
|
|
||||||
<a key={site.name} href={siteUrl(site.url, site.domain)} target="_blank" rel="noopener noreferrer" className="sc" style={{
|
|
||||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--border)',
|
|
||||||
borderRadius: 12, padding: 22, textDecoration: 'none', minHeight: 140,
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)', transition: 'all 0.2s',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
||||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: site.dot, flexShrink: 0 }}></div>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', flex: 1 }}>{site.name}</div>
|
|
||||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: c.light, color: c.tag, fontWeight: 500, letterSpacing: '0.03em' }}>{site.tag}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, flex: 1 }}>{site.desc}</div>
|
|
||||||
<div className="ch" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 'auto', paddingTop: 8, opacity: 0, transform: 'translateY(4px)', transition: 'all 0.25s ease' }}>{site.domain} →</div>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<style>{`.sc:hover { border-color: #2563eb !important; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04) !important; transform: translateY(-1px); } .sc:hover .ch { opacity: 1 !important; transform: translateY(0) !important; } .lo:hover { background: var(--bg-hover) !important; color: var(--text) !important; border-color: var(--text-muted) !important; }`}</style>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue