Files
zhiyin/zhiyin-app/src/pages/admin/admin.vue
T

531 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="hero">
<text class="hero-title">管理后台</text>
<text class="hero-sub" v-if="!verified">使用管理员账号点击下方按钮验证</text>
<text class="hero-sub" v-else>欢迎回来{{ adminName }}</text>
</view>
<!-- 登录 -->
<view class="login-area" v-if="!verified">
<button class="btn-verify" @click="doVerify">验证管理员身份</button>
</view>
<!-- 管理后台 -->
<view class="body" v-if="verified">
<view class="tabs">
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户</text>
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试</text>
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
<text class="tab" :class="{ active: tab === 'pricing' }" @click="switchTab('pricing')">定价</text>
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理</text>
</view>
<!-- 概览 -->
<view v-if="tab === 'overview' && !loading" class="overview">
<view class="stat-cards">
<view class="stat-card">
<text class="stat-num">{{ overview.userCount }}</text>
<text class="stat-label">总用户</text>
<text class="stat-sub">今日 +{{ overview.todayUsers }}</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ overview.interviewCount }}</text>
<text class="stat-label">总面试</text>
<text class="stat-sub">今日 +{{ overview.todayInterviews }}</text>
</view>
</view>
</view>
<!-- 用户 -->
<view v-if="tab === 'users'" class="section">
<view class="search-bar">
<input v-model="userKeyword" placeholder="搜索手机号/昵称" class="search-input" @confirm="loadUsers" />
<button class="search-btn" @click="loadUsers">搜索</button>
</view>
<view class="user-list" v-if="!usersLoading">
<view class="user-row" v-for="u in users" :key="u._id">
<text class="user-phone">{{ u.phone || '--' }}</text>
<text class="user-name">{{ u.nickname || '--' }}</text>
<text class="user-plan" :class="{ vip: u.plan === 'vip' }">{{ u.plan === 'vip' ? '会员' : '免费' }}</text>
<text class="user-remaining">{{ u.remaining || 0 }}</text>
<text class="user-vip-btn" v-if="u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
</view>
<text class="load-more" v-if="usersTotal > users.length" @click="loadMoreUsers">加载更多</text>
</view>
<text class="loading-text" v-if="usersLoading">加载中...</text>
</view>
<!-- 面试 -->
<view v-if="tab === 'interviews'" class="section">
<view class="iv-list" v-if="!ivLoading">
<view class="iv-row" v-for="iv in interviews" :key="iv._id">
<text class="iv-pos">{{ iv.position }}</text>
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
<text class="iv-questions">{{ iv.questionCount || 0 }}</text>
</view>
</view>
<text class="loading-text" v-if="ivLoading">加载中...</text>
</view>
<!-- 订单 -->
<view v-if="tab === 'orders'" class="section">
<view class="tabs in-tab">
<text class="tab" :class="{ active: orderFilter === '' }" @click="orderFilter='';loadOrders()">全部</text>
<text class="tab" :class="{ active: orderFilter === 'pending' }" @click="orderFilter='pending';loadOrders()">待支付</text>
<text class="tab" :class="{ active: orderFilter === 'success' }" @click="orderFilter='success';loadOrders()">已支付</text>
<text class="tab" :class="{ active: orderFilter === 'refunded' }" @click="orderFilter='refunded';loadOrders()">已退款</text>
</view>
<view class="order-list" v-if="!orderLoading">
<view class="order-row" v-for="o in orders" :key="o._id">
<view class="order-info">
<text class="order-id">订单号: {{ o.outTradeNo }}</text>
<text class="order-user">用户: {{ o.userPhone || o.userId.slice(-6) }}</text>
</view>
<view class="order-meta">
<text class="order-amount">¥{{ (o.amount / 100).toFixed(1) }}</text>
<view class="order-status" :class="o.status === 'success' ? 'paid' : o.status === 'refunded' ? 'refund' : 'pend'">
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
</view>
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
<view class="order-actions" v-if="o.status === 'pending'">
<text class="sync-btn" @click="syncOrder(o.outTradeNo)">同步</text>
</view>
</view>
</view>
<text class="load-more" v-if="ordersTotal > orders.length" @click="loadMoreOrders">加载更多</text>
<text class="empty-text" v-if="orders.length === 0 && !orderLoading">暂无订单</text>
</view>
<text class="loading-text" v-if="orderLoading">加载中...</text>
</view>
<!-- 定价管理 -->
<view v-if="tab === 'pricing'" class="section">
<view class="config-card">
<view class="cfg-title">产品定价</view>
<view class="cfg-row">
<text>AI 面试/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.interview.pricePerSession" @blur="calcInterviewPrice" />
</view>
<view class="cfg-row">
<text>简历优化/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.pricePerOptimize" />
</view>
<view class="cfg-row">
<text>简历下载/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.resumeDownload.pricePerDownload" />
</view>
<view class="cfg-row">
<text>免费优化次数</text>
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.freeLimit" />
</view>
</view>
<view class="config-card">
<view class="cfg-title">成长版 ¥{{ growthPriceDisplay }}</view>
<view class="cfg-row">
<text>价格/</text>
<input class="cfg-input" type="digit" v-model.number="growthPriceTemp" />
</view>
<view class="cfg-row">
<text>面试额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.interview" />
</view>
<view class="cfg-row">
<text>优化额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeOptimize" />
</view>
<view class="cfg-row">
<text>下载额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeDownload" />
</view>
<view class="cfg-row">
<text>功能列表每行一个</text>
</view>
<textarea class="cfg-textarea" v-model="growthFeaturesText" placeholder="每行一个功能" />
</view>
<view class="config-card">
<view class="cfg-title">冲刺版 ¥{{ sprintPriceDisplay }}</view>
<view class="cfg-row">
<text>价格/</text>
<input class="cfg-input" type="digit" v-model.number="sprintPriceTemp" />
</view>
<view class="cfg-row">
<text>面试额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.interview" />
</view>
<view class="cfg-row">
<text>优化额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeOptimize" />
</view>
<view class="cfg-row">
<text>下载额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeDownload" />
</view>
<view class="cfg-row">
<text>功能列表每行一个</text>
</view>
<textarea class="cfg-textarea" v-model="sprintFeaturesText" placeholder="每行一个功能" />
</view>
<view class="config-card">
<view class="cfg-title">其他配置</view>
<view class="cfg-row">
<text>会员有效期</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.durationDays" />
</view>
</view>
<button class="save-btn" @click="savePricing" :disabled="pricingLoading">保存定价配置</button>
<text class="loading-text" v-if="pricingLoading">保存中...</text>
</view>
<!-- 管理员 -->
<view v-if="tab === 'admins'" class="section">
<view class="search-bar">
<input v-model="adminKeyword" placeholder="搜索用户ID或手机号设为管理员" class="search-input" @confirm="searchAdmin" />
<button class="search-btn" @click="searchAdmin">搜索</button>
</view>
<view class="section-label">当前管理员</view>
<view class="user-list">
<view class="admin-row" v-for="a in adminList" :key="a._id">
<text class="admin-phone">{{ a.phone || '--' }}</text>
<text class="admin-name">{{ a.nickname || '--' }}</text>
<text class="admin-badge" v-if="a.isSystemAdmin">系统</text>
</view>
<text class="empty-text" v-if="adminList.length === 0">暂无管理员</text>
</view>
<view class="section-label" v-if="searchResult">搜索结果</view>
<view class="user-list" v-if="searchResult">
<view class="admin-row">
<text class="admin-phone">{{ searchResult.phone || '--' }}</text>
<text class="admin-name">{{ searchResult.nickname || '--' }}</text>
<text class="admin-set-btn" v-if="searchResult.role !== 'admin'" @click="setAdmin(searchResult._id)">设为管理员</text>
<text class="admin-set-btn done" v-else>已是管理员</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { api, API_ENDPOINTS } from '../../config'
const verified = ref(false)
const adminName = ref('')
const tab = ref('overview')
const loading = ref(false)
const usersLoading = ref(false)
const ivLoading = ref(false)
const userKeyword = ref('')
const usersPage = ref(1)
const overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0 })
const users = ref([])
const usersTotal = ref(0)
const interviews = ref([])
const adminKeyword = ref('')
const adminList = ref([])
const searchResult = ref(null)
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
const cfgLoading = ref(false)
const pricing = ref({
interview: { pricePerSession: 500 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300 },
resumeDownload: { pricePerDownload: 200 },
plans: {
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试无限次'] },
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益'] },
},
})
const pricingLoading = ref(false)
const growthPriceTemp = ref(19.9)
const sprintPriceTemp = ref(49.9)
const growthFeaturesText = ref('')
const sprintFeaturesText = ref('')
const growthPriceDisplay = computed(() => growthPriceTemp.value.toFixed(1))
const sprintPriceDisplay = computed(() => sprintPriceTemp.value.toFixed(1))
const calcInterviewPrice = () => {
// Convert to 分 on save
}
const orders = ref([])
const ordersTotal = ref(0)
const ordersPage = ref(1)
const orderLoading = ref(false)
const orderFilter = ref('')
const token = () => uni.getStorageSync('token') || ''
const apiAdmin = (path, opts = {}) => {
return uni.request({
url: api('/admin' + path),
method: opts.method || 'GET',
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
data: opts.body || opts.data,
})
}
const doVerify = async () => {
const t = token()
if (!t) { uni.navigateTo({ url: '/pages/login/login' }); return }
try {
const res = await apiAdmin('/check')
if (res.statusCode === 200 && res.data?.isAdmin) {
adminName.value = '管理员'
verified.value = true
loadOverview()
} else throw new Error('无管理员权限')
} catch (e) {
uni.showToast({ title: '当前账号非管理员,无权限访问', icon: 'none' })
}
}
const loadOverview = async () => {
loading.value = true
try {
const res = await apiAdmin('/overview')
if (res.statusCode === 200) overview.value = res.data
} catch (e) { console.error(e) }
finally { loading.value = false }
}
const switchTab = (t) => {
tab.value = t
if (t === 'users' && users.value.length === 0) loadUsers()
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
if (t === 'pricing') loadPricing()
if (t === 'orders') loadOrders()
}
const loadUsers = async () => {
usersLoading.value = true
usersPage.value = 1
try {
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=1&limit=20')
if (res.statusCode === 200) { users.value = res.data.users || []; usersTotal.value = res.data.total || 0 }
} catch (e) { console.error(e) }
finally { usersLoading.value = false }
}
const loadMoreUsers = async () => {
usersPage.value++
try {
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=' + usersPage.value + '&limit=20')
if (res.statusCode === 200) users.value = [...users.value, ...(res.data.users || [])]
} catch (e) { console.error(e) }
}
const loadInterviews = async () => {
ivLoading.value = true
try {
const res = await apiAdmin('/interviews?page=1&limit=20')
if (res.statusCode === 200) interviews.value = res.data.interviews || []
} catch (e) { console.error(e) }
finally { ivLoading.value = false }
}
const loadPricing = async () => {
pricingLoading.value = true
try {
const res = await apiAdmin('/pricing')
if (res.statusCode === 200 && res.data) {
pricing.value = res.data
growthPriceTemp.value = (res.data.plans?.growth?.price || 1990) / 100
sprintPriceTemp.value = (res.data.plans?.sprint?.price || 4990) / 100
growthFeaturesText.value = (res.data.plans?.growth?.features || []).join('\n')
sprintFeaturesText.value = (res.data.plans?.sprint?.features || []).join('\n')
}
} catch (e) { console.error(e) }
finally { pricingLoading.value = false }
}
const savePricing = async () => {
pricingLoading.value = true
try {
const data = JSON.parse(JSON.stringify(pricing.value))
data.plans.growth.price = Math.round(growthPriceTemp.value * 100)
data.plans.sprint.price = Math.round(sprintPriceTemp.value * 100)
data.plans.growth.features = growthFeaturesText.value.split('\n').filter(f => f.trim())
data.plans.sprint.features = sprintFeaturesText.value.split('\n').filter(f => f.trim())
const res = await apiAdmin('/pricing/save', { method: 'POST', data })
if (res.statusCode === 200) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
console.error(e)
}
finally { pricingLoading.value = false }
}
const loadConfig = async () => {
cfgLoading.value = true
try {
const res = await apiAdmin('/config')
if (res.statusCode === 200) memberConfig.value = res.data
} catch(e) { console.error(e) }
finally { cfgLoading.value = false }
}
const loadOrders = async () => {
orderLoading.value = true
ordersPage.value = 1
try {
let url = '/orders?page=1&limit=20'
if (orderFilter.value) url += '&status=' + orderFilter.value
const res = await apiAdmin(url)
if (res.statusCode === 200) { orders.value = res.data.orders || []; ordersTotal.value = res.data.total || 0 }
} catch(e) { console.error(e) }
finally { orderLoading.value = false }
}
const loadMoreOrders = async () => {
ordersPage.value++
let url = '/orders?page=' + ordersPage.value + '&limit=20'
if (orderFilter.value) url += '&status=' + orderFilter.value
try {
const res = await apiAdmin(url)
if (res.statusCode === 200) orders.value = [...orders.value, ...(res.data.orders || [])]
} catch(e) { console.error(e) }
}
const syncOrder = async (outTradeNo) => {
uni.showToast({ title: '同步中...', icon: 'none' })
try {
const res = await apiAdmin('/order/sync', { method: 'POST', data: { outTradeNo } })
if (res.statusCode === 200) {
uni.showToast({ title: '同步完成', icon: 'success' })
loadOrders()
} else { uni.showToast({ title: '同步失败', icon: 'none' }) }
} catch { uni.showToast({ title: '同步失败', icon: 'none' }) }
}
const loadAdmins = async () => {
try {
const res = await apiAdmin('/admins')
if (res.statusCode === 200) adminList.value = res.data.admins || []
} catch(e) { console.error(e) }
}
const searchAdmin = async () => {
if (!adminKeyword.value.trim()) return
try {
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(adminKeyword.value) + '&limit=1')
if (res.statusCode === 200 && res.data.users?.length > 0) {
searchResult.value = res.data.users[0]
} else {
uni.showToast({ title: '未找到该用户', icon: 'none' })
searchResult.value = null
}
} catch { searchResult.value = null }
}
const setAdmin = async (targetUserId) => {
uni.showModal({
title: '设为管理员', content: '确定将该用户设为管理员?', success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/set-admin', { method: 'POST', data: { userId: targetUserId } })
if (res.statusCode === 200) {
uni.showToast({ title: '已设为管理员', icon: 'success' })
searchResult.value = null
adminKeyword.value = ''
loadAdmins()
} else throw new Error()
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
}
})
}
const setVip = async (targetUserId) => {
uni.showModal({
title: '设为会员', content: '确定将该用户升级为月度会员?', success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/set-vip', { method: 'POST', data: { userId: targetUserId } })
if (res.statusCode === 200) {
uni.showToast({ title: '已设为会员', icon: 'success' })
loadUsers()
} else throw new Error()
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
}
})
}
</script>
<style scoped>
.page { background: var(--color-bg); min-height: 100vh; }
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.login-area { padding: 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 16rpx; }
.admin-input { height: 72rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 24rpx; }
.btn-verify { height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; }
.body { padding: 20rpx 32rpx 48rpx; margin-top: -40rpx; }
.tabs { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; margin-bottom: 20rpx; }
.tab { flex: 1; text-align: center; padding: 14rpx; font-size: 24rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); }
.tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
.stat-cards { display: flex; gap: 16rpx; }
.stat-card { flex: 1; background: #FFF; border-radius: var(--radius-lg); padding: 28rpx; text-align: center; box-shadow: var(--shadow-sm); }
.stat-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); display: block; }
.stat-label { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 8rpx; display: block; }
.stat-sub { font-size: 20rpx; color: var(--color-success); margin-top: 4rpx; display: block; }
.search-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; }
.search-input { flex: 1; height: 64rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 16rpx; font-size: 24rpx; }
.search-btn { height: 64rpx; padding: 0 24rpx; background: var(--color-primary); color: #FFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
.user-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; }
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
.user-plan { font-size: 20rpx; background: #EEF2FF; color: var(--color-primary); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.user-remaining { font-size: 20rpx; color: var(--color-text-tertiary); }
.loading-text { text-align: center; padding: 40rpx; color: var(--color-text-tertiary); font-size: 24rpx; }
.load-more { text-align: center; padding: 20rpx; color: var(--color-primary); font-size: 24rpx; display: block; }
.iv-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
.iv-status { font-size: 20rpx; background: #FFF7ED; color: var(--color-warning); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.iv-status.done { background: #ECFDF5; color: var(--color-success); }
.iv-questions { font-size: 20rpx; color: var(--color-text-tertiary); }
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; margin-top: 12rpx; }
.admin-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
.admin-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.admin-name { font-size: 22rpx; color: var(--color-text-secondary); flex: 1; }
.admin-set-btn { font-size: 22rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.admin-set-btn.done { color: var(--color-success); border-color: var(--color-success); }
.admin-badge { font-size: 18rpx; background: var(--color-primary); color: #FFF; padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.empty-text { text-align: center; padding: 20rpx; color: var(--color-text-tertiary); font-size: 22rpx; display: block; }
.order-list { display: flex; flex-direction: column; gap: 8rpx; }
.order-row { background: #FFF; border-radius: var(--radius-sm); padding: 16rpx; }
.order-info { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
.order-id { font-size: 22rpx; color: var(--color-text); font-weight: 500; }
.order-user { font-size: 20rpx; color: var(--color-text-tertiary); }
.order-meta { display: flex; align-items: center; gap: 12rpx; }
.order-amount { font-size: 28rpx; font-weight: 700; color: var(--color-primary); }
.order-status { font-size: 20rpx; padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.order-status.paid { background: #ECFDF5; color: var(--color-success); }
.order-status.refund { background: #FEF3C7; color: var(--color-warning); }
.order-status.pend { background: #F3F4F6; color: var(--color-text-tertiary); }
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
.order-actions { }
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
.cfg-val { font-weight: 600; color: var(--color-primary); }
.cfg-input { width: 160rpx; height: 56rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 12rpx; font-size: 22rpx; text-align: center; }
.cfg-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 12rpx; font-size: 22rpx; margin-top: 8rpx; box-sizing: border-box; }
.save-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; margin-top: 12rpx; }
.save-btn:disabled { opacity: 0.6; }
</style>