Compare commits
4 commits
1b01182028
...
52332babea
| Author | SHA1 | Date | |
|---|---|---|---|
| 52332babea | |||
| 0c91abd722 | |||
| 4d37f0dfe2 | |||
| e216539f34 |
10 changed files with 371 additions and 59 deletions
3
.env
3
.env
|
|
@ -19,3 +19,6 @@ RUSTFS_BUCKET=fromis-9
|
|||
|
||||
# Kakao API
|
||||
KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8
|
||||
|
||||
# YouTube API
|
||||
YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
|
||||
|
|
|
|||
|
|
@ -1176,7 +1176,7 @@ router.get("/schedules", async (req, res) => {
|
|||
const [schedules] = await pool.query(
|
||||
`SELECT
|
||||
s.id, s.title, s.date, s.time, s.end_date, s.end_time,
|
||||
s.category_id, s.description, s.source_url,
|
||||
s.category_id, s.description, s.source_url, s.source_name,
|
||||
s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng,
|
||||
s.created_at,
|
||||
c.name as category_name, c.color as category_color
|
||||
|
|
@ -1233,6 +1233,7 @@ router.post(
|
|||
category,
|
||||
description,
|
||||
url,
|
||||
sourceName,
|
||||
members,
|
||||
locationName,
|
||||
locationAddress,
|
||||
|
|
@ -1249,9 +1250,9 @@ router.post(
|
|||
// 일정 삽입
|
||||
const [scheduleResult] = await connection.query(
|
||||
`INSERT INTO schedules
|
||||
(title, date, time, end_date, end_time, category_id, description, source_url,
|
||||
(title, date, time, end_date, end_time, category_id, description, source_url, source_name,
|
||||
location_name, location_address, location_detail, location_lat, location_lng)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
title,
|
||||
date,
|
||||
|
|
@ -1261,6 +1262,7 @@ router.post(
|
|||
category || null,
|
||||
description || null,
|
||||
url || null,
|
||||
sourceName || null,
|
||||
locationName || null,
|
||||
locationAddress || null,
|
||||
locationDetail || null,
|
||||
|
|
@ -1426,6 +1428,7 @@ router.put(
|
|||
category,
|
||||
description,
|
||||
url,
|
||||
sourceName,
|
||||
members,
|
||||
locationName,
|
||||
locationAddress,
|
||||
|
|
@ -1451,6 +1454,7 @@ router.put(
|
|||
category_id = ?,
|
||||
description = ?,
|
||||
source_url = ?,
|
||||
source_name = ?,
|
||||
location_name = ?,
|
||||
location_address = ?,
|
||||
location_detail = ?,
|
||||
|
|
@ -1466,6 +1470,7 @@ router.put(
|
|||
category || null,
|
||||
description || null,
|
||||
url || null,
|
||||
sourceName || null,
|
||||
locationName || null,
|
||||
locationAddress || null,
|
||||
locationDetail || null,
|
||||
|
|
@ -1662,12 +1667,12 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
|
|||
// 봇 목록 조회
|
||||
router.get("/bots", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [bots] = await pool.query(
|
||||
`SELECT b.*, c.channel_id, c.channel_name, c.rss_url, c.include_shorts
|
||||
FROM bots b
|
||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
ORDER BY b.created_at DESC`
|
||||
);
|
||||
const [bots] = await pool.query(`
|
||||
SELECT b.*, c.channel_id, c.rss_url, c.channel_name
|
||||
FROM bots b
|
||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
ORDER BY b.id ASC
|
||||
`);
|
||||
res.json(bots);
|
||||
} catch (error) {
|
||||
console.error("봇 목록 조회 오류:", error);
|
||||
|
|
|
|||
|
|
@ -15,11 +15,16 @@ router.get("/", async (req, res) => {
|
|||
s.time,
|
||||
s.category_id,
|
||||
s.source_url,
|
||||
s.source_name,
|
||||
s.location_name,
|
||||
c.name as category_name,
|
||||
c.color as category_color
|
||||
c.color as category_color,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
GROUP BY s.id
|
||||
ORDER BY s.date DESC, s.time DESC
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import albumsRouter from "./routes/albums.js";
|
|||
import statsRouter from "./routes/stats.js";
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import schedulesRouter from "./routes/schedules.js";
|
||||
import { initScheduler } from "./services/youtube-scheduler.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
|
@ -40,6 +41,14 @@ app.get("*", (req, res) => {
|
|||
res.sendFile(path.join(__dirname, "dist", "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
|
||||
|
||||
// YouTube 봇 스케줄러 초기화
|
||||
try {
|
||||
await initScheduler();
|
||||
console.log("📺 YouTube 봇 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("YouTube 스케줄러 초기화 오류:", error);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,12 +5,37 @@ import pool from "../lib/db.js";
|
|||
const YOUTUBE_API_KEY =
|
||||
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
|
||||
|
||||
// RSS 파서 설정
|
||||
// 봇별 커스텀 설정 (DB 대신 코드에서 관리)
|
||||
// botId를 키로 사용
|
||||
const BOT_CUSTOM_CONFIG = {
|
||||
// MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출
|
||||
3: {
|
||||
titleFilter: "성수기",
|
||||
defaultMemberId: 7, // 이채영
|
||||
extractMembersFromDesc: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 봇 커스텀 설정 조회
|
||||
*/
|
||||
function getBotCustomConfig(botId) {
|
||||
return (
|
||||
BOT_CUSTOM_CONFIG[botId] || {
|
||||
titleFilter: null,
|
||||
defaultMemberId: null,
|
||||
extractMembersFromDesc: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// RSS 파서 설정 (media:description 포함)
|
||||
const rssParser = new Parser({
|
||||
customFields: {
|
||||
item: [
|
||||
["yt:videoId", "videoId"],
|
||||
["yt:channelId", "channelId"],
|
||||
["media:group", "mediaGroup"],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -89,9 +114,16 @@ export async function parseRSSFeed(rssUrl) {
|
|||
const videoType = getVideoType(link);
|
||||
const publishedAt = toKST(new Date(item.pubDate));
|
||||
|
||||
// media:group에서 description 추출
|
||||
let description = "";
|
||||
if (item.mediaGroup && item.mediaGroup["media:description"]) {
|
||||
description = item.mediaGroup["media:description"][0] || "";
|
||||
}
|
||||
|
||||
return {
|
||||
videoId,
|
||||
title: item.title,
|
||||
description,
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
|
|
@ -186,6 +218,7 @@ export async function fetchAllVideosFromAPI(channelId) {
|
|||
videos.push({
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || "",
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
|
|
@ -209,8 +242,17 @@ export async function fetchAllVideosFromAPI(channelId) {
|
|||
|
||||
/**
|
||||
* 영상을 일정으로 추가 (source_url로 중복 체크)
|
||||
* @param {Object} video - 영상 정보
|
||||
* @param {number} categoryId - 카테고리 ID
|
||||
* @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택)
|
||||
* @param {string} sourceName - 출처 이름 (선택)
|
||||
*/
|
||||
export async function createScheduleFromVideo(video, categoryId) {
|
||||
export async function createScheduleFromVideo(
|
||||
video,
|
||||
categoryId,
|
||||
memberIds = [],
|
||||
sourceName = null
|
||||
) {
|
||||
try {
|
||||
// source_url로 중복 체크
|
||||
const [existing] = await pool.query(
|
||||
|
|
@ -224,29 +266,80 @@ export async function createScheduleFromVideo(video, categoryId) {
|
|||
|
||||
// 일정 생성
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO schedules (title, date, time, category_id, source_url)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[video.title, video.date, video.time, categoryId, video.videoUrl]
|
||||
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
video.title,
|
||||
video.date,
|
||||
video.time,
|
||||
categoryId,
|
||||
video.videoUrl,
|
||||
sourceName,
|
||||
]
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// 멤버 연결
|
||||
if (memberIds.length > 0) {
|
||||
const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거
|
||||
const memberValues = uniqueMemberIds.map((memberId) => [
|
||||
scheduleId,
|
||||
memberId,
|
||||
]);
|
||||
await pool.query(
|
||||
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
|
||||
[memberValues]
|
||||
);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
} catch (error) {
|
||||
console.error("일정 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 이름 목록 조회
|
||||
*/
|
||||
async function getMemberNameMap() {
|
||||
const [members] = await pool.query("SELECT id, name FROM members");
|
||||
const nameMap = {};
|
||||
for (const m of members) {
|
||||
nameMap[m.name] = m.id;
|
||||
}
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* description에서 멤버 이름 추출
|
||||
*/
|
||||
function extractMemberIdsFromDescription(description, memberNameMap) {
|
||||
if (!description) return [];
|
||||
|
||||
const memberIds = [];
|
||||
for (const [name, id] of Object.entries(memberNameMap)) {
|
||||
if (description.includes(name)) {
|
||||
memberIds.push(id);
|
||||
}
|
||||
}
|
||||
return memberIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇의 새 영상 동기화 (RSS 기반)
|
||||
*/
|
||||
export async function syncNewVideos(botId) {
|
||||
try {
|
||||
// 봇 정보 조회
|
||||
// 봇 정보 조회 (bot_youtube_config 조인)
|
||||
const [bots] = await pool.query(
|
||||
`SELECT b.*, c.channel_id, c.rss_url, c.include_shorts
|
||||
FROM bots b
|
||||
JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?`,
|
||||
`
|
||||
SELECT b.*, c.channel_id, c.rss_url
|
||||
FROM bots b
|
||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?
|
||||
`,
|
||||
[botId]
|
||||
);
|
||||
|
||||
|
|
@ -255,19 +348,58 @@ export async function syncNewVideos(botId) {
|
|||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
if (!bot.rss_url) {
|
||||
throw new Error("RSS URL이 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
// 봇별 커스텀 설정 조회
|
||||
const customConfig = getBotCustomConfig(botId);
|
||||
|
||||
const categoryId = await getYoutubeCategory();
|
||||
|
||||
// RSS 피드 파싱
|
||||
const videos = await parseRSSFeed(bot.rss_url);
|
||||
let addedCount = 0;
|
||||
|
||||
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
||||
let memberNameMap = null;
|
||||
if (customConfig.extractMembersFromDesc) {
|
||||
memberNameMap = await getMemberNameMap();
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
// Shorts 필터링
|
||||
if (!bot.include_shorts && video.videoType === "shorts") {
|
||||
continue;
|
||||
// 제목 필터 적용 (설정된 경우)
|
||||
if (
|
||||
customConfig.titleFilter &&
|
||||
!video.title.includes(customConfig.titleFilter)
|
||||
) {
|
||||
continue; // 필터에 맞지 않으면 스킵
|
||||
}
|
||||
|
||||
const scheduleId = await createScheduleFromVideo(video, categoryId);
|
||||
// 멤버 ID 수집
|
||||
const memberIds = [];
|
||||
|
||||
// 기본 멤버 추가
|
||||
if (customConfig.defaultMemberId) {
|
||||
memberIds.push(customConfig.defaultMemberId);
|
||||
}
|
||||
|
||||
// description에서 멤버 추출 (설정된 경우)
|
||||
if (customConfig.extractMembersFromDesc && memberNameMap) {
|
||||
const extractedIds = extractMemberIdsFromDescription(
|
||||
video.description,
|
||||
memberNameMap
|
||||
);
|
||||
memberIds.push(...extractedIds);
|
||||
}
|
||||
|
||||
const scheduleId = await createScheduleFromVideo(
|
||||
video,
|
||||
categoryId,
|
||||
memberIds,
|
||||
bot.name
|
||||
);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
}
|
||||
|
|
@ -303,12 +435,14 @@ export async function syncNewVideos(botId) {
|
|||
*/
|
||||
export async function syncAllVideos(botId) {
|
||||
try {
|
||||
// 봇 정보 조회
|
||||
// 봇 정보 조회 (bot_youtube_config 조인)
|
||||
const [bots] = await pool.query(
|
||||
`SELECT b.*, c.channel_id, c.include_shorts
|
||||
FROM bots b
|
||||
JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?`,
|
||||
`
|
||||
SELECT b.*, c.channel_id, c.rss_url
|
||||
FROM bots b
|
||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?
|
||||
`,
|
||||
[botId]
|
||||
);
|
||||
|
||||
|
|
@ -317,19 +451,58 @@ export async function syncAllVideos(botId) {
|
|||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
if (!bot.channel_id) {
|
||||
throw new Error("Channel ID가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
// 봇별 커스텀 설정 조회
|
||||
const customConfig = getBotCustomConfig(botId);
|
||||
|
||||
const categoryId = await getYoutubeCategory();
|
||||
|
||||
// API로 전체 영상 수집
|
||||
const videos = await fetchAllVideosFromAPI(bot.channel_id);
|
||||
let addedCount = 0;
|
||||
|
||||
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
||||
let memberNameMap = null;
|
||||
if (customConfig.extractMembersFromDesc) {
|
||||
memberNameMap = await getMemberNameMap();
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
// Shorts 필터링
|
||||
if (!bot.include_shorts && video.videoType === "shorts") {
|
||||
continue;
|
||||
// 제목 필터 적용 (설정된 경우)
|
||||
if (
|
||||
customConfig.titleFilter &&
|
||||
!video.title.includes(customConfig.titleFilter)
|
||||
) {
|
||||
continue; // 필터에 맞지 않으면 스킵
|
||||
}
|
||||
|
||||
const scheduleId = await createScheduleFromVideo(video, categoryId);
|
||||
// 멤버 ID 수집
|
||||
const memberIds = [];
|
||||
|
||||
// 기본 멤버 추가
|
||||
if (customConfig.defaultMemberId) {
|
||||
memberIds.push(customConfig.defaultMemberId);
|
||||
}
|
||||
|
||||
// description에서 멤버 추출 (설정된 경우)
|
||||
if (customConfig.extractMembersFromDesc && memberNameMap) {
|
||||
const extractedIds = extractMemberIdsFromDescription(
|
||||
video.description,
|
||||
memberNameMap
|
||||
);
|
||||
memberIds.push(...extractedIds);
|
||||
}
|
||||
|
||||
const scheduleId = await createScheduleFromVideo(
|
||||
video,
|
||||
categoryId,
|
||||
memberIds,
|
||||
bot.name
|
||||
);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,24 @@ import { syncNewVideos } from "./youtube-bot.js";
|
|||
// 봇별 스케줄러 인스턴스 저장
|
||||
const schedulers = new Map();
|
||||
|
||||
/**
|
||||
* 봇이 메모리에서 실행 중인지 확인
|
||||
*/
|
||||
export function isBotRunning(botId) {
|
||||
return schedulers.has(botId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 봇 스케줄 등록
|
||||
*/
|
||||
export function registerBot(botId, intervalMinutes = 2) {
|
||||
export function registerBot(botId, intervalMinutes = 2, cronExpression = null) {
|
||||
// 기존 스케줄이 있으면 제거
|
||||
unregisterBot(botId);
|
||||
|
||||
// cron 표현식 생성 (1분 시작, X분 간격: 1,3,5,7...)
|
||||
const cronExpression = `1-59/${intervalMinutes} * * * *`;
|
||||
// cron 표현식: 지정된 표현식 사용, 없으면 기본값 생성
|
||||
const expression = cronExpression || `1-59/${intervalMinutes} * * * *`;
|
||||
|
||||
const task = cron.schedule(cronExpression, async () => {
|
||||
const task = cron.schedule(expression, async () => {
|
||||
console.log(`[Bot ${botId}] 동기화 시작...`);
|
||||
try {
|
||||
const result = await syncNewVideos(botId);
|
||||
|
|
@ -26,9 +33,7 @@ export function registerBot(botId, intervalMinutes = 2) {
|
|||
});
|
||||
|
||||
schedulers.set(botId, task);
|
||||
console.log(
|
||||
`[Bot ${botId}] 스케줄 등록됨 (${intervalMinutes}분 간격, 1분 오프셋)`
|
||||
);
|
||||
console.log(`[Bot ${botId}] 스케줄 등록됨 (cron: ${expression})`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,20 +47,48 @@ export function unregisterBot(botId) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 10초 간격으로 메모리 상태와 DB status 동기화
|
||||
*/
|
||||
async function syncBotStatuses() {
|
||||
try {
|
||||
const [bots] = await pool.query("SELECT id, status FROM bots");
|
||||
|
||||
for (const bot of bots) {
|
||||
const isRunningInMemory = schedulers.has(bot.id);
|
||||
const isRunningInDB = bot.status === "running";
|
||||
|
||||
// 메모리에 없는데 DB가 running이면 → 서버 크래시 등으로 불일치, DB를 stopped로 업데이트
|
||||
if (!isRunningInMemory && isRunningInDB) {
|
||||
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [
|
||||
bot.id,
|
||||
]);
|
||||
console.log(`[Scheduler] Bot ${bot.id} 상태 동기화: stopped`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Scheduler] 상태 동기화 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시작 시 실행 중인 봇들 스케줄 등록
|
||||
*/
|
||||
export async function initScheduler() {
|
||||
try {
|
||||
const [bots] = await pool.query(
|
||||
"SELECT id, check_interval FROM bots WHERE status = 'running'"
|
||||
"SELECT id, check_interval, cron_expression FROM bots WHERE status = 'running'"
|
||||
);
|
||||
|
||||
for (const bot of bots) {
|
||||
registerBot(bot.id, bot.check_interval);
|
||||
registerBot(bot.id, bot.check_interval, bot.cron_expression);
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`);
|
||||
|
||||
// 10초 간격으로 상태 동기화 (DB status와 메모리 상태 일치 유지)
|
||||
setInterval(syncBotStatuses, 10000);
|
||||
console.log(`[Scheduler] 10초 간격 상태 동기화 시작`);
|
||||
} catch (error) {
|
||||
console.error("[Scheduler] 초기화 오류:", error);
|
||||
}
|
||||
|
|
@ -72,8 +105,8 @@ export async function startBot(botId) {
|
|||
|
||||
const bot = bots[0];
|
||||
|
||||
// 스케줄 등록
|
||||
registerBot(botId, bot.check_interval);
|
||||
// 스케줄 등록 (cron_expression 우선 사용)
|
||||
registerBot(botId, bot.check_interval, bot.cron_expression);
|
||||
|
||||
// 상태 업데이트
|
||||
await pool.query(
|
||||
|
|
@ -104,4 +137,5 @@ export default {
|
|||
unregisterBot,
|
||||
startBot,
|
||||
stopBot,
|
||||
isBotRunning,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -726,23 +726,55 @@ function Schedule() {
|
|||
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
||||
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={14} style={{ color: categoryColor }} />
|
||||
<Clock size={16} style={{ color: categoryColor }} />
|
||||
<span>{schedule.time.slice(0, 5)}</span>
|
||||
</div>
|
||||
)}
|
||||
{categoryName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
<span>{categoryName}</span>
|
||||
<span>
|
||||
{categoryName}
|
||||
{schedule.source_name && ` · ${schedule.source_name}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 멤버 태그 (별도 줄) */}
|
||||
{(() => {
|
||||
const memberList = schedule.members
|
||||
? schedule.members.map(m => m.name)
|
||||
: (schedule.member_names ? schedule.member_names.split(',') : []);
|
||||
if (memberList.length === 0) return null;
|
||||
|
||||
// 5명 이상이면 '프로미스나인' 단일 태그
|
||||
if (memberList.length >= 5) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 그 외에는 멤버별 개별 태그
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{memberList.map((name, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -952,10 +952,33 @@ function AdminSchedule() {
|
|||
>
|
||||
{schedule.category_name || '미지정'}
|
||||
</span>
|
||||
{schedule.source_name && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
|
||||
{schedule.source_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-400">{schedule.time?.slice(0, 5)}</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">{schedule.title}</h4>
|
||||
<p className="text-sm text-gray-500">{schedule.description}</p>
|
||||
<h4 className="font-medium text-gray-900 mb-2">{schedule.title}</h4>
|
||||
{schedule.description && (
|
||||
<p className="text-sm text-gray-500 mb-1">{schedule.description}</p>
|
||||
)}
|
||||
{/* 멤버 태그 */}
|
||||
{schedule.members && schedule.members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{schedule.members.length >= 5 ? (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
schedule.members.map((member) => (
|
||||
<span key={member.id} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
{member.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function AdminScheduleBots() {
|
|||
};
|
||||
|
||||
// 봇 시작/정지 토글
|
||||
const toggleBot = async (botId, currentStatus) => {
|
||||
const toggleBot = async (botId, currentStatus, botName) => {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const action = currentStatus === 'running' ? 'stop' : 'start';
|
||||
|
|
@ -76,7 +76,7 @@ function AdminScheduleBots() {
|
|||
if (response.ok) {
|
||||
setToast({
|
||||
type: 'success',
|
||||
message: action === 'start' ? '봇이 시작되었습니다.' : '봇이 정지되었습니다.'
|
||||
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
|
||||
});
|
||||
fetchBots(); // 목록 새로고침
|
||||
} else {
|
||||
|
|
@ -105,14 +105,16 @@ function AdminScheduleBots() {
|
|||
type: 'success',
|
||||
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`
|
||||
});
|
||||
fetchBots();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setToast({ type: 'error', message: data.error || '동기화 실패' });
|
||||
}
|
||||
// 성공/실패 모두 목록 갱신
|
||||
fetchBots();
|
||||
} catch (error) {
|
||||
console.error('전체 동기화 오류:', error);
|
||||
setToast({ type: 'error', message: '동기화 중 오류가 발생했습니다.' });
|
||||
fetchBots(); // 오류에도 목록 갱신
|
||||
} finally {
|
||||
setSyncing(null);
|
||||
}
|
||||
|
|
@ -171,6 +173,19 @@ function AdminScheduleBots() {
|
|||
});
|
||||
};
|
||||
|
||||
// 간격 포맷 (분 → 분/시간/일)
|
||||
const formatInterval = (minutes) => {
|
||||
if (!minutes) return '-';
|
||||
if (minutes >= 1440) {
|
||||
const days = Math.floor(minutes / 1440);
|
||||
return `${days}일`;
|
||||
} else if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}시간`;
|
||||
}
|
||||
return `${minutes}분`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
|
@ -296,9 +311,7 @@ function AdminScheduleBots() {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
채널: {bot.channel_name || bot.channel_id} |
|
||||
{bot.include_shorts ? ' Shorts 포함' : ' Shorts 제외'} |
|
||||
{bot.check_interval}분 간격
|
||||
채널: {bot.channel_name || bot.channel_id} | {formatInterval(bot.check_interval)} 간격
|
||||
</p>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
|
|
@ -341,7 +354,7 @@ function AdminScheduleBots() {
|
|||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleBot(bot.id, bot.status)}
|
||||
onClick={() => toggleBot(bot.id, bot.status, bot.name)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
|
|
|
|||
|
|
@ -619,6 +619,7 @@ function AdminScheduleForm() {
|
|||
category: '',
|
||||
description: '',
|
||||
url: '',
|
||||
sourceName: '',
|
||||
members: [],
|
||||
images: [],
|
||||
// 장소 정보
|
||||
|
|
@ -757,6 +758,7 @@ function AdminScheduleForm() {
|
|||
category: data.category_id || '',
|
||||
description: data.description || '',
|
||||
url: data.source_url || '',
|
||||
sourceName: data.source_name || '',
|
||||
members: data.members?.map(m => m.id) || [],
|
||||
images: [],
|
||||
locationName: data.location_name || '',
|
||||
|
|
@ -989,6 +991,7 @@ function AdminScheduleForm() {
|
|||
category: formData.category,
|
||||
description: formData.description.trim() || null,
|
||||
url: formData.url.trim() || null,
|
||||
sourceName: formData.sourceName.trim() || null,
|
||||
members: formData.members,
|
||||
locationName: formData.locationName.trim() || null,
|
||||
locationAddress: formData.locationAddress.trim() || null,
|
||||
|
|
@ -1462,6 +1465,18 @@ function AdminScheduleForm() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출처 이름 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">출처 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sourceName}
|
||||
onChange={(e) => setFormData({ ...formData, sourceName: e.target.value })}
|
||||
placeholder="예: 스프:스튜디오 프로미스나인, MUSINSA TV"
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue