feat: migrate app routes and business modules
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="家族视频"
|
||||
:action="pageState === 'list' ? '发布' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="openPublishForm"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">{{
|
||||
isEdit ? "编辑家族视频" : "发布家族视频"
|
||||
}}</text>
|
||||
<text class="video-panel__note">{{
|
||||
isEdit
|
||||
? "原视频、封面和相关设置会保留。"
|
||||
: "视频上传成功后会自动关联,无需额外填写。"
|
||||
}}</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"
|
||||
><text class="required-mark">*</text>视频文件</text
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="isEdit || uploading || coverUploading || submitting"
|
||||
@click="selectVideo"
|
||||
>
|
||||
{{
|
||||
isEdit
|
||||
? "当前视频已关联"
|
||||
: uploading
|
||||
? "上传中…"
|
||||
: receipt
|
||||
? "重新选择视频"
|
||||
: "选择视频"
|
||||
}}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt"
|
||||
>已上传:{{ receipt.fileName || "视频" }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label">视频封面</text>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || coverUploading || submitting"
|
||||
@click="selectCover"
|
||||
>
|
||||
{{
|
||||
coverUploading
|
||||
? "上传中…"
|
||||
: coverReceipt
|
||||
? "重新选择封面"
|
||||
: "选择图片"
|
||||
}}
|
||||
</button>
|
||||
<text v-if="coverReceipt" class="upload-receipt"
|
||||
>已上传:{{ coverReceipt.fileName || "视频封面" }}</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="coverUploadError" class="field-error">{{
|
||||
coverUploadError
|
||||
}}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"
|
||||
><text class="required-mark">*</text>视频标题</text
|
||||
>
|
||||
<input
|
||||
v-model="form.videoTitle"
|
||||
maxlength="100"
|
||||
placeholder="例如:2026 年清明祭祖活动"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea
|
||||
v-model="form.videoDesc"
|
||||
maxlength="500"
|
||||
auto-height
|
||||
placeholder="补充视频中的人物、场景或故事"
|
||||
placeholder-class="placeholder"
|
||||
@input="submitError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="uploading || coverUploading || submitting"
|
||||
:label="submitting ? '正在提交…' : isEdit ? '保存视频' : '发布视频'"
|
||||
@click="submitVideo"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'form-loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取视频详情" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="pageState === 'list'" class="video-list-panel">
|
||||
<view v-if="videoListState === 'loading'" class="video-state-card">
|
||||
<AppLoading text="正在读取家族视频" />
|
||||
</view>
|
||||
<view v-else-if="videoListState === 'error'" class="video-state-card">
|
||||
<text class="video-state-card__title">暂时无法读取视频</text>
|
||||
<text class="video-state-card__copy">请检查网络后重新加载。</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadVideos"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="!videos.length" class="video-state-card">
|
||||
<text class="video-state-card__title">还没有家族视频</text>
|
||||
<text class="video-state-card__copy"
|
||||
>可以先发布一段值得留存的影像。</text
|
||||
>
|
||||
<AppButton block label="发布视频" @click="openPublishForm" />
|
||||
</view>
|
||||
<view v-else class="video-card-list">
|
||||
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||
<video
|
||||
class="video-card__player"
|
||||
:src="video.videoFile.accessUrl"
|
||||
controls
|
||||
/>
|
||||
<text class="video-card__title">{{ video.title }}</text>
|
||||
<text v-if="video.description" class="video-card__copy">{{
|
||||
video.description
|
||||
}}</text>
|
||||
<text class="video-card__meta">{{
|
||||
video.publishTime || "刚刚发布"
|
||||
}}</text>
|
||||
<view
|
||||
v-if="video.canEdit || video.canDelete"
|
||||
class="video-card__actions"
|
||||
>
|
||||
<AppButton
|
||||
v-if="video.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑视频"
|
||||
@click="openEditVideo(video)"
|
||||
/>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="deletingVideoId === video.id"
|
||||
:label="deletingVideoId === video.id ? '正在删除' : '删除视频'"
|
||||
@click="requestDeleteVideo(video)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="videoActionError" class="field-error">{{
|
||||
videoActionError
|
||||
}}</text>
|
||||
</view>
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这段家族视频?"
|
||||
:message="deleteTarget ? `《${deleteTarget.title}》删除后不可恢复。` : ''"
|
||||
:confirm-text="deletingVideoId ? '正在删除' : '确认删除'"
|
||||
cancel-text="保留视频"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDeleteVideo"
|
||||
@cancel="deleteConfirmVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
isVideoPickCancelled,
|
||||
pickAndUploadImage,
|
||||
pickAndUploadVideo,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("list");
|
||||
const videoListState = ref("loading");
|
||||
const videos = ref([]);
|
||||
const receipt = ref(null);
|
||||
const coverReceipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const coverUploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverUploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const editingVideo = ref(null);
|
||||
const videoListRequestController = createRequestController();
|
||||
const videoDetailRequestController = createRequestController();
|
||||
const videoUploadRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const videoSaveRequestController = createRequestController();
|
||||
const videoDeletionRequestController = createRequestController();
|
||||
const videoCreateGuard = createNonIdempotentWriteGuard();
|
||||
const deleteTarget = ref(null);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
const deletingVideoId = ref("");
|
||||
const videoActionError = ref("");
|
||||
let pageActive = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingVideo.value));
|
||||
const stateCopy = computed(() =>
|
||||
pageState.value === "success"
|
||||
? {
|
||||
title: "视频已保存",
|
||||
copy: "视频已关联到当前家谱,播放内容准备好后会在这里显示。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "暂时无法发布视频",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
else loadVideos();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
videoListRequestController.abort();
|
||||
videoDetailRequestController.abort();
|
||||
videoUploadRequestController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
videoSaveRequestController.abort();
|
||||
videoDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const loadVideos = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
videoListState.value = "loading";
|
||||
try {
|
||||
const videoRows = await familyMediaApi.getVideos(genealogyId.value, {
|
||||
requestController: videoListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
videos.value = videoRows;
|
||||
videoListState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoListState.value = "error";
|
||||
}
|
||||
};
|
||||
const openPublishForm = () => {
|
||||
if (!hasValidContext.value) return;
|
||||
receipt.value = null;
|
||||
coverReceipt.value = null;
|
||||
editingVideo.value = null;
|
||||
form.videoTitle = "";
|
||||
form.videoDesc = "";
|
||||
uploadError.value = "";
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
};
|
||||
const openEditVideo = async (video) => {
|
||||
if (
|
||||
!video?.canEdit ||
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return;
|
||||
pageState.value = "form-loading";
|
||||
videoActionError.value = "";
|
||||
try {
|
||||
const detail = await familyMediaApi.getVideoDetail(genealogyId.value, video.id, {
|
||||
requestController: videoDetailRequestController,
|
||||
});
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.videoFile?.ossId ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.durationSeconds) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("视频信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
receipt.value = {
|
||||
ossId: detail.videoFile.ossId,
|
||||
fileName: detail.videoFile.fileName,
|
||||
};
|
||||
coverReceipt.value = detail.coverFile
|
||||
? { ossId: detail.coverFile.ossId, fileName: detail.coverFile.fileName }
|
||||
: null;
|
||||
form.videoTitle = detail.title;
|
||||
form.videoDesc = detail.description;
|
||||
editingVideo.value = {
|
||||
id: detail.id,
|
||||
durationSeconds: detail.durationSeconds,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
uploadError.value = "";
|
||||
coverUploadError.value = "";
|
||||
submitError.value = "";
|
||||
pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) {
|
||||
videoActionError.value = "视频信息不完整,暂未保存修改,以免覆盖原内容。";
|
||||
pageState.value = "list";
|
||||
}
|
||||
}
|
||||
};
|
||||
const requestDeleteVideo = (video) => {
|
||||
if (!video?.canDelete || deletingVideoId.value) return;
|
||||
videoActionError.value = "";
|
||||
deleteTarget.value = video;
|
||||
deleteConfirmVisible.value = true;
|
||||
};
|
||||
const confirmDeleteVideo = async () => {
|
||||
const target = deleteTarget.value;
|
||||
if (!target?.canDelete || deletingVideoId.value) return;
|
||||
deletingVideoId.value = target.id;
|
||||
videoActionError.value = "";
|
||||
try {
|
||||
await familyMediaApi.deleteVideo(genealogyId.value, target.id, {
|
||||
requestController: videoDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadVideos();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
videoActionError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"视频删除失败,请稍后重试",
|
||||
);
|
||||
deleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deletingVideoId.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (
|
||||
isEdit.value ||
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const videoReceipt = await pickAndUploadVideo({
|
||||
requestController: videoUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
receipt.value = videoReceipt;
|
||||
} catch (error) {
|
||||
if (pageActive && !isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = getRequestErrorMessage(error, "视频上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectCover = async () => {
|
||||
if (uploading.value || coverUploading.value || submitting.value) return;
|
||||
coverUploading.value = true;
|
||||
coverUploadError.value = "";
|
||||
try {
|
||||
const uploadedCover = await pickAndUploadImage({
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverReceipt.value = uploadedCover;
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
coverUploadError.value = getRequestErrorMessage(
|
||||
error,
|
||||
"视频封面上传失败,请稍后重试",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) coverUploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (
|
||||
uploading.value ||
|
||||
coverUploading.value ||
|
||||
submitting.value ||
|
||||
!hasValidContext.value
|
||||
)
|
||||
return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
...(coverReceipt.value ? { coverOssId: coverReceipt.value.ossId } : {}),
|
||||
...(editingVideo.value
|
||||
? {
|
||||
durationSeconds: editingVideo.value.durationSeconds,
|
||||
sortOrder: editingVideo.value.sortOrder,
|
||||
status: editingVideo.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingVideo.value ? null : videoCreateGuard.begin(payload);
|
||||
if (!editingVideo.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
if (editingVideo.value) {
|
||||
await familyMediaApi.updateVideo(
|
||||
genealogyId.value,
|
||||
editingVideo.value.id,
|
||||
payload,
|
||||
{
|
||||
requestController: videoSaveRequestController,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await familyMediaApi.createVideo(genealogyId.value, payload, {
|
||||
requestController: videoSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
editingVideo.value = null;
|
||||
pageState.value = "list";
|
||||
await loadVideos();
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!editingVideo.value && videoCreateGuard.recordFailure(createAttempt, error)) {
|
||||
submitError.value =
|
||||
"发布结果暂时无法确认,请先返回视频列表检查,避免重复发布。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = getRequestErrorMessage(error, "视频发布失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.video-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.video-panel,
|
||||
.video-state-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.video-list-panel {
|
||||
@include adaptive-family-content;
|
||||
}
|
||||
.video-panel {
|
||||
padding: 30rpx;
|
||||
}
|
||||
.video-panel__title,
|
||||
.video-panel__note,
|
||||
.video-field__label,
|
||||
.video-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.video-panel__title,
|
||||
.video-state-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-panel__note,
|
||||
.video-state-card__copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.video-field {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.video-field__label {
|
||||
margin-bottom: 10rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-field input,
|
||||
.video-field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.video-field input {
|
||||
min-height: 76rpx;
|
||||
padding: 0 18rpx;
|
||||
}
|
||||
.video-field textarea {
|
||||
min-height: 140rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
}
|
||||
.video-field--upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.video-field--upload .video-field__label {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-button {
|
||||
margin: 0;
|
||||
padding: 0 26rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 8rpx;
|
||||
background: #fffaf0;
|
||||
color: #805723;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 64rpx;
|
||||
}
|
||||
.upload-receipt {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.required-mark,
|
||||
.field-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.field-error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.video-panel .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.video-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-card-list {
|
||||
display: grid;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.video-card {
|
||||
padding: 22rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.78);
|
||||
}
|
||||
.video-card__player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 340rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #161616;
|
||||
}
|
||||
.video-card__title,
|
||||
.video-card__copy,
|
||||
.video-card__meta {
|
||||
display: block;
|
||||
}
|
||||
.video-card__title {
|
||||
margin-top: 16rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(17px, 28rpx, 21px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-card__copy {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.video-card__meta {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.video-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user