This commit is contained in:
yml
2026-04-08 16:30:42 +08:00
commit 313c036845
131 changed files with 22393 additions and 0 deletions
@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
page: number
pageSize: number
total: number
loading?: boolean
}>()
const emit = defineEmits<{
(event: 'change', page: number): void
}>()
function goPrev() {
if (props.page <= 1 || props.loading) {
return
}
emit('change', props.page - 1)
}
function goNext() {
if (props.loading || props.page * props.pageSize >= props.total) {
return
}
emit('change', props.page + 1)
}
</script>
<template>
<footer class="pagination-bar">
<span> {{ page }} {{ Math.max(1, Math.ceil(total / pageSize)) }} 合计 {{ total }} </span>
<div class="actions">
<el-button :disabled="page <= 1 || loading" round @click="goPrev">上一页</el-button>
<el-button :disabled="page * pageSize >= total || loading" round @click="goNext">下一页</el-button>
</div>
</footer>
</template>
<style scoped>
.pagination-bar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-top: 14px;
color: #64748b;
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
@media (max-width: 780px) {
.pagination-bar {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { formatStatusWithRaw } from '@/utils/admin-display'
const props = defineProps<{
status: string
}>()
function resolveTone(status: string) {
const normalized = String(status || '').trim().toLowerCase()
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
return 'success'
}
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) {
return 'primary'
}
if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started'].includes(normalized)) {
return 'warning'
}
if (['closed', 'failed', 'unpaid', 'revoked', 'disabled'].includes(normalized)) {
return 'danger'
}
return 'default'
}
function resolveLabel(status: string) {
const normalized = String(status || '').trim()
if (!normalized) {
return '-'
}
const labelMap: Record<string, string> = {
created: '已创建',
paid: '已支付',
unpaid: '未支付',
failed: '失败',
refunded: '已退款',
refunding: '退款中',
closed: '已关闭',
pending_payment: '待支付',
waiting_inventory: '待库存',
cdk_reserved: '已预占',
link_generated: '已生成链接',
claimed: '已创建会话',
role_confirmed: '已确认角色',
redeeming: '兑换中',
redeemed: '已兑换',
retry_pending: '待重试',
manual_review: '人工处理',
expired: '已过期',
available: '可用',
reserved: '已预占',
delivered: '已发放',
invalid: '已作废',
active: '生效中',
admin: '管理员',
operator: '普通运营',
revoked: '已撤销',
disabled: '已停用',
success: '成功',
processing: '处理中',
system_bound: '系统已绑定',
pending_binding: '待系统绑定',
not_started: '未开始',
waiting_user_claim: '待用户打开链接',
link_opened: '已打开链接',
binding_confirmed: '已提交绑定',
binding_in_progress: '绑定处理中',
binding_completed: '完整绑定成功',
binding_exception: '绑定异常',
user_binding: '用户绑定中',
}
return formatStatusWithRaw(normalized, labelMap)
}
</script>
<template>
<span class="status-tag" :data-tone="resolveTone(props.status)">
{{ resolveLabel(props.status) }}
</span>
</template>
<style scoped>
.status-tag {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.status-tag[data-tone='success'] {
color: #0f766e;
background: #ecfdf3;
border-color: #a6f4c5;
}
.status-tag[data-tone='primary'] {
color: #175cd3;
background: #eff8ff;
border-color: #b2ddff;
}
.status-tag[data-tone='warning'] {
color: #b54708;
background: #fffaeb;
border-color: #fedf89;
}
.status-tag[data-tone='danger'] {
color: #b42318;
background: #fef3f2;
border-color: #fecdca;
}
.status-tag[data-tone='default'] {
color: #475467;
background: #f2f4f7;
border-color: #d0d5dd;
}
</style>
@@ -0,0 +1,389 @@
<script setup lang="ts">
import type { TencentLoginType } from '@/types/tencent/session'
type LoginTab = {
value: TencentLoginType
label: string
}
const props = defineProps<{
currentLoginTabLabel: string
hasSession: boolean
initButtonLabel: string
loginTabs: LoginTab[]
loginType: TencentLoginType
loginTypeLabel: string
qrFigureStyle: { width: string; maxWidth: string }
qrImage: string
scanInstruction: string
sessionStatus?: string
sessionId: string
sessionLoading: boolean
sessionNotice: string
}>()
const emit = defineEmits<{
createSession: [loginType: TencentLoginType]
openQrPreview: []
qrImageLoad: [event: Event]
reloadSession: []
switchLoginType: [loginType: TencentLoginType]
}>()
</script>
<template>
<section class="auth-card">
<div class="login-tabs">
<button
v-for="tab in props.loginTabs"
:key="tab.value"
:class="['login-tab', { active: props.loginType === tab.value }]"
type="button"
@click="emit('switchLoginType', tab.value)"
>
{{ tab.label }}
</button>
</div>
<div class="qr-stage">
<button
v-if="props.qrImage && props.sessionStatus === 'waiting_scan'"
class="qr-figure"
:style="props.qrFigureStyle"
type="button"
@click="emit('openQrPreview')"
>
<img
:src="props.qrImage"
:alt="`Tencent ${props.loginTypeLabel} Login QR`"
decoding="async"
@load="emit('qrImageLoad', $event)"
/>
</button>
<div v-else-if="props.sessionStatus === 'scanned'" class="status-state confirm-state">
<strong>确认中</strong>
<p>扫码已完成请在手机上点击确认登录确认后页面会继续同步角色信息</p>
</div>
<div
v-else-if="['logged_in', 'ready_to_redeem', 'redeeming', 'redeemed'].includes(String(props.sessionStatus || ''))"
class="status-state success-state"
>
<strong>已完成登录</strong>
<p>二维码已自动收起当前会话已登录正在同步角色或等待后续兑换操作</p>
</div>
<div v-else class="qr-empty">
<strong>{{ props.hasSession ? '二维码生成中' : props.currentLoginTabLabel }}</strong>
<p>
{{
props.hasSession
? '浏览器会话已启动,等待二维码加载。'
: `先选择${props.loginTypeLabel},再点击下方按钮开始初始化。`
}}
</p>
</div>
</div>
<div class="auth-copy">
<p class="scan-hint">
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
</p>
<p class="scan-note">
{{
props.sessionNotice ||
(props.hasSession
? `请使用${props.loginTypeLabel}扫码登录`
: `点击“${props.initButtonLabel}”后,页面才会真正创建浏览器会话`)
}}
</p>
</div>
<div class="auth-footer">
<div class="session-strip">
<span>当前会话</span>
<strong>{{ props.sessionId || '尚未创建' }}</strong>
</div>
<div class="action-row">
<el-button
:loading="props.sessionLoading"
class="primary-action"
round
size="large"
type="primary"
@click="emit('createSession', props.loginType)"
>
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
</el-button>
<el-button
:disabled="!props.hasSession"
:loading="props.sessionLoading"
class="secondary-action"
round
size="large"
@click="emit('reloadSession')"
>
刷新后端页面
</el-button>
</div>
</div>
</section>
</template>
<style scoped>
.auth-card {
display: flex;
flex-direction: column;
height: 100%;
padding: 22px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
backdrop-filter: blur(18px);
}
.login-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
padding: 0;
border-radius: 16px;
overflow: hidden;
border: 1px solid #dbe4ee;
background: #f8fafc;
}
.login-tab {
min-height: 60px;
padding: 0 16px;
border: none;
background: transparent;
color: #606f84;
cursor: pointer;
font-size: 17px;
font-weight: 700;
transition:
background-color 180ms ease,
color 180ms ease,
box-shadow 180ms ease;
}
.login-tab.active {
color: #ffffff;
background: linear-gradient(180deg, #6ba5f7 0%, #5b8ff0 100%);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.12);
}
.qr-stage {
margin-top: 18px;
padding: 18px 16px;
border-radius: 20px;
border: 1px solid rgba(86, 108, 138, 0.08);
background:
linear-gradient(180deg, rgba(245, 248, 252, 0.98), rgba(252, 253, 255, 0.96)),
radial-gradient(circle at top, rgba(101, 146, 233, 0.08), transparent 48%);
display: grid;
place-items: center;
}
.qr-figure {
display: block;
width: min(220px, 100%);
padding: 0;
border: none;
background: transparent;
cursor: zoom-in;
}
.qr-figure img {
display: block;
width: 100%;
height: auto;
border-radius: 16px;
border: 1px solid #d9e2ef;
box-shadow: 0 10px 24px rgba(31, 50, 79, 0.08);
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
.qr-empty {
width: min(220px, 100%);
min-height: 220px;
padding: 18px;
border-radius: 16px;
border: 1px dashed #d4ddeb;
background: rgba(247, 250, 254, 0.92);
display: grid;
place-items: center;
text-align: center;
}
.qr-empty strong {
font-size: 18px;
color: #27384f;
}
.qr-empty p {
margin: 10px 0 0;
color: #67778d;
font-size: 14px;
line-height: 1.6;
}
.status-state {
width: min(220px, 100%);
min-height: 220px;
padding: 18px;
border-radius: 16px;
display: grid;
place-items: center;
text-align: center;
}
.status-state strong {
font-size: 20px;
}
.status-state p {
margin: 10px 0 0;
font-size: 14px;
line-height: 1.7;
}
.confirm-state {
border: 1px solid #f9c74f;
background: linear-gradient(180deg, rgba(255, 247, 214, 0.95), rgba(255, 251, 235, 0.98));
}
.confirm-state strong {
color: #9a3412;
}
.confirm-state p {
color: #b45309;
}
.success-state {
border: 1px solid #86efac;
background: linear-gradient(180deg, rgba(220, 252, 231, 0.96), rgba(240, 253, 244, 0.98));
}
.success-state strong {
color: #166534;
}
.success-state p {
color: #15803d;
}
.auth-copy {
margin-top: 18px;
}
.scan-hint {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #243852;
}
.scan-note {
margin: 8px 0 0;
color: #64748b;
line-height: 1.7;
}
.auth-footer {
margin-top: auto;
padding-top: 20px;
}
.session-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 46px;
padding: 0 16px;
border-radius: 16px;
background: #f6f9fd;
border: 1px solid #e2e9f2;
}
.session-strip span {
color: #64748b;
font-size: 13px;
}
.session-strip strong {
display: block;
min-width: 0;
flex: 1;
text-align: right;
color: #22354e;
font-size: 13px;
font-family: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-row {
margin-top: 16px;
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(132px, 0.95fr);
gap: 10px;
}
.primary-action :deep(span) {
font-weight: 700;
}
.action-row :deep(.el-button) {
width: 100%;
min-width: 0;
margin: 0;
}
.action-row :deep(.el-button > span) {
white-space: nowrap;
}
.primary-action,
.secondary-action {
min-width: 0;
}
@media (max-width: 760px) {
.auth-card {
padding: 18px;
}
.login-tab {
font-size: 15px;
min-height: 54px;
}
.scan-hint {
font-size: 15px;
}
.session-strip,
.action-row {
flex-direction: column;
align-items: flex-start;
}
.action-row {
grid-template-columns: 1fr;
}
.action-row :deep(.el-button) {
width: 100%;
}
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
type FactItem = {
label: string
value: string
accent?: boolean
}
const props = defineProps<{
canRedeem: boolean
canConfirmRole?: boolean
confirmActionLabel?: string
confirmActionLoading?: boolean
confirmActionVisible?: boolean
confirmReadonly?: boolean
hideRedeemForm?: boolean
loginTypeLabel: string
maxAttempts: number
redeemBlockedReason: string
redeemButtonLabel: string
redeemCode: string
redeemLoading: boolean
roleConfirmed: boolean
roleFacts: FactItem[]
roleReady: boolean
statusLabel: string
}>()
const emit = defineEmits<{
confirmRole: []
redeem: []
'update:maxAttempts': [value: number]
'update:redeemCode': [value: string]
'update:roleConfirmed': [value: boolean]
}>()
</script>
<template>
<section class="panel-card">
<div class="section-head">
<strong>角色与兑换</strong>
</div>
<div class="inline-summary">
<div class="inline-summary-item">
<span>会话状态</span>
<strong>{{ props.statusLabel }}</strong>
</div>
<div class="inline-summary-item">
<span>登录方式</span>
<strong>{{ props.loginTypeLabel }}</strong>
</div>
</div>
<div class="fact-grid role-grid">
<article
v-for="fact in props.roleFacts"
:key="fact.label"
:class="['fact-card', { accent: fact.accent }]"
>
<span>{{ fact.label }}</span>
<strong>{{ fact.value }}</strong>
</article>
</div>
<div v-if="props.confirmActionVisible" class="warning-action-box">
<div class="warning-copy">
<strong>请先确认当前角色与大区准确无误</strong>
<p>确认后将按当前识别到的角色继续兑换选错角色可能导致发放错误</p>
</div>
<el-button
:disabled="!props.canConfirmRole"
:loading="props.confirmActionLoading"
class="warning-action-button"
round
size="large"
type="danger"
@click="emit('confirmRole')"
>
{{ props.confirmActionLabel || '确认当前角色' }}
</el-button>
</div>
<div v-else class="confirm-box">
<el-checkbox
:model-value="props.roleConfirmed"
:disabled="props.confirmReadonly || !props.roleReady"
@update:model-value="emit('update:roleConfirmed', Boolean($event))"
>
我已确认当前角色与大区无误
</el-checkbox>
</div>
<el-form v-if="!props.hideRedeemForm" label-position="top" class="redeem-form">
<el-form-item label="兑换码">
<el-input
:model-value="props.redeemCode"
placeholder="请输入兑换码"
@update:model-value="emit('update:redeemCode', String($event))"
/>
</el-form-item>
<div class="field-row compact">
<el-form-item label="验证码重试次数">
<el-input-number
:max="12"
:min="1"
:model-value="props.maxAttempts"
@update:model-value="emit('update:maxAttempts', Number($event || 1))"
/>
</el-form-item>
</div>
</el-form>
<div class="redeem-actions">
<el-button
:disabled="!props.canRedeem"
:loading="props.redeemLoading"
class="redeem-button"
round
size="large"
type="success"
@click="emit('redeem')"
>
{{ props.redeemButtonLabel }}
</el-button>
</div>
<p class="helper-copy">
{{ props.redeemBlockedReason || '当前状态允许直接发起兑换' }}
</p>
</section>
</template>
<style scoped>
.panel-card {
height: 100%;
padding: 22px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
backdrop-filter: blur(18px);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-head strong {
display: block;
font-size: 16px;
color: #22354e;
}
.inline-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.inline-summary-item {
padding: 12px 14px;
border-radius: 14px;
background: #f7f9fc;
}
.inline-summary-item span,
.fact-card span {
display: block;
font-size: 12px;
color: #708096;
letter-spacing: 0.02em;
}
.inline-summary-item strong,
.fact-card strong {
display: block;
margin-top: 6px;
color: #1f324a;
line-height: 1.5;
word-break: break-word;
}
.fact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.role-grid {
margin-top: 12px;
}
.fact-card {
padding: 14px;
border-radius: 14px;
background: #f7f9fc;
}
.fact-card.accent {
background: linear-gradient(180deg, rgba(86, 143, 244, 0.14), rgba(86, 143, 244, 0.08));
}
.confirm-box {
margin-top: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(95, 150, 245, 0.08);
border: 1px solid rgba(95, 150, 245, 0.14);
}
.warning-action-box {
margin-top: 14px;
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(254, 226, 226, 0.92), rgba(254, 242, 242, 0.96));
border: 1px solid rgba(248, 113, 113, 0.3);
display: grid;
gap: 12px;
}
.warning-copy strong {
display: block;
color: #991b1b;
font-size: 15px;
}
.warning-copy p {
margin: 6px 0 0;
color: #b91c1c;
line-height: 1.7;
font-size: 13px;
}
.warning-action-button {
min-width: 220px;
justify-self: start;
}
.redeem-form {
margin-top: 14px;
}
.field-row.compact {
display: grid;
grid-template-columns: 220px;
gap: 12px;
}
.redeem-actions {
margin-top: 16px;
display: grid;
gap: 10px;
}
.redeem-button {
min-width: 220px;
}
.helper-copy {
margin: 12px 0 0;
color: #64748b;
line-height: 1.7;
}
@media (max-width: 1140px) {
.inline-summary,
.fact-grid,
.role-grid,
.field-row.compact {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.panel-card {
padding: 18px;
}
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
type FactItem = {
label: string
value: string
}
const props = defineProps<{
resultFacts: FactItem[]
screenshotEmptyMessage: string
screenshotEmptyTitle: string
screenshotUrl: string
showScreenshot: boolean
}>()
const emit = defineEmits<{
openScreenshotPreview: []
}>()
</script>
<template>
<section class="result-card">
<div class="section-head">
<strong>兑换结果</strong>
</div>
<div class="result-layout">
<div class="fact-grid result-grid">
<article v-for="fact in props.resultFacts" :key="fact.label" class="fact-card">
<span>{{ fact.label }}</span>
<strong>{{ fact.value }}</strong>
</article>
</div>
<button
v-if="props.showScreenshot"
class="screenshot-figure"
type="button"
@click="emit('openScreenshotPreview')"
>
<img :src="props.screenshotUrl" alt="Tencent redeem screenshot" />
</button>
<div v-else class="empty-block">
<strong>{{ props.screenshotEmptyTitle }}</strong>
<p>{{ props.screenshotEmptyMessage }}</p>
</div>
</div>
</section>
</template>
<style scoped>
.result-card {
padding: 22px 22px 20px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
backdrop-filter: blur(18px);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-head strong {
display: block;
font-size: 16px;
color: #22354e;
}
.result-layout {
display: grid;
gap: 14px;
}
.fact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.fact-card {
padding: 14px;
border-radius: 14px;
background: #f7f9fc;
}
.fact-card span {
display: block;
font-size: 12px;
color: #708096;
letter-spacing: 0.02em;
}
.fact-card strong {
display: block;
margin-top: 6px;
color: #1f324a;
line-height: 1.5;
word-break: break-word;
}
.screenshot-figure {
display: block;
width: 100%;
padding: 0;
border: 1px solid #e2e9f2;
border-radius: 22px;
overflow: hidden;
background: #f4f8fc;
cursor: zoom-in;
}
.screenshot-figure img {
display: block;
width: 100%;
height: auto;
}
.empty-block {
margin-top: 14px;
min-height: 180px;
border-radius: 18px;
display: grid;
place-items: center;
text-align: center;
padding: 24px;
background: #f8fbff;
border: 1px dashed #d5dfec;
}
.empty-block strong {
color: #2c4058;
font-size: 16px;
}
.empty-block p {
margin: 8px 0 0;
color: #6b7a91;
line-height: 1.7;
}
@media (max-width: 1140px) {
.fact-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.result-card {
padding: 18px;
}
}
</style>