DomainView.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955
  1. <script setup>
  2. import { ref, onMounted, reactive, computed, inject } from 'vue'
  3. import {
  4. listTeamDomains,
  5. createTeamDomain,
  6. updateTeamDomain,
  7. deleteTeamDomain,
  8. getTeamDomain,
  9. showTeamDomains,
  10. getTeamDomainDailyStatistics,
  11. getTeamDomainAllStatistics
  12. } from '@/services/api'
  13. import { useToast } from 'primevue/usetoast'
  14. import { useConfirm } from 'primevue/useconfirm'
  15. import DataTable from 'primevue/datatable'
  16. import Column from 'primevue/column'
  17. import Button from 'primevue/button'
  18. import InputText from 'primevue/inputtext'
  19. import Select from 'primevue/select'
  20. import Dialog from 'primevue/dialog'
  21. import Textarea from 'primevue/textarea'
  22. import { useDateFormat } from '@vueuse/core'
  23. import { useUserStore } from '@/stores/user'
  24. import { useTeamStore } from '@/stores/team'
  25. const toast = useToast()
  26. const confirm = useConfirm()
  27. const userStore = useUserStore()
  28. const teamStore = useTeamStore()
  29. // 注入权限信息
  30. const isAdmin = inject('isAdmin')
  31. const isTeam = inject('isTeam')
  32. const isPromoter = inject('isPromoter')
  33. const tableRef = ref(null)
  34. const tableData = ref({
  35. data: [],
  36. meta: {
  37. total: 0,
  38. page: 0,
  39. size: 20
  40. }
  41. })
  42. // 管理员专用的分组数据
  43. const adminGroupedData = ref({})
  44. // 域名统计数据
  45. const domainStatistics = ref({})
  46. const domainAllStatistics = ref({})
  47. const selectedDomain = ref(null)
  48. const dialogVisible = ref(false)
  49. const isEditing = ref(false)
  50. const domainModel = reactive({
  51. teamId: null,
  52. domain: '',
  53. description: ''
  54. })
  55. // 搜索表单
  56. const searchForm = ref({
  57. domain: '',
  58. teamId: null
  59. })
  60. // 计算当前用户的团队ID
  61. const currentTeamId = computed(() => {
  62. if (isAdmin.value) {
  63. // 管理员可以选择团队,这里先返回null,在创建/编辑时手动指定
  64. return null
  65. } else if (isTeam.value) {
  66. // 队长从team表获取teamId
  67. return userStore.userInfo?.teamId
  68. } else if (isPromoter.value) {
  69. // 推广员从team-members表获取teamId
  70. return userStore.userInfo?.teamId
  71. }
  72. return null
  73. })
  74. // 计算是否有操作权限
  75. const canCreate = computed(() => isAdmin.value)
  76. const canUpdate = computed(() => isAdmin.value || isTeam.value)
  77. const canDelete = computed(() => isAdmin.value || isTeam.value)
  78. const canView = computed(() => isAdmin.value || isTeam.value || isPromoter.value)
  79. const fetchData = async (page = 0) => {
  80. try {
  81. if (isAdmin.value) {
  82. // 管理员使用新的分组接口
  83. const result = await showTeamDomains(undefined, searchForm.value.teamId, searchForm.value.domain || undefined)
  84. adminGroupedData.value = result || {}
  85. // 同时获取统计数据
  86. await fetchDomainStatistics()
  87. } else {
  88. // 其他角色使用原有接口
  89. const result = await listTeamDomains(
  90. page,
  91. tableData.value.meta?.size || 20,
  92. undefined,
  93. searchForm.value.teamId || currentTeamId.value,
  94. searchForm.value.domain || undefined
  95. )
  96. tableData.value = {
  97. data: result?.content || [],
  98. meta: {
  99. total: result?.metadata?.total || 0,
  100. page: result?.metadata?.page || 0,
  101. size: result?.metadata?.size || 20,
  102. totalPages: Math.ceil((result?.metadata?.total || 0) / (result?.metadata?.size || 20))
  103. }
  104. }
  105. }
  106. } catch (error) {
  107. console.error('获取域名列表失败', error)
  108. toast.add({ severity: 'error', summary: '错误', detail: '获取域名列表失败', life: 3000 })
  109. if (isAdmin.value) {
  110. adminGroupedData.value = {}
  111. } else {
  112. tableData.value = {
  113. data: [],
  114. meta: {
  115. total: 0,
  116. page: 0,
  117. size: 20,
  118. totalPages: 0
  119. }
  120. }
  121. }
  122. }
  123. }
  124. const handlePageChange = (event) => {
  125. if (!isAdmin.value) {
  126. fetchData(event.page)
  127. }
  128. }
  129. const refreshData = () => {
  130. if (isAdmin.value) {
  131. fetchData(0)
  132. } else {
  133. const page = tableData.value.meta?.page || 0
  134. fetchData(page)
  135. }
  136. }
  137. const handleSearch = () => {
  138. if (!isAdmin.value && tableData.value.meta) {
  139. tableData.value.meta.page = 0
  140. }
  141. fetchData(0)
  142. }
  143. const handleRefresh = () => {
  144. searchForm.value = {
  145. domain: '',
  146. teamId: null
  147. }
  148. if (!isAdmin.value && tableData.value.meta) {
  149. tableData.value.meta.page = 0
  150. }
  151. fetchData(0)
  152. }
  153. const formatDate = (date) => {
  154. if (!date) return '-'
  155. return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
  156. }
  157. // 复制域名到剪贴板
  158. const copyDomain = async (domain) => {
  159. try {
  160. await navigator.clipboard.writeText(domain)
  161. } catch (error) {
  162. console.error('复制失败:', error)
  163. toast.add({
  164. severity: 'error',
  165. summary: '复制失败',
  166. detail: '无法复制到剪贴板',
  167. life: 3000
  168. })
  169. }
  170. }
  171. // 获取域名统计数据
  172. const fetchDomainStatistics = async () => {
  173. if (!isAdmin.value) return
  174. try {
  175. // 获取今日统计数据
  176. const todayResult = await getTeamDomainDailyStatistics()
  177. const todayStatsMap = {}
  178. if (Array.isArray(todayResult)) {
  179. todayResult.forEach((stat) => {
  180. todayStatsMap[stat.domain] = {
  181. todayNewUsers: stat.todayNewUsers || 0,
  182. todayIncome: stat.todayIncome || 0
  183. }
  184. })
  185. }
  186. domainStatistics.value = todayStatsMap
  187. // 获取所有统计数据
  188. const allResult = await getTeamDomainAllStatistics()
  189. const allStatsMap = {}
  190. if (Array.isArray(allResult)) {
  191. allResult.forEach((stat) => {
  192. allStatsMap[stat.domain] = {
  193. totalNewUsers: stat.totalNewUsers || 0,
  194. totalIncome: stat.totalIncome || 0
  195. }
  196. })
  197. }
  198. domainAllStatistics.value = allStatsMap
  199. } catch (error) {
  200. console.error('获取域名统计数据失败', error)
  201. domainStatistics.value = {}
  202. domainAllStatistics.value = {}
  203. }
  204. }
  205. const resetModel = () => {
  206. domainModel.teamId = currentTeamId.value
  207. domainModel.domain = ''
  208. domainModel.description = ''
  209. }
  210. const onEdit = async (domain = null) => {
  211. resetModel()
  212. if (domain) {
  213. isEditing.value = true
  214. selectedDomain.value = domain
  215. try {
  216. const detail = await getTeamDomain(domain.id)
  217. // 填充表单数据
  218. domainModel.teamId = detail.teamId
  219. domainModel.domain = detail.domain
  220. domainModel.description = detail.description
  221. } catch (error) {
  222. toast.add({ severity: 'error', summary: '错误', detail: '获取域名详情失败', life: 3000 })
  223. return
  224. }
  225. } else {
  226. isEditing.value = false
  227. }
  228. dialogVisible.value = true
  229. }
  230. const onCancel = () => {
  231. dialogVisible.value = false
  232. }
  233. const validateForm = () => {
  234. if (!domainModel.domain) {
  235. toast.add({ severity: 'warn', summary: '警告', detail: '域名不能为空', life: 3000 })
  236. return false
  237. }
  238. // 验证域名格式
  239. const domains = domainModel.domain.split('\n').filter((domain) => domain.trim())
  240. if (domains.length === 0) {
  241. toast.add({ severity: 'warn', summary: '警告', detail: '请输入有效的域名', life: 3000 })
  242. return false
  243. }
  244. // 管理员必须指定teamId
  245. if (isAdmin.value && !domainModel.teamId) {
  246. toast.add({ severity: 'warn', summary: '警告', detail: '请选择团队', life: 3000 })
  247. return false
  248. }
  249. return true
  250. }
  251. const onSubmit = async () => {
  252. if (!validateForm()) return
  253. try {
  254. if (isEditing.value) {
  255. // 编辑模式:只支持单个域名
  256. const domainData = {
  257. domain: domainModel.domain.trim(),
  258. description: domainModel.description
  259. }
  260. // 管理员需要传递teamId
  261. if (isAdmin.value) {
  262. domainData.teamId = domainModel.teamId
  263. }
  264. await updateTeamDomain(selectedDomain.value.id, domainData)
  265. toast.add({ severity: 'success', summary: '成功', detail: '更新域名成功', life: 3000 })
  266. } else {
  267. // 创建模式:支持批量创建
  268. const domains = domainModel.domain.split('\n').filter((domain) => domain.trim())
  269. let successCount = 0
  270. let failCount = 0
  271. for (const domain of domains) {
  272. try {
  273. const domainData = {
  274. domain: domain.trim(),
  275. description: domainModel.description
  276. }
  277. // 管理员需要传递teamId
  278. if (isAdmin.value) {
  279. domainData.teamId = domainModel.teamId
  280. }
  281. await createTeamDomain(domainData)
  282. successCount++
  283. } catch (error) {
  284. console.error(`创建域名 ${domain} 失败:`, error)
  285. failCount++
  286. }
  287. }
  288. if (successCount > 0) {
  289. toast.add({
  290. severity: 'success',
  291. summary: '成功',
  292. detail: `成功创建 ${successCount} 个域名${failCount > 0 ? `,${failCount} 个失败` : ''}`,
  293. life: 3000
  294. })
  295. } else {
  296. toast.add({ severity: 'error', summary: '失败', detail: '所有域名创建失败', life: 3000 })
  297. }
  298. }
  299. dialogVisible.value = false
  300. refreshData()
  301. } catch (error) {
  302. toast.add({
  303. severity: 'error',
  304. summary: '错误',
  305. detail: isEditing.value ? '更新域名失败' : '创建域名失败',
  306. life: 3000
  307. })
  308. }
  309. }
  310. const onDelete = async (domain) => {
  311. confirm.require({
  312. message: `确定要删除域名 "${domain.domain}" 吗?`,
  313. header: '删除确认',
  314. icon: 'pi pi-exclamation-triangle',
  315. rejectLabel: '取消',
  316. rejectProps: {
  317. label: '取消',
  318. severity: 'secondary'
  319. },
  320. acceptLabel: '删除',
  321. acceptProps: {
  322. label: '删除',
  323. severity: 'danger'
  324. },
  325. accept: async () => {
  326. try {
  327. await deleteTeamDomain(domain.id)
  328. toast.add({ severity: 'success', summary: '成功', detail: '删除域名成功', life: 3000 })
  329. refreshData()
  330. } catch (error) {
  331. toast.add({ severity: 'error', summary: '错误', detail: '删除域名失败', life: 3000 })
  332. }
  333. }
  334. })
  335. }
  336. // 计算团队选项(仅管理员需要)
  337. const teamOptions = computed(() => {
  338. if (!isAdmin.value) return []
  339. return [
  340. { label: '全部团队', value: null },
  341. ...teamStore.teams.map((team) => ({
  342. label: team.name,
  343. value: team.id
  344. }))
  345. ]
  346. })
  347. // 获取团队名称
  348. const getTeamName = (teamId) => {
  349. if (!teamId) return '-'
  350. const team = teamStore.teams.find((t) => t.id === teamId)
  351. return team ? team.name : '-'
  352. }
  353. // 计算管理员的分组数据,转换为数组格式
  354. const adminGroupedList = computed(() => {
  355. if (!isAdmin.value) return []
  356. return Object.keys(adminGroupedData.value).map((teamId) => ({
  357. teamId: parseInt(teamId),
  358. teamName: getTeamName(parseInt(teamId)),
  359. domains: adminGroupedData.value[teamId] || []
  360. }))
  361. })
  362. onMounted(() => {
  363. fetchData()
  364. })
  365. </script>
  366. <template>
  367. <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
  368. <!-- 权限检查 -->
  369. <div v-if="!canView" class="text-center py-8">
  370. <p class="text-gray-500">您没有权限访问域名管理</p>
  371. </div>
  372. <!-- 主要内容 -->
  373. <div v-else>
  374. <!-- 操作栏 -->
  375. <div class="search-toolbar">
  376. <div class="toolbar-left">
  377. <Button icon="pi pi-refresh" @click="refreshData" size="small" label="刷新" />
  378. <Button v-if="canCreate" icon="pi pi-plus" @click="onEdit()" label="添加" size="small" />
  379. </div>
  380. <div class="toolbar-right">
  381. <div class="search-group">
  382. <InputText
  383. v-model="searchForm.domain"
  384. placeholder="域名搜索"
  385. size="small"
  386. class="search-field"
  387. @keyup.enter="handleSearch"
  388. />
  389. <Select
  390. v-if="isAdmin"
  391. v-model="searchForm.teamId"
  392. :options="teamOptions"
  393. optionLabel="label"
  394. optionValue="value"
  395. placeholder="选择团队"
  396. size="small"
  397. class="team-field"
  398. clearable
  399. />
  400. </div>
  401. <div class="action-group">
  402. <Button icon="pi pi-search" @click="handleSearch" label="搜索" size="small" severity="secondary" />
  403. <Button icon="pi pi-refresh" @click="handleRefresh" label="重置" size="small" />
  404. </div>
  405. </div>
  406. </div>
  407. <!-- 管理员卡片展示 -->
  408. <div v-if="isAdmin" class="space-y-6">
  409. <div v-for="team in adminGroupedList" :key="team.teamId" class="team-card">
  410. <div class="team-header">
  411. <h3 class="team-name">{{ team.teamName }}</h3>
  412. <span class="domain-count">{{ team.domains.length }} 个域名</span>
  413. </div>
  414. <div class="domains-grid">
  415. <div v-for="domain in team.domains" :key="domain.id" class="domain-card">
  416. <div class="domain-info">
  417. <div class="domain-name" @click="copyDomain(domain.domain)" :title="'点击复制: ' + domain.domain">
  418. {{ domain.domain }}
  419. <i class="pi pi-copy copy-icon"></i>
  420. </div>
  421. <div class="domain-description">{{ domain.description || '暂无描述' }}</div>
  422. <div class="domain-time">创建时间: {{ formatDate(domain.createdAt) }}</div>
  423. </div>
  424. <div class="domain-stats-section">
  425. <div class="domain-stats-row">
  426. <span class="stat-label">今日:</span>
  427. <span class="stat-item">
  428. <i class="pi pi-user text-xs"></i>
  429. <span class="stat-text">{{ domainStatistics[domain.domain]?.todayNewUsers || 0 }}</span>
  430. </span>
  431. <span class="stat-item">
  432. <i class="pi pi-dollar text-xs"></i>
  433. <span class="stat-text">{{ (domainStatistics[domain.domain]?.todayIncome || 0).toFixed(2) }}</span>
  434. </span>
  435. </div>
  436. <div class="domain-stats-row">
  437. <span class="stat-label">总计:</span>
  438. <span class="stat-item">
  439. <i class="pi pi-user text-xs"></i>
  440. <span class="stat-text">{{ domainAllStatistics[domain.domain]?.totalNewUsers || 0 }}</span>
  441. </span>
  442. <span class="stat-item">
  443. <i class="pi pi-dollar text-xs"></i>
  444. <span class="stat-text">{{
  445. (domainAllStatistics[domain.domain]?.totalIncome || 0).toFixed(2)
  446. }}</span>
  447. </span>
  448. </div>
  449. <div class="domain-actions">
  450. <Button v-if="canUpdate" icon="pi pi-pencil" @click="onEdit(domain)" size="small" text />
  451. <Button
  452. v-if="canDelete"
  453. icon="pi pi-trash"
  454. @click="onDelete(domain)"
  455. size="small"
  456. severity="danger"
  457. text
  458. />
  459. </div>
  460. </div>
  461. </div>
  462. </div>
  463. </div>
  464. <!-- 空数据提示 -->
  465. <div v-if="adminGroupedList.length === 0" class="text-center py-8 text-gray-500">暂无域名数据</div>
  466. </div>
  467. <!-- 其他角色表格展示 -->
  468. <div v-else>
  469. <DataTable
  470. ref="tableRef"
  471. :value="tableData.data"
  472. :paginator="true"
  473. paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown JumpToPageInput"
  474. currentPageReportTemplate="{totalRecords} 条记录 "
  475. :rows="Number(tableData.meta?.size || 20)"
  476. :rowsPerPageOptions="[10, 20, 50, 100]"
  477. :totalRecords="tableData.meta?.total || 0"
  478. @page="handlePageChange"
  479. lazy
  480. scrollable
  481. stripedRows
  482. showGridlines
  483. >
  484. <Column field="domain" header="域名" style="min-width: 200px" headerClass="font-bold"></Column>
  485. <Column field="description" header="描述" style="min-width: 250px" headerClass="font-bold">
  486. <template #body="slotProps">
  487. <div class="max-w-xl truncate">
  488. {{ slotProps.data.description || '-' }}
  489. </div>
  490. </template>
  491. </Column>
  492. <Column field="createdAt" header="创建时间" style="min-width: 150px" headerClass="font-bold">
  493. <template #body="slotProps">
  494. {{ formatDate(slotProps.data.createdAt) }}
  495. </template>
  496. </Column>
  497. <Column header="操作" style="min-width: 120px" headerClass="font-bold" align="center">
  498. <template #body="slotProps">
  499. <div class="flex gap-2 justify-center">
  500. <Button v-if="canUpdate" icon="pi pi-pencil" @click="onEdit(slotProps.data)" size="small" />
  501. <Button
  502. v-if="canDelete"
  503. icon="pi pi-trash"
  504. @click="onDelete(slotProps.data)"
  505. size="small"
  506. severity="danger"
  507. />
  508. </div>
  509. </template>
  510. </Column>
  511. </DataTable>
  512. </div>
  513. <!-- 添加/编辑域名对话框 -->
  514. <Dialog
  515. v-model:visible="dialogVisible"
  516. :modal="true"
  517. :header="isEditing ? '编辑域名' : '添加域名'"
  518. :style="{ width: '550px' }"
  519. >
  520. <div class="grid grid-cols-1 gap-4 p-4">
  521. <!-- 管理员选择团队 -->
  522. <div v-if="isAdmin" class="flex flex-col gap-2">
  523. <label class="font-medium">选择团队</label>
  524. <Select
  525. v-model="domainModel.teamId"
  526. :options="teamOptions"
  527. optionLabel="label"
  528. optionValue="value"
  529. placeholder="请选择团队"
  530. :disabled="isEditing"
  531. :showClear="true"
  532. />
  533. <small v-if="!domainModel.teamId" class="text-red-500">请选择团队</small>
  534. </div>
  535. <div class="flex flex-col gap-2">
  536. <label class="font-medium">域名</label>
  537. <Textarea v-model="domainModel.domain" placeholder="请输入域名,多个域名请换行输入" rows="4" />
  538. <small v-if="!domainModel.domain" class="text-red-500">请输入域名</small>
  539. </div>
  540. <div class="flex flex-col gap-2">
  541. <label class="font-medium">描述</label>
  542. <Textarea v-model="domainModel.description" placeholder="请输入域名描述" rows="3" />
  543. </div>
  544. </div>
  545. <template #footer>
  546. <Button label="取消" icon="pi pi-times" @click="onCancel" class="p-button-text" />
  547. <Button label="保存" icon="pi pi-check" @click="onSubmit" />
  548. </template>
  549. </Dialog>
  550. </div>
  551. </div>
  552. </template>
  553. <style scoped>
  554. .team-name-text {
  555. color: #7c3aed;
  556. font-weight: 500;
  557. }
  558. /* 管理员卡片样式 */
  559. .team-card {
  560. background: white;
  561. border-radius: 12px;
  562. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  563. overflow: hidden;
  564. transition: box-shadow 0.3s ease;
  565. }
  566. .team-card:hover {
  567. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  568. }
  569. .team-header {
  570. background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
  571. color: #475569;
  572. padding: 16px 20px;
  573. display: flex;
  574. justify-content: space-between;
  575. align-items: center;
  576. }
  577. .team-name {
  578. font-size: 18px;
  579. font-weight: 600;
  580. margin: 0;
  581. }
  582. .domain-count {
  583. background: rgba(71, 85, 105, 0.1);
  584. color: #475569;
  585. padding: 4px 12px;
  586. border-radius: 20px;
  587. font-size: 14px;
  588. font-weight: 500;
  589. }
  590. .domains-grid {
  591. padding: 20px;
  592. display: grid;
  593. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  594. gap: 16px;
  595. }
  596. .domain-card {
  597. background: #f8fafc;
  598. border: 1px solid #e2e8f0;
  599. border-radius: 8px;
  600. padding: 16px;
  601. display: flex;
  602. flex-direction: column;
  603. transition: all 0.3s ease;
  604. }
  605. .domain-card:hover {
  606. background: #f1f5f9;
  607. border-color: #cbd5e1;
  608. transform: translateY(-2px);
  609. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  610. }
  611. .domain-info {
  612. flex: 1;
  613. margin-right: 12px;
  614. }
  615. .domain-name {
  616. font-size: 16px;
  617. font-weight: 600;
  618. color: #1e40af;
  619. margin-bottom: 8px;
  620. background: #dbeafe;
  621. padding: 8px 12px;
  622. border-radius: 6px;
  623. cursor: pointer;
  624. display: flex;
  625. justify-content: space-between;
  626. align-items: center;
  627. transition: all 0.3s ease;
  628. border: 1px solid #bfdbfe;
  629. }
  630. .domain-name:hover {
  631. background: #bfdbfe;
  632. border-color: #93c5fd;
  633. transform: translateY(-1px);
  634. box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
  635. }
  636. .copy-icon {
  637. font-size: 14px;
  638. opacity: 0.6;
  639. transition: opacity 0.3s ease;
  640. }
  641. .domain-name:hover .copy-icon {
  642. opacity: 1;
  643. }
  644. .domain-description {
  645. font-size: 14px;
  646. color: #475569;
  647. margin-bottom: 8px;
  648. line-height: 1.4;
  649. background: #f1f5f9;
  650. padding: 12px;
  651. border-radius: 6px;
  652. min-height: 60px;
  653. display: flex;
  654. align-items: flex-start;
  655. }
  656. .domain-time {
  657. font-size: 12px;
  658. color: #94a3b8;
  659. }
  660. .domain-actions {
  661. display: flex;
  662. gap: 8px;
  663. flex-shrink: 0;
  664. }
  665. .domain-stats-section {
  666. margin-top: 12px;
  667. padding-top: 12px;
  668. border-top: 1px solid #e2e8f0;
  669. position: relative;
  670. }
  671. .domain-stats-row {
  672. display: flex;
  673. gap: 12px;
  674. align-items: center;
  675. margin-bottom: 6px;
  676. }
  677. .domain-stats-row:last-of-type {
  678. margin-bottom: 12px;
  679. }
  680. .domain-actions {
  681. position: absolute;
  682. bottom: 0;
  683. right: 0;
  684. display: flex;
  685. gap: 8px;
  686. }
  687. .stat-label {
  688. font-size: 12px;
  689. color: #475569;
  690. font-weight: 600;
  691. }
  692. .stat-item {
  693. display: flex;
  694. align-items: center;
  695. gap: 4px;
  696. font-size: 12px;
  697. color: #64748b;
  698. }
  699. .stat-item i {
  700. color: #94a3b8;
  701. }
  702. .stat-text {
  703. font-weight: 500;
  704. }
  705. .domain-actions-bottom .p-button {
  706. width: 28px;
  707. height: 28px;
  708. padding: 0;
  709. background: transparent !important;
  710. border: none !important;
  711. box-shadow: none !important;
  712. }
  713. .domain-actions-bottom .p-button:hover {
  714. background: rgba(0, 0, 0, 0.05) !important;
  715. }
  716. .domain-actions-bottom .p-button.p-button-danger:hover {
  717. background: rgba(239, 68, 68, 0.1) !important;
  718. }
  719. /* 搜索工具栏样式 */
  720. .search-toolbar {
  721. display: flex;
  722. flex-wrap: wrap;
  723. align-items: center;
  724. justify-content: space-between;
  725. gap: 16px;
  726. margin-bottom: 20px;
  727. padding: 16px;
  728. background: #f8fafc;
  729. border-radius: 8px;
  730. border: 1px solid #e2e8f0;
  731. }
  732. .toolbar-left {
  733. display: flex;
  734. gap: 8px;
  735. align-items: center;
  736. }
  737. .toolbar-left .p-button {
  738. font-size: 13px;
  739. padding: 6px 12px;
  740. border-radius: 6px;
  741. font-weight: 500;
  742. }
  743. .toolbar-right {
  744. display: flex;
  745. gap: 12px;
  746. align-items: center;
  747. flex-wrap: wrap;
  748. }
  749. .search-group {
  750. display: flex;
  751. gap: 8px;
  752. align-items: center;
  753. }
  754. .search-field {
  755. width: 140px;
  756. font-size: 13px;
  757. padding: 6px 10px;
  758. border-radius: 6px;
  759. border: 1px solid #d1d5db;
  760. transition: all 0.2s ease;
  761. }
  762. .search-field:focus {
  763. border-color: #3b82f6;
  764. box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  765. }
  766. .team-field {
  767. width: 160px;
  768. font-size: 13px;
  769. }
  770. .team-field .p-select {
  771. border-radius: 6px;
  772. }
  773. .action-group {
  774. display: flex;
  775. gap: 8px;
  776. align-items: center;
  777. }
  778. .action-group .p-button {
  779. font-size: 13px;
  780. padding: 6px 12px;
  781. border-radius: 6px;
  782. font-weight: 500;
  783. }
  784. /* 响应式设计 */
  785. @media (max-width: 768px) {
  786. .domains-grid {
  787. grid-template-columns: 1fr;
  788. }
  789. .team-header {
  790. flex-direction: column;
  791. gap: 8px;
  792. text-align: center;
  793. }
  794. .domain-card {
  795. flex-direction: column;
  796. }
  797. .domain-stats-section {
  798. position: relative;
  799. }
  800. .domain-stats-row {
  801. justify-content: center;
  802. }
  803. .domain-actions {
  804. position: static;
  805. justify-content: center;
  806. margin-top: 8px;
  807. }
  808. /* 移动端搜索工具栏适配 */
  809. .search-toolbar {
  810. flex-direction: column;
  811. gap: 12px;
  812. align-items: stretch;
  813. padding: 12px;
  814. }
  815. .toolbar-left {
  816. justify-content: center;
  817. gap: 8px;
  818. }
  819. .toolbar-left .p-button {
  820. flex: 1;
  821. max-width: 120px;
  822. font-size: 13px;
  823. padding: 8px 12px;
  824. }
  825. .toolbar-right {
  826. flex-direction: column;
  827. gap: 12px;
  828. align-items: stretch;
  829. }
  830. .search-group {
  831. flex-direction: column;
  832. gap: 8px;
  833. }
  834. .search-field,
  835. .team-field {
  836. width: 100%;
  837. font-size: 14px;
  838. padding: 10px 12px;
  839. }
  840. .action-group {
  841. justify-content: center;
  842. gap: 8px;
  843. }
  844. .action-group .p-button {
  845. flex: 1;
  846. max-width: 140px;
  847. font-size: 13px;
  848. padding: 8px 12px;
  849. }
  850. }
  851. </style>