@@ -0,0 +1,619 @@
< template >
< h2 class = "ops-page-title" > 订阅包审批 < / h2 >
< div class = "ops-card" >
< div class = "ops-toolbar kol-subscriptions-toolbar" >
< a-input
v -model :value = "filter.keyword"
class = "kol-subscriptions-search"
placeholder = "搜索租户 / 手机号 / KOL / 模板包 / ID"
allow -clear
@ press -enter = " reload "
@change ="onKeywordChange"
/ >
< a-select
v -model :value = "filter.status"
class = "kol-subscriptions-status"
:options = "statusOptions"
placeholder = "订阅状态"
allow -clear
@change ="reload"
/ >
< a-button @click ="reload" > 刷新 < / a -button >
< span class = "kol-subscriptions-toolbar__spacer" / >
< a-tag color = "orange" > 待审批 { { pendingRowsCount } } < / a-tag >
< a-tag > 当前筛选 { { total } } < / a-tag >
< a-button type = "primary" @click ="openManualBind" >
< template # icon > < PlusOutlined / > < / template >
绑定模板包
< / a-button >
< / div >
< a-table
:columns = "columns"
:data-source = "rows"
:loading = "loading"
:pagination = "pagination"
row -key = " id "
@change ="onTableChange"
>
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key === 'subscriber'" >
< div class = "primary-cell" >
< strong > { { record . tenant _name } } < / strong >
< span >
{ { record . subscriber _phone ? ` 手机 ${ record . subscriber _phone } ` : "手机号 —" } }
· 租户 # { { record . tenant _id } } · 申请 # { { record . id } }
< / span >
< / div >
< / template >
< template v-else-if = "column.key === 'package'" >
< div class = "primary-cell" >
< strong > { { record . package _name } } < / strong >
< span > 包 # { { record . package _id } } < / span >
< / div >
< / template >
< template v-else-if = "column.key === 'creator'" >
< div class = "creator-cell" >
< a-avatar : src = "record.kol_avatar_url || undefined" size = "small" >
{ { record . kol _display _name . charAt ( 0 ) || "K" } }
< / a-avatar >
< div class = "primary-cell" >
< strong > { { record . kol _display _name } } < / strong >
< span > { { record . creator _tenant _name } } · 租户 # { { record . creator _tenant _id } } < / span >
< / div >
< / div >
< / template >
< template v-else-if = "column.key === 'status'" >
< a -space direction = "vertical" :size = "4" >
< a-tag :color = "statusColor(record.status)" >
{ { statusLabel ( record . status ) } }
< / a-tag >
< a-tag : color = "record.package_status === 'published' ? 'green' : 'default'" >
包 : { { packageStatusLabel ( record . package _status ) } }
< / a-tag >
< / a-space >
< / template >
< template v-else-if = "column.key === 'prompts'" >
< div class = "prompt-count" >
< strong > { { record . access _prompt _count } } < / strong >
< span > / { { record . prompt _count } } 个 Prompt < / span >
< / div >
< / template >
< template v-else-if = "column.key === 'period'" >
< div class = "period-cell" >
< span > 开始 : { { formatOptionalDate ( record . start _at ) } } < / span >
< span > 到期 : { { formatOptionalDate ( record . end _at , "永不过期" ) } } < / span >
< / div >
< / template >
< template v-else-if = "column.key === 'created_at'" >
{{ formatDate ( record.created_at ) }}
< / template >
< template v-else-if = "column.key === 'actions'" >
< div class = "table-actions-row" >
< a-tooltip v-if = "record.status === 'pending'" title="通过" >
< a -button
type = "text"
shape = "circle"
size = "small"
class = "action-btn action-approve"
@click ="openApprove(record)"
>
< CheckOutlined / >
< / a-button >
< / a-tooltip >
< a-tooltip v-if = "record.status === 'pending' || record.status === 'active'" title="撤销订阅" >
< a -popconfirm
title = "确认撤销该订阅?撤销后订阅方将无法使用包内 Prompt。"
: ok -button -props = " { danger : true , loading : revokeLoadingId = = = record.id } "
@confirm ="revoke(record)"
>
< a-button
type = "text"
shape = "circle"
size = "small"
class = "action-btn action-revoke"
>
< StopOutlined / >
< / a-button >
< / a-popconfirm >
< / a-tooltip >
< span v-if = "record.status !== 'pending' && record.status !== 'active'" class="muted-text" > 无操作 < / span >
< / div >
< / template >
< / template >
< / a -table >
< / div >
< a-modal
v -model :open = "approveOpen"
: title = "approveTarget ? `通过订阅申请 #${approveTarget.id}` : '通过订阅申请'"
:confirm-loading = "approveLoading"
ok -text = " 通过 "
@ok ="submitApprove"
@cancel ="approveOpen = false"
>
< a-alert
v-if = "approveTarget"
class = "approve-alert"
type = "info"
show -icon
: message = "`${approveTarget.tenant_name} 将获得「${approveTarget.package_name}」的使用权限`"
/ >
< a-form layout = "vertical" :model = "approveForm" >
< a-form-item label = "订阅有效期" help = "留空表示不过期;过渡期可先用人工控制。" >
< a-date-picker
v -model :value = "approveForm.end_date"
class = "approve-date-picker"
format = "YYYY-MM-DD"
value -format = " YYYY -MM -DD "
allow -clear
placeholder = "永不过期"
/ >
< / a-form-item >
< / a-form >
< / a-modal >
< a-modal
v -model :open = "manualBindOpen"
title = "手动绑定精调模板包"
:confirm-loading = "manualBindLoading"
ok -text = " 绑定 "
@ok ="submitManualBind"
@cancel ="manualBindOpen = false"
>
< a-alert
class = "manual-bind-alert"
type = "info"
show -icon
message = "输入用户手机号并选择精调模板市场的模板包,保存后会直接发放包内 Prompt 使用权限。"
/ >
< a-form layout = "vertical" :model = "manualBindForm" >
< a-form-item label = "用户手机号" required >
< a-input
v -model :value = "manualBindForm.phone"
placeholder = "13800138000"
allow -clear
@ press -enter = " submitManualBind "
/ >
< / a-form-item >
< a-form-item label = "精调模板包" required >
< a-select
v -model :value = "manualBindForm.package_id"
show -search
allow -clear
:filter-option = "false"
:loading = "packageLoading"
:options = "packageOptions"
placeholder = "搜索模板包 / KOL / 包 ID"
@focus ="loadPackages()"
@search ="onPackageSearch"
/ >
< / a-form-item >
< div v-if = "selectedPackage" class="manual-bind-preview" >
< div class = "creator-cell" >
< a-avatar : src = "selectedPackage.kol_avatar_url || undefined" size = "small" >
{ { selectedPackage . kol _display _name . charAt ( 0 ) || "K" } }
< / a-avatar >
< div class = "primary-cell" >
< strong > { { selectedPackage . name } } < / strong >
< span > { { selectedPackage . kol _display _name } } · { { selectedPackage . creator _tenant _name } } < / span >
< / div >
< / div >
< a-tag color = "blue" > { { selectedPackage . prompt _count } } 个 Prompt < / a-tag >
< / div >
< a-form-item label = "授权有效期" help = "留空表示不过期;如果该用户已有待审批/已通过记录,会更新为这里选择的有效期。" >
< a-date-picker
v -model :value = "manualBindForm.end_date"
class = "approve-date-picker"
format = "YYYY-MM-DD"
value -format = " YYYY -MM -DD "
allow -clear
placeholder = "永不过期"
/ >
< / a-form-item >
< / a-form >
< / a-modal >
< / template >
< script setup lang = "ts" >
import { PlusOutlined , CheckOutlined , StopOutlined } from "@ant-design/icons-vue" ;
import type { TableColumnsType , TablePaginationConfig } from "ant-design-vue" ;
import { message } from "ant-design-vue" ;
import dayjs from "dayjs" ;
import { computed , onMounted , reactive , ref } from "vue" ;
import { OpsApiError , http } from "@/lib/http" ;
interface KolSubscriptionRow {
id : number ;
tenant _id : number ;
tenant _name : string ;
subscriber _name : string | null ;
subscriber _phone : string | null ;
package _id : number ;
package _name : string ;
package _status : string ;
creator _tenant _id : number ;
creator _tenant _name : string ;
kol _profile _id : number ;
kol _display _name : string ;
kol _avatar _url : string | null ;
status : string ;
prompt _count : number ;
access _prompt _count : number ;
start _at : string | null ;
end _at : string | null ;
created _at : string ;
updated _at : string ;
}
interface ListResult {
items : KolSubscriptionRow [ ] ;
total : number ;
page : number ;
size : number ;
}
interface KolPackageOption {
id : number ;
name : string ;
status : string ;
creator _tenant _id : number ;
creator _tenant _name : string ;
kol _profile _id : number ;
kol _display _name : string ;
kol _avatar _url : string | null ;
prompt _count : number ;
created _at : string ;
updated _at : string ;
}
const filter = reactive ( {
keyword : "" ,
status : "pending" as string | undefined ,
} ) ;
const statusOptions = [
{ label : "审核中" , value : "pending" } ,
{ label : "已通过" , value : "active" } ,
{ label : "已撤销" , value : "revoked" } ,
{ label : "已过期" , value : "expired" } ,
] ;
const columns : TableColumnsType < KolSubscriptionRow > = [
{ title : "申请租户" , key : "subscriber" , width : 220 } ,
{ title : "订阅包" , key : "package" , width : 220 } ,
{ title : "KOL" , key : "creator" , width : 260 } ,
{ title : "状态" , key : "status" , width : 130 } ,
{ title : "授权 Prompt" , key : "prompts" , width : 130 } ,
{ title : "有效期" , key : "period" , width : 210 } ,
{ title : "申请时间" , key : "created_at" , width : 180 } ,
{ title : "操作" , key : "actions" , width : 120 } ,
] ;
const rows = ref < KolSubscriptionRow [ ] > ( [ ] ) ;
const packageRows = ref < KolPackageOption [ ] > ( [ ] ) ;
const loading = ref ( false ) ;
const packageLoading = ref ( false ) ;
const page = ref ( 1 ) ;
const size = ref ( 20 ) ;
const total = ref ( 0 ) ;
const pagination = computed < TablePaginationConfig > ( ( ) => ( {
current : page . value ,
pageSize : size . value ,
total : total . value ,
showSizeChanger : true ,
pageSizeOptions : [ "10" , "20" , "50" , "100" ] ,
} ) ) ;
const pendingRowsCount = computed ( ( ) => rows . value . filter ( ( row ) => row . status === "pending" ) . length ) ;
const packageOptions = computed ( ( ) =>
packageRows . value . map ( ( item ) => ( {
label : ` ${ item . name } · ${ item . kol _display _name } · ${ item . prompt _count } 个 Prompt ` ,
value : item . id ,
} ) ) ,
) ;
let keywordTimer : number | null = null ;
let packageSearchTimer : number | null = null ;
function onKeywordChange ( ) {
if ( keywordTimer ) window . clearTimeout ( keywordTimer ) ;
keywordTimer = window . setTimeout ( ( ) => {
page . value = 1 ;
void reload ( ) ;
} , 300 ) ;
}
async function reload ( ) {
loading . value = true ;
try {
const result = await http . get < ListResult > ( "/kol/subscriptions" , {
keyword : filter . keyword || undefined ,
status : filter . status || undefined ,
page : page . value ,
size : size . value ,
} ) ;
rows . value = result . items ;
total . value = result . total ;
} catch ( error ) {
if ( error instanceof OpsApiError ) message . error ( error . detail || error . message ) ;
} finally {
loading . value = false ;
}
}
async function loadPackages ( keyword = "" ) {
packageLoading . value = true ;
try {
packageRows . value = await http . get < KolPackageOption [ ] > ( "/kol/packages" , {
keyword : keyword || undefined ,
size : 50 ,
} ) ;
} catch ( error ) {
if ( error instanceof OpsApiError ) message . error ( error . detail || error . message ) ;
} finally {
packageLoading . value = false ;
}
}
function onPackageSearch ( value : string ) {
if ( packageSearchTimer ) window . clearTimeout ( packageSearchTimer ) ;
packageSearchTimer = window . setTimeout ( ( ) => {
void loadPackages ( value . trim ( ) ) ;
} , 300 ) ;
}
function onTableChange ( pag : TablePaginationConfig ) {
page . value = pag . current ? ? 1 ;
size . value = pag . pageSize ? ? 20 ;
void reload ( ) ;
}
const manualBindOpen = ref ( false ) ;
const manualBindLoading = ref ( false ) ;
const manualBindForm = reactive ( {
phone : "" ,
package _id : undefined as number | undefined ,
end _date : undefined as string | undefined ,
} ) ;
const selectedPackage = computed ( ( ) =>
packageRows . value . find ( ( item ) => item . id === manualBindForm . package _id ) ? ? null ,
) ;
function resetManualBind ( ) {
manualBindForm . phone = "" ;
manualBindForm . package _id = undefined ;
manualBindForm . end _date = undefined ;
}
function openManualBind ( ) {
resetManualBind ( ) ;
manualBindOpen . value = true ;
void loadPackages ( ) ;
}
async function submitManualBind ( ) {
const phone = normalizePhone ( manualBindForm . phone ) ;
if ( ! phone ) {
message . warning ( "请输入有效手机号" ) ;
return ;
}
if ( ! manualBindForm . package _id ) {
message . warning ( "请选择精调模板包" ) ;
return ;
}
if ( manualBindForm . end _date && dayjs ( manualBindForm . end _date ) . endOf ( "day" ) . isBefore ( dayjs ( ) ) ) {
message . warning ( "有效期不能早于今天" ) ;
return ;
}
manualBindLoading . value = true ;
try {
const result = await http . post < KolSubscriptionRow > ( "/kol/subscriptions/manual-bind" , {
phone ,
package _id : manualBindForm . package _id ,
end _at : manualBindForm . end _date ? dayjs ( manualBindForm . end _date ) . endOf ( "day" ) . toISOString ( ) : null ,
} ) ;
message . success ( ` 已为 ${ phone } 绑定「 ${ result . package _name } 」 ` ) ;
manualBindOpen . value = false ;
filter . keyword = phone ;
filter . status = "active" ;
page . value = 1 ;
void reload ( ) ;
} catch ( error ) {
if ( error instanceof OpsApiError ) message . error ( error . detail || error . message ) ;
} finally {
manualBindLoading . value = false ;
}
}
const approveOpen = ref ( false ) ;
const approveLoading = ref ( false ) ;
const approveTarget = ref < KolSubscriptionRow | null > ( null ) ;
const approveForm = reactive ( {
end _date : undefined as string | undefined ,
} ) ;
function openApprove ( row : KolSubscriptionRow ) {
approveTarget . value = row ;
approveForm . end _date = undefined ;
approveOpen . value = true ;
}
async function submitApprove ( ) {
if ( ! approveTarget . value ) return ;
if ( approveForm . end _date && dayjs ( approveForm . end _date ) . endOf ( "day" ) . isBefore ( dayjs ( ) ) ) {
message . warning ( "有效期不能早于今天" ) ;
return ;
}
approveLoading . value = true ;
try {
await http . post < KolSubscriptionRow > ( ` /kol/subscriptions/ ${ approveTarget . value . id } /approve ` , {
end _at : approveForm . end _date ? dayjs ( approveForm . end _date ) . endOf ( "day" ) . toISOString ( ) : null ,
} ) ;
message . success ( "订阅已通过,Prompt 权限已发放" ) ;
approveOpen . value = false ;
void reload ( ) ;
} catch ( error ) {
if ( error instanceof OpsApiError ) message . error ( error . detail || error . message ) ;
} finally {
approveLoading . value = false ;
}
}
const revokeLoadingId = ref < number | null > ( null ) ;
async function revoke ( row : KolSubscriptionRow ) {
revokeLoadingId . value = row . id ;
try {
await http . post < KolSubscriptionRow > ( ` /kol/subscriptions/ ${ row . id } /revoke ` ) ;
message . success ( "订阅已撤销" ) ;
void reload ( ) ;
} catch ( error ) {
if ( error instanceof OpsApiError ) message . error ( error . detail || error . message ) ;
} finally {
revokeLoadingId . value = null ;
}
}
function statusLabel ( status : string ) : string {
if ( status === "pending" ) return "审核中" ;
if ( status === "active" ) return "已通过" ;
if ( status === "revoked" ) return "已撤销" ;
if ( status === "expired" ) return "已过期" ;
return status ;
}
function statusColor ( status : string ) : string {
if ( status === "pending" ) return "orange" ;
if ( status === "active" ) return "green" ;
if ( status === "revoked" ) return "red" ;
if ( status === "expired" ) return "default" ;
return "default" ;
}
function packageStatusLabel ( status : string ) : string {
if ( status === "published" ) return "已发布" ;
if ( status === "draft" ) return "草稿" ;
if ( status === "archived" ) return "已归档" ;
return status ;
}
function normalizePhone ( value : string ) : string {
const phone = value . trim ( ) . replace ( /[\s-]/g , "" ) ;
return /^\+?\d{6,20}$/ . test ( phone ) ? phone : "" ;
}
function formatOptionalDate ( value : string | null , empty = "—" ) : string {
if ( ! value ) return empty ;
return dayjs ( value ) . format ( "YYYY-MM-DD" ) ;
}
function formatDate ( value : string ) : string {
return dayjs ( value ) . format ( "YYYY-MM-DD HH:mm:ss" ) ;
}
onMounted ( ( ) => {
void reload ( ) ;
} ) ;
< / script >
< style scoped >
. kol - subscriptions - toolbar {
align - items : center ;
}
. kol - subscriptions - search {
width : 320 px ;
}
. kol - subscriptions - status {
width : 140 px ;
}
. kol - subscriptions - toolbar _ _spacer {
flex : 1 ;
}
. primary - cell {
display : flex ;
flex - direction : column ;
gap : 3 px ;
}
. primary - cell strong {
color : # 0 f172a ;
font - weight : 650 ;
}
. primary - cell span ,
. muted - text {
color : # 94 a3b8 ;
font - size : 12 px ;
}
. creator - cell {
display : flex ;
gap : 10 px ;
align - items : center ;
}
. prompt - count {
display : flex ;
align - items : baseline ;
gap : 4 px ;
}
. prompt - count strong {
color : # 0 f172a ;
font - size : 18 px ;
}
. prompt - count span {
color : # 64748 b ;
font - size : 12 px ;
}
. period - cell {
display : flex ;
flex - direction : column ;
gap : 4 px ;
color : # 64748 b ;
font - size : 12 px ;
}
. danger - link {
color : # dc2626 ;
}
. approve - alert {
margin - bottom : 16 px ;
}
. manual - bind - alert {
margin - bottom : 16 px ;
}
. manual - bind - preview {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
margin : - 2 px 0 16 px ;
padding : 12 px ;
border : 1 px solid # dbeafe ;
border - radius : 8 px ;
background : # f8fbff ;
}
. approve - date - picker {
width : 100 % ;
}
< / style >