Merge branch 'devlop' of http://192.168.1.201:3000/fnvtk/Mycontent into devlop
@@ -1,184 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Users, Zap, Link2, ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface MatchStats {
|
||||
totalMatches: number
|
||||
todayMatches: number
|
||||
byType: { matchType: string; count: number }[]
|
||||
uniqueUsers: number
|
||||
matchRevenue?: number
|
||||
paidMatchCount?: number
|
||||
}
|
||||
|
||||
interface CKBPlanStats {
|
||||
ckbTotal: number
|
||||
withContact: number
|
||||
byType: { matchType: string; total: number }[]
|
||||
ckbApiKey: string
|
||||
ckbApiUrl: string
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = { partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募', join: '加入', match: '匹配' }
|
||||
const typeIcons: Record<string, string> = { partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮', join: '📋', match: '🔗' }
|
||||
|
||||
interface Props {
|
||||
onSwitchTab?: (tabId: string) => void
|
||||
onOpenCKB?: (tab?: string) => void
|
||||
}
|
||||
|
||||
export function CKBStatsTab({ onSwitchTab, onOpenCKB }: Props = {}) {
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState<MatchStats | null>(null)
|
||||
const [ckbStats, setCkbStats] = useState<CKBPlanStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [statsRes, ckbRes] = await Promise.allSettled([
|
||||
get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true'),
|
||||
get<{ success?: boolean; data?: CKBPlanStats }>('/api/db/ckb-plan-stats'),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled' && statsRes.value?.success && statsRes.value.data) {
|
||||
let result = statsRes.value.data
|
||||
if (result.totalMatches > 0 && (!result.uniqueUsers || result.uniqueUsers === 0)) {
|
||||
try {
|
||||
const allRec = await get<{ success?: boolean; records?: { userId: string }[]; total?: number }>('/api/db/match-records?page=1&pageSize=200')
|
||||
if (allRec?.success && allRec.records) {
|
||||
const userSet = new Set(allRec.records.map(r => r.userId).filter(Boolean))
|
||||
result = { ...result, uniqueUsers: userSet.size }
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
setStats(result)
|
||||
}
|
||||
|
||||
if (ckbRes.status === 'fulfilled' && ckbRes.value?.success && ckbRes.value.data) {
|
||||
setCkbStats(ckbRes.value.data)
|
||||
}
|
||||
} catch (e) { console.error('加载统计失败:', e) }
|
||||
finally { setIsLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
|
||||
const v = (n: number | undefined) => isLoading ? '—' : String(n ?? 0)
|
||||
const avgMatch = stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 核心指标:一行紧凑卡片 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<Card className="bg-[#0f2137] border-gray-700/40 cursor-pointer hover:border-[#38bdac]/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-gray-400 text-xs">总匹配</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{v(stats?.totalMatches)}</p>
|
||||
<p className="text-[#38bdac] text-[10px] mt-1 flex items-center gap-0.5"><ExternalLink className="w-2.5 h-2.5" /> 查看记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-gray-400 text-xs">今日</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{v(stats?.todayMatches)}</p>
|
||||
<p className="text-yellow-400/60 text-[10px] mt-1 flex items-center gap-0.5"><Zap className="w-2.5 h-2.5" /> 实时</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/40 cursor-pointer hover:border-blue-500/60 transition-all" onClick={() => navigate('/users')}>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-gray-400 text-xs">用户数</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{v(stats?.uniqueUsers)}</p>
|
||||
<p className="text-blue-400/60 text-[10px] mt-1 flex items-center gap-0.5"><Users className="w-2.5 h-2.5" /> 查看用户</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-gray-400 text-xs">人均匹配</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{isLoading ? '—' : avgMatch}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-gray-400 text-xs">付费匹配</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{v(stats?.paidMatchCount)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 类型分布 + AI 获客:并排两列 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 左列:匹配类型分布 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/40">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="text-sm font-medium text-white mb-3">匹配类型分布</h4>
|
||||
{stats?.byType && stats.byType.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{stats.byType.map(item => {
|
||||
const pct = stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100) : 0
|
||||
return (
|
||||
<div key={item.matchType} className="flex items-center gap-3">
|
||||
<span className="text-lg shrink-0">{typeIcons[item.matchType] || '📊'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between text-xs mb-0.5">
|
||||
<span className="text-gray-300">{typeLabels[item.matchType] || item.matchType}</span>
|
||||
<span className="text-gray-500">{item.count} ({pct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-700/50 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#38bdac] rounded-full" style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-xs">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右列:AI 获客概览 */}
|
||||
<Card className="bg-[#0f2137] border-orange-500/20">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="text-sm font-medium text-white mb-3 flex items-center gap-1.5">
|
||||
<Link2 className="w-4 h-4 text-orange-400" /> AI 获客
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="bg-[#0a1628] rounded-lg p-3 cursor-pointer hover:border-orange-500/50 border border-transparent transition-colors" onClick={() => onOpenCKB?.('submitted')}>
|
||||
<p className="text-gray-400 text-xs">已提交线索</p>
|
||||
<p className="text-xl font-bold text-white">{isLoading ? '—' : (ckbStats?.ckbTotal ?? 0)}</p>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] rounded-lg p-3 cursor-pointer hover:border-orange-500/50 border border-transparent transition-colors" onClick={() => onOpenCKB?.('contact')}>
|
||||
<p className="text-gray-400 text-xs">有联系方式</p>
|
||||
<p className="text-xl font-bold text-white">{isLoading ? '—' : (ckbStats?.withContact ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{ckbStats?.byType && ckbStats.byType.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{ckbStats.byType.map(item => (
|
||||
<div key={item.matchType} className="flex items-center gap-2 text-xs">
|
||||
<span>{typeIcons[item.matchType] || '📋'}</span>
|
||||
<span className="text-gray-400">{typeLabels[item.matchType] || item.matchType}</span>
|
||||
<span className="ml-auto text-white font-medium">{item.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCKB?.('test')}
|
||||
className="mt-3 w-full text-xs text-orange-400 hover:text-orange-300 text-center py-1.5 bg-orange-500/10 rounded"
|
||||
>
|
||||
查看 AI 添加进度 →
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { normalizeImageUrl } from '@/lib/utils'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface MatchRecord {
|
||||
id: string; userId: string; matchedUserId: string; matchType: string
|
||||
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
|
||||
userAvatar?: string; matchedUserAvatar?: string; matchScore?: number; createdAt: string
|
||||
}
|
||||
|
||||
const matchTypeLabels: Record<string, string> = {
|
||||
partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
|
||||
}
|
||||
|
||||
export function MatchRecordsTab() {
|
||||
const [records, setRecords] = useState<MatchRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [matchTypeFilter, setMatchTypeFilter] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||||
|
||||
async function loadRecords() {
|
||||
setIsLoading(true); setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
|
||||
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
|
||||
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
|
||||
else setError('加载匹配记录失败')
|
||||
} catch { setError('加载失败,请检查网络后重试') }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadRecords() }, [page, matchTypeFilter])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
const UserCell = ({ userId, nickname, avatar }: { userId: string; nickname?: string; avatar?: string }) => (
|
||||
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => setDetailUserId(userId)}>
|
||||
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
|
||||
{avatar ? <img src={normalizeImageUrl(avatar)} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
|
||||
<span className={avatar ? 'hidden' : ''}>{(nickname || userId || '?').charAt(0)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white group-hover:text-[#38bdac] transition-colors">{nickname || userId}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{userId?.slice(0, 16)}{userId?.length > 16 ? '...' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">×</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-gray-400">共 {total} 条匹配记录 · 点击用户名查看详情</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<select value={matchTypeFilter} onChange={e => { setMatchTypeFilter(e.target.value); setPage(1) }}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(matchTypeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={loadRecords} disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /><span className="ml-2 text-gray-400">加载中...</span></div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">发起人</TableHead>
|
||||
<TableHead className="text-gray-400">匹配到</TableHead>
|
||||
<TableHead className="text-gray-400">类型</TableHead>
|
||||
<TableHead className="text-gray-400">联系方式</TableHead>
|
||||
<TableHead className="text-gray-400">匹配时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map(r => (
|
||||
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<UserCell userId={r.userId} nickname={r.userNickname} avatar={r.userAvatar} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.matchedUserId ? (
|
||||
<UserCell userId={r.matchedUserId} nickname={r.matchedNickname} avatar={r.matchedUserAvatar} />
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{matchTypeLabels[r.matchType] || r.matchType}</Badge></TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.phone && <div className="text-green-400">📱 {r.phone}</div>}
|
||||
{r.wechatId && <div className="text-blue-400">💬 {r.wechatId}</div>}
|
||||
{!r.phone && !r.wechatId && <span className="text-gray-600">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{records.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-12 text-gray-500">暂无匹配记录</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize}
|
||||
onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<UserDetailModal
|
||||
open={!!detailUserId}
|
||||
onClose={() => setDetailUserId(null)}
|
||||
userId={detailUserId}
|
||||
onUserUpdated={loadRecords}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Dockerfile.local - 使用本地 Go 编译后的二进制,无需拉取 golang 镜像
|
||||
# 使用方式:deploy.py --mode docker --local-go
|
||||
# 依赖:先由本地 go build 生成 soul-api 可执行文件
|
||||
|
||||
# 使用标准引用,配合 --pull=false 使用本地缓存的 alpine:3.19
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata wget
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN adduser -D -g '' appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY soul-api .
|
||||
# 微信支付等证书(须存在于构建上下文,勿在 .dockerignore 中排除)
|
||||
COPY certs/ /app/certs/
|
||||
|
||||
# 由 devloy 传入 --build-arg ENV_FILE=(如 .env / .env.development)
|
||||
ARG ENV_FILE=.env.production
|
||||
COPY ${ENV_FILE} /app/.env
|
||||
|
||||
RUN mkdir -p /app/uploads && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./soul-api"]
|
||||
@@ -1,28 +0,0 @@
|
||||
# soul-api Runner 容器
|
||||
# 红蓝切换在容器内完成,宝塔固定 proxy_pass 到 127.0.0.1:9001
|
||||
# 使用 network_mode: host,无需端口映射,避免 iptables 问题
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates tzdata wget nginx redis \
|
||||
psmisc \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 Runner 脚本与 nginx 模板(context 为 soul-api 根目录)
|
||||
COPY deploy/runner/entrypoint.sh /app/
|
||||
COPY deploy/runner/deploy.sh /app/
|
||||
COPY deploy/runner/nginx.conf.template /app/
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh /app/deploy.sh
|
||||
|
||||
# 创建目录(blue/green 由 deploy.sh 创建)
|
||||
RUN mkdir -p /app/uploads /app/blue /app/green
|
||||
|
||||
EXPOSE 9001
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Runner 部署脚本(在宿主机执行)
|
||||
# 用法:./deploy-runner-remote.sh [path-to-deploy.tar.gz]
|
||||
# 默认 tar 路径:${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}/soul_api_deploy.tar.gz
|
||||
# 仅负责:将 tar 拷入容器并触发容器内 deploy.sh,不涉及宝塔/Nginx 配置
|
||||
|
||||
set -e
|
||||
CONTAINER="${DEPLOY_RUNNER_CONTAINER:-soul-api-runner}"
|
||||
DEPLOY_PATH="${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}"
|
||||
TAR="${1:-$DEPLOY_PATH/soul_api_deploy.tar.gz}"
|
||||
|
||||
if [ -z "$TAR" ] || [ ! -f "$TAR" ]; then
|
||||
echo "[ERROR] 用法: $0 [path-to-deploy.tar.gz]"
|
||||
echo " 默认: $DEPLOY_PATH/soul_api_deploy.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/2] 拷贝部署包到容器 ..."
|
||||
docker cp "$TAR" "$CONTAINER:/tmp/incoming.tar.gz"
|
||||
|
||||
echo "[2/2] 执行容器内红蓝切换 ..."
|
||||
docker exec "$CONTAINER" /app/deploy.sh /tmp/incoming.tar.gz
|
||||
|
||||
rm -f "$TAR"
|
||||
echo ""
|
||||
echo "[SUCCESS] 部署完成,宝塔代理 9001 无需修改"
|
||||
@@ -1,62 +0,0 @@
|
||||
# soul-api 蓝绿部署 - 支持无缝切换
|
||||
# blue=9001, green=9002,部署时先启新实例,健康检查通过后切换 Nginx,再停旧实例
|
||||
# 用法:见 deploy.py --mode docker
|
||||
|
||||
services:
|
||||
soul-api-blue:
|
||||
image: soul-api:latest
|
||||
container_name: soul-api-blue
|
||||
restart: "no"
|
||||
environment:
|
||||
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
|
||||
- GIN_MODE=release
|
||||
- APP_ENV=production
|
||||
# 测试/预发布环境可设 SKIP_PROD_SECRET_CHECK=staging,正式生产请使用真实密钥并移除此项
|
||||
- SKIP_PROD_SECRET_CHECK=staging
|
||||
ports:
|
||||
- "9001:8080"
|
||||
volumes:
|
||||
- soul_uploads:/app/uploads
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
|
||||
soul-api-green:
|
||||
image: soul-api:latest
|
||||
container_name: soul-api-green
|
||||
restart: "no"
|
||||
environment:
|
||||
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
|
||||
- GIN_MODE=release
|
||||
- APP_ENV=production
|
||||
- SKIP_PROD_SECRET_CHECK=staging
|
||||
ports:
|
||||
- "9002:8080"
|
||||
volumes:
|
||||
- soul_uploads:/app/uploads
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
|
||||
redis:
|
||||
# 与 soul-api 一并打包上传,使用本地 DaoCloud 镜像名(与 pack 中 docker save 一致)
|
||||
image: docker.m.daocloud.io/library/redis:7-alpine
|
||||
container_name: soul-redis
|
||||
command: redis-server --appendonly yes --requirepass "soul-docker-redis"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
soul_uploads:
|
||||
@@ -1,42 +0,0 @@
|
||||
# soul-api 生产环境 Docker 部署
|
||||
# 用法:在 soul-api 根目录执行
|
||||
# docker compose -f deploy/docker-compose.production.yml up -d
|
||||
#
|
||||
# Redis 7-alpine:与宝塔已有 Redis 隔离,仅容器内网使用
|
||||
|
||||
services:
|
||||
soul-api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: soul-api:latest
|
||||
container_name: soul-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
|
||||
- GIN_MODE=release
|
||||
- APP_ENV=production
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- soul_uploads:/app/uploads
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: soul-redis
|
||||
command: redis-server --appendonly yes --requirepass "soul-docker-redis"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
soul_uploads:
|
||||
@@ -1,14 +0,0 @@
|
||||
# soul-api Runner 部署(仅用已加载镜像,无 build)
|
||||
# 用于 devloy.py --init-runner 推送镜像后启动
|
||||
|
||||
services:
|
||||
soul-api-runner:
|
||||
image: soul-api-runner:latest
|
||||
container_name: soul-api-runner
|
||||
network_mode: host
|
||||
volumes:
|
||||
- soul_runner_data:/app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
soul_runner_data:
|
||||
@@ -1,21 +0,0 @@
|
||||
# soul-api Runner 部署
|
||||
# 红蓝切换在容器内完成,宝塔固定 proxy_pass 到 127.0.0.1:9001
|
||||
# 使用 network_mode: host,无需端口映射,避免 iptables DOCKER 链问题
|
||||
#
|
||||
# 首次启动:docker compose -f docker-compose.runner.yml up -d
|
||||
# 部署新版本:上传 tar 后执行 deploy-runner-remote.sh
|
||||
|
||||
services:
|
||||
soul-api-runner:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.runner
|
||||
image: soul-api-runner:latest
|
||||
container_name: soul-api-runner
|
||||
network_mode: host
|
||||
volumes:
|
||||
- soul_runner_data:/app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
soul_runner_data:
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Docker 蓝绿部署脚本(在服务器上执行)
|
||||
# 用法:./docker-deploy-remote.sh /tmp/soul_api_image.tar.gz [--skip-nginx]
|
||||
# --skip-nginx:跳过 Nginx 切换,由宝塔 API 在本地执行
|
||||
|
||||
set -e
|
||||
PROJECT_ROOT="${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}"
|
||||
ACTIVE_FILE="$PROJECT_ROOT/.active"
|
||||
NGINX_CONF="${DEPLOY_NGINX_CONF:-}"
|
||||
IMAGE_TAR="${1:-}"
|
||||
SKIP_NGINX=""
|
||||
if [ "${2:-}" = "--skip-nginx" ]; then
|
||||
SKIP_NGINX=1
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE_TAR" ] || [ ! -f "$IMAGE_TAR" ]; then
|
||||
echo "[ERROR] usage: $0 <path-to-image.tar.gz> [--skip-nginx]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 兼容 docker-compose / docker compose(不同系统安装不一致)
|
||||
dc() {
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
else
|
||||
docker compose "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# 兼容 curl / wget(健康检查工具不一定都有)
|
||||
health_ok() {
|
||||
url="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -sf "$url" >/dev/null 2>&1
|
||||
else
|
||||
wget -qO- "$url" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# 加载新镜像
|
||||
echo "[1/5] 加载 Docker 镜像 ..."
|
||||
gunzip -c "$IMAGE_TAR" | docker load
|
||||
rm -f "$IMAGE_TAR"
|
||||
|
||||
# 确定当前活跃实例与待启动实例
|
||||
CURRENT="blue"
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
CURRENT=$(cat "$ACTIVE_FILE")
|
||||
fi
|
||||
if [ "$CURRENT" = "blue" ]; then
|
||||
NEW="green"
|
||||
OLD_PORT=9001
|
||||
NEW_PORT=9002
|
||||
else
|
||||
NEW="blue"
|
||||
OLD_PORT=9002
|
||||
NEW_PORT=9001
|
||||
fi
|
||||
|
||||
echo "[2/5] 当前活跃: $CURRENT ($OLD_PORT),将启动: $NEW ($NEW_PORT)"
|
||||
|
||||
# 启动新实例
|
||||
echo "[3/5] 启动 soul-api-$NEW ..."
|
||||
# --no-deps:线上 Redis 已在跑,不再让 compose 拉起/重建依赖
|
||||
dc -f docker-compose.bluegreen.yml up -d --no-deps "soul-api-$NEW"
|
||||
|
||||
# 等待健康检查(镜像已从 tar.gz 加载,无需联网拉取,最多 120 秒)
|
||||
echo "[4/5] 等待健康检查 ..."
|
||||
sleep 5
|
||||
for i in $(seq 1 58); do
|
||||
if health_ok "http://127.0.0.1:$NEW_PORT/health"; then
|
||||
echo " 健康检查通过 ($((5 + i * 2))s)"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ $i -eq 58 ]; then
|
||||
echo "[ERROR] 健康检查超时(120s),新实例未就绪。可查看: docker-compose -f docker-compose.bluegreen.yml logs soul-api-$NEW"
|
||||
dc -f docker-compose.bluegreen.yml stop "soul-api-$NEW"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 切换 Nginx(若配置了 NGINX_CONF):将 proxy_pass 中的端口改为 NEW_PORT
|
||||
if [ -z "$SKIP_NGINX" ]; then
|
||||
CONF_TO_EDIT="$NGINX_CONF"
|
||||
# 自动兜底:如果未传入 DEPLOY_NGINX_CONF,则尝试在宝塔默认目录中定位 vhost 配置文件
|
||||
if [ -z "$CONF_TO_EDIT" ] || [ ! -f "$CONF_TO_EDIT" ]; then
|
||||
CONF_DIR="${DEPLOY_NGINX_CONF_DIR:-/www/server/panel/vhost/nginx}"
|
||||
if [ -d "$CONF_DIR" ]; then
|
||||
# 优先匹配旧/新端口对应的 proxy_pass,尽量减少误命中
|
||||
for p in "$OLD_PORT" "$NEW_PORT"; do
|
||||
# proxy_pass 前可能带空格;用正则增强匹配容错
|
||||
match="$(grep -rlE "proxy_pass[[:space:]]+http://(127\\.0\\.0\\.1|localhost|0\\.0\\.0\\.0):${p}" "$CONF_DIR" 2>/dev/null | sed -n '1p')"
|
||||
if [ -n "$match" ]; then
|
||||
CONF_TO_EDIT="$match"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# 如果仍未匹配,尝试按域名关键字(可选:DEPLOY_DOMAIN)
|
||||
if [ -z "$CONF_TO_EDIT" ] && [ -n "${DEPLOY_DOMAIN:-}" ]; then
|
||||
match="$(grep -rl "${DEPLOY_DOMAIN}" "$CONF_DIR" 2>/dev/null | head -n 1)"
|
||||
if [ -n "$match" ]; then
|
||||
CONF_TO_EDIT="$match"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CONF_TO_EDIT" ] && [ -f "$CONF_TO_EDIT" ]; then
|
||||
echo "[5/5] 切换 Nginx 到 $NEW_PORT ...(编辑: $CONF_TO_EDIT)"
|
||||
# 只在同一个 vhost 配置里替换 proxy_pass 上游端口
|
||||
sed -i.bak "s|proxy_pass http://127.0.0.1:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
|
||||
sed -i.bak "s|proxy_pass http://localhost:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
|
||||
sed -i.bak "s|proxy_pass http://0.0.0.0:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
|
||||
nginx -t && nginx -s reload
|
||||
echo " Nginx 已重载"
|
||||
else
|
||||
echo "[5/5] 未找到可编辑的 nginx 配置文件,跳过 Nginx 切换。请手动将 proxy_pass 改为 127.0.0.1:$NEW_PORT"
|
||||
fi
|
||||
else
|
||||
echo "[5/5] 已跳过 Nginx 切换(--skip-nginx)"
|
||||
fi
|
||||
|
||||
# 停止旧实例(首次部署时可能不存在,忽略错误)
|
||||
dc -f docker-compose.bluegreen.yml stop "soul-api-$CURRENT" 2>/dev/null || true
|
||||
echo "$NEW" > "$ACTIVE_FILE"
|
||||
echo ""
|
||||
echo "[SUCCESS] 部署完成,当前活跃: $NEW (端口 $NEW_PORT)"
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Runner 首次初始化(在宿主机执行)
|
||||
# 构建并启动 Runner 容器,之后用 devloy.py --mode runner 部署
|
||||
#
|
||||
# 用法:在 soul-api 根目录执行
|
||||
# cd /path/to/soul-api
|
||||
# bash deploy/runner-init.sh
|
||||
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
echo "=== soul-api Runner 初始化 ==="
|
||||
echo " 项目目录: $ROOT"
|
||||
echo ""
|
||||
|
||||
# 兼容 docker-compose / docker compose
|
||||
dc() {
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
else
|
||||
docker compose "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/2] 构建 Runner 镜像 ..."
|
||||
docker build -f deploy/Dockerfile.runner -t soul-api-runner:latest .
|
||||
|
||||
echo "[2/2] 启动 Runner 容器 ..."
|
||||
dc -f deploy/docker-compose.runner.yml up -d
|
||||
|
||||
echo ""
|
||||
echo "[SUCCESS] Runner 已启动"
|
||||
echo " 宝塔反向代理保持 proxy_pass http://127.0.0.1:9001"
|
||||
echo " 首次部署: python devloy.py --mode runner"
|
||||
echo ""
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
# soul-api Runner 容器内红蓝切换脚本
|
||||
# 用法: /app/deploy.sh /tmp/incoming.tar.gz
|
||||
# 将新版本解压到非活跃目录,健康检查通过后切换 nginx 并停旧实例
|
||||
|
||||
set -e
|
||||
INCOMING="${1:-}"
|
||||
APP_ROOT="/app"
|
||||
BLUE="$APP_ROOT/blue"
|
||||
GREEN="$APP_ROOT/green"
|
||||
ACTIVE_FILE="$APP_ROOT/.active"
|
||||
NGINX_CONF="$APP_ROOT/nginx.conf"
|
||||
NGINX_PID="/tmp/nginx.pid"
|
||||
REDIS_PASS="soul-docker-redis"
|
||||
|
||||
health_ok() {
|
||||
local url="$1"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$url" >/dev/null 2>&1
|
||||
else
|
||||
[ -x /usr/bin/wget ] && /usr/bin/wget -qO- "$url" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$INCOMING" ] || [ ! -f "$INCOMING" ]; then
|
||||
echo "[ERROR] 用法: $0 <path-to-deploy.tar.gz>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 确定当前活跃与待部署目录
|
||||
CURRENT="blue"
|
||||
[ -f "$ACTIVE_FILE" ] && CURRENT=$(cat "$ACTIVE_FILE")
|
||||
[ "$CURRENT" != "blue" ] && [ "$CURRENT" != "green" ] && CURRENT="blue"
|
||||
|
||||
if [ "$CURRENT" = "blue" ]; then
|
||||
NEW="green"
|
||||
NEW_PORT=18082
|
||||
OLD_PORT=18081
|
||||
else
|
||||
NEW="blue"
|
||||
NEW_PORT=18081
|
||||
OLD_PORT=18082
|
||||
fi
|
||||
|
||||
NEW_DIR="$APP_ROOT/$NEW"
|
||||
echo "[1/5] 当前活跃: $CURRENT ($OLD_PORT),将部署到: $NEW ($NEW_PORT)"
|
||||
|
||||
# 解压到新目录
|
||||
echo "[2/5] 解压到 $NEW_DIR ..."
|
||||
rm -rf "$NEW_DIR"
|
||||
mkdir -p "$NEW_DIR"
|
||||
tar -xzf "$INCOMING" -C "$NEW_DIR"
|
||||
rm -f "$INCOMING"
|
||||
|
||||
# 设置 PORT 和 REDIS_URL
|
||||
ENV_FILE="$NEW_DIR/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
sed -i "s/^PORT=.*/PORT=$NEW_PORT/" "$ENV_FILE"
|
||||
grep -q "^REDIS_URL=" "$ENV_FILE" || echo "REDIS_URL=redis://:${REDIS_PASS}@127.0.0.1:6379/0" >> "$ENV_FILE"
|
||||
sed -i "s|^REDIS_URL=.*|REDIS_URL=redis://:${REDIS_PASS}@127.0.0.1:6379/0|" "$ENV_FILE"
|
||||
fi
|
||||
chmod +x "$NEW_DIR/soul-api" 2>/dev/null || true
|
||||
|
||||
# 启动新实例
|
||||
echo "[3/5] 启动 soul-api-$NEW (端口 $NEW_PORT) ..."
|
||||
cd "$NEW_DIR"
|
||||
export PORT=$NEW_PORT
|
||||
export REDIS_URL="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
|
||||
nohup ./soul-api >> soul-api.log 2>&1 &
|
||||
NEW_PID=$!
|
||||
echo $NEW_PID > "$APP_ROOT/.pid.$NEW"
|
||||
cd - >/dev/null
|
||||
|
||||
# 等待健康检查(最多 120 秒)
|
||||
echo "[4/5] 等待健康检查 ..."
|
||||
sleep 5
|
||||
for i in $(seq 1 58); do
|
||||
if health_ok "http://127.0.0.1:$NEW_PORT/health"; then
|
||||
echo " 健康检查通过"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ $i -eq 58 ]; then
|
||||
echo "[ERROR] 健康检查超时,新实例未就绪"
|
||||
kill $NEW_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 更新 nginx 配置并重载
|
||||
echo "[5/5] 切换 nginx 到 $NEW_PORT ..."
|
||||
sed "s/__BACKEND_PORT__/$NEW_PORT/g" "$APP_ROOT/nginx.conf.template" > "$NGINX_CONF"
|
||||
nginx -s reload 2>/dev/null || nginx -c "$NGINX_CONF" 2>/dev/null || true
|
||||
|
||||
# 停止旧实例(通过 PID 文件或端口)
|
||||
OLD_PID_FILE="$APP_ROOT/.pid.$CURRENT"
|
||||
if [ -f "$OLD_PID_FILE" ]; then
|
||||
OLD_PID=$(cat "$OLD_PID_FILE")
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo " 停止旧实例 (PID $OLD_PID)"
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
rm -f "$OLD_PID_FILE"
|
||||
fi
|
||||
# 兜底:通过端口杀进程(Alpine 可用 fuser 或 ss)
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "$OLD_PORT/tcp" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "$NEW" > "$ACTIVE_FILE"
|
||||
echo ""
|
||||
echo "[SUCCESS] 部署完成,当前活跃: $NEW (端口 $NEW_PORT)"
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/sh
|
||||
# soul-api Runner 容器入口
|
||||
# 启动 Redis、Nginx,首次部署时需外部调用 deploy.sh
|
||||
|
||||
set -e
|
||||
APP_ROOT="/app"
|
||||
REDIS_PASS="soul-docker-redis"
|
||||
|
||||
# 启动 Redis(后台)
|
||||
if ! pgrep -x redis-server >/dev/null 2>&1; then
|
||||
redis-server --requirepass "$REDIS_PASS" --daemonize yes
|
||||
fi
|
||||
|
||||
# 生成初始 nginx 配置(默认指向 blue 18081,若 blue 未部署则 18082)
|
||||
BACKEND=18081
|
||||
[ -f "$APP_ROOT/.active" ] && [ "$(cat $APP_ROOT/.active)" = "green" ] && BACKEND=18082
|
||||
sed "s/__BACKEND_PORT__/$BACKEND/g" "$APP_ROOT/nginx.conf.template" > "$APP_ROOT/nginx.conf"
|
||||
|
||||
# 若已有活跃实例,启动它
|
||||
if [ -f "$APP_ROOT/.active" ]; then
|
||||
ACTIVE=$(cat "$APP_ROOT/.active")
|
||||
ACTIVE_DIR="$APP_ROOT/$ACTIVE"
|
||||
if [ -d "$ACTIVE_DIR" ] && [ -x "$ACTIVE_DIR/soul-api" ]; then
|
||||
PORT=18081
|
||||
[ "$ACTIVE" = "green" ] && PORT=18082
|
||||
cd "$ACTIVE_DIR"
|
||||
export PORT=$PORT
|
||||
export REDIS_URL="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
|
||||
nohup ./soul-api >> soul-api.log 2>&1 &
|
||||
echo $! > "$APP_ROOT/.pid.$ACTIVE"
|
||||
cd - >/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动 Nginx(前台,保持容器运行)
|
||||
exec nginx -c "$APP_ROOT/nginx.conf" -g "daemon off;"
|
||||
@@ -1,31 +0,0 @@
|
||||
# soul-api Runner - Nginx 反向代理
|
||||
# 监听 9001,代理到当前活跃实例(blue=18081, green=18082)
|
||||
# 宝塔固定 proxy_pass 到 127.0.0.1:9001,无需改配置
|
||||
|
||||
worker_processes 1;
|
||||
error_log /dev/stderr warn;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events { worker_connections 64; }
|
||||
|
||||
http {
|
||||
access_log /dev/stdout;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 9001;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:__BACKEND_PORT__;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1,6 +0,0 @@
|
||||
16Personalities 彩色小人 SVG(官方静态站)
|
||||
|
||||
版权归属:16personalities.com / NERIS Analytics Limited。
|
||||
请勿在未取得授权的情况下用于商业产品对外分发;上架前请改用自有素材或书面许可。
|
||||
|
||||
文件名:{TYPE}.svg 便于与后台 MBTI key 对应;内容来自官网角色英文名路径。
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
@@ -1,6 +0,0 @@
|
||||
16Personalities 彩色小人 SVG(官方静态站)
|
||||
|
||||
版权归属:16personalities.com / NERIS Analytics Limited。
|
||||
请勿在未取得授权的情况下用于商业产品对外分发;上架前请改用自有素材或书面许可。
|
||||
|
||||
文件名:{TYPE}.svg 便于与后台 MBTI key 对应;内容来自官网角色英文名路径。
|
||||